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
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-spinner"
4
+ require_relative "db_exec"
5
+ require_relative "db_psql"
6
+
7
+ module Captive
8
+ module Commands
9
+ class DbCommands < Thor
10
+ include CLIDependencies
11
+
12
+ def self.exit_on_failure? = true
13
+
14
+ desc "dump APP [OUTPUT_FILE]", "Exporte la base vers un fichier local"
15
+ long_desc <<~DESC
16
+ Lance pg_dump dans le pod postgres et rapatrie le dump en local.
17
+ Format : custom (-Fc), compatible avec `captive db restore`.
18
+
19
+ \x05Exemple :
20
+ \x05 captive db dump my-app
21
+ \x05 captive db dump my-app /tmp/backup.pgsql
22
+ DESC
23
+ def dump(app, output_file = nil)
24
+ labels = { dumping: "Dump en cours..." }
25
+ result = DbDump.new(kubectl: kubectl, trail: trail).run(app, output_file) { |step| puts paint.dim(" → #{labels[step]}") }
26
+ puts paint.green(" ✓ Dump terminé : #{result}")
27
+ rescue Captive::DumpError => e
28
+ abort paint.red("✗ #{e.message}")
29
+ end
30
+
31
+ desc "exec APP FICHIER.sql", "Exécute un fichier SQL sur la base"
32
+ long_desc <<~DESC
33
+ Joue un fichier SQL sur la base de l'app, sous un rôle admin non-superuser.
34
+ Par défaut tout ou rien : la moindre erreur annule l'intégralité du fichier.
35
+
36
+ \x05Exemple :
37
+ \x05 captive db exec my-app-staging sql/95_ma_migration.sql
38
+ \x05 captive db exec my-app-staging sql/95_index.sql --no-transaction
39
+ DESC
40
+ method_option :transaction, type: :boolean, default: true,
41
+ desc: "Joue le fichier dans une transaction unique " \
42
+ "(--no-transaction pour CREATE INDEX CONCURRENTLY, pg_cron…)"
43
+ def exec(app, file)
44
+ result = DbExec.new(kubectl: kubectl).run(app, file, single_transaction: options[:transaction])
45
+ puts paint.green(" ✓ #{result} appliqué.")
46
+ rescue Errno::ENOENT => e
47
+ abort paint.red("✗ Fichier introuvable : #{e.message}")
48
+ rescue Captive::ExecError => e
49
+ abort paint.red("✗ #{e.message}")
50
+ end
51
+
52
+ desc "psql APP", "Ouvre une session psql interactive sur la base"
53
+ long_desc <<~DESC
54
+ Session psql interactive sur la base de l'app, sous un rôle admin
55
+ non-superuser. L'équivalent du SQL Editor de Supabase, en ligne de commande.
56
+
57
+ \x05Exemple :
58
+ \x05 captive db psql my-app-staging
59
+ DESC
60
+ def psql(app)
61
+ DbPsql.new(kubectl: kubectl).run(app)
62
+ end
63
+
64
+ desc "restore APP DUMP_FILE", "Restaure la base depuis un dump local"
65
+ long_desc <<~DESC
66
+ Arrête l'app, restaure la base, relance l'app.
67
+ Formats : .pgsql, .dump, .tar.gz, .sql, .sql.gz.
68
+
69
+ \x05Exemple :
70
+ \x05 captive db restore my-app dump.pgsql
71
+ \x05 captive db restore my-app 20260602_backup.tar.gz
72
+ DESC
73
+ def restore(app, dump_file)
74
+ labels = {
75
+ scaling_down: "Arrêt de l'app...",
76
+ transferring: "Transfert du dump...",
77
+ restoring: "Restauration...",
78
+ scaling_up: "Relance de l'app...",
79
+ }
80
+ spinner = restore_spinner
81
+ DbRestore.new(kubectl: kubectl, trail: trail).run(app, dump_file) do |step, elapsed = nil, info = nil|
82
+ if step == :waiting
83
+ spinner.auto_spin unless spinner.spinning?
84
+ spinner.update(info: (info && !info.empty?) ? info : "en cours... #{elapsed.to_i}s")
85
+ else
86
+ spinner.success if spinner.spinning?
87
+ puts paint.dim(" → #{labels[step]}")
88
+ end
89
+ end
90
+ puts paint.green(" ✓ Restauration terminée.")
91
+ rescue Errno::ENOENT => e
92
+ abort paint.red("✗ Fichier introuvable : #{e.message}")
93
+ rescue Captive::RestoreError => e
94
+ spinner.error if spinner.spinning?
95
+ abort paint.red("✗ #{e.message}")
96
+ end
97
+
98
+ private
99
+
100
+ def restore_spinner
101
+ TTY::Spinner.new(" [:spinner] :info", format: :dots)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Captive
6
+ module Commands
7
+ class DbDump
8
+ def initialize(kubectl:, trail:)
9
+ @kubectl = kubectl
10
+ @trail = trail
11
+ end
12
+
13
+ def run(app, output_file)
14
+ @trail.db_dumped(app)
15
+ output_file ||= default_filename(app)
16
+ pg_pod = @kubectl.postgres_pod_name(app, app)
17
+ password = @kubectl.postgres_secret_password(app, app)
18
+ cmd = "PGPASSWORD='#{password}' pg_dump -h localhost -U app -d app -Fc"
19
+
20
+ yield :dumping if block_given?
21
+ ok = @kubectl.dump_from_pod(app, pg_pod, output_file, cmd)
22
+ raise DumpError, "Le dump a échoué" if ok == false
23
+
24
+ output_file
25
+ end
26
+
27
+ def default_filename(app)
28
+ base = ENV.fetch("CAPTIVE_ORIG_DIR", Dir.pwd)
29
+ File.join(base, "#{app}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.pgsql")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "db_target"
5
+
6
+ module Captive
7
+ module Commands
8
+ class DbExec
9
+ def initialize(kubectl:, target: DbTarget.new(kubectl: kubectl))
10
+ @kubectl = kubectl
11
+ @target = target
12
+ end
13
+
14
+ def run(app, file, single_transaction: true)
15
+ pod, user, password = @target.resolve(app)
16
+ ok = @kubectl.psql_file(app, pod, file,
17
+ user: user, password: password, single_transaction: single_transaction)
18
+ raise ExecError, "L'exécution de #{file} a échoué" if ok == false
19
+
20
+ file
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "db_target"
5
+
6
+ module Captive
7
+ module Commands
8
+ class DbPsql
9
+ def initialize(kubectl:, target: DbTarget.new(kubectl: kubectl))
10
+ @kubectl = kubectl
11
+ @target = target
12
+ end
13
+
14
+ def run(app)
15
+ pod, user, password = @target.resolve(app)
16
+ @kubectl.psql_interactive(app, pod, user: user, password: password)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "open3"
5
+ require "shellwords"
6
+ require_relative "../errors"
7
+ require_relative "../poller"
8
+ require_relative "../flux"
9
+
10
+ module Captive
11
+ module Commands
12
+ class DbRestore
13
+ TEMP_REMOTE = "/controller/tmp/dump.tmp"
14
+ EXIT_FILE = "/controller/tmp/restore.exit"
15
+ LOG_FILE = "/controller/tmp/restore.log"
16
+ FLUX_KUSTOMIZATION = "services"
17
+
18
+ def initialize(kubectl:, trail:, poller: nil, flux: nil)
19
+ @kubectl = kubectl
20
+ @trail = trail
21
+ @poller = poller || Poller.new(interval: 10, timeout: 1800)
22
+ @flux = flux || Flux.new
23
+ end
24
+
25
+ def detect_format(filename)
26
+ basename = File.basename(filename)
27
+ return :custom if basename.match?(/\.(pgsql|dump|custom|tar\.gz|tgz)$/)
28
+
29
+ :sql
30
+ end
31
+
32
+ def postgres_pod(app)
33
+ @kubectl.postgres_pod_name(app, app)
34
+ end
35
+
36
+ def run(app, dump_file)
37
+ dump_file = resolve_path(dump_file)
38
+ raise Errno::ENOENT, dump_file unless File.exist?(dump_file)
39
+
40
+ # Après le contrôle du fichier : un dump introuvable n'a touché à rien, l'inscrire au
41
+ # fil de l'app serait du bruit.
42
+ @trail.db_restored(app)
43
+
44
+ pg_pod = postgres_pod(app)
45
+ password = @kubectl.postgres_secret_password(app, app)
46
+ format = detect_format(dump_file)
47
+ stream = build_stream_cmd(dump_file)
48
+
49
+ yield :scaling_down if block_given?
50
+ @flux.suspend_kustomization(FLUX_KUSTOMIZATION)
51
+ @kubectl.scale(app, app, 0)
52
+
53
+ begin
54
+ yield :transferring if block_given?
55
+ @kubectl.transfer_file_to_pod(app, pg_pod, stream, TEMP_REMOTE)
56
+ yield :restoring if block_given?
57
+ on_tick = lambda { |elapsed|
58
+ activity = @kubectl.exec_on_pod_output(app, pg_pod, current_activity_cmd)
59
+ yield(:waiting, elapsed, activity&.strip) if block_given?
60
+ }
61
+ restore_on_pod(app, pg_pod, password, format, &on_tick)
62
+ cleanup_pod(app, pg_pod)
63
+ ensure
64
+ yield :scaling_up if block_given?
65
+ @kubectl.scale(app, app, 1)
66
+ @flux.resume_kustomization(FLUX_KUSTOMIZATION)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def inner_is_custom?(file)
73
+ inner = `tar -tzf #{Shellwords.escape(file)} 2>/dev/null | grep -Ev '/$' | head -1`.strip
74
+ inner.match?(/\.(pgsql|dump|custom)$/)
75
+ end
76
+
77
+ def build_stream_cmd(file)
78
+ basename = File.basename(file)
79
+ escaped = Shellwords.escape(file)
80
+
81
+ if basename.match?(/\.tar\.gz$|\.tgz$/)
82
+ inner = `tar -tzf #{escaped} 2>/dev/null | grep -Ev '/$' | head -1`.strip
83
+ "tar -xOf #{escaped} #{Shellwords.escape(inner)}"
84
+ elsif basename.match?(/\.(gz)$/)
85
+ "gunzip -c #{escaped}"
86
+ else
87
+ "cat #{escaped}"
88
+ end
89
+ end
90
+
91
+ def restore_on_pod(app, pg_pod, password, format, &on_tick)
92
+ pgpass = Shellwords.escape(password)
93
+ @kubectl.exec_on_pod(app, pg_pod,
94
+ "PGPASSWORD=#{pgpass} psql -h localhost -U app -d app " \
95
+ "-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO app;' 2>&1")
96
+
97
+ cmd = restore_cmd(password, format)
98
+ inner_script = "#{cmd}; echo $? > #{EXIT_FILE}"
99
+ @kubectl.exec_on_pod(app, pg_pod,
100
+ "rm -f #{EXIT_FILE}; nohup bash -c #{Shellwords.escape(inner_script)} > #{LOG_FILE} 2>&1 < /dev/null &")
101
+
102
+ exit_code = wait_for_exit_code(app, pg_pod, &on_tick)
103
+ raise RestoreError, "La restauration a échoué (code #{exit_code})" unless exit_code == "0"
104
+ end
105
+
106
+ def wait_for_exit_code(app, pg_pod, &on_tick)
107
+ exit_code = nil
108
+ @poller.poll_until(context: "fin du restore (#{EXIT_FILE})", on_tick: on_tick) do
109
+ exit_code = @kubectl.exec_on_pod_output(app, pg_pod, "cat #{EXIT_FILE} 2>/dev/null")
110
+ exit_code && !exit_code.strip.empty?
111
+ end
112
+ exit_code.strip
113
+ end
114
+
115
+ def restore_cmd(password, format)
116
+ pgpass = Shellwords.escape(password)
117
+ if format == :custom
118
+ "(PGPASSWORD=#{pgpass} pg_restore --no-owner --no-acl --verbose " \
119
+ "-h localhost -U app -d app #{TEMP_REMOTE} 2>&1 | grep -v 'no privileges'; exit ${PIPESTATUS[0]})"
120
+ else
121
+ "PGPASSWORD=#{pgpass} psql -h localhost -U app -d app < #{TEMP_REMOTE} 2>&1"
122
+ end
123
+ end
124
+
125
+ def current_activity_cmd
126
+ "psql -U postgres -d app -tAc " \
127
+ "\"SELECT query FROM pg_stat_activity WHERE datname='app' AND pid != pg_backend_pid() " \
128
+ "AND state='active' AND query !~ 'autovacuum' ORDER BY query_start DESC LIMIT 1;\" 2>/dev/null"
129
+ end
130
+
131
+ def cleanup_pod(app, pg_pod)
132
+ @kubectl.run("exec", pg_pod, "-n", app, "--", "rm", "-f", TEMP_REMOTE)
133
+ rescue StandardError
134
+ nil # ignore cleanup errors
135
+ end
136
+
137
+ def resolve_path(path)
138
+ return path if Pathname.new(path).absolute?
139
+
140
+ base = ENV.fetch("CAPTIVE_ORIG_DIR", Dir.pwd)
141
+ File.join(base, path)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ # Résout la cible de connexion psql d'une app : le pod primaire du cluster
6
+ # Postgres, et le rôle sous lequel jouer le SQL de l'utilisateur.
7
+ class DbTarget
8
+ def initialize(kubectl:)
9
+ @kubectl = kubectl
10
+ end
11
+
12
+ def resolve(app)
13
+ pod = @kubectl.postgres_pod_name(app, app)
14
+ user, password = @kubectl.admin_credentials(app, app)
15
+ [ pod, user, password ]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "env_list_kube"
4
+
5
+ module Captive
6
+ module Commands
7
+ class EnvKubeCommands < 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 depuis K8s"
13
+ long_desc <<~DESC
14
+ Lit directement le secret <app>-env depuis le cluster Kubernetes.
15
+
16
+ \x05Exemple :
17
+ \x05 captive env list my-app-staging
18
+ DESC
19
+ def list(app)
20
+ vars = EnvListKube.new(kubectl: kubectl).run(app)
21
+ vars.sort.each { |v| puts v }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ class EnvListKube
6
+ def initialize(kubectl:)
7
+ @kubectl = kubectl
8
+ end
9
+
10
+ def run(app)
11
+ data = @kubectl.get_secret_data(app, "#{app}-env")
12
+ data.map { |k, v| "#{k}=#{v}" }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../log_noise_filter"
5
+ require_relative "../null_log_filter"
6
+ require_relative "../one_off_spec"
7
+ require_relative "../presenter/log_colorizer"
8
+
9
+ module Captive
10
+ module Commands
11
+ # Affiche les logs d'une app en mode live (kubectl) ou historique (Loki via dashboard).
12
+ class Logs
13
+ def initialize(kubectl:, dashboard: DashboardClient.new, paint: Pastel.new)
14
+ @kubectl = kubectl
15
+ @dashboard = dashboard
16
+ @colorizer = Presenter::LogColorizer.new(paint: paint)
17
+ end
18
+
19
+ def run(app, follow: false, container: nil, worker: false, lines: 100, from: nil, to: nil, raw: false)
20
+ if from || to
21
+ run_historical(app, from: from, to: to, lines: lines, raw: raw)
22
+ else
23
+ run_live(app, follow: follow, container: container, worker: worker, lines: lines, raw: raw)
24
+ end
25
+ end
26
+
27
+ private
28
+
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))
35
+ end
36
+
37
+ def run_historical(app, from:, to:, raw:, **)
38
+ filter = log_filter(raw)
39
+ data = @dashboard.logs(app, start_at: from, end_at: to)
40
+ lines = data.reject { |line| filter.noise?(line["text"].to_s) }.map { |line| format_line(line) }
41
+ with_pager { |out| lines.each { |l| out.puts l } }
42
+ end
43
+
44
+ def log_filter(raw) = raw ? NullLogFilter.new : LogNoiseFilter.new
45
+
46
+ def with_pager
47
+ if $stdout.tty?
48
+ require "tempfile"
49
+ Tempfile.create([ "captive-logs", ".txt" ]) do |f|
50
+ yield f
51
+ f.flush
52
+ system("less", "-R", "+1", f.path)
53
+ end
54
+ else
55
+ yield $stdout
56
+ end
57
+ end
58
+
59
+ def format_line(line)
60
+ ts, label, message = line.values_at("ts", "process", "text")
61
+ @colorizer.format_line(ts: ts, label: label || "?", message: message.to_s)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../one_off_spec"
4
+ require_relative "../session_id"
5
+ require_relative "../terminal"
6
+ require_relative "../ui/banner"
7
+
8
+ module Captive
9
+ module Commands
10
+ # Exécute une commande dans un Job one-off qui reprend l'image et l'env de l'app,
11
+ # attaché devant un TTY (humain), en suivant ses logs sinon (agent, CI, pipe).
12
+ #
13
+ # La session est tirée avant tout le reste et remise à l'appelant pour qu'il l'inscrive
14
+ # au fil, puis elle nomme le Job. Un one-off abandonné avant même sa création laisse donc
15
+ # une entrée qui pointe vers un log simplement vide, jamais vers la session d'un autre.
16
+ class OneOffSession
17
+ def initialize(kubectl:, banner: UI::Banner.new, session_id: SessionId, tty: Terminal.method(:interactive?))
18
+ @kubectl = kubectl
19
+ @banner = banner
20
+ @session_id = session_id
21
+ @tty = tty
22
+ end
23
+
24
+ def interactive?
25
+ @tty.call
26
+ end
27
+
28
+ # @return [Integer, nil] exit code of the one-off command (nil when attached)
29
+ def run(app, command, &register)
30
+ interactive? ? attach(app, command, &register) : stream(app, command, &register)
31
+ end
32
+
33
+ def attach(app, command)
34
+ session = @session_id.generate.tap { |id| yield id }
35
+ @banner.show(app)
36
+ job = create_job(app, command, session, interactive: true)
37
+ pod = @kubectl.wait_job_pod(app, job)
38
+ begin
39
+ @kubectl.wait_pod_ready(app, pod)
40
+ @kubectl.attach(app, pod)
41
+ ensure
42
+ cleanup(app, job, pod)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def stream(app, command)
49
+ session = @session_id.generate.tap { |id| yield id }
50
+ job = create_job(app, command, session, interactive: false)
51
+ @kubectl.follow_job_logs(app, job)
52
+ @kubectl.job_exit_code(app, job)
53
+ end
54
+
55
+ def create_job(app, command, session, interactive:)
56
+ resource = OneOffSpec.new(
57
+ app: app,
58
+ deployment: @kubectl.get_deployment_json(app, app),
59
+ command: command,
60
+ interactive: interactive,
61
+ session: session
62
+ ).to_h
63
+
64
+ @kubectl.create_from_resource(resource)
65
+ end
66
+
67
+ # Une session terminée normalement est laissée au TTL du Job : le pod
68
+ # survit quelques minutes, le temps d'être relu par captive logs. Une
69
+ # session abandonnée (détachement sans exit) tournerait sinon jusqu'à
70
+ # activeDeadlineSeconds, on la supprime tout de suite.
71
+ def cleanup(app, job, pod)
72
+ @kubectl.delete_job(app, job) unless @kubectl.pod_terminated?(app, pod)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "one_off_session"
4
+
5
+ module Captive
6
+ module Commands
7
+ # Verbe unique des one-off (ADR 0017) : la commande passe par sh -c, comme
8
+ # scalingo run, et la session n'est interactive que devant un TTY.
9
+ class Run
10
+ def initialize(kubectl:, trail:, one_off: OneOffSession.new(kubectl: kubectl))
11
+ @trail = trail
12
+ @one_off = one_off
13
+ end
14
+
15
+ # @return [Integer, nil] exit code of the one-off command (nil when attached)
16
+ def run(app, *cmd)
17
+ script = cmd.join(" ")
18
+ @one_off.run(app, [ "sh", "-c", script ]) do |session|
19
+ @trail.command_run(app, [ script ], session: session)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Captive
6
+ module Commands
7
+ class UserLogin
8
+ Browser = Struct.new(:open_cmd) do
9
+ def open(url) = system(open_cmd, url)
10
+ end
11
+
12
+ def initialize(github:, cluster:, token_path:, kubeconfig_path:,
13
+ ui: method(:puts), browser: Browser.new("open"))
14
+ @github = github
15
+ @cluster = cluster
16
+ @token_path = token_path
17
+ @kubeconfig_path = kubeconfig_path
18
+ @ui = ui
19
+ @browser = browser
20
+ end
21
+
22
+ def call
23
+ ensure_dirs
24
+ device = @github.request_device_code
25
+ @browser.open(device[:verification_uri])
26
+ @ui.call("Entre le code sur #{device[:verification_uri]} : #{device[:user_code]}")
27
+ token = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
28
+ File.write(@token_path, token)
29
+ File.chmod(0o600, @token_path)
30
+ File.write(@kubeconfig_path, kubeconfig_content(token))
31
+ File.chmod(0o600, @kubeconfig_path)
32
+ end
33
+
34
+ private
35
+
36
+ def ensure_dirs
37
+ [ @token_path, @kubeconfig_path ].each { |p| FileUtils.mkdir_p(File.dirname(p)) }
38
+ end
39
+
40
+ def kubeconfig_content(token)
41
+ endpoint = @cluster.fetch(token)
42
+ <<~YAML
43
+ apiVersion: v1
44
+ kind: Config
45
+ clusters:
46
+ - name: captive
47
+ cluster:
48
+ server: #{endpoint[:server]}
49
+ certificate-authority-data: #{endpoint[:certificate_authority_data]}
50
+ users:
51
+ - name: captive-user
52
+ user:
53
+ token: #{token}
54
+ contexts:
55
+ - name: captive
56
+ context:
57
+ cluster: captive
58
+ user: captive-user
59
+ current-context: captive
60
+ YAML
61
+ end
62
+ end
63
+ end
64
+ end