captive-cli 1.3.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f79f7992c692e80419a6da77317d0e3d50d72ee4b48d41ea6001fa3e8a968400
4
- data.tar.gz: 43220b3bb3e0b9cdaae4bf96de10d80eb3145af3f52584bff1c73fb45d4e7349
3
+ metadata.gz: 415acf4d1e664b24fe084bd17b74fbbb5922f64465e37a360464b138dcc58bab
4
+ data.tar.gz: 48ea97307c38ab7db3f28ce3da3950f876aacc8fb528effd46758cf4b35131a6
5
5
  SHA512:
6
- metadata.gz: cec86c2467edb73bb4c46c520c2fb793c1c4b67800213d5ef68348aa2ddae4c91b25e85d82fe73e599fc7cb188aee96b63feb5674b10885e7aa7100027f240ac
7
- data.tar.gz: a69f88f24623b2329515daff60be1df8d3092046c51cd20b995cb6a0ba58a176dc29898640a07cdc6d9904315b10a321b7d7f6cc9cb7c301e768e991d55a3980
6
+ metadata.gz: 2cf47f38a58c871f6b8aae1e92b168ee95068ee333b9f93f1bfdba0161eb5c05615f2031e97de47091459c21ebbc9a5b1e061752b9d4d373d6e66fd0a1495dde
7
+ data.tar.gz: 897af0a7bd2e7685c143a3b2b69b2c4a4dcea02cae52fce02b1ff6c7677be1da310d8f2db9022d6840b7cb4d3f670ae2c441053f7bec717c6a1f609008b06d88
@@ -24,6 +24,8 @@ module Captive
24
24
  def apps_api = @apps_api ||= Dashboard::AppsResource.new(api: dashboard)
25
25
  def env_api = @env_api ||= Dashboard::EnvVarsResource.new(api: dashboard)
26
26
  def crons_api = @crons_api ||= Dashboard::ScheduledTasksResource.new(api: dashboard)
27
+ def domains_api = @domains_api ||= Dashboard::DomainsResource.new(api: dashboard)
28
+ def addons_api = @addons_api ||= Dashboard::AddonsResource.new(api: dashboard)
27
29
 
28
30
  def active_kubeconfig
29
31
  env_kc = ENV["KUBECONFIG"]
data/lib/captive/cli.rb CHANGED
@@ -6,6 +6,8 @@ require_relative "cli/dependencies"
6
6
  require_relative "commands/apps_commands"
7
7
  require_relative "commands/env_commands"
8
8
  require_relative "commands/crons_commands"
9
+ require_relative "commands/domains_commands"
10
+ require_relative "commands/addons_commands"
9
11
  require_relative "commands/db_commands"
10
12
  require_relative "commands/user_login"
11
13
  require_relative "commands/auth_commands"
@@ -62,6 +64,12 @@ module Captive
62
64
  desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
63
65
  subcommand "crons", Commands::CronsCommands
64
66
 
67
+ desc "domains SUBCOMMAND", "Domaines d'une app"
68
+ subcommand "domains", Commands::DomainsCommands
69
+
70
+ desc "addons SUBCOMMAND", "Add-ons d'une app (bases, Supabase…)"
71
+ subcommand "addons", Commands::AddonsCommands
72
+
65
73
  desc "auth SUBCOMMAND", "Jeton pour kubectl", hide: true
66
74
  subcommand "auth", Commands::AuthCommands
67
75
 
@@ -142,13 +150,12 @@ module Captive
142
150
 
143
151
  desc "logs APP", "Affiche les logs de l'app"
144
152
  long_desc <<~DESC
145
- MODE TEMPS RÉEL (défaut) — stream les logs kubectl du pod running.
153
+ MODE TEMPS RÉEL (défaut) — dernières lignes du web et des one-offs, via le dashboard.
146
154
  -f, --follow Suit en continu (Ctrl+C pour arrêter)
147
155
  -n, --lines N Nombre de lignes affichées (défaut : 100)
148
- -c, --container NOM Container spécifique (ex : migrate, redis)
149
- -w, --worker Cible le pod worker au lieu du pod web
156
+ -w, --worker Affiche le worker au lieu du web
150
157
  --raw Garde les lignes rack-timeout de la readinessProbe
151
- En cas de CrashLoopBackOff, --previous est activé automatiquement.
158
+ Il suffit d'être Membre de l'Organization de l'app, sans accès au repo GitHub.
152
159
 
153
160
  MODE HISTORIQUE (--from / --to) — interroge Loki via le dashboard.
154
161
  --from DATE Début de la plage (requis pour ce mode)
@@ -161,14 +168,12 @@ module Captive
161
168
  \x05 captive logs my-app
162
169
  \x05 captive logs my-app -f
163
170
  \x05 captive logs my-app -n 200
164
- \x05 captive logs my-app -c migrate
165
171
  \x05 captive logs my-app --worker
166
172
  \x05 captive logs my-app --raw
167
173
  \x05 captive logs my-app --from "2026-08-31 23:00" --to "2026-09-01 00:00"
168
174
  \x05 captive logs my-app --from "2026-08-31 23:00"
169
175
  DESC
170
176
  method_option :follow, aliases: "-f", type: :boolean, default: false
171
- method_option :container, aliases: "-c", type: :string
172
177
  method_option :worker, aliases: "-w", type: :boolean, default: false
173
178
  method_option :lines, aliases: "-n", type: :numeric, default: 100
174
179
  method_option :raw, type: :boolean, default: false, desc: "Affiche le bruit rack-timeout des sondes readiness"
@@ -177,9 +182,8 @@ module Captive
177
182
  def logs(app)
178
183
  from = parse_time(options[:from])
179
184
  to = parse_time(options[:to])
180
- Commands::Logs.new(kubectl: kubectl).run(
181
- app, follow: options[:follow], container: options[:container],
182
- worker: options[:worker], lines: options[:lines],
185
+ Commands::Logs.new.run(
186
+ app, follow: options[:follow], worker: options[:worker], lines: options[:lines],
183
187
  from: from, to: to, raw: options[:raw]
184
188
  )
185
189
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ # Les Add-ons d'une app via le dashboard (ADR 0034) : tout ce que la page Add-ons permet,
6
+ # sans quitter le terminal. L'ajout est réservé aux admins de l'organisation.
7
+ class AddonsCommands < Thor
8
+ include CLIDependencies
9
+
10
+ def self.exit_on_failure? = true
11
+
12
+ desc "list APP", "Liste les add-ons de l'app et leur statut"
13
+ long_desc <<~DESC
14
+ \x05Exemple :
15
+ \x05 captive addons list my-app-staging
16
+ DESC
17
+ def list(app)
18
+ installed = addons_api.list(app)["installed"]
19
+ return puts(paint.dim("Aucun add-on. Voir : captive addons catalog #{app}")) if installed.empty?
20
+
21
+ installed.each { |addon| puts presenter.installed(addon) }
22
+ end
23
+
24
+ desc "catalog APP", "Liste les add-ons qu'on peut ajouter à l'app"
25
+ long_desc <<~DESC
26
+ \x05Exemple :
27
+ \x05 captive addons catalog my-app-staging
28
+ DESC
29
+ def catalog(app)
30
+ addons_api.list(app)["catalog"].each { |entry| puts presenter.catalog(entry) }
31
+ end
32
+
33
+ desc "add APP ADDON", "Ajoute un add-on à l'app (postgres, supabase…)"
34
+ long_desc <<~DESC
35
+ Un add-on « sur demande » (redis, egress-ip) prévient l'équipe plateforme au lieu de
36
+ l'ajouter. Réservé aux admins de l'organisation.
37
+
38
+ \x05Exemple :
39
+ \x05 captive addons add my-app-staging supabase
40
+ DESC
41
+ def add(app, addon)
42
+ result = addons_api.add(app, addon)
43
+ if result["status"] == "requested"
44
+ puts paint.green(" ✓ #{addon} demandé à l'équipe plateforme")
45
+ else
46
+ puts paint.green(" ✓ #{addon} en cours d'ajout : #{ASYNC_NOTICE}")
47
+ puts paint.dim(" Suivre : captive addons list #{app}")
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def presenter = Presenter::Addon.new(paint: paint)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Captive
6
+ module Commands
7
+ # Dit ce qu'est devenu un geste sur un Domaine. Sans attente, la demande est seulement
8
+ # acceptée ; avec une attente, le bloc la reçoit et rend le Domaine appliqué.
9
+ class DomainAnnouncer
10
+ def initialize(paint:, presenter:, waiter: nil)
11
+ @paint = paint
12
+ @presenter = presenter
13
+ @waiter = waiter
14
+ end
15
+
16
+ def call(message)
17
+ return puts(@paint.green(" ✓ #{message} : #{CLIDependencies::ASYNC_NOTICE}")) unless @waiter
18
+
19
+ applied(message, yield(@waiter))
20
+ rescue PollTimeout => timeout
21
+ abort @paint.red("✗ Timeout : #{timeout.message}")
22
+ end
23
+
24
+ private
25
+
26
+ def applied(message, entry)
27
+ puts @paint.green(" ✓ #{message} : appliqué")
28
+ puts " #{@presenter.line(entry)}" if entry
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../poller"
4
+ require_relative "../errors"
5
+
6
+ module Captive
7
+ module Commands
8
+ # Attend que le cluster reflète un geste sur un Domaine : ni en attente ni échoué, et
9
+ # présent (absent après une suppression). Le DNS n'entre pas en compte : il dépend du
10
+ # propriétaire du Domaine, pas de Captive.
11
+ class DomainWait
12
+ def initialize(domains:, poller: nil, timeout: 600)
13
+ @domains = domains
14
+ @poller = poller || Poller.new(interval: 10, timeout: timeout)
15
+ end
16
+
17
+ # Rend le Domaine tel que le cluster le montre une fois le geste appliqué.
18
+ def until_applied(app, hostname)
19
+ entry = nil
20
+ poll(app, hostname) { |found| (entry = found) && !found["pending"] }
21
+ entry
22
+ end
23
+
24
+ def until_gone(app, hostname)
25
+ poll(app, hostname, &:nil?)
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def poll(app, hostname)
32
+ @poller.poll_until(context: "#{hostname} sur #{app}") do
33
+ yield check(hostname, @domains.find(app, hostname))
34
+ end
35
+ end
36
+
37
+ def check(hostname, entry)
38
+ return entry unless entry&.fetch("failed", false)
39
+
40
+ raise DomainChangeFailed, "#{hostname} : #{entry["failure_reason"] || "échec"}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ # Les Domaines d'une app via le dashboard. Chaque geste est appliqué plus tard par
6
+ # captive-platform ; --wait attend que le cluster le reflète.
7
+ class DomainsCommands < Thor
8
+ include CLIDependencies
9
+
10
+ def self.exit_on_failure? = true
11
+
12
+ WAIT_OPTIONS = {
13
+ wait: { type: :boolean, desc: "Attend que le changement soit appliqué" },
14
+ timeout: { type: :numeric, default: 600, desc: "Attente max en secondes avec --wait" },
15
+ }.freeze
16
+
17
+ def self.wait_options
18
+ WAIT_OPTIONS.each { |name, settings| method_option(name, **settings) }
19
+ end
20
+
21
+ desc "list APP", "Liste les domaines de l'app et l'état de leur DNS"
22
+ long_desc <<~DESC
23
+ \x05Exemple :
24
+ \x05 captive domains list my-app
25
+ \x05 captive domains list my-app --refresh
26
+ DESC
27
+ method_option :refresh, type: :boolean, desc: "Vérifie à nouveau le DNS sans attendre le cache"
28
+ def list(app)
29
+ entries = options[:refresh] ? domains_api.recheck(app) : domains_api.list(app)
30
+ return puts(paint.dim("Aucun domaine.")) if entries.empty?
31
+
32
+ entries.each { |entry| puts presenter.line(entry) }
33
+ end
34
+
35
+ desc "add APP DOMAINE", "Ajoute un domaine à l'app"
36
+ long_desc <<~DESC
37
+ L'enregistrement DNS à créer s'affiche ensuite dans `captive domains list`.
38
+
39
+ \x05Exemple :
40
+ \x05 captive domains add my-app www.example.fr --wait
41
+ DESC
42
+ wait_options
43
+ def add(app, hostname)
44
+ domains_api.add(app, hostname)
45
+ announcer.call("#{hostname} ajouté") { |wait| wait.until_applied(app, hostname) }
46
+ puts paint.dim(" DNS à créer : captive domains list #{app}") unless options[:wait]
47
+ end
48
+
49
+ desc "rm APP DOMAINE", "Supprime un domaine de l'app"
50
+ long_desc <<~DESC
51
+ Le domaine cesse aussitôt de servir l'app : il faut le retaper pour confirmer.
52
+
53
+ \x05Exemple :
54
+ \x05 captive domains rm my-app www.example.fr
55
+ \x05 captive domains rm my-app www.example.fr --yes
56
+ DESC
57
+ method_option :yes, type: :boolean, desc: "Supprime sans demander de confirmation"
58
+ wait_options
59
+ def rm(app, hostname)
60
+ confirm_removal(hostname)
61
+ domains_api.remove(app, hostname)
62
+ announcer.call("#{hostname} supprimé") { |wait| wait.until_gone(app, hostname) }
63
+ end
64
+
65
+ desc "redirect APP DOMAINE", "Redirige un domaine vers un autre domaine de l'app"
66
+ long_desc <<~DESC
67
+ \x05Exemple :
68
+ \x05 captive domains redirect my-app example.fr --to www.example.fr
69
+ DESC
70
+ method_option :to, type: :string, required: true, desc: "Domaine servi vers lequel rediriger"
71
+ method_option :status, type: :numeric, default: 301, enum: [ 301, 302 ], desc: "Code HTTP de redirection"
72
+ wait_options
73
+ def redirect(app, hostname)
74
+ target = options[:to]
75
+ domains_api.redirect(app, hostname, { to: target, status: options[:status] })
76
+ announcer.call("#{hostname} redirigé vers #{target}") { |wait| wait.until_applied(app, hostname) }
77
+ end
78
+
79
+ desc "serve APP DOMAINE", "Repasse un domaine redirigé en domaine servi"
80
+ long_desc <<~DESC
81
+ \x05Exemple :
82
+ \x05 captive domains serve my-app example.fr
83
+ DESC
84
+ wait_options
85
+ def serve(app, hostname)
86
+ domains_api.serve(app, hostname)
87
+ announcer.call("#{hostname} servi") { |wait| wait.until_applied(app, hostname) }
88
+ end
89
+
90
+ private
91
+
92
+ def presenter = Presenter::Domain.new(paint: paint)
93
+ def domain_wait = DomainWait.new(domains: domains_api, timeout: options[:timeout])
94
+
95
+ def announcer
96
+ DomainAnnouncer.new(paint: paint, presenter: presenter, waiter: (domain_wait if options[:wait]))
97
+ end
98
+
99
+ def confirm_removal(hostname)
100
+ return if options[:yes]
101
+ return if ask("Retape #{hostname} pour confirmer la suppression :").to_s.strip == hostname
102
+
103
+ warn paint.red("Abandon : rien n'a été supprimé.")
104
+ exit 1
105
+ end
106
+ end
107
+ end
108
+ end
@@ -2,36 +2,47 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "../log_noise_filter"
5
+ require_relative "../log_stream"
5
6
  require_relative "../null_log_filter"
6
- require_relative "../one_off_spec"
7
7
  require_relative "../presenter/log_colorizer"
8
8
 
9
9
  module Captive
10
10
  module Commands
11
- # Affiche les logs d'une app en mode live (kubectl) ou historique (Loki via dashboard).
11
+ # Affiche les logs d'une app, toujours via le dashboard (Loki) : seule l'Organization décide
12
+ # de l'accès, plus le repo GitHub. Dernières lignes, suivi en direct (-f) ou plage (--from/--to).
12
13
  class Logs
13
- def initialize(kubectl:, dashboard: DashboardClient.new, paint: Pastel.new)
14
- @kubectl = kubectl
14
+ WORKER = "worker"
15
+
16
+ def initialize(dashboard: DashboardClient.new, stream: LogStream.new, paint: Pastel.new)
15
17
  @dashboard = dashboard
18
+ @stream = stream
16
19
  @colorizer = Presenter::LogColorizer.new(paint: paint)
17
20
  end
18
21
 
19
- def run(app, follow: false, container: nil, worker: false, lines: 100, from: nil, to: nil, raw: false)
22
+ def run(app, follow: false, worker: false, lines: 100, from: nil, to: nil, raw: false)
20
23
  if from || to
21
- run_historical(app, from: from, to: to, lines: lines, raw: raw)
24
+ run_historical(app, from: from, to: to, raw: raw)
22
25
  else
23
- run_live(app, follow: follow, container: container, worker: worker, lines: lines, raw: raw)
26
+ run_live(app, follow: follow, worker: worker, lines: lines, raw: raw)
24
27
  end
25
28
  end
26
29
 
27
30
  private
28
31
 
29
- def run_live(app, follow:, container:, worker:, lines:, raw:)
30
- selector = worker ? "app=#{app}-worker" : OneOffSpec.live_logs_selector(app)
31
- crashing = !follow && @kubectl.pod_crashing?(app, selector)
32
- warn "⚠️ Pod en crash — affichage des logs du dernier run (--previous)" if crashing
33
- @kubectl.logs(app, selector, follow: follow, container: container, previous: crashing, tail: lines,
34
- filter: log_filter(raw))
32
+ def run_live(app, follow:, worker:, lines:, raw:)
33
+ printer = live_printer(worker, log_filter(raw))
34
+ follow ? @stream.call(app, tail: lines, &printer) : @dashboard.logs(app, limit: lines).each(&printer)
35
+ rescue Interrupt
36
+ nil
37
+ end
38
+
39
+ # Comme le sélecteur kubectl d'avant : le web et les one-offs, ou le worker seul.
40
+ def live_printer(worker, filter)
41
+ lambda do |line|
42
+ next if (line["process"] == WORKER) != worker || filter.noise?(line["text"].to_s)
43
+
44
+ $stdout.puts format_line(line)
45
+ end
35
46
  end
36
47
 
37
48
  def run_historical(app, from:, to:, raw:, **)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Les Add-ons d'une app et le Catalogue (ADR 0034) : la même API que la page Add-ons du
6
+ # dashboard. Un ajout est appliqué plus tard par captive-platform (réponse 202).
7
+ class AddonsResource
8
+ PATH = AppPath.new("addons")
9
+
10
+ def initialize(api:)
11
+ @api = api
12
+ end
13
+
14
+ def list(app) = @api.get(PATH.call(app))
15
+ def add(app, addon) = @api.post(PATH.call(app), { addon: addon })
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Le chemin d'une collection rattachée à une app (`/apps/:app/<collection>`), et d'un de
6
+ # ses éléments, chaque segment encodé.
7
+ AppPath = Data.define(:collection) do
8
+ def call(app, key = nil)
9
+ base = "/apps/#{DashboardApi.segment(app)}/#{collection}"
10
+ key ? "#{base}/#{DashboardApi.segment(key)}" : base
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Les Domaines d'une app : lus du cluster, modifiés via captive-platform (réponse 202).
6
+ # `recheck` relit le DNS sans attendre le cache ; une redirection est `{ to:, status: }`.
7
+ class DomainsResource
8
+ PATH = AppPath.new("domains")
9
+
10
+ def initialize(api:)
11
+ @api = api
12
+ end
13
+
14
+ def list(app) = @api.get(path(app))
15
+ def recheck(app) = @api.get("#{path(app)}?refresh=1")
16
+ def find(app, hostname) = list(app).find { |domain| domain["hostname"] == hostname }
17
+ def add(app, hostname) = @api.post(path(app), { hostname: hostname })
18
+ def remove(app, hostname) = @api.delete(path(app, hostname))
19
+ def serve(app, hostname) = @api.patch(path(app, hostname), { mode: "serve" })
20
+ def redirect(app, hostname, rule) = @api.patch(path(app, hostname), rule)
21
+
22
+ private
23
+
24
+ def path(...) = PATH.call(...)
25
+ end
26
+ end
27
+ end
@@ -4,6 +4,8 @@ module Captive
4
4
  module Dashboard
5
5
  # Les variables d'environnement d'une app : lues du Secret, écrites par set-env.
6
6
  class EnvVarsResource
7
+ PATH = AppPath.new("env_vars")
8
+
7
9
  def initialize(api:)
8
10
  @api = api
9
11
  end
@@ -18,10 +20,7 @@ module Captive
18
20
 
19
21
  private
20
22
 
21
- def path(app, key = nil)
22
- base = "/apps/#{DashboardApi.segment(app)}/env_vars"
23
- key ? "#{base}/#{DashboardApi.segment(key)}" : base
24
- end
23
+ def path(...) = PATH.call(...)
25
24
  end
26
25
  end
27
26
  end
@@ -4,6 +4,8 @@ module Captive
4
4
  module Dashboard
5
5
  # Les tâches planifiées d'une app : lues du cluster, déclarées via captive-platform.
6
6
  class ScheduledTasksResource
7
+ PATH = AppPath.new("scheduled_tasks")
8
+
7
9
  def initialize(api:)
8
10
  @api = api
9
11
  end
@@ -23,10 +25,7 @@ module Captive
23
25
 
24
26
  private
25
27
 
26
- def path(app, name = nil)
27
- base = "/apps/#{DashboardApi.segment(app)}/scheduled_tasks"
28
- name ? "#{base}/#{DashboardApi.segment(name)}" : base
29
- end
28
+ def path(...) = PATH.call(...)
30
29
  end
31
30
  end
32
31
  end
@@ -24,4 +24,5 @@ module Captive
24
24
  class NotFoundError < DashboardError; end
25
25
  class ValidationError < DashboardError; end
26
26
  class ServiceUnavailableError < DashboardError; end
27
+ class DomainChangeFailed < DashboardError; end
27
28
  end
@@ -4,9 +4,7 @@ require "base64"
4
4
  require "json"
5
5
  require "open3"
6
6
  require_relative "errors"
7
- require_relative "log_noise_filter"
8
7
  require_relative "one_off_spec"
9
- require_relative "presenter/log_line"
10
8
 
11
9
  module Captive
12
10
  class Kubectl
@@ -174,42 +172,6 @@ module Captive
174
172
  system("kubectl", *kubeconfig_args, "exec", "-n", namespace, "-i", pod, "--", *cmd, in: File.open(file))
175
173
  end
176
174
 
177
- def pod_crashing?(namespace, selector)
178
- json = run("get", "pod", "-n", namespace, "-l", selector, "-o", "json")
179
- items = JSON.parse(json)["items"] || []
180
- items.any? do |pod|
181
- statuses = (pod.dig("status", "containerStatuses") || []) +
182
- (pod.dig("status", "initContainerStatuses") || [])
183
- statuses.any? do |s|
184
- s["restartCount"].to_i > 0 ||
185
- s.dig("state", "waiting", "reason")&.include?("CrashLoop") ||
186
- s.dig("lastState", "terminated", "exitCode").to_i != 0
187
- end
188
- end
189
- end
190
-
191
- def logs(namespace, selector, follow: false, container: nil, previous: false, tail: 100,
192
- formatter: Presenter::LogLine.new(app: namespace), filter: LogNoiseFilter.new)
193
- args = [ "logs", "-n", namespace, "-l", selector, "--timestamps", "--prefix" ]
194
- args << "-f" if follow
195
- args << "-p" if previous
196
- args += [ "--tail", tail.to_s ] unless follow
197
- args += [ "-c", container ] if container
198
- success = print_logs(args, formatter, filter)
199
- return if success || !previous
200
-
201
- warn "⚠️ Logs du run précédent non disponibles (purgés par le runtime), affichage des logs actuels"
202
- print_logs(args - [ "-p" ], formatter, filter)
203
- end
204
-
205
- def print_logs(args, formatter, filter)
206
- stream_logs(*args) do |line|
207
- next if filter.noise?(line)
208
-
209
- puts formatter.call(line)
210
- end
211
- end
212
-
213
175
  def run_interactive(*args)
214
176
  system("kubectl", *kubeconfig_args, *args)
215
177
  end
@@ -13,11 +13,16 @@ require_relative "one_off_spec"
13
13
  require_relative "session_id"
14
14
  require_relative "poller"
15
15
  require_relative "dashboard_api"
16
+ require_relative "dashboard/app_path"
16
17
  require_relative "dashboard/apps_resource"
17
18
  require_relative "dashboard/env_vars_resource"
18
19
  require_relative "dashboard/scheduled_tasks_resource"
20
+ require_relative "dashboard/domains_resource"
21
+ require_relative "dashboard/addons_resource"
19
22
  require_relative "presenter/app_info"
20
23
  require_relative "presenter/scheduled_task"
24
+ require_relative "presenter/domain"
25
+ require_relative "presenter/addon"
21
26
  require_relative "activity_trail"
22
27
  require_relative "errors"
23
28
  require_relative "ui/spinner"
@@ -28,8 +33,7 @@ require_relative "commands/one_off_session"
28
33
  require_relative "commands/run"
29
34
  require_relative "commands/logs"
30
35
  require_relative "commands/apps_restart"
36
+ require_relative "commands/domain_wait"
37
+ require_relative "commands/domain_announcer"
31
38
  require_relative "commands/db_dump"
32
39
  require_relative "commands/db_restore"
33
-
34
-
35
- require_relative "presenter/log_line"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "dashboard_client"
7
+ require_relative "dashboard_response"
8
+ require_relative "session_token"
9
+ require_relative "sse_parser"
10
+
11
+ module Captive
12
+ # Suit les logs d'une app en direct, en Server-Sent Events depuis le dashboard
13
+ # (`GET /api/apps/:id/logs/stream`). Le dashboard ferme le flux au bout de quelques minutes :
14
+ # on se reconnecte alors avec le dernier id reçu, l'horodatage de la dernière ligne, pour
15
+ # reprendre sans perte ni doublon. Seul Ctrl+C (Interrupt) ou un refus du dashboard l'arrête.
16
+ class LogStream
17
+ RECONNECT_DELAY = 1
18
+ NETWORK_ERRORS = [ IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError ].freeze
19
+
20
+ def initialize(session: SessionToken.new)
21
+ @session = session
22
+ end
23
+
24
+ def self.stream_uri(app, tail)
25
+ uri = URI.parse("#{DashboardClient::DASHBOARD_URL}/api/apps/#{URI.encode_uri_component(app)}/logs/stream")
26
+ uri.query = URI.encode_www_form(tail:)
27
+ uri
28
+ end
29
+
30
+ def self.headers(last_id)
31
+ last_id ? { "Accept" => "text/event-stream", "Last-Event-ID" => last_id } : { "Accept" => "text/event-stream" }
32
+ end
33
+
34
+ def self.read(response, &)
35
+ DashboardResponse.new(response).parse unless response.code.to_i == 200
36
+ response.read_body(&)
37
+ end
38
+
39
+ def call(app, tail:, &)
40
+ uri = self.class.stream_uri(app, tail)
41
+ @last_id = nil
42
+ loop do
43
+ follow(uri, &)
44
+ rescue *NETWORK_ERRORS
45
+ sleep RECONNECT_DELAY
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # @last_id avance à chaque événement, pas en fin de connexion : une coupure réseau ne doit
52
+ # pas faire reprendre plus tôt, ce qui réafficherait des lignes.
53
+ def follow(uri, &)
54
+ parser = SseParser.new
55
+ open_stream(uri, self.class.headers(@last_id)) { |chunk| relay(parser, chunk, &) }
56
+ end
57
+
58
+ def relay(parser, chunk)
59
+ parser.feed(chunk) do |event|
60
+ @last_id = event[:id] || @last_id
61
+ yield JSON.parse(event[:data])
62
+ end
63
+ end
64
+
65
+ # Pas de read_timeout court : le flux reste ouvert, le serveur envoie un battement de cœur.
66
+ def open_stream(uri, headers, &)
67
+ @session.authorize do |token|
68
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 60) do |http|
69
+ request = Net::HTTP::Get.new(uri, headers.merge("Authorization" => "Bearer #{token}"))
70
+ http.request(request) { |response| self.class.read(response, &) }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -45,10 +45,6 @@ module Captive
45
45
  "#{app}-run"
46
46
  end
47
47
 
48
- def self.live_logs_selector(app)
49
- "app in (#{app},#{run_app_label(app)})"
50
- end
51
-
52
48
  private
53
49
 
54
50
  def job_manifest
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Presenter
5
+ # Une ligne par Add-on présent (« Les vôtres ») ou proposé (« Catalogue »).
6
+ class Addon
7
+ def initialize(paint:)
8
+ @paint = paint
9
+ end
10
+
11
+ def installed(addon)
12
+ details = [ addon["name"], addon["tier"]&.upcase ].compact.join(" ")
13
+ [ @paint.bold(addon["addon"]), details, status(addon["status"]) ].reject(&:empty?).join(" ")
14
+ end
15
+
16
+ def catalog(entry)
17
+ head = "#{@paint.bold(entry["id"])} #{entry["description"]}"
18
+ notes = []
19
+ notes << "inclut #{entry["includes"].join(", ")}" if entry["includes"].any?
20
+ notes << "sur demande" if entry["mode"] == "request"
21
+ notes << @paint.green("ajouté") if entry["installed"]
22
+ notes.empty? ? head : "#{head} #{@paint.dim("(#{notes.join(", ")})")}"
23
+ end
24
+
25
+ private
26
+
27
+ def status(value) = value == "ready" ? @paint.green("✓") : @paint.yellow("en cours")
28
+ end
29
+ end
30
+ end
@@ -26,6 +26,8 @@ module Captive
26
26
  end
27
27
 
28
28
  def workload(entry)
29
+ return "#{entry["name"]} #{@paint.yellow(entry["status_reason"])}" if entry["status"] == "config_required"
30
+
29
31
  ready = "#{entry["ready"]}/#{entry["total"]}"
30
32
  ready = @paint.yellow(ready) unless entry["ready"] == entry["total"]
31
33
  "#{entry["name"]} #{ready} prêt(s), tier #{entry["tier"] || "–"}"
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Presenter
5
+ # Une ligne par Domaine : son nom, son état (servi, redirigé, en attente, échoué) et ce que
6
+ # dit le DNS. Un geste en cours masque le diagnostic, qui ne vaut que pour l'état atteint.
7
+ class Domain
8
+ HEALTH = { "certificate_pending" => "certificat en cours", "invalid" => "route refusée" }.freeze
9
+
10
+ def initialize(paint:)
11
+ @paint = paint
12
+ end
13
+
14
+ def line(entry)
15
+ change = change(entry)
16
+ [ entry["hostname"], change || reached(entry), (health(entry) unless change) ].compact.join(" ")
17
+ end
18
+
19
+ private
20
+
21
+ # Un geste en attente ou échoué ; nil quand le Domaine est dans l'état demandé.
22
+ def change(entry)
23
+ return failure(entry) if entry["failed"]
24
+
25
+ @paint.yellow("en attente (#{entry["pending_change"]})") if entry["pending"]
26
+ end
27
+
28
+ def reached(entry)
29
+ redirect = entry["redirect"]
30
+ return "→ #{redirect["to"]} (#{redirect["status"]})" if redirect
31
+
32
+ entry["primary"] ? "servi, principal" : "servi"
33
+ end
34
+
35
+ def failure(entry)
36
+ reason = entry["failure_reason"]
37
+ @paint.red(reason ? "échoué : #{reason}" : "échoué")
38
+ end
39
+
40
+ def health(entry)
41
+ status = entry["health"]
42
+ return @paint.green("✓") if status == "valid"
43
+
44
+ expected = entry.dig("dns", "expected")
45
+ return @paint.red("DNS à créer : #{expected.values_at("type", "name").join(" ")} → #{expected["value"]}") if expected
46
+
47
+ @paint.yellow(HEALTH.fetch(status, status.to_s))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,12 +2,14 @@
2
2
 
3
3
  module Captive
4
4
  module ShellCompletions
5
- ROOT_COMMANDS = %w[apps env crons db login whoami console run logs version completions].freeze
5
+ ROOT_COMMANDS = %w[apps env crons domains addons db login whoami console run logs version completions].freeze
6
6
 
7
7
  SUBCOMMANDS = {
8
8
  "apps" => %w[list info restart rebuild],
9
9
  "env" => %w[list set unset],
10
10
  "crons" => %w[list add edit rm suspend resume trigger runs],
11
+ "domains" => %w[list add rm redirect serve],
12
+ "addons" => %w[list catalog add],
11
13
  "db" => %w[restore],
12
14
  }.freeze
13
15
 
@@ -23,6 +25,8 @@ module Captive
23
25
  apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
24
26
  env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
25
27
  crons) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["crons"].join(" ")}" -- "$cur")); return ;;
28
+ domains) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["domains"].join(" ")}" -- "$cur")); return ;;
29
+ addons) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["addons"].join(" ")}" -- "$cur")); return ;;
26
30
  db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
27
31
  esac
28
32
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Découpe un flux Server-Sent Events, reçu par morceaux arbitraires, en événements
5
+ # `{ id:, data: }`. Les commentaires (`:`, battements de cœur) et `retry` sont ignorés.
6
+ class SseParser
7
+ def initialize
8
+ @buffer = +""
9
+ end
10
+
11
+ def feed(chunk)
12
+ @buffer << chunk
13
+ while (fin = @buffer.index("\n\n"))
14
+ event = self.class.parse(@buffer.slice!(0, fin + 2))
15
+ yield event if event[:data]
16
+ end
17
+ end
18
+
19
+ def self.parse(bloc)
20
+ bloc.each_line(chomp: true).each_with_object({}) do |ligne, event|
21
+ champ, valeur = ligne.split(": ", 2)
22
+ event[champ.to_sym] = valeur if %w[id data].include?(champ)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Captive
4
- VERSION = "1.3.0"
4
+ VERSION = "1.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: captive-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Captive Studio
@@ -107,6 +107,7 @@ files:
107
107
  - lib/captive/cli.rb
108
108
  - lib/captive/cli/dependencies.rb
109
109
  - lib/captive/cluster_endpoint.rb
110
+ - lib/captive/commands/addons_commands.rb
110
111
  - lib/captive/commands/apps_commands.rb
111
112
  - lib/captive/commands/apps_restart.rb
112
113
  - lib/captive/commands/auth_commands.rb
@@ -120,6 +121,9 @@ files:
120
121
  - lib/captive/commands/db_psql.rb
121
122
  - lib/captive/commands/db_restore.rb
122
123
  - lib/captive/commands/db_target.rb
124
+ - lib/captive/commands/domain_announcer.rb
125
+ - lib/captive/commands/domain_wait.rb
126
+ - lib/captive/commands/domains_commands.rb
123
127
  - lib/captive/commands/env_commands.rb
124
128
  - lib/captive/commands/env_set.rb
125
129
  - lib/captive/commands/logs.rb
@@ -128,7 +132,10 @@ files:
128
132
  - lib/captive/commands/user_login.rb
129
133
  - lib/captive/credentials.rb
130
134
  - lib/captive/credentials_store.rb
135
+ - lib/captive/dashboard/addons_resource.rb
136
+ - lib/captive/dashboard/app_path.rb
131
137
  - lib/captive/dashboard/apps_resource.rb
138
+ - lib/captive/dashboard/domains_resource.rb
132
139
  - lib/captive/dashboard/env_vars_resource.rb
133
140
  - lib/captive/dashboard/scheduled_tasks_resource.rb
134
141
  - lib/captive/dashboard_api.rb
@@ -141,17 +148,20 @@ files:
141
148
  - lib/captive/kubectl.rb
142
149
  - lib/captive/loader.rb
143
150
  - lib/captive/log_noise_filter.rb
151
+ - lib/captive/log_stream.rb
144
152
  - lib/captive/null_log_filter.rb
145
153
  - lib/captive/one_off_spec.rb
146
154
  - lib/captive/poller.rb
155
+ - lib/captive/presenter/addon.rb
147
156
  - lib/captive/presenter/ansi_stripper.rb
148
157
  - lib/captive/presenter/app_info.rb
158
+ - lib/captive/presenter/domain.rb
149
159
  - lib/captive/presenter/log_colorizer.rb
150
- - lib/captive/presenter/log_line.rb
151
160
  - lib/captive/presenter/scheduled_task.rb
152
161
  - lib/captive/session_id.rb
153
162
  - lib/captive/session_token.rb
154
163
  - lib/captive/shell_completions.rb
164
+ - lib/captive/sse_parser.rb
155
165
  - lib/captive/terminal.rb
156
166
  - lib/captive/token_refresher.rb
157
167
  - lib/captive/ui/banner.rb
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "captive/log_utils"
4
- require_relative "log_colorizer"
5
-
6
- module Captive
7
- module Presenter
8
- # Parses a raw kubectl log line and renders it as a colorized terminal string.
9
- class LogLine
10
- BRACKET_RE = /\A\[(?:pod\/)?(?<pod>[^\/\]]+)(?:\/(?<container>[^\/\]]+))?\]\z/
11
-
12
- def initialize(app:, paint: Pastel.new)
13
- @app = app
14
- @colorizer = LogColorizer.new(paint: paint)
15
- end
16
-
17
- def call(raw_line)
18
- line = raw_line.chomp
19
- first, second, message = line.split(" ", 3)
20
- return line if first.nil? || second.nil? || message.nil?
21
-
22
- bracket, ts = pick_bracket_and_timestamp(first, second)
23
- return line unless bracket
24
-
25
- label = LogUtils::ProcessLabel.for(pod: bracket[:pod], namespace: @app, container: bracket[:container])
26
- @colorizer.format_line(ts: ts, label: label, message: message)
27
- end
28
-
29
- private
30
-
31
- def pick_bracket_and_timestamp(first, second)
32
- if (bracket = BRACKET_RE.match(first))
33
- [ bracket, second ]
34
- elsif (bracket = BRACKET_RE.match(second))
35
- [ bracket, first ]
36
- end
37
- end
38
- end
39
- end
40
- end