captive-cli 1.0.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/captive +6 -0
  3. data/lib/captive/activity_trail.rb +38 -0
  4. data/lib/captive/cli/dependencies.rb +42 -0
  5. data/lib/captive/cli.rb +170 -0
  6. data/lib/captive/cluster_apps.rb +82 -0
  7. data/lib/captive/cluster_endpoint.rb +23 -0
  8. data/lib/captive/commands/apps_commands.rb +104 -0
  9. data/lib/captive/commands/apps_info.rb +70 -0
  10. data/lib/captive/commands/apps_rebuild.rb +52 -0
  11. data/lib/captive/commands/apps_restart.rb +24 -0
  12. data/lib/captive/commands/console.rb +32 -0
  13. data/lib/captive/commands/db_commands.rb +105 -0
  14. data/lib/captive/commands/db_dump.rb +33 -0
  15. data/lib/captive/commands/db_exec.rb +24 -0
  16. data/lib/captive/commands/db_psql.rb +20 -0
  17. data/lib/captive/commands/db_restore.rb +145 -0
  18. data/lib/captive/commands/db_target.rb +19 -0
  19. data/lib/captive/commands/env_kube_commands.rb +25 -0
  20. data/lib/captive/commands/env_list_kube.rb +16 -0
  21. data/lib/captive/commands/logs.rb +65 -0
  22. data/lib/captive/commands/one_off_session.rb +76 -0
  23. data/lib/captive/commands/run.rb +24 -0
  24. data/lib/captive/commands/user_login.rb +64 -0
  25. data/lib/captive/dashboard_client.rb +119 -0
  26. data/lib/captive/errors.rb +16 -0
  27. data/lib/captive/flux.rb +15 -0
  28. data/lib/captive/gh_cli.rb +89 -0
  29. data/lib/captive/github_device_flow.rb +39 -0
  30. data/lib/captive/http_client.rb +31 -0
  31. data/lib/captive/kubectl.rb +330 -0
  32. data/lib/captive/loader.rb +33 -0
  33. data/lib/captive/log_noise_filter.rb +17 -0
  34. data/lib/captive/null_log_filter.rb +8 -0
  35. data/lib/captive/one_off_spec.rb +144 -0
  36. data/lib/captive/platform_root.rb +25 -0
  37. data/lib/captive/poller.rb +35 -0
  38. data/lib/captive/presenter/ansi_stripper.rb +22 -0
  39. data/lib/captive/presenter/log_colorizer.rb +54 -0
  40. data/lib/captive/presenter/log_line.rb +40 -0
  41. data/lib/captive/session_id.rb +20 -0
  42. data/lib/captive/shell_completions.rb +61 -0
  43. data/lib/captive/terminal.rb +10 -0
  44. data/lib/captive/ui/banner.rb +37 -0
  45. data/lib/captive/ui/spinner.rb +23 -0
  46. data/lib/captive/update_checker.rb +65 -0
  47. data/lib/captive/version.rb +5 -0
  48. metadata +172 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6ec4e1f102b8ed8f597677d727876ad7a0be2996de6cd51de68cb1e4730953e2
4
+ data.tar.gz: 2a7e112840ea957652e340a70d9586328a65e95cf154b6d5de1a7b3f518a400b
5
+ SHA512:
6
+ metadata.gz: 84a84d1504ace4bc7ddfc544a930df455297e4f806509f387241371f6f0f256b7ad2d0aee1ed870fc494a9a93eed36ee33e601808f7ee4363b62908e241076f6
7
+ data.tar.gz: c5ba6f27ac8b9efb857b62c2f241ade477b976717f5225e4e1423b4be3407a88cadb89dc151e220394efa8d5e09c2bae28e215b491258148ba367adc0f046046
data/exe/captive ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "captive/cli"
5
+
6
+ Captive::CLI.start(ARGV)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dashboard_client"
4
+
5
+ module Captive
6
+ # Ce qu'on signale au dashboard des accès qu'on ouvre sur une app : console, shell, commande
7
+ # one-off, export et restauration de la base.
8
+ #
9
+ # Cette unité change quand les accès qu'on trace changent, là où les commandes changent quand
10
+ # ce qu'elles font change. Elle porte les `kind`, que le dashboard traduit en événements :
11
+ # les laisser dans les actions Thor les éparpillait sur cinq commandes.
12
+ class ActivityTrail
13
+ def initialize(dashboard: DashboardClient.new)
14
+ @dashboard = dashboard
15
+ end
16
+
17
+ # `session` n'accompagne que les accès qui tournent dans un Job one-off : eux seuls
18
+ # laissent un log dans Loki que le dashboard saura rattacher à l'entrée du fil. Un shell
19
+ # passe par kubectl exec et n'est capturé nulle part, donc n'en porte pas.
20
+ def console_opened(app, session: nil) = signaler(app, "console", session: session)
21
+ def db_dumped(app) = signaler(app, "db_dump")
22
+ def db_restored(app) = signaler(app, "db_restore")
23
+
24
+ def command_run(app, command, session: nil)
25
+ signaler(app, "run", command: Array(command).join(" "), session: session)
26
+ end
27
+
28
+ private
29
+
30
+ # Un dashboard indisponible ne doit jamais empêcher d'ouvrir une console : l'accès est ce
31
+ # que l'utilisateur a demandé, le signaler ne l'est pas.
32
+ def signaler(app, kind, command: nil, session: nil)
33
+ @dashboard.record_activity(app, kind: kind, command: command, session: session)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module CLIDependencies
5
+ private
6
+
7
+ def kubectl
8
+ @kubectl ||= begin
9
+ raise NotAuthenticatedError, "Pas de session active. Lance : captive login" if active_kubeconfig.nil?
10
+
11
+ Kubectl.new(kubeconfig: active_kubeconfig) if binary_on_path?("kubectl")
12
+ end
13
+ end
14
+ def gh_cli = @gh_cli ||= GhCLI.new
15
+ def trail = @trail ||= ActivityTrail.new
16
+ def paint = @paint ||= Pastel.new
17
+
18
+ def active_kubeconfig
19
+ env_kc = ENV["KUBECONFIG"]
20
+ return env_kc if env_kc && File.exist?(env_kc)
21
+
22
+ user_kc = File.expand_path("~/.config/captive/kubeconfig")
23
+ user_kc if File.exist?(user_kc)
24
+ end
25
+
26
+ def binary_on_path?(name)
27
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
28
+ end
29
+
30
+ def parse_time(value)
31
+ return nil if value.nil?
32
+
33
+ Time.parse(value)
34
+ rescue ArgumentError
35
+ abort "Format de date invalide : #{value.inspect}. Exemple : '2026-08-31 23:00' ou '2026-08-31T23:00:00+02:00'"
36
+ end
37
+
38
+ def spin(label, success_msg, &block)
39
+ UI::Spinner.new(label).run(success_msg: paint.green(success_msg), &block)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "loader"
5
+ require_relative "cli/dependencies"
6
+ require_relative "commands/apps_commands"
7
+ require_relative "commands/env_kube_commands"
8
+ require_relative "commands/db_commands"
9
+ require_relative "commands/user_login"
10
+ require_relative "dashboard_client"
11
+ require_relative "github_device_flow"
12
+ require_relative "cluster_endpoint"
13
+ require_relative "update_checker"
14
+ require_relative "shell_completions"
15
+
16
+ module Captive
17
+ class CLI < Thor
18
+ include CLIDependencies
19
+
20
+ def self.exit_on_failure? = true
21
+
22
+ def self.start(args = ARGV, config = {})
23
+ args = [ "help", args.first ] if args.length == 2 && %w[--help -h].include?(args.last)
24
+
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
32
+ super
33
+ rescue AuthError, NotAuthenticatedError, NoTtyError => e
34
+ warn e.message
35
+ exit 1
36
+ rescue StandardError => e
37
+ warn "Une erreur inattendue s'est produite : #{e.message}"
38
+ warn "Si le problème persiste, contacte l'équipe Captive."
39
+ exit 1
40
+ end
41
+
42
+ # ── Subcommands ─────────────────────────────────────────────────────────
43
+ desc "apps SUBCOMMAND", "Gestion des apps"
44
+ subcommand "apps", Commands::AppsCommands
45
+
46
+ desc "db SUBCOMMAND", "Base de données"
47
+ subcommand "db", Commands::DbCommands
48
+
49
+ desc "env SUBCOMMAND", "Variables d'environnement d'une app"
50
+ subcommand "env", Commands::EnvKubeCommands
51
+
52
+ # ── Root commands ────────────────────────────────────────────────────────
53
+ map "--version" => :version
54
+ desc "version", "Affiche la version de la CLI"
55
+ def version = puts VERSION
56
+
57
+ desc "login", "Authentifie l'utilisateur via GitHub"
58
+ long_desc <<~DESC
59
+ Authentifie l'utilisateur via GitHub Device Flow et génère un kubeconfig.
60
+
61
+ \x05Exemple :
62
+ \x05 captive login
63
+ DESC
64
+ def login
65
+ Commands::UserLogin.new(
66
+ github: GithubDeviceFlow.new,
67
+ cluster: ClusterEndpoint.new,
68
+ token_path: File.expand_path("~/.config/captive/token"),
69
+ kubeconfig_path: File.expand_path("~/.config/captive/kubeconfig")
70
+ ).call
71
+ puts "Authentifié avec succès."
72
+ end
73
+
74
+ desc "console APP", "Ouvre une console Rails (pod one-off)"
75
+ long_desc <<~DESC
76
+ Lance un pod one-off avec la même image/env que l'app, y attache une
77
+ console Rails interactive. L'entrée/sortie est capturée dans les logs
78
+ (Promtail → Loki) et reste visible via captive logs.
79
+
80
+ \x05Exemple :
81
+ \x05 captive console my-app
82
+ DESC
83
+ def console(app)
84
+ Commands::Console.new(trail: trail, one_off: Commands::OneOffSession.new(kubectl: kubectl)).run(app)
85
+ end
86
+
87
+ desc "run APP CMD", "Exécute une commande (Job one-off)"
88
+ long_desc <<~DESC
89
+ Lance un Job Kubernetes one-off avec la même image/env que l'app et y
90
+ exécute CMD via sh -c. Depuis un terminal, la session est interactive ;
91
+ sans TTY (agent, CI, pipe), la sortie est streamée et le code retour
92
+ propagé. Dans les deux cas elle est conservée dans les logs.
93
+
94
+ \x05Exemple :
95
+ \x05 captive run my-app "bash"
96
+ \x05 captive run my-app "rake db:migrate"
97
+ \x05 captive run my-app "bundle exec rails runner 'puts User.count'"
98
+ DESC
99
+ map "run" => :run_cmd
100
+ def run_cmd(app, *cmd)
101
+ exit_code = Commands::Run.new(kubectl: kubectl, trail: trail).run(app, *cmd)
102
+ exit(exit_code) if exit_code && exit_code != 0
103
+ end
104
+
105
+ desc "whoami", "Affiche l'identité K8s courante"
106
+ def whoami
107
+ puts kubectl.run("auth", "whoami")
108
+ end
109
+
110
+ desc "completions SHELL", "Génère le script de complétion shell (bash ou zsh)"
111
+ long_desc <<~DESC
112
+ Génère un script de complétion pour bash ou zsh.
113
+
114
+ \x05Exemple :
115
+ \x05 eval "$(captive completions bash)"
116
+ \x05 eval "$(captive completions zsh)"
117
+ DESC
118
+ def completions(shell)
119
+ case shell
120
+ when "bash" then print ShellCompletions.bash
121
+ when "zsh" then print ShellCompletions.zsh
122
+ else abort "Shell non supporté : #{shell}. Utilise bash ou zsh."
123
+ end
124
+ end
125
+
126
+ desc "logs APP", "Affiche les logs de l'app"
127
+ long_desc <<~DESC
128
+ MODE TEMPS RÉEL (défaut) — stream les logs kubectl du pod running.
129
+ -f, --follow Suit en continu (Ctrl+C pour arrêter)
130
+ -n, --lines N Nombre de lignes affichées (défaut : 100)
131
+ -c, --container NOM Container spécifique (ex : migrate, redis)
132
+ -w, --worker Cible le pod worker au lieu du pod web
133
+ --raw Garde les lignes rack-timeout de la readinessProbe
134
+ En cas de CrashLoopBackOff, --previous est activé automatiquement.
135
+
136
+ MODE HISTORIQUE (--from / --to) — interroge Loki via le dashboard.
137
+ --from DATE Début de la plage (requis pour ce mode)
138
+ --to DATE Fin de la plage (défaut : maintenant)
139
+ -n, --lines N Nombre de lignes max (défaut : 100)
140
+ Requiert d'être authentifié (captive login). Rétention : 28 jours.
141
+ Formats de date acceptés : "2026-08-31 23:00", "2026-08-31T21:00:00Z".
142
+
143
+ \x05Exemples :
144
+ \x05 captive logs my-app
145
+ \x05 captive logs my-app -f
146
+ \x05 captive logs my-app -n 200
147
+ \x05 captive logs my-app -c migrate
148
+ \x05 captive logs my-app --worker
149
+ \x05 captive logs my-app --raw
150
+ \x05 captive logs my-app --from "2026-08-31 23:00" --to "2026-09-01 00:00"
151
+ \x05 captive logs my-app --from "2026-08-31 23:00"
152
+ DESC
153
+ method_option :follow, aliases: "-f", type: :boolean, default: false
154
+ method_option :container, aliases: "-c", type: :string
155
+ method_option :worker, aliases: "-w", type: :boolean, default: false
156
+ method_option :lines, aliases: "-n", type: :numeric, default: 100
157
+ method_option :raw, type: :boolean, default: false, desc: "Affiche le bruit rack-timeout des sondes readiness"
158
+ method_option :from, type: :string, desc: "Début de la plage (ex: '2026-08-31 23:00' ou ISO8601)"
159
+ method_option :to, type: :string, desc: "Fin de la plage (ex: '2026-09-01 00:00' ou ISO8601)"
160
+ def logs(app)
161
+ from = parse_time(options[:from])
162
+ to = parse_time(options[:to])
163
+ Commands::Logs.new(kubectl: kubectl).run(
164
+ app, follow: options[:follow], container: options[:container],
165
+ worker: options[:worker], lines: options[:lines],
166
+ from: from, to: to, raw: options[:raw]
167
+ )
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Captive
6
+ # Ce que le cluster sait des apps déployées. Le CLI développeur décrit l'état réellement
7
+ # servi, pas l'état désiré dans le repo GitOps — auquel il n'a pas accès.
8
+ class ClusterApps
9
+ def initialize(kubectl:)
10
+ @kubectl = kubectl
11
+ end
12
+
13
+ def all
14
+ web_deployments.map do |deployment|
15
+ namespace = deployment.dig("metadata", "namespace")
16
+ { name: namespace, stage: stage_of(namespace), domains: domains_by_namespace[namespace] || [] }
17
+ end
18
+ end
19
+
20
+ def domains(app) = domains_by_namespace[app] || []
21
+
22
+ def stage(app) = stage_of(app)
23
+
24
+ def tier(app) = web_of(app)&.dig("metadata", "annotations", "captive.dev/tier")
25
+
26
+ def workers(app) = of_type(app, "worker").map { |d| d.dig("metadata", "name") }
27
+
28
+ # L'add-on peut ne pas être posé, ou la CRD absente du cluster : les deux se lisent
29
+ # comme « pas d'IP fixe », pas comme une panne.
30
+ def egress_ip(app)
31
+ first_item("egresspolicy", app)&.dig("spec", "egressIP", "ipv4")
32
+ end
33
+
34
+ def postgres(app)
35
+ spec = first_item("cluster", app)&.fetch("spec", nil) or return nil
36
+
37
+ { instances: spec["instances"],
38
+ memory: spec.dig("resources", "limits", "memory"),
39
+ storage: spec.dig("storage", "size"), }.compact
40
+ end
41
+
42
+ private
43
+
44
+ def first_item(kind, app)
45
+ items(@kubectl.run("get", kind, "-n", app, "-o", "json")).first
46
+ rescue RuntimeError
47
+ nil
48
+ end
49
+
50
+ def web_of(app) = of_type(app, "web").first
51
+
52
+ def of_type(app, type)
53
+ workloads(app).select { |d| d.dig("metadata", "labels", "captive.dev/type") == type }
54
+ end
55
+
56
+ def workloads(app)
57
+ @workloads ||= {}
58
+ @workloads[app] ||= items(@kubectl.run("get", "deployment", "-n", app, "-o", "json"))
59
+ end
60
+
61
+
62
+ # Une app est un Deployment portant le type web : le même label que les composants de
63
+ # tier ciblent, et que la conformité exige.
64
+ def web_deployments
65
+ items(@kubectl.run("get", "deployment", "-A", "-l", "captive.dev/type=web", "-o", "json"))
66
+ end
67
+
68
+ def domains_by_namespace
69
+ @domains_by_namespace ||= items(@kubectl.run("get", "httproute", "-A", "-o", "json"))
70
+ .group_by { |route| route.dig("metadata", "namespace") }
71
+ .transform_values { |routes| routes.flat_map { |r| Array(r.dig("spec", "hostnames")) } }
72
+ end
73
+
74
+ def items(json) = JSON.parse(json).fetch("items", [])
75
+
76
+ def stage_of(namespace)
77
+ return "production" if namespace.end_with?("-production")
78
+
79
+ "staging" if namespace.end_with?("-staging")
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+ require_relative "errors"
5
+
6
+ module Captive
7
+ # Où joindre le cluster : le gem est public, il ne porte ni l'adresse du kube-apiserver ni
8
+ # son CA (ADR 0018). Le dashboard les sert à tout utilisateur authentifié.
9
+ class ClusterEndpoint
10
+ URL = "https://dashboard.captive.dev/api/cluster"
11
+
12
+ def initialize(http: HttpClient.new)
13
+ @http = http
14
+ end
15
+
16
+ def fetch(token)
17
+ data = @http.get(URL, "Authorization" => "Bearer #{token}")
18
+ { server: data.fetch("server"), certificate_authority_data: data.fetch("certificate_authority_data") }
19
+ rescue KeyError, JSON::ParserError
20
+ raise AuthError, "Le dashboard n'a pas fourni l'adresse du cluster. Réessaie : captive login"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ class AppsCommands < Thor
6
+ include CLIDependencies
7
+
8
+ def self.exit_on_failure? = true
9
+
10
+ desc "list", "Liste toutes les apps déployées sur la plateforme"
11
+ long_desc <<~DESC
12
+ Liste les apps déployées sur le cluster, avec leurs domaines et leur stage.
13
+
14
+ \x05Exemple :
15
+ \x05 captive apps list
16
+ DESC
17
+ def list
18
+ apps = ClusterApps.new(kubectl: kubectl).all
19
+ puts paint.bold("Apps (#{apps.size}) :")
20
+ 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
32
+ end
33
+ end
34
+
35
+ desc "info APP", "Affiche le statut et les infos de l'app"
36
+ long_desc <<~DESC
37
+ Affiche l'état complet : pod, replicas, stage, tier, domaines, addons.
38
+
39
+ \x05Exemple :
40
+ \x05 captive apps info my-app
41
+ DESC
42
+ 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
57
+ end
58
+
59
+ desc "restart APP", "Redémarre le deployment"
60
+ long_desc <<~DESC
61
+ Rollout restart du deployment + attend pod Ready.
62
+
63
+ \x05Exemple :
64
+ \x05 captive apps restart my-app
65
+ DESC
66
+ 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)
70
+ puts paint.green(" ✓ #{app} redémarré")
71
+ puts paint.dim(" pod: #{pod_name} (#{age})")
72
+ rescue PollTimeout => e
73
+ abort paint.red("✗ Timeout : #{e.message}")
74
+ rescue RuntimeError => e
75
+ abort paint.red("✗ #{e.message.split("failed: ").last}")
76
+ end
77
+
78
+ desc "rebuild APP", "Déclenche un rebuild sans changer le code"
79
+ long_desc <<~DESC
80
+ workflow_dispatch sur le repo GitHub. Utile après changement d'env vars.
81
+
82
+ \x05Exemple :
83
+ \x05 captive apps rebuild my-app-staging
84
+ DESC
85
+ 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}")
90
+ end
91
+
92
+ private
93
+
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
+ "?"
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ # L'état d'une app tel que le cluster le sert. Le CLI développeur n'a pas accès au repo
6
+ # GitOps : tout se lit sur les ressources vivantes, ce qui décrit l'état réel plutôt que
7
+ # l'état désiré — un déploiement en cours s'y voit, un manifeste poussé mais non
8
+ # réconcilié ne s'y voit pas encore.
9
+ class AppsInfo
10
+ TIER_RESOURCES = {
11
+ "xs" => { cpu: "100m → 500m", memory: "256Mi → 512Mi" },
12
+ "s" => { cpu: "250m → 1", memory: "512Mi → 1Gi" },
13
+ "m" => { cpu: "500m → 2", memory: "1Gi → 2Gi" },
14
+ "l" => { cpu: "1 → 4", memory: "2Gi → 4Gi" },
15
+ }.freeze
16
+
17
+ def initialize(kubectl:, cluster_apps: nil)
18
+ @kubectl = kubectl
19
+ @apps = cluster_apps || ClusterApps.new(kubectl: kubectl)
20
+ end
21
+
22
+ def gather(app)
23
+ tier = @apps.tier(app)
24
+ postgres = @apps.postgres(app)
25
+ egress = @apps.egress_ip(app)
26
+ {
27
+ pod_status: pod_status(app),
28
+ replicas: replicas(app),
29
+ postgres: !postgres.nil?,
30
+ redis: redis?(app),
31
+ egress_ip: !egress.nil?,
32
+ tier: tier,
33
+ resources: TIER_RESOURCES[tier],
34
+ domains: @apps.domains(app),
35
+ stage: @apps.stage(app),
36
+ workers: @apps.workers(app),
37
+ postgres_details: postgres,
38
+ redis_details: redis_details(app),
39
+ egress_ip_details: egress && { ip: egress },
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def pod_status(app)
46
+ @kubectl.run("get", "pod", "-n", app, "-l", "app=#{app}",
47
+ "-o", "jsonpath={.items[0].status.phase}").strip
48
+ end
49
+
50
+ def replicas(app)
51
+ @kubectl.run("get", "deployment", app, "-n", app,
52
+ "-o", "jsonpath={.spec.replicas}").strip.to_i
53
+ end
54
+
55
+ def redis?(app)
56
+ !@kubectl.run("get", "deployment", "-n", app, "-l", "app=redis",
57
+ "--no-headers", "-o", "name").strip.empty?
58
+ end
59
+
60
+ # Redis est un Deployment ordinaire : sa taille se lit sur le conteneur, pas sur une CRD.
61
+ def redis_details(app)
62
+ limits = @kubectl.run(
63
+ "get", "deployment", "-n", app, "-l", "app=redis",
64
+ "-o", "jsonpath={.items[0].spec.template.spec.containers[0].resources.limits.memory}"
65
+ ).strip
66
+ limits.empty? ? nil : { memory: limits }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../platform_root"
5
+
6
+ module Captive
7
+ module Commands
8
+ class AppsRebuild
9
+ # Dernière commande du CLI développeur qui lit le repo GitOps, contre l'ADR 0016.
10
+ # Elle déduit le repo GitHub de l'image, or les images valent
11
+ # ghcr.io/captive-studio/captive-platform/<app> : la déduction rend trois segments, qui
12
+ # ne désignent aucun repo. Rien dans le service ni sur le cluster n'enregistre le repo
13
+ # d'origine, donc la commande n'est pas portable en l'état — voir la note de l'étape 9.
14
+ # Elle exige donc un clone de captive-platform (PlatformRoot), jusqu'à passer par l'API
15
+ # du dashboard.
16
+ def initialize(gh:, repo_root: nil)
17
+ @gh = gh
18
+ @repo_root = repo_root
19
+ end
20
+
21
+ def run(app)
22
+ repo = extract_repo(app)
23
+ version = current_version(app)
24
+ fields = version ? { version: version } : {}
25
+ @gh.trigger_workflow(repo, ref: "main", fields: fields)
26
+ end
27
+
28
+ private
29
+
30
+ def repo_root = @repo_root ||= PlatformRoot.find
31
+
32
+ def extract_repo(app)
33
+ image_file = File.join(repo_root, "services", app, "image.yaml")
34
+ raise ImageYamlNotFound, "image.yaml introuvable pour '#{app}'" unless File.exist?(image_file)
35
+
36
+ content = File.read(image_file)
37
+ image = content.match(/image:\s+ghcr\.io\/([^\s:]+)/)&.captures&.first
38
+ raise ImageYamlNotFound, "image introuvable dans image.yaml de '#{app}'" unless image
39
+
40
+ image
41
+ end
42
+
43
+ def current_version(app)
44
+ deploy_file = File.join(repo_root, "services", app, "#{app}.yaml")
45
+ return nil unless File.exist?(deploy_file)
46
+
47
+ tag = File.read(deploy_file).match(/:(v\d+\.\d+\.\d+(?:-\d{14})?)/)&.captures&.first
48
+ tag&.sub(/-\d{14}$/, "")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../poller"
4
+ require_relative "../errors"
5
+
6
+ module Captive
7
+ module Commands
8
+ class AppsRestart
9
+ def initialize(kubectl:, poller: nil)
10
+ @kubectl = kubectl
11
+ @poller = poller || Poller.new(interval: 5, timeout: 120)
12
+ end
13
+
14
+ 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
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "one_off_session"
5
+
6
+ module Captive
7
+ module Commands
8
+ # Raccourci Rails de captive run. Une console sans TTY quitterait sur EOF :
9
+ # on refuse avant de créer le Job et on indique le chemin non interactif.
10
+ class Console
11
+ COMMAND = [ "bundle", "exec", "rails", "console" ].freeze
12
+
13
+ def initialize(trail:, one_off:)
14
+ @trail = trail
15
+ @one_off = one_off
16
+ end
17
+
18
+ def run(app)
19
+ raise NoTtyError, no_tty_message(app) unless @one_off.interactive?
20
+
21
+ @one_off.attach(app, COMMAND) { |session| @trail.console_opened(app, session: session) }
22
+ end
23
+
24
+ private
25
+
26
+ def no_tty_message(app)
27
+ "captive console exige un terminal interactif (TTY). " \
28
+ "Sans TTY, utilise : captive run #{app} \"bundle exec rails runner '<ruby>'\""
29
+ end
30
+ end
31
+ end
32
+ end