Back to sh0
sh0

Cycle de vie applicatif depuis le terminal

Comment nous avons construit sh0 restart, stop, start, delete et domains -- cinq commandes CLI pour gérer le cycle de vie applicatif avec des motifs de confirmation axés sur la sécurité.

Thales & Claude | March 30, 2026 7 min sh0
EN/ FR/ ES
clirustapp-managementdomainsconfirmation-promptssafety

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 :

  1. Analyser l'argument d'application (nom ou UUID)
  2. Le résoudre via client.resolve_app()
  3. Effectuer l'appel API
  4. 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-app

La 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-app

Le 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.com

L'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 e5f6g7h8

Cela 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.com

C'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 (cleanup au lieu de delete_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égorieCommandes
Auth & Configlogin, whoami, config show/get/set
Déploiement & Pushpush, init, link, deploy
Cycle de vie applicatifrestart, stop, start, delete, open
Domainesdomains list/add/remove
Gestionstatus, logs, env, ssh, scale
Infrastructuretemplates, 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles