captive-cli 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/captive/cli/dependencies.rb +12 -2
  3. data/lib/captive/cli.rb +30 -13
  4. data/lib/captive/commands/apps_commands.rb +25 -50
  5. data/lib/captive/commands/apps_restart.rb +23 -9
  6. data/lib/captive/commands/auth_commands.rb +16 -0
  7. data/lib/captive/commands/auth_token.rb +29 -0
  8. data/lib/captive/commands/cron_declaration.rb +25 -0
  9. data/lib/captive/commands/crons_commands.rb +123 -0
  10. data/lib/captive/commands/env_commands.rb +66 -0
  11. data/lib/captive/commands/env_set.rb +40 -0
  12. data/lib/captive/commands/user_login.rb +20 -13
  13. data/lib/captive/credentials.rb +43 -0
  14. data/lib/captive/credentials_store.rb +47 -0
  15. data/lib/captive/dashboard/apps_resource.rb +26 -0
  16. data/lib/captive/dashboard/env_vars_resource.rb +27 -0
  17. data/lib/captive/dashboard/scheduled_tasks_resource.rb +32 -0
  18. data/lib/captive/dashboard_api.rb +58 -0
  19. data/lib/captive/dashboard_client.rb +25 -25
  20. data/lib/captive/dashboard_response.rb +39 -0
  21. data/lib/captive/errors.rb +14 -3
  22. data/lib/captive/flux.rb +11 -2
  23. data/lib/captive/github_device_flow.rb +5 -3
  24. data/lib/captive/kubectl.rb +0 -10
  25. data/lib/captive/loader.rb +6 -4
  26. data/lib/captive/presenter/app_info.rb +40 -0
  27. data/lib/captive/presenter/scheduled_task.rb +36 -0
  28. data/lib/captive/session_token.rb +60 -0
  29. data/lib/captive/shell_completions.rb +4 -2
  30. data/lib/captive/token_refresher.rb +27 -0
  31. data/lib/captive/validation_message.rb +26 -0
  32. data/lib/captive/version.rb +1 -1
  33. metadata +19 -8
  34. data/lib/captive/cluster_apps.rb +0 -82
  35. data/lib/captive/commands/apps_info.rb +0 -70
  36. data/lib/captive/commands/apps_rebuild.rb +0 -52
  37. data/lib/captive/commands/env_kube_commands.rb +0 -25
  38. data/lib/captive/commands/env_list_kube.rb +0 -16
  39. data/lib/captive/gh_cli.rb +0 -89
  40. data/lib/captive/platform_root.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ec4e1f102b8ed8f597677d727876ad7a0be2996de6cd51de68cb1e4730953e2
4
- data.tar.gz: 2a7e112840ea957652e340a70d9586328a65e95cf154b6d5de1a7b3f518a400b
3
+ metadata.gz: f79f7992c692e80419a6da77317d0e3d50d72ee4b48d41ea6001fa3e8a968400
4
+ data.tar.gz: 43220b3bb3e0b9cdaae4bf96de10d80eb3145af3f52584bff1c73fb45d4e7349
5
5
  SHA512:
6
- metadata.gz: 84a84d1504ace4bc7ddfc544a930df455297e4f806509f387241371f6f0f256b7ad2d0aee1ed870fc494a9a93eed36ee33e601808f7ee4363b62908e241076f6
7
- data.tar.gz: c5ba6f27ac8b9efb857b62c2f241ade477b976717f5225e4e1423b4be3407a88cadb89dc151e220394efa8d5e09c2bae28e215b491258148ba367adc0f046046
6
+ metadata.gz: cec86c2467edb73bb4c46c520c2fb793c1c4b67800213d5ef68348aa2ddae4c91b25e85d82fe73e599fc7cb188aee96b63feb5674b10885e7aa7100027f240ac
7
+ data.tar.gz: a69f88f24623b2329515daff60be1df8d3092046c51cd20b995cb6a0ba58a176dc29898640a07cdc6d9904315b10a321b7d7f6cc9cb7c301e768e991d55a3980
@@ -2,18 +2,28 @@
2
2
 
3
3
  module Captive
4
4
  module CLIDependencies
5
+ # Les écritures du dashboard sont appliquées plus tard par captive-platform (réponse 202).
6
+ ASYNC_NOTICE = "demande envoyée, appliquée sous quelques minutes"
7
+
5
8
  private
6
9
 
7
10
  def kubectl
8
11
  @kubectl ||= begin
9
12
  raise NotAuthenticatedError, "Pas de session active. Lance : captive login" if active_kubeconfig.nil?
10
13
 
11
- Kubectl.new(kubeconfig: active_kubeconfig) if binary_on_path?("kubectl")
14
+ unless binary_on_path?("kubectl")
15
+ raise MissingDependencyError, "kubectl introuvable. Installe-le : brew install kubernetes-cli"
16
+ end
17
+
18
+ Kubectl.new(kubeconfig: active_kubeconfig)
12
19
  end
13
20
  end
14
- def gh_cli = @gh_cli ||= GhCLI.new
15
21
  def trail = @trail ||= ActivityTrail.new
16
22
  def paint = @paint ||= Pastel.new
23
+ def dashboard = @dashboard ||= DashboardApi.new
24
+ def apps_api = @apps_api ||= Dashboard::AppsResource.new(api: dashboard)
25
+ def env_api = @env_api ||= Dashboard::EnvVarsResource.new(api: dashboard)
26
+ def crons_api = @crons_api ||= Dashboard::ScheduledTasksResource.new(api: dashboard)
17
27
 
18
28
  def active_kubeconfig
19
29
  env_kc = ENV["KUBECONFIG"]
data/lib/captive/cli.rb CHANGED
@@ -4,9 +4,12 @@ require "time"
4
4
  require_relative "loader"
5
5
  require_relative "cli/dependencies"
6
6
  require_relative "commands/apps_commands"
7
- require_relative "commands/env_kube_commands"
7
+ require_relative "commands/env_commands"
8
+ require_relative "commands/crons_commands"
8
9
  require_relative "commands/db_commands"
9
10
  require_relative "commands/user_login"
11
+ require_relative "commands/auth_commands"
12
+ require_relative "credentials_store"
10
13
  require_relative "dashboard_client"
11
14
  require_relative "github_device_flow"
12
15
  require_relative "cluster_endpoint"
@@ -22,15 +25,9 @@ module Captive
22
25
  def self.start(args = ARGV, config = {})
23
26
  args = [ "help", args.first ] if args.length == 2 && %w[--help -h].include?(args.last)
24
27
 
25
- case UpdateChecker.new.check
26
- when :blocked
27
- warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
28
- exit 1
29
- when :update_available
30
- warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
31
- end
28
+ check_for_update unless args.first(2) == %w[auth token]
32
29
  super
33
- rescue AuthError, NotAuthenticatedError, NoTtyError => e
30
+ rescue AuthError, NotAuthenticatedError, NoTtyError, MissingDependencyError, DashboardError => e
34
31
  warn e.message
35
32
  exit 1
36
33
  rescue StandardError => e
@@ -39,6 +36,19 @@ module Captive
39
36
  exit 1
40
37
  end
41
38
 
39
+ # kubectl appelle `captive auth token` à chaque commande : ni requête réseau en plus, ni
40
+ # avertissement à afficher.
41
+ def self.check_for_update
42
+ case UpdateChecker.new.check
43
+ when :blocked
44
+ warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
45
+ exit 1
46
+ when :update_available
47
+ warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
48
+ end
49
+ end
50
+ private_class_method :check_for_update
51
+
42
52
  # ── Subcommands ─────────────────────────────────────────────────────────
43
53
  desc "apps SUBCOMMAND", "Gestion des apps"
44
54
  subcommand "apps", Commands::AppsCommands
@@ -47,7 +57,13 @@ module Captive
47
57
  subcommand "db", Commands::DbCommands
48
58
 
49
59
  desc "env SUBCOMMAND", "Variables d'environnement d'une app"
50
- subcommand "env", Commands::EnvKubeCommands
60
+ subcommand "env", Commands::EnvCommands
61
+
62
+ desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
63
+ subcommand "crons", Commands::CronsCommands
64
+
65
+ desc "auth SUBCOMMAND", "Jeton pour kubectl", hide: true
66
+ subcommand "auth", Commands::AuthCommands
51
67
 
52
68
  # ── Root commands ────────────────────────────────────────────────────────
53
69
  map "--version" => :version
@@ -65,7 +81,7 @@ module Captive
65
81
  Commands::UserLogin.new(
66
82
  github: GithubDeviceFlow.new,
67
83
  cluster: ClusterEndpoint.new,
68
- token_path: File.expand_path("~/.config/captive/token"),
84
+ store: CredentialsStore.new,
69
85
  kubeconfig_path: File.expand_path("~/.config/captive/kubeconfig")
70
86
  ).call
71
87
  puts "Authentifié avec succès."
@@ -102,9 +118,10 @@ module Captive
102
118
  exit(exit_code) if exit_code && exit_code != 0
103
119
  end
104
120
 
105
- desc "whoami", "Affiche l'identité K8s courante"
121
+ desc "whoami", "Affiche l'utilisateur connecté au dashboard"
106
122
  def whoami
107
- puts kubectl.run("auth", "whoami")
123
+ me = dashboard.get("/me")
124
+ puts [ me["login"], ("(#{me["name"]})" if me["name"]) ].compact.join(" ")
108
125
  end
109
126
 
110
127
  desc "completions SHELL", "Génère le script de complétion shell (bash ou zsh)"
@@ -5,99 +5,74 @@ module Captive
5
5
  class AppsCommands < Thor
6
6
  include CLIDependencies
7
7
 
8
+ STAGE_BADGES = { "staging" => [ :yellow, "[staging]" ], "production" => [ :green, "[prod]" ] }.freeze
9
+
8
10
  def self.exit_on_failure? = true
9
11
 
10
- desc "list", "Liste toutes les apps déployées sur la plateforme"
12
+ desc "list", "Liste les apps auxquelles tu as accès"
11
13
  long_desc <<~DESC
12
- Liste les apps déployées sur le cluster, avec leurs domaines et leur stage.
14
+ Liste les apps de tes Organizations, avec leur stage et leur URL (API du dashboard).
13
15
 
14
16
  \x05Exemple :
15
17
  \x05 captive apps list
16
18
  DESC
17
19
  def list
18
- apps = ClusterApps.new(kubectl: kubectl).all
20
+ apps = apps_api.list
19
21
  puts paint.bold("Apps (#{apps.size}) :")
20
22
  apps.each do |app|
21
- stage_badge = case app[:stage]
22
- when "staging" then " " + paint.yellow("[staging]")
23
- when "production" then " " + paint.green("[prod]")
24
- else ""
25
- end
26
- puts " - #{paint.bold(app[:name])}#{stage_badge}"
27
- if app[:domains]&.any?
28
- app[:domains].each { |d| puts " #{paint.cyan("https://#{d}")}" }
29
- else
30
- puts paint.dim(" <no domain>")
31
- end
23
+ puts " - #{paint.bold(app["name"])}#{stage_badge(app["stage"])}"
24
+ puts app["app_url"] ? " #{paint.cyan(app["app_url"])}" : paint.dim(" <no domain>")
32
25
  end
33
26
  end
34
27
 
35
28
  desc "info APP", "Affiche le statut et les infos de l'app"
36
29
  long_desc <<~DESC
37
- Affiche l'état complet : pod, replicas, stage, tier, domaines, addons.
30
+ Affiche stage, statut, URL, processus (replicas prêts, tier), datastores et domaines.
38
31
 
39
32
  \x05Exemple :
40
33
  \x05 captive apps info my-app
41
34
  DESC
42
35
  def info(app)
43
- i = AppsInfo.new(kubectl: kubectl).gather(app)
44
- puts paint.bold("\n#{app}")
45
- puts " Stage : #{i[:stage] || "–"}"
46
- puts " Tier : #{i[:tier] || "–"}"
47
- puts " Pod : #{i[:pod_status]} (#{i[:replicas]} replica(s))"
48
- puts " Postgres : #{i[:postgres] ? paint.green("✓") : paint.dim("–")}"
49
- puts " Redis : #{i[:redis] ? paint.green("✓") : paint.dim("–")}"
50
- puts " IP fixe : #{i[:egress_ip] ? paint.green("✓") : paint.dim("–")}"
51
- i[:domains]&.any? && i[:domains].each { |d| puts " → #{d}" }
52
- i[:workers]&.any? && puts(" Workers : #{i[:workers].join(", ")}")
53
- (pg = i[:postgres_details]) && puts(" Postgres : #{pg[:instances] || 1} instance(s), #{pg[:memory] || "–"} RAM")
54
- (rd = i[:redis_details]) && puts(" Redis : #{rd[:memory] || "–"} RAM, #{rd[:storage] || "–"}")
55
- (eg = i[:egress_ip_details]) && puts(" IP fixe : #{eg[:ip] || "–"}")
56
- puts
36
+ lines = Presenter::AppInfo.new(paint: paint).lines(
37
+ app: apps_api.show(app), workloads: apps_api.workloads(app).fetch("workloads", []),
38
+ datastores: apps_api.datastores(app), domains: apps_api.domains(app)
39
+ )
40
+ puts lines
57
41
  end
58
42
 
59
- desc "restart APP", "Redémarre le deployment"
43
+ desc "restart APP", "Redémarre les processus de l'app"
60
44
  long_desc <<~DESC
61
- Rollout restart du deployment + attend pod Ready.
45
+ Demande le redémarrage au dashboard, puis attend (120 s max) que tous les
46
+ processus soient de nouveau prêts.
62
47
 
63
48
  \x05Exemple :
64
49
  \x05 captive apps restart my-app
65
50
  DESC
66
51
  def restart(app)
67
- AppsRestart.new(kubectl: kubectl).run(app)
68
- pod_name, created_at = kubectl.pod_info(app, app)
69
- age = format_age(created_at)
52
+ AppsRestart.new(apps: apps_api).run(app)
70
53
  puts paint.green(" ✓ #{app} redémarré")
71
- puts paint.dim(" pod: #{pod_name} (#{age})")
72
54
  rescue PollTimeout => e
73
55
  abort paint.red("✗ Timeout : #{e.message}")
74
- rescue RuntimeError => e
75
- abort paint.red("✗ #{e.message.split("failed: ").last}")
76
56
  end
77
57
 
78
- desc "rebuild APP", "Déclenche un rebuild sans changer le code"
58
+ desc "rebuild APP", "Redéploie l'app sans changer le code"
79
59
  long_desc <<~DESC
80
- workflow_dispatch sur le repo GitHub. Utile après changement d'env vars.
60
+ Relance le build et le déploiement de la version courante via le dashboard.
61
+ Utile après un changement de variables d'environnement.
81
62
 
82
63
  \x05Exemple :
83
64
  \x05 captive apps rebuild my-app-staging
84
65
  DESC
85
66
  def rebuild(app)
86
- AppsRebuild.new(gh: gh_cli).run(app)
87
- puts paint.green(" ✓ Rebuild déclenché pour '#{app}'")
88
- rescue WorkflowDispatchNotConfigured, ImageYamlNotFound, PlatformRootNotFound => e
89
- abort paint.red("✗ #{e.message}")
67
+ apps_api.redeploy(app)
68
+ puts paint.green(" ✓ Redéploiement déclenché pour '#{app}'")
90
69
  end
91
70
 
92
71
  private
93
72
 
94
- def format_age(created_at)
95
- return "?" unless created_at
96
-
97
- require "time"
98
- "#{((Time.now - Time.parse(created_at)) / 60).round}s"
99
- rescue ArgumentError
100
- "?"
73
+ def stage_badge(stage)
74
+ color, label = STAGE_BADGES[stage]
75
+ color ? " #{paint.public_send(color, label)}" : ""
101
76
  end
102
77
  end
103
78
  end
@@ -1,23 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
3
4
  require_relative "../poller"
4
5
  require_relative "../errors"
5
6
 
6
7
  module Captive
7
8
  module Commands
9
+ # Demande le redémarrage au dashboard, puis attend que chaque workload ait tous ses
10
+ # replicas prêts, sur des pods créés après la demande : juste après le POST, les anciens
11
+ # pods sont encore prêts et le redémarrage paraîtrait déjà fini.
8
12
  class AppsRestart
9
- def initialize(kubectl:, poller: nil)
10
- @kubectl = kubectl
11
- @poller = poller || Poller.new(interval: 5, timeout: 120)
13
+ def initialize(apps:, poller: nil)
14
+ @apps = apps
15
+ @poller = poller || Poller.new(interval: 5, timeout: 120)
12
16
  end
13
17
 
14
18
  def run(app)
15
- old_pod, = @kubectl.pod_info(app, app)
16
- @kubectl.restart_deployment(app, app)
17
- @poller.poll_until(context: "nouveau pod #{app} Running") do
18
- new_pod, = @kubectl.pod_info(app, app)
19
- new_pod && !new_pod.empty? && new_pod != old_pod
20
- end
19
+ restarted_at = Time.parse(@apps.restart(app).fetch("restarted_at"))
20
+ @poller.poll_until(context: "redémarrage de #{app}") { restarted?(app, restarted_at) }
21
+ end
22
+
23
+ private
24
+
25
+ def restarted?(app, restarted_at)
26
+ state = @apps.workloads(app)
27
+ workloads = state.fetch("workloads", [])
28
+ workloads.any? && workloads.all? { |w| w["ready"] == w["total"] } &&
29
+ state.fetch("pods", []).all? { |pod| fresh?(pod, restarted_at) }
30
+ end
31
+
32
+ def fresh?(pod, restarted_at)
33
+ created_at = pod["created_at"]
34
+ created_at.nil? || Time.parse(created_at.to_s) >= restarted_at
21
35
  end
22
36
  end
23
37
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_token"
4
+ require_relative "../session_token"
5
+
6
+ module Captive
7
+ module Commands
8
+ # `captive auth` : ce dont kubectl a besoin pour s'authentifier, masqué de l'aide.
9
+ class AuthCommands < Thor
10
+ def self.exit_on_failure? = true
11
+
12
+ desc "token", "Imprime un ExecCredential pour kubectl (appelé par le kubeconfig)"
13
+ def token = AuthToken.new(session: SessionToken.new).call
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Captive
7
+ module Commands
8
+ # Le plugin d'authentification exec de kubectl : un ExecCredential sur stdout, et rien d'autre.
9
+ class AuthToken
10
+ def initialize(session:, out: $stdout)
11
+ @session = session
12
+ @out = out
13
+ end
14
+
15
+ def call
16
+ @out.puts JSON.generate(exec_credential(@session.current))
17
+ end
18
+
19
+ private
20
+
21
+ def exec_credential(credentials)
22
+ expires_at = credentials.expires_at
23
+ status = { token: credentials.access_token }
24
+ status[:expirationTimestamp] = expires_at.utc.iso8601 if expires_at
25
+ { apiVersion: "client.authentication.k8s.io/v1", kind: "ExecCredential", status: status }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ # Le corps d'une tâche planifiée à déclarer : les options données sur la ligne de
6
+ # commande, posées sur une base (les défauts du dashboard pour un ajout, la tâche
7
+ # actuelle pour une édition).
8
+ class CronDeclaration
9
+ DEFAULTS = { "schedule" => "", "timezone" => "Etc/UTC", "mode" => "http", "target" => "",
10
+ "timeout_seconds" => 60, "suspended" => false, }.freeze
11
+ OPTIONS = { schedule: "schedule", timezone: "timezone", mode: "mode", target: "target",
12
+ timeout: "timeout_seconds", suspended: "suspended", }.freeze
13
+
14
+ def initialize(name, base: DEFAULTS)
15
+ @name = name
16
+ @base = base
17
+ end
18
+
19
+ def with(options)
20
+ given = OPTIONS.filter_map { |option, field| [ field, options[option] ] unless options[option].nil? }
21
+ DEFAULTS.merge(@base.slice(*DEFAULTS.keys)).merge(given.to_h).merge("name" => @name)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cron_declaration"
4
+
5
+ module Captive
6
+ module Commands
7
+ class CronsCommands < Thor
8
+ include CLIDependencies
9
+
10
+ def self.exit_on_failure? = true
11
+
12
+ DECLARATION_OPTIONS = {
13
+ schedule: { type: :string, desc: "Planning cron à 5 champs, ex. \"0 3 * * *\"" },
14
+ target: { type: :string, desc: "URL (mode http) ou commande (mode command)" },
15
+ mode: { type: :string, enum: %w[http command], desc: "http (défaut) ou command" },
16
+ timezone: { type: :string, desc: "Fuseau IANA (défaut Etc/UTC)" },
17
+ timeout: { type: :numeric, desc: "Durée max en secondes, 1 à 3600 (défaut 60)" },
18
+ suspended: { type: :boolean, desc: "Déclare la tâche suspendue" },
19
+ }.freeze
20
+
21
+ def self.declaration_options
22
+ DECLARATION_OPTIONS.each { |name, settings| method_option(name, **settings) }
23
+ end
24
+
25
+ desc "list APP", "Liste les tâches planifiées de l'app"
26
+ long_desc <<~DESC
27
+ \x05Exemple :
28
+ \x05 captive crons list my-app
29
+ DESC
30
+ def list(app)
31
+ tasks = crons_api.list(app)
32
+ return puts(paint.dim("Aucune tâche planifiée.")) if tasks.empty?
33
+
34
+ tasks.each { |task| puts presenter.lines(task) }
35
+ end
36
+
37
+ desc "add APP NAME --schedule CRON --target CIBLE", "Déclare une tâche planifiée"
38
+ long_desc <<~DESC
39
+ \x05Exemple :
40
+ \x05 captive crons add my-app nettoyage --schedule "0 3 * * *" --target /cron/cleanup
41
+ \x05 captive crons add my-app purge --schedule "*/15 * * * *" --mode command --target "bin/rails purge"
42
+ DESC
43
+ declaration_options
44
+ def add(app, name)
45
+ crons_api.create(app, CronDeclaration.new(name).with(options.transform_keys(&:to_sym)))
46
+ accepted("#{name} déclarée")
47
+ end
48
+
49
+ desc "edit APP NAME [options]", "Modifie une tâche planifiée"
50
+ long_desc <<~DESC
51
+ Les options absentes gardent la valeur actuelle de la tâche.
52
+
53
+ \x05Exemple :
54
+ \x05 captive crons edit my-app nettoyage --schedule "0 4 * * *"
55
+ DESC
56
+ declaration_options
57
+ def edit(app, name)
58
+ base = crons_api.find(app, name)
59
+ crons_api.update(app, name, CronDeclaration.new(name, base: base).with(options.transform_keys(&:to_sym)))
60
+ accepted("#{name} modifiée")
61
+ end
62
+
63
+ desc "rm APP NAME", "Supprime une tâche planifiée"
64
+ long_desc <<~DESC
65
+ \x05Exemple :
66
+ \x05 captive crons rm my-app nettoyage
67
+ DESC
68
+ def rm(app, name)
69
+ crons_api.delete(app, name)
70
+ accepted("#{name} supprimée")
71
+ end
72
+
73
+ desc "suspend APP NAME", "Suspend une tâche planifiée"
74
+ long_desc <<~DESC
75
+ \x05Exemple :
76
+ \x05 captive crons suspend my-app nettoyage
77
+ DESC
78
+ def suspend(app, name)
79
+ crons_api.suspend(app, name)
80
+ accepted("#{name} suspendue")
81
+ end
82
+
83
+ desc "resume APP NAME", "Reprend une tâche planifiée suspendue"
84
+ long_desc <<~DESC
85
+ \x05Exemple :
86
+ \x05 captive crons resume my-app nettoyage
87
+ DESC
88
+ def resume(app, name)
89
+ crons_api.resume(app, name)
90
+ accepted("#{name} reprise")
91
+ end
92
+
93
+ desc "trigger APP NAME", "Lance une tâche planifiée maintenant"
94
+ long_desc <<~DESC
95
+ \x05Exemple :
96
+ \x05 captive crons trigger my-app nettoyage
97
+ DESC
98
+ def trigger(app, name)
99
+ job = crons_api.trigger(app, name).fetch("job_name")
100
+ puts paint.green(" ✓ #{name} lancée (job #{job})")
101
+ end
102
+
103
+ desc "runs APP NAME", "Affiche la dernière exécution d'une tâche"
104
+ long_desc <<~DESC
105
+ Le cluster ne garde que la dernière exécution (un succès disparaît après 5 minutes).
106
+
107
+ \x05Exemple :
108
+ \x05 captive crons runs my-app nettoyage
109
+ DESC
110
+ def runs(app, name)
111
+ puts presenter.last_run(crons_api.find(app, name)["last_run"]).strip
112
+ end
113
+
114
+ private
115
+
116
+ def presenter = Presenter::ScheduledTask.new(paint: paint)
117
+
118
+ def accepted(message)
119
+ puts paint.green(" ✓ #{message} : #{ASYNC_NOTICE}")
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "env_set"
4
+
5
+ module Captive
6
+ module Commands
7
+ class EnvCommands < Thor
8
+ include CLIDependencies
9
+
10
+ def self.exit_on_failure? = true
11
+
12
+ desc "list APP", "Liste les variables d'environnement de l'app"
13
+ long_desc <<~DESC
14
+ Lit les variables via le dashboard. Une écriture pas encore appliquée est signalée.
15
+
16
+ \x05Exemple :
17
+ \x05 captive env list my-app-staging
18
+ DESC
19
+ def list(app)
20
+ env_api.list(app).sort_by { |var| var["key"] }.each do |var|
21
+ puts "#{var["key"]}=#{var["value"]}#{state(var)}"
22
+ end
23
+ end
24
+
25
+ desc "set APP KEY=VALUE [KEY=VALUE...]", "Crée ou modifie des variables d'environnement"
26
+ long_desc <<~DESC
27
+ Crée les clés absentes, met à jour les autres. Appliqué par la plateforme
28
+ sous quelques minutes.
29
+
30
+ \x05Exemple :
31
+ \x05 captive env set my-app-staging FOO=bar BAZ=qux
32
+ DESC
33
+ def set(app, *pairs)
34
+ abort "Indique au moins une paire KEY=VALUE." if pairs.empty?
35
+
36
+ EnvSet.new(env_vars: env_api).run(app, pairs).each do |key, outcome|
37
+ puts paint.green(" ✓ #{key} #{outcome == :created ? "créée" : "modifiée"}")
38
+ end
39
+ puts paint.dim(" #{ASYNC_NOTICE}")
40
+ end
41
+
42
+ desc "unset APP KEY [KEY...]", "Supprime des variables d'environnement"
43
+ long_desc <<~DESC
44
+ \x05Exemple :
45
+ \x05 captive env unset my-app-staging FOO BAZ
46
+ DESC
47
+ def unset(app, *keys)
48
+ abort "Indique au moins une clé." if keys.empty?
49
+
50
+ keys.each do |key|
51
+ env_api.delete(app, key)
52
+ puts paint.green(" ✓ #{key} supprimée")
53
+ end
54
+ puts paint.dim(" #{ASYNC_NOTICE}")
55
+ end
56
+
57
+ private
58
+
59
+ def state(var)
60
+ return paint.red(" [échec : #{var["failure_reason"] || "écriture refusée"}]") if var["failed"]
61
+
62
+ var["pending"] ? paint.yellow(" [en attente : #{var["pending_change"]}]") : ""
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Captive
6
+ module Commands
7
+ # Écrit des paires KEY=VALUE : une clé déjà présente se met à jour, une nouvelle se crée.
8
+ class EnvSet
9
+ def initialize(env_vars:)
10
+ @env_vars = env_vars
11
+ end
12
+
13
+ # Retourne [[clé, :created|:updated], …] dans l'ordre des paires.
14
+ def run(app, pairs)
15
+ parsed = pairs.map { |pair| parse(pair) }
16
+ existing = @env_vars.list(app).to_set { |var| var["key"] }
17
+ parsed.map { |key, value| write(app, key, value, existing.include?(key)) }
18
+ end
19
+
20
+ private
21
+
22
+ def parse(pair)
23
+ key, separator, value = pair.partition("=")
24
+ raise ValidationError, "Paire invalide : #{pair.inspect}. Attendu KEY=VALUE" if separator.empty? || key.empty?
25
+
26
+ [ key, value ]
27
+ end
28
+
29
+ def write(app, key, value, exists)
30
+ if exists
31
+ @env_vars.update(app, key, value)
32
+ [ key, :updated ]
33
+ else
34
+ @env_vars.create(app, key, value)
35
+ [ key, :created ]
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end