Déployer une application est le début, pas la fin. Après le déploiement vient le travail quotidien de gestion : redémarrer après un changement de configuration, arrêter pendant une maintenance, supprimer quand un projet est terminé, ajouter des domaines personnalisés quand il passe en production.
La Phase 3 a ajouté cinq commandes pour gérer l'intégralité du cycle de vie applicatif. L'implémentation technique est simple -- ce sont de fines couches d'interface autour de endpoints API existants. Ce qui les rend intéressantes, c'est le design de sécurité : comment laisser un développeur supprimer une application depuis le terminal sans rendre trop facile la suppression de la mauvaise ?
Le motif de la fine couche d'interface
Les cinq commandes de la Phase 3 suivent toutes la même structure :
- Analyser l'argument d'application (nom ou UUID)
- Le résoudre via
client.resolve_app() - Effectuer l'appel API
- Afficher le résultat
Voici restart dans son intégralité :
rustpub async fn run(client: &Sh0Client, app: &str) -> Result<()> {
let app_info = client.resolve_app(app).await?;
client.post(&format!("/apps/{}/restart", app_info.id), &()).await?;
print_success(&format!("Restarted {}", app_info.name));
Ok(())
}Sept lignes. Pas de logique métier, pas de gestion d'état, pas de gestion d'erreurs complexe. Le serveur fait le travail. Le CLI fournit l'interface.
start est identique en structure. stop et delete ajoutent des invites de confirmation.
Invites de confirmation : deux niveaux de prudence
Arrêter une application est réversible. La supprimer ne l'est pas. Le CLI reflète cela avec deux motifs de confirmation différents.
Stop : simple oui/non
$ sh0 stop my-app
Stop my-app? This will take the app offline. [y/N] y
Stopped my-appLa valeur par défaut est N (non). Un Entrée accidentel ne fait rien. Le flag --yes saute l'invite pour le scripting :
rustif !yes {
print!(" Stop {}? This will take the app offline. [y/N] ", app_info.name);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "y" {
println!(" Cancelled");
return Ok(());
}
}Delete : taper le nom
Arrêter, c'est une mauvaise journée. Supprimer, c'est une catastrophe. La confirmation pour delete exige que le développeur tape le nom complet de l'application :
$ sh0 delete my-production-app
This will permanently delete my-production-app and all its data.
Type the app name to confirm: my-production-app
Deleted my-production-appLe nom tapé doit correspondre exactement. Pas de correspondance floue, pas de raccourci « yes ». C'est le même motif que GitHub utilise pour supprimer des dépôts, et pour la même raison : la friction est la fonctionnalité.
rustif !yes {
println!(" This will permanently delete {} and all its data.", app_info.name);
print!(" Type the app name to confirm: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim() != app_info.name {
println!(" Name does not match. Cancelled.");
return Ok(());
}
}
client.delete(&format!("/apps/{}?delete_volumes=true", app_info.id)).await?;Le bug du paramètre de requête
L'audit a trouvé le bug le plus subtil de tout le projet d'amélioration du CLI dans cette commande. L'implémentation originale utilisait ?cleanup=true comme paramètre de requête. Le serveur attendait ?delete_volumes=true.
Pourquoi était-ce critique ? Parce que serde ignore silencieusement les paramètres de requête inconnus. L'appel de suppression réussissait -- l'application était supprimée -- mais ses volumes Docker étaient préservés. Des volumes orphelins s'accumulant sur le disque, invisibles pour l'utilisateur, consommant du stockage indéfiniment.
La correction a été un seul mot : cleanup remplacé par delete_volumes. Mais le bug aurait été invisible en test car l'opération de suppression elle-même réussissait. Seul un audit minutieux comparant la chaîne de requête du CLI au handler du serveur a révélé le décalage.
Gestion des domaines : une sous-commande
Les domaines sont différents des autres commandes car ils ont plusieurs actions. Au lieu de quatre commandes séparées (sh0 domains-list, sh0 domains-add, etc.), nous avons utilisé un motif de sous-commande :
$ sh0 domains my-app list
DOMAIN PRIMARY ID
my-app.sh0.app yes a1b2c3d4
custom.example.com no e5f6g7h8
$ sh0 domains my-app add staging.example.com
Added staging.example.com
$ sh0 domains my-app add production.example.com --primary
Added production.example.com (primary)
$ sh0 domains my-app remove staging.example.com
Remove staging.example.com? [y/N] y
Removed staging.example.comL'action list affiche un tableau formaté utilisant le même utilitaire print_table que les autres commandes CLI. L'action add accepte un flag optionnel --primary. L'action remove inclut une invite de confirmation qui affiche le nom de domaine, pas juste l'ID.
Résolution de domaine par nom ou ID
L'audit a amélioré l'action remove. L'implémentation originale n'acceptait que les ID de domaine :
$ sh0 domains my-app remove e5f6g7h8Cela forçait le développeur à d'abord exécuter list, trouver l'ID, le copier et le coller dans la commande remove. La correction a été de résoudre les domaines par nom d'abord, avec repli sur l'ID :
rust// Essayer de trouver par nom de domaine d'abord
let domain = domains.iter()
.find(|d| d.domain == domain_arg)
.or_else(|| domains.iter().find(|d| d.id == domain_arg));
match domain {
Some(d) => {
// Confirmer avec le nom de domaine, pas l'ID
if !yes {
print!(" Remove {}? [y/N] ", d.domain);
// ...
}
client.delete(&format!("/apps/{}/domains/{}", app_info.id, d.id)).await?;
}
None => anyhow::bail!("Domain '{}' not found", domain_arg),
}Désormais, le développeur peut taper ce qu'il voit :
$ sh0 domains my-app remove staging.example.comC'est un petit changement qui élimine un aller-retour complet (list puis remove) du workflow.
Le motif resolve_app
Chaque commande de la Phase 3 commence par client.resolve_app(app). Cette fonction accepte à la fois des noms d'applications et des UUID :
rustpub async fn resolve_app(&self, name_or_id: &str) -> Result<AppInfo> {
// Essayer comme UUID d'abord
if let Ok(uuid) = uuid::Uuid::parse_str(name_or_id) {
if let Ok(app) = self.get_app(&uuid.to_string()).await {
return Ok(app);
}
}
// Rechercher par nom
let apps = self.get_apps(1, 200).await?;
apps.into_iter()
.find(|a| a.name == name_or_id)
.ok_or_else(|| anyhow!("App '{}' not found", name_or_id))
}L'audit global a ensuite découvert que l'implémentation originale ne récupérait que les 100 premières applications (per_page=100). Un serveur avec plus de 100 applications échouerait silencieusement à trouver des applications existantes. La correction a augmenté la limite à 200 (la taille de page maximale du serveur).
Ce n'est toujours pas parfait -- un serveur avec plus de 200 applications nécessiterait de la pagination. Mais l'échelle de déploiement actuelle de sh0 en fait un compromis raisonnable. La correction sera revue quand le premier client atteindra 200 applications.
Résultats d'audit
La Phase 3 a eu un round d'audit :
- 1 Critique : mauvais paramètre de requête sur delete (
cleanupau lieu dedelete_volumes) - 0 Important
- 4 Mineurs (2 corrigés) : domain remove résout maintenant par nom, l'invite de confirmation affiche le nom de domaine
La seule découverte Critique -- un bug d'un seul mot qui aurait causé des fuites de données silencieuses -- valide la méthodologie d'audit. Le développeur qui a écrit le code connaissait l'API de suppression du serveur. Il avait lu le handler. Il a quand même écrit le mauvais paramètre de requête, parce qu'il pensait à l'expérience utilisateur du CLI, pas au parsing de requêtes du serveur.
Un regard neuf, concentré sur la correction plutôt que sur les fonctionnalités, l'a détecté en quelques minutes.
La surface CLI complète
Après la Phase 3, le CLI sh0 compte 25 commandes réparties en six catégories :
| Catégorie | Commandes |
|---|---|
| Auth & Config | login, whoami, config show/get/set |
| Déploiement & Push | push, init, link, deploy |
| Cycle de vie applicatif | restart, stop, start, delete, open |
| Domaines | domains list/add/remove |
| Gestion | status, logs, env, ssh, scale |
| Infrastructure | templates, compose, cron, yaml, hooks, preview, export |
Chaque action disponible dans le tableau de bord est maintenant disponible depuis le terminal. Le CLI n'est pas un sous-ensemble du tableau de bord -- c'est une interface alternative complète.
Prochain dans la série : Mode watch et streaming WebSocket -- Auto-déploiement sur changement de fichier et passage du polling HTTP au streaming de logs de build en temps réel via WebSocket.