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
|
@@ -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, ®ister)
|
|
30
|
+
interactive? ? attach(app, command, ®ister) : stream(app, command, ®ister)
|
|
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
|