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,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
|
data/lib/captive/flux.rb
ADDED
|
@@ -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"
|