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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "time"
7
+ require_relative "errors"
8
+
9
+ module Captive
10
+ class DashboardClient
11
+ DASHBOARD_URL = "https://dashboard.captive.dev"
12
+ TOKEN_PATH = File.expand_path("~/.config/captive/token")
13
+
14
+ PAGE_SIZE = 500
15
+
16
+ def logs(app, start_at: nil, end_at: nil, limit: nil)
17
+ token = read_token
18
+
19
+ if start_at || end_at
20
+ fetch_all(app, token, start_at:, end_at:)
21
+ else
22
+ uri = build_uri(app, limit: limit || PAGE_SIZE)
23
+ response = get(uri, token)
24
+ page, _ = extract_page(handle_response(response))
25
+ page
26
+ end
27
+ end
28
+
29
+ # Signale au dashboard un accès ouvert sur l'app. L'acteur n'est pas transmis : le
30
+ # dashboard le dérive du token, ce qui le rend infalsifiable côté client.
31
+ def record_activity(app, kind:, command: nil, session: nil)
32
+ uri = URI.parse("#{DASHBOARD_URL}/api/apps/#{URI.encode_uri_component(app)}/activity")
33
+ body = { kind: kind }
34
+ body[:command] = command if command
35
+ body[:session] = session if session
36
+
37
+ post(uri, read_token, body)
38
+ end
39
+
40
+ private
41
+
42
+ # Pagine en remontant depuis end_at vers start_at (direction backward côté Loki).
43
+ # Chaque page utilise le ts du log le plus ancien comme prochain curseur `before`.
44
+ def fetch_all(app, token, start_at:, end_at:)
45
+ pages = []
46
+ cursor = end_at
47
+
48
+ loop do
49
+ uri = build_uri(app, start_at:, before: cursor, limit: PAGE_SIZE)
50
+ response = get(uri, token)
51
+ page, has_more = extract_page(handle_response(response))
52
+
53
+ break if page.empty?
54
+
55
+ pages << page
56
+ break unless has_more
57
+
58
+ # page est trié ASC — le premier élément est le plus ancien
59
+ cursor = Time.parse(page.first["ts"]) - Rational(1, 1000)
60
+ break if start_at && cursor <= start_at
61
+ end
62
+
63
+ pages.reverse.flatten(1)
64
+ end
65
+
66
+ # Accepte l'ancien format (Array) et le nouveau ({lines, has_more}).
67
+ # Pour l'ancien format, on continue tant qu'on reçoit des résultats :
68
+ # le curseur (cursor <= start_at) est le seul stop fiable.
69
+ def extract_page(data)
70
+ if data.is_a?(Hash)
71
+ [ data.fetch("lines", []), data.fetch("has_more", false) ]
72
+ else
73
+ [ data, !data.empty? ]
74
+ end
75
+ end
76
+
77
+ def read_token
78
+ raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(TOKEN_PATH)
79
+
80
+ File.read(TOKEN_PATH).strip
81
+ end
82
+
83
+ def build_uri(app, start_at: nil, end_at: nil, before: nil, limit:)
84
+ params = { limit: }
85
+ params[:start_at] = start_at.utc.iso8601(3) if start_at
86
+ params[:end_at] = end_at.utc.iso8601(3) if end_at
87
+ params[:before] = before.utc.iso8601(3) if before
88
+ uri = URI.parse("#{DASHBOARD_URL}/api/apps/#{URI.encode_uri_component(app)}/logs")
89
+ uri.query = URI.encode_www_form(params)
90
+ uri
91
+ end
92
+
93
+ def get(uri, token)
94
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
95
+ open_timeout: 5, read_timeout: 30) do |http|
96
+ http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
97
+ end
98
+ end
99
+
100
+ # Timeouts courts : une console ne doit pas attendre un dashboard qui ne répond pas.
101
+ def post(uri, token, body)
102
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
103
+ open_timeout: 2, read_timeout: 5) do |http|
104
+ http.post(uri.request_uri, JSON.generate(body),
105
+ "Authorization" => "Bearer #{token}",
106
+ "Accept" => "application/json",
107
+ "Content-Type" => "application/json")
108
+ end
109
+ end
110
+
111
+ def handle_response(response)
112
+ case response.code.to_i
113
+ when 200 then JSON.parse(response.body)
114
+ when 401 then raise AuthError, "Token invalide ou expiré. Lance : captive login"
115
+ else raise "Erreur dashboard (HTTP #{response.code})"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ class NoPodFound < StandardError; end
5
+ class UnknownAppType < StandardError; end
6
+ class PollTimeout < StandardError; end
7
+ class ImageYamlNotFound < StandardError; end
8
+ class WorkflowDispatchNotConfigured < StandardError; end
9
+ class RestoreError < StandardError; end
10
+ class DumpError < StandardError; end
11
+ class ExecError < StandardError; end
12
+ class AuthError < StandardError; end
13
+ class NotAuthenticatedError < StandardError; end
14
+ class NoTtyError < StandardError; end
15
+ class PlatformRootNotFound < StandardError; end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Captive
6
+ class Flux
7
+ def suspend_kustomization(name)
8
+ Open3.capture3("flux", "suspend", "kustomization", name)
9
+ end
10
+
11
+ def resume_kustomization(name)
12
+ Open3.capture3("flux", "resume", "kustomization", name)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require_relative "errors"
6
+
7
+ module Captive
8
+ class GhCLI
9
+ def trigger_workflow(repo, workflow: "ci.yml", ref: "main", fields: {})
10
+ field_args = fields.flat_map { |k, v| [ "--field", "#{k}=#{v}" ] }
11
+ run("workflow", "run", workflow, "--repo", repo, "--ref", ref, *field_args)
12
+ rescue RuntimeError => e
13
+ raise WorkflowDispatchNotConfigured,
14
+ "Le workflow '#{workflow}' de '#{repo}' n'a pas de trigger workflow_dispatch.\n" \
15
+ " Regénère le ci.yml : captive apps launch <app> dans le repo de l'app." \
16
+ if e.message.include?("workflow_dispatch")
17
+ raise
18
+ end
19
+
20
+ def workflow_exists?(repo)
21
+ %w[deploy.yml ci.yml].any? do |file|
22
+ run("api", "/repos/#{repo}/contents/.github/workflows/#{file}") && true
23
+ rescue RuntimeError
24
+ false
25
+ end
26
+ end
27
+
28
+ def latest_run_status(repo)
29
+ output = run("run", "list", "--repo", repo, "--limit", "1",
30
+ "--json", "status,conclusion", "--jq", ".[0]")
31
+ data = JSON.parse(output)
32
+ return :success if data["status"] == "completed" && data["conclusion"] == "success"
33
+ return :failure if data["status"] == "completed"
34
+
35
+ :in_progress
36
+ end
37
+
38
+ def image_exists?(repo, tag)
39
+ output = run("api", "/repos/#{repo}/packages/container/#{repo.split("/").last}/versions",
40
+ "--jq", ".[].metadata.container.tags[]")
41
+ output.split("\n").include?(tag)
42
+ rescue RuntimeError => e
43
+ return false if e.message.include?("404") || e.message.include?("Not Found")
44
+
45
+ raise
46
+ end
47
+
48
+ def app_installed?(repo)
49
+ run("api", "repos/#{repo}/installation")
50
+ true
51
+ rescue RuntimeError
52
+ false
53
+ end
54
+
55
+ def detect_type(repo)
56
+ data = JSON.parse(run("api", "repos/#{repo}"))
57
+ case data["language"]
58
+ when "Ruby" then "rails-app"
59
+ when "JavaScript", "TypeScript" then "node-app"
60
+ else raise UnknownAppType, "language non supportée : #{data["language"]}"
61
+ end
62
+ end
63
+
64
+ PLATFORM_REPO = "captive-studio/captive-platform"
65
+
66
+ def dispatch_build(repo)
67
+ repo_name = repo.split("/").last.downcase
68
+ sha = run("api", "repos/#{repo}/git/ref/heads/main", "--jq", ".object.sha").strip
69
+ body = JSON.generate(
70
+ event_type: "build-app",
71
+ client_payload: { repo: repo, repo_name: repo_name, sha: sha, ref: "refs/heads/main" }
72
+ )
73
+ run_with_input("api", "repos/#{PLATFORM_REPO}/dispatches", "-X", "POST", "--input", "-",
74
+ input: body)
75
+ end
76
+
77
+ def run(*args, input: nil)
78
+ opts = input ? { stdin_data: input } : {}
79
+ out, err, status = Open3.capture3("gh", *args, **opts)
80
+ raise "gh #{args.join(" ")} failed: #{err}" unless status.success?
81
+
82
+ out
83
+ end
84
+
85
+ def run_with_input(*args, input:)
86
+ run(*args, input: input)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+
5
+ module Captive
6
+ class GithubDeviceFlow
7
+ CLIENT_ID = "Iv23liHr9n6eK4XhwtnP"
8
+
9
+ def initialize(http: HttpClient.new)
10
+ @http = http
11
+ end
12
+
13
+ RETRIABLE_ERRORS = %w[authorization_pending slow_down].freeze
14
+ DeviceFlowError = Class.new(StandardError)
15
+
16
+ def poll_token(device_code:, interval:)
17
+ loop do
18
+ sleep(interval)
19
+ data = @http.post("https://github.com/login/oauth/access_token",
20
+ client_id: CLIENT_ID, device_code: device_code,
21
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code")
22
+ return data["access_token"] if data["access_token"]
23
+ raise DeviceFlowError, data["error"] unless RETRIABLE_ERRORS.include?(data["error"])
24
+ end
25
+ end
26
+
27
+ def request_device_code
28
+ data = @http.post("https://github.com/login/device/code", client_id: CLIENT_ID)
29
+ raise DeviceFlowError, "Erreur GitHub : #{data.inspect}" unless data["user_code"]
30
+
31
+ {
32
+ device_code: data["device_code"],
33
+ user_code: data["user_code"],
34
+ verification_uri: data["verification_uri"],
35
+ interval: data["interval"],
36
+ }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Captive
8
+ class HttpClient
9
+ def post(url, params)
10
+ request(url) do |http, uri|
11
+ http.post(uri.request_uri, URI.encode_www_form(params),
12
+ "Accept" => "application/json",
13
+ "Content-Type" => "application/x-www-form-urlencoded")
14
+ end
15
+ end
16
+
17
+ def get(url, headers = {})
18
+ request(url) { |http, uri| http.get(uri.request_uri, { "Accept" => "application/json" }.merge(headers)) }
19
+ end
20
+
21
+ private
22
+
23
+ def request(url, &block)
24
+ uri = URI.parse(url)
25
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
26
+ open_timeout: 2, read_timeout: 2) do |http|
27
+ JSON.parse(block.call(http, uri).body)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "open3"
6
+ require_relative "errors"
7
+ require_relative "log_noise_filter"
8
+ require_relative "one_off_spec"
9
+ require_relative "presenter/log_line"
10
+
11
+ module Captive
12
+ class Kubectl
13
+ EXIT_SUCCESS = 0
14
+ EXIT_FAILURE = 1
15
+ TERMINAL_PHASES = %w[Succeeded Failed].freeze
16
+
17
+ # Rôle admin non-superuser sous lequel tourne le SQL utilisateur, sur le
18
+ # modèle du rôle `postgres` de Supabase hébergé et du `neon_superuser` de
19
+ # Neon. Les apps sans backend Supabase n'ont que le rôle propriétaire `app`.
20
+ ADMIN_ROLE = "app_admin"
21
+ ADMIN_SECRET = "supabase-app-admin"
22
+ OWNER_ROLE = "app"
23
+
24
+ def initialize(kubeconfig: nil)
25
+ @kubeconfig = kubeconfig
26
+ end
27
+
28
+ def get_pod(namespace, app)
29
+ name, = pod_info(namespace, app)
30
+ raise NoPodFound, "aucun pod Running pour '#{app}' dans '#{namespace}'" if name.nil? || name.empty?
31
+
32
+ name
33
+ end
34
+
35
+ def get_deployment_json(namespace, name)
36
+ JSON.parse(run("get", "deployment", name, "-n", namespace, "-o", "json"))
37
+ end
38
+
39
+ def create_from_resource(resource)
40
+ json = JSON.generate(resource)
41
+ out, err, status = Open3.capture3(
42
+ "kubectl", *kubeconfig_args, "create", "-f", "-", "-o", "jsonpath={.metadata.name}",
43
+ stdin_data: json
44
+ )
45
+ raise AuthError, "Votre session a expiré. Reconnectez-vous avec : captive login" if err.include?("Unauthorized")
46
+ raise "kubectl create failed: #{err}" unless status.success?
47
+
48
+ out.strip
49
+ end
50
+
51
+ def wait_pod_ready(namespace, pod, timeout: "120s")
52
+ run("wait", "--for=condition=Ready", "pod/#{pod}", "-n", namespace, "--timeout=#{timeout}")
53
+ end
54
+
55
+ def wait_job_complete(namespace, job, timeout: "3600s")
56
+ run("wait", "--for=condition=complete", "job/#{job}", "-n", namespace, "--timeout=#{timeout}")
57
+ end
58
+
59
+ def attach(namespace, pod)
60
+ run_interactive("attach", "-n", namespace, "-it", pod, "-c", OneOffSpec::CONTAINER_NAME)
61
+ end
62
+
63
+ def delete_pod(namespace, pod)
64
+ run("delete", "pod", pod, "-n", namespace, "--ignore-not-found=true", "--wait=false")
65
+ end
66
+
67
+ # Attend que le pod atteigne une phase terminale. Sert à distinguer une
68
+ # session console sortie proprement (à laisser au TTL du Job) d’une session
69
+ # abandonnée (à supprimer tout de suite).
70
+ def pod_terminated?(namespace, pod, timeout: "10s")
71
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + parse_timeout(timeout)
72
+ loop do
73
+ return true if TERMINAL_PHASES.include?(pod_phase(namespace, pod))
74
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
75
+
76
+ sleep 0.5
77
+ end
78
+ end
79
+
80
+ def pod_phase(namespace, pod)
81
+ run("get", "pod", pod, "-n", namespace, "-o", "jsonpath={.status.phase}").strip
82
+ rescue RuntimeError
83
+ ""
84
+ end
85
+
86
+ def delete_job(namespace, job)
87
+ run("delete", "job", job, "-n", namespace, "--ignore-not-found=true", "--wait=false")
88
+ end
89
+
90
+ def follow_job_logs(namespace, job)
91
+ wait_job_pod(namespace, job)
92
+ stream_logs("logs", "-n", namespace, "job/#{job}", "-c", OneOffSpec::CONTAINER_NAME, "-f") do |line|
93
+ puts line
94
+ end
95
+ end
96
+
97
+ def job_exit_code(namespace, job)
98
+ json = JSON.parse(run("get", "job", job, "-n", namespace, "-o", "json"))
99
+ return EXIT_SUCCESS if json.dig("status", "succeeded").to_i >= 1
100
+
101
+ pod = job_pod_name(namespace, job)
102
+ return EXIT_FAILURE if pod.nil?
103
+
104
+ pod_json = JSON.parse(run("get", "pod", pod, "-n", namespace, "-o", "json"))
105
+ statuses = pod_json.dig("status", "containerStatuses") || []
106
+ statuses.first&.dig("state", "terminated", "exitCode") ||
107
+ statuses.first&.dig("lastState", "terminated", "exitCode") ||
108
+ EXIT_FAILURE
109
+ end
110
+
111
+ def wait_job_pod(namespace, job, timeout: "120s")
112
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + parse_timeout(timeout)
113
+ loop do
114
+ name = job_pod_name(namespace, job)
115
+ return name if name
116
+
117
+ raise "aucun pod pour le Job '#{job}' dans '#{namespace}'" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
118
+
119
+ sleep 0.5
120
+ end
121
+ end
122
+
123
+ def job_pod_name(namespace, job)
124
+ output = run(
125
+ "get", "pod", "-n", namespace, "-l", "job-name=#{job}",
126
+ "-o", "jsonpath={.items[0].metadata.name}"
127
+ ).strip
128
+ output.empty? ? nil : output
129
+ rescue RuntimeError => e
130
+ return nil if e.message.include?("index out of bounds") || e.message.include?("array index")
131
+
132
+ raise
133
+ end
134
+
135
+ def create_empty_secret_yaml(name, namespace)
136
+ run("create", "secret", "generic", name, "-n", namespace, "--dry-run=client", "-o", "yaml")
137
+ end
138
+
139
+ def create_secret_yaml(name, vars, namespace: name)
140
+ literals = vars.map { |k, v| "--from-literal=#{k}=#{v}" }
141
+ run("create", "secret", "generic", name, "-n", namespace, *literals, "--dry-run=client", "-o", "yaml")
142
+ end
143
+
144
+ def scale(namespace, deployment, replicas)
145
+ run("scale", "deployment", deployment, "-n", namespace, "--replicas=#{replicas}")
146
+ end
147
+
148
+ def restore(namespace, pod, dump_file, format)
149
+ cmd = format == :custom ?
150
+ [ "pg_restore", "--clean", "--if-exists", "--no-owner", "--no-acl", "-d", "app" ] :
151
+ [ "psql", "app" ]
152
+ exec_with_file(namespace, pod, dump_file, *cmd)
153
+ end
154
+
155
+ def admin_credentials(namespace, app)
156
+ [ ADMIN_ROLE, get_secret_password(namespace, ADMIN_SECRET) ]
157
+ rescue RuntimeError => e
158
+ raise unless e.message.include?("NotFound")
159
+
160
+ [ OWNER_ROLE, postgres_secret_password(namespace, app) ]
161
+ end
162
+
163
+ def psql_file(namespace, pod, file, user:, password:, single_transaction: true)
164
+ flags = [ "-v", "ON_ERROR_STOP=1" ]
165
+ flags << "--single-transaction" if single_transaction
166
+ exec_with_file(namespace, pod, file, *psql_argv(user, password, flags))
167
+ end
168
+
169
+ def psql_interactive(namespace, pod, user:, password:)
170
+ run_interactive("exec", "-n", namespace, "-it", pod, "--", *psql_argv(user, password))
171
+ end
172
+
173
+ def exec_with_file(namespace, pod, file, *cmd)
174
+ system("kubectl", *kubeconfig_args, "exec", "-n", namespace, "-i", pod, "--", *cmd, in: File.open(file))
175
+ end
176
+
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
+ def run_interactive(*args)
214
+ system("kubectl", *kubeconfig_args, *args)
215
+ end
216
+
217
+ # external_encoding forcé : sans LANG, Ruby lit en US-ASCII et les octets
218
+ # UTF-8 des séquences TTY d’une console one-off font planter la lecture.
219
+ def stream_logs(*args)
220
+ IO.popen([ "kubectl", *kubeconfig_args, *args ],
221
+ err: [ :child, :out ], external_encoding: Encoding::UTF_8) do |io|
222
+ io.each_line { |line| yield line.scrub }
223
+ end
224
+ $?.success?
225
+ end
226
+
227
+ def pod_info(namespace, app)
228
+ output = run(
229
+ "get", "pod", "-n", namespace, "-l", "app=#{app}",
230
+ "--field-selector=status.phase=Running",
231
+ "-o", "jsonpath={.items[0].metadata.name} {.items[0].metadata.creationTimestamp}"
232
+ ).strip
233
+ output.empty? ? [ nil, nil ] : output.split(" ", 2)
234
+ rescue RuntimeError => e
235
+ return [ nil, nil ] if e.message.include?("index out of bounds") || e.message.include?("array index")
236
+
237
+ raise
238
+ end
239
+
240
+ def get_secret_data(namespace, secret_name)
241
+ json = run("get", "secret", secret_name, "-n", namespace, "-o", "json")
242
+ data = JSON.parse(json).fetch("data", {})
243
+ data.transform_values { |v| Base64.strict_decode64(v) }
244
+ end
245
+
246
+ def get_secret_password(namespace, secret_name)
247
+ encoded = run("get", "secret", secret_name, "-n", namespace,
248
+ "-o", "jsonpath={.data.password}").strip
249
+ Base64.strict_decode64(encoded)
250
+ end
251
+
252
+ def postgres_secret_password(namespace, app)
253
+ with_cnpg_fallback("postgres-#{app}-app", "postgres-#{app}-cnpg-app") do |name|
254
+ get_secret_password(namespace, name)
255
+ end
256
+ end
257
+
258
+ def postgres_pod_name(namespace, app)
259
+ with_cnpg_fallback("postgres-#{app}-1", "postgres-#{app}-cnpg-1") do |name|
260
+ run("get", "pod", name, "-n", namespace, "-o", "jsonpath={.metadata.name}")
261
+ name
262
+ end
263
+ end
264
+
265
+ def transfer_file_to_pod(namespace, pod, stream_cmd, remote_path)
266
+ system_pipe("#{stream_cmd} | #{kubectl_cmd} exec -i #{pod} -n #{namespace} -- bash -c 'cat > #{remote_path}'")
267
+ end
268
+
269
+ def dump_from_pod(namespace, pod, local_path, cmd)
270
+ system_pipe("#{kubectl_cmd} exec #{pod} -n #{namespace} -- bash -c '#{cmd}' > #{Shellwords.escape(local_path)}")
271
+ end
272
+
273
+ def kubeconfig_args
274
+ @kubeconfig ? [ "--kubeconfig", @kubeconfig ] : []
275
+ end
276
+
277
+ def kubectl_cmd
278
+ ([ "kubectl" ] + kubeconfig_args).map { |a| Shellwords.escape(a) }.join(" ")
279
+ end
280
+
281
+ def system_pipe(cmd)
282
+ system("bash", "-c", cmd)
283
+ end
284
+
285
+ def exec_on_pod(namespace, pod, cmd)
286
+ run_interactive("exec", pod, "-n", namespace, "--", "bash", "-c", cmd)
287
+ end
288
+
289
+ def exec_on_pod_output(namespace, pod, cmd)
290
+ out, _err, status = Open3.capture3("kubectl", *kubeconfig_args, "exec", pod, "-n", namespace, "--", "bash", "-c", cmd)
291
+ status.success? ? out : nil
292
+ end
293
+
294
+ def restart_deployment(namespace, name)
295
+ run("rollout", "restart", "deployment", "-n", namespace, name)
296
+ end
297
+
298
+ def run(*args)
299
+ out, err, status = Open3.capture3("kubectl", *kubeconfig_args, *args)
300
+ raise AuthError, "Votre session a expiré. Reconnectez-vous avec : captive login" if err.include?("Unauthorized")
301
+ raise "kubectl #{args.join(" ")} failed: #{err}" unless status.success?
302
+
303
+ out
304
+ end
305
+
306
+ private
307
+
308
+ def psql_argv(user, password, flags = [])
309
+ [ "env", "PGPASSWORD=#{password}",
310
+ "psql", *flags, "-h", "localhost", "-U", user, "-d", "app", ]
311
+ end
312
+
313
+ def parse_timeout(timeout)
314
+ case timeout
315
+ when Integer then timeout
316
+ when /\A(\d+)s\z/ then Regexp.last_match(1).to_i
317
+ when /\A(\d+)m\z/ then Regexp.last_match(1).to_i * 60
318
+ else 120
319
+ end
320
+ end
321
+
322
+ def with_cnpg_fallback(standard_name, cnpg_name)
323
+ yield standard_name
324
+ rescue RuntimeError => e
325
+ raise unless e.message.include?("NotFound")
326
+
327
+ yield cnpg_name
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "version"
5
+ require "pastel"
6
+ require "tty-prompt"
7
+
8
+ require_relative "kubectl"
9
+ require_relative "cluster_apps"
10
+ require_relative "log_noise_filter"
11
+ require_relative "presenter/ansi_stripper"
12
+ require_relative "null_log_filter"
13
+ require_relative "one_off_spec"
14
+ require_relative "session_id"
15
+ require_relative "poller"
16
+ require_relative "gh_cli"
17
+ require_relative "activity_trail"
18
+ require_relative "errors"
19
+ require_relative "ui/spinner"
20
+ require_relative "ui/banner"
21
+
22
+ require_relative "commands/console"
23
+ require_relative "commands/one_off_session"
24
+ require_relative "commands/run"
25
+ require_relative "commands/logs"
26
+ require_relative "commands/apps_info"
27
+ require_relative "commands/apps_restart"
28
+ require_relative "commands/apps_rebuild"
29
+ require_relative "commands/db_dump"
30
+ require_relative "commands/db_restore"
31
+
32
+
33
+ require_relative "presenter/log_line"