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.
- checksums.yaml +7 -0
- data/exe/captive +6 -0
- data/lib/captive/activity_trail.rb +38 -0
- data/lib/captive/cli/dependencies.rb +42 -0
- data/lib/captive/cli.rb +170 -0
- data/lib/captive/cluster_apps.rb +82 -0
- data/lib/captive/cluster_endpoint.rb +23 -0
- data/lib/captive/commands/apps_commands.rb +104 -0
- data/lib/captive/commands/apps_info.rb +70 -0
- data/lib/captive/commands/apps_rebuild.rb +52 -0
- data/lib/captive/commands/apps_restart.rb +24 -0
- data/lib/captive/commands/console.rb +32 -0
- data/lib/captive/commands/db_commands.rb +105 -0
- data/lib/captive/commands/db_dump.rb +33 -0
- data/lib/captive/commands/db_exec.rb +24 -0
- data/lib/captive/commands/db_psql.rb +20 -0
- data/lib/captive/commands/db_restore.rb +145 -0
- data/lib/captive/commands/db_target.rb +19 -0
- data/lib/captive/commands/env_kube_commands.rb +25 -0
- data/lib/captive/commands/env_list_kube.rb +16 -0
- data/lib/captive/commands/logs.rb +65 -0
- data/lib/captive/commands/one_off_session.rb +76 -0
- data/lib/captive/commands/run.rb +24 -0
- data/lib/captive/commands/user_login.rb +64 -0
- data/lib/captive/dashboard_client.rb +119 -0
- data/lib/captive/errors.rb +16 -0
- data/lib/captive/flux.rb +15 -0
- data/lib/captive/gh_cli.rb +89 -0
- data/lib/captive/github_device_flow.rb +39 -0
- data/lib/captive/http_client.rb +31 -0
- data/lib/captive/kubectl.rb +330 -0
- data/lib/captive/loader.rb +33 -0
- data/lib/captive/log_noise_filter.rb +17 -0
- data/lib/captive/null_log_filter.rb +8 -0
- data/lib/captive/one_off_spec.rb +144 -0
- data/lib/captive/platform_root.rb +25 -0
- data/lib/captive/poller.rb +35 -0
- data/lib/captive/presenter/ansi_stripper.rb +22 -0
- data/lib/captive/presenter/log_colorizer.rb +54 -0
- data/lib/captive/presenter/log_line.rb +40 -0
- data/lib/captive/session_id.rb +20 -0
- data/lib/captive/shell_completions.rb +61 -0
- data/lib/captive/terminal.rb +10 -0
- data/lib/captive/ui/banner.rb +37 -0
- data/lib/captive/ui/spinner.rb +23 -0
- data/lib/captive/update_checker.rb +65 -0
- data/lib/captive/version.rb +5 -0
- 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,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
|
data/lib/captive/cli.rb
ADDED
|
@@ -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
|