captive-cli 1.0.0 → 1.3.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/captive/cli/dependencies.rb +12 -2
  3. data/lib/captive/cli.rb +30 -13
  4. data/lib/captive/commands/apps_commands.rb +25 -50
  5. data/lib/captive/commands/apps_restart.rb +23 -9
  6. data/lib/captive/commands/auth_commands.rb +16 -0
  7. data/lib/captive/commands/auth_token.rb +29 -0
  8. data/lib/captive/commands/cron_declaration.rb +25 -0
  9. data/lib/captive/commands/crons_commands.rb +123 -0
  10. data/lib/captive/commands/env_commands.rb +66 -0
  11. data/lib/captive/commands/env_set.rb +40 -0
  12. data/lib/captive/commands/user_login.rb +20 -13
  13. data/lib/captive/credentials.rb +43 -0
  14. data/lib/captive/credentials_store.rb +47 -0
  15. data/lib/captive/dashboard/apps_resource.rb +26 -0
  16. data/lib/captive/dashboard/env_vars_resource.rb +27 -0
  17. data/lib/captive/dashboard/scheduled_tasks_resource.rb +32 -0
  18. data/lib/captive/dashboard_api.rb +58 -0
  19. data/lib/captive/dashboard_client.rb +25 -25
  20. data/lib/captive/dashboard_response.rb +39 -0
  21. data/lib/captive/errors.rb +14 -3
  22. data/lib/captive/flux.rb +11 -2
  23. data/lib/captive/github_device_flow.rb +5 -3
  24. data/lib/captive/kubectl.rb +0 -10
  25. data/lib/captive/loader.rb +6 -4
  26. data/lib/captive/presenter/app_info.rb +40 -0
  27. data/lib/captive/presenter/scheduled_task.rb +36 -0
  28. data/lib/captive/session_token.rb +60 -0
  29. data/lib/captive/shell_completions.rb +4 -2
  30. data/lib/captive/token_refresher.rb +27 -0
  31. data/lib/captive/validation_message.rb +26 -0
  32. data/lib/captive/version.rb +1 -1
  33. metadata +19 -8
  34. data/lib/captive/cluster_apps.rb +0 -82
  35. data/lib/captive/commands/apps_info.rb +0 -70
  36. data/lib/captive/commands/apps_rebuild.rb +0 -52
  37. data/lib/captive/commands/env_kube_commands.rb +0 -25
  38. data/lib/captive/commands/env_list_kube.rb +0 -16
  39. data/lib/captive/gh_cli.rb +0 -89
  40. data/lib/captive/platform_root.rb +0 -25
@@ -9,36 +9,36 @@ module Captive
9
9
  def open(url) = system(open_cmd, url)
10
10
  end
11
11
 
12
- def initialize(github:, cluster:, token_path:, kubeconfig_path:,
12
+ def initialize(github:, cluster:, store:, kubeconfig_path:,
13
13
  ui: method(:puts), browser: Browser.new("open"))
14
14
  @github = github
15
15
  @cluster = cluster
16
- @token_path = token_path
16
+ @store = store
17
17
  @kubeconfig_path = kubeconfig_path
18
18
  @ui = ui
19
19
  @browser = browser
20
20
  end
21
21
 
22
22
  def call
23
- ensure_dirs
24
23
  device = @github.request_device_code
25
24
  @browser.open(device[:verification_uri])
26
25
  @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)
26
+ credentials = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
27
+ @store.write(credentials)
28
+ write_kubeconfig(@cluster.fetch(credentials.access_token))
32
29
  end
33
30
 
34
31
  private
35
32
 
36
- def ensure_dirs
37
- [ @token_path, @kubeconfig_path ].each { |p| FileUtils.mkdir_p(File.dirname(p)) }
33
+ def write_kubeconfig(endpoint)
34
+ FileUtils.mkdir_p(File.dirname(@kubeconfig_path))
35
+ File.write(@kubeconfig_path, kubeconfig_content(endpoint))
36
+ File.chmod(0o600, @kubeconfig_path)
38
37
  end
39
38
 
40
- def kubeconfig_content(token)
41
- endpoint = @cluster.fetch(token)
39
+ # Pas de jeton dans le kubeconfig : kubectl demande à `captive auth token` un jeton
40
+ # renouvelé quand il le faut.
41
+ def kubeconfig_content(endpoint)
42
42
  <<~YAML
43
43
  apiVersion: v1
44
44
  kind: Config
@@ -50,7 +50,14 @@ module Captive
50
50
  users:
51
51
  - name: captive-user
52
52
  user:
53
- token: #{token}
53
+ exec:
54
+ apiVersion: client.authentication.k8s.io/v1
55
+ command: captive
56
+ args:
57
+ - auth
58
+ - token
59
+ interactiveMode: Never
60
+ provideClusterInfo: false
54
61
  contexts:
55
62
  - name: captive
56
63
  context:
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Captive
6
+ # Les jetons GitHub de l'utilisateur et leurs échéances. Un jeton sans `expires_at` vient d'une
7
+ # GitHub App sans expiration (ou de l'ancien fichier `token`) : il n'est jamais renouvelé.
8
+ Credentials = Data.define(:access_token, :refresh_token, :expires_at, :refresh_token_expires_at) do
9
+ def self.from_github(data, now:)
10
+ new(access_token: data.fetch("access_token"),
11
+ refresh_token: data["refresh_token"],
12
+ expires_at: after(now, data["expires_in"]),
13
+ refresh_token_expires_at: after(now, data["refresh_token_expires_in"]))
14
+ end
15
+
16
+ def self.from_json_hash(hash)
17
+ new(access_token: hash.fetch("access_token"),
18
+ refresh_token: hash["refresh_token"],
19
+ expires_at: parse_time(hash["expires_at"]),
20
+ refresh_token_expires_at: parse_time(hash["refresh_token_expires_at"]))
21
+ end
22
+
23
+ def self.after(now, seconds) = seconds && (now + seconds.to_i)
24
+ def self.parse_time(value) = value && Time.iso8601(value)
25
+ private_class_method :after, :parse_time
26
+
27
+ def initialize(access_token:, refresh_token: nil, expires_at: nil, refresh_token_expires_at: nil) = super
28
+
29
+ # Moins de `margin` secondes de validité : on renouvelle avant qu'une requête n'échoue.
30
+ def expiring?(now, margin: 300) = expirable? && expires_at - now < margin
31
+ def expirable? = !expires_at.nil?
32
+ def refreshable?(now) = !refresh_token.nil? && (refresh_token_expires_at.nil? || refresh_token_expires_at > now)
33
+
34
+ def to_json_hash
35
+ {
36
+ "access_token" => access_token,
37
+ "refresh_token" => refresh_token,
38
+ "expires_at" => expires_at&.utc&.iso8601,
39
+ "refresh_token_expires_at" => refresh_token_expires_at&.utc&.iso8601,
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "credentials"
7
+ require_relative "errors"
8
+
9
+ module Captive
10
+ # Où vivent les jetons : `credentials.json` (0600, écrit atomiquement), et en repli l'ancien
11
+ # fichier `token` des postes qui ne se sont pas reconnectés depuis l'arrivée du renouvellement.
12
+ class CredentialsStore
13
+ DIR = File.expand_path("~/.config/captive")
14
+
15
+ def initialize(dir: DIR)
16
+ @path = File.join(dir, "credentials.json")
17
+ @legacy_path = File.join(dir, "token")
18
+ @lock_path = File.join(dir, "credentials.lock")
19
+ end
20
+
21
+ def read
22
+ return Credentials.from_json_hash(JSON.parse(File.read(@path))) if File.exist?(@path)
23
+ return Credentials.new(access_token: File.read(@legacy_path).strip) if File.exist?(@legacy_path)
24
+
25
+ raise NotAuthenticatedError, "Pas de session active. Lance : captive login"
26
+ end
27
+
28
+ # Un fichier temporaire renommé : un lecteur concurrent voit l'ancien contenu ou le nouveau,
29
+ # jamais un fichier à moitié écrit.
30
+ def write(credentials)
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ tmp = "#{@path}.#{SecureRandom.hex(4)}.tmp"
33
+ File.write(tmp, JSON.pretty_generate(credentials.to_json_hash), perm: 0o600)
34
+ File.rename(tmp, @path)
35
+ end
36
+
37
+ # Verrou inter-processus : deux commandes (ou kubectl et une commande) ne renouvellent pas en
38
+ # même temps un refresh_token à usage unique.
39
+ def exclusively
40
+ FileUtils.mkdir_p(File.dirname(@lock_path))
41
+ File.open(@lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
42
+ lock.flock(File::LOCK_EX)
43
+ yield
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Les apps vues par le dashboard : leur fiche, ce qui tourne, et les gestes sur l'app.
6
+ class AppsResource
7
+ def initialize(api:)
8
+ @api = api
9
+ end
10
+
11
+ def list = @api.get("/apps")
12
+ def show(app) = @api.get(path(app))
13
+ def datastores(app) = @api.get("#{path(app)}/datastores").fetch("datastores", [])
14
+ def domains(app) = @api.get("#{path(app)}/domains")
15
+ def restart(app) = @api.post("#{path(app)}/restart")
16
+ def redeploy(app) = @api.post("#{path(app)}/deployments/redeploy")
17
+
18
+ # Le dashboard répond null quand le namespace n'a encore rien déployé.
19
+ def workloads(app) = @api.get("#{path(app)}/workloads") || {}
20
+
21
+ private
22
+
23
+ def path(app) = "/apps/#{DashboardApi.segment(app)}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Les variables d'environnement d'une app : lues du Secret, écrites par set-env.
6
+ class EnvVarsResource
7
+ def initialize(api:)
8
+ @api = api
9
+ end
10
+
11
+ def list(app) = @api.get(path(app))
12
+
13
+ def create(app, key, value) = @api.post(path(app), { env_var: { key: key, value: value } })
14
+
15
+ def update(app, key, value) = @api.patch(path(app, key), { env_var: { value: value } })
16
+
17
+ def delete(app, key) = @api.delete(path(app, key))
18
+
19
+ private
20
+
21
+ def path(app, key = nil)
22
+ base = "/apps/#{DashboardApi.segment(app)}/env_vars"
23
+ key ? "#{base}/#{DashboardApi.segment(key)}" : base
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Les tâches planifiées d'une app : lues du cluster, déclarées via captive-platform.
6
+ class ScheduledTasksResource
7
+ def initialize(api:)
8
+ @api = api
9
+ end
10
+
11
+ def list(app) = @api.get(path(app))
12
+ def create(app, declaration) = @api.post(path(app), { scheduled_task: declaration })
13
+ def update(app, name, declaration) = @api.patch(path(app, name), { scheduled_task: declaration })
14
+ def delete(app, name) = @api.delete(path(app, name))
15
+ def suspend(app, name) = @api.post("#{path(app, name)}/suspension")
16
+ def resume(app, name) = @api.delete("#{path(app, name)}/suspension")
17
+ def trigger(app, name) = @api.post("#{path(app, name)}/runs")
18
+
19
+ def find(app, name)
20
+ list(app).find { |task| task["name"] == name } ||
21
+ raise(NotFoundError, "Tâche planifiée « #{name} » introuvable sur #{app}")
22
+ end
23
+
24
+ private
25
+
26
+ def path(app, name = nil)
27
+ base = "/apps/#{DashboardApi.segment(app)}/scheduled_tasks"
28
+ name ? "#{base}/#{DashboardApi.segment(name)}" : base
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "session_token"
7
+ require_relative "dashboard_response"
8
+
9
+ module Captive
10
+ # Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer (rejoué une fois renouvelé
11
+ # sur un 401), timeouts courts, et
12
+ # erreurs HTTP traduites par DashboardResponse. Les chemins sont relatifs à /api.
13
+ class DashboardApi
14
+ BASE_URL = "https://dashboard.captive.dev"
15
+ VERBS = {
16
+ get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete,
17
+ }.freeze
18
+
19
+ def self.segment(value) = URI.encode_uri_component(value.to_s)
20
+
21
+ def initialize(token: SessionToken.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
22
+ read_timeout: 15)
23
+ @token = token
24
+ @base_url = base_url
25
+ @starter = starter
26
+ @read_timeout = read_timeout
27
+ end
28
+
29
+ def get(path) = request(:get, path)
30
+ def post(path, body = nil) = request(:post, path, body)
31
+ def patch(path, body = nil) = request(:patch, path, body)
32
+ def delete(path) = request(:delete, path)
33
+
34
+ private
35
+
36
+ def request(verb, path, body = nil)
37
+ uri = URI.parse("#{@base_url}/api#{path}")
38
+ response = @token.authorize do |token|
39
+ @starter.call(uri.host, uri.port, use_ssl: uri.scheme == "https",
40
+ open_timeout: 5, read_timeout: @read_timeout) do |http|
41
+ http.request(build(verb, uri, body, token))
42
+ end
43
+ end
44
+ DashboardResponse.new(response).parse
45
+ end
46
+
47
+ def build(verb, uri, body, token)
48
+ req = VERBS.fetch(verb).new(uri.request_uri)
49
+ req["Authorization"] = "Bearer #{token}"
50
+ req["Accept"] = "application/json"
51
+ if body
52
+ req["Content-Type"] = "application/json"
53
+ req.body = JSON.generate(body)
54
+ end
55
+ req
56
+ end
57
+ end
58
+ end
@@ -5,22 +5,24 @@ require "uri"
5
5
  require "json"
6
6
  require "time"
7
7
  require_relative "errors"
8
+ require_relative "session_token"
8
9
 
9
10
  module Captive
10
11
  class DashboardClient
11
12
  DASHBOARD_URL = "https://dashboard.captive.dev"
12
- TOKEN_PATH = File.expand_path("~/.config/captive/token")
13
13
 
14
14
  PAGE_SIZE = 500
15
15
 
16
- def logs(app, start_at: nil, end_at: nil, limit: nil)
17
- token = read_token
16
+ def initialize(session: SessionToken.new)
17
+ @session = session
18
+ end
18
19
 
20
+ def logs(app, start_at: nil, end_at: nil, limit: nil)
19
21
  if start_at || end_at
20
- fetch_all(app, token, start_at:, end_at:)
22
+ fetch_all(app, start_at:, end_at:)
21
23
  else
22
24
  uri = build_uri(app, limit: limit || PAGE_SIZE)
23
- response = get(uri, token)
25
+ response = get(uri)
24
26
  page, _ = extract_page(handle_response(response))
25
27
  page
26
28
  end
@@ -34,20 +36,20 @@ module Captive
34
36
  body[:command] = command if command
35
37
  body[:session] = session if session
36
38
 
37
- post(uri, read_token, body)
39
+ post(uri, body)
38
40
  end
39
41
 
40
42
  private
41
43
 
42
44
  # Pagine en remontant depuis end_at vers start_at (direction backward côté Loki).
43
45
  # Chaque page utilise le ts du log le plus ancien comme prochain curseur `before`.
44
- def fetch_all(app, token, start_at:, end_at:)
46
+ def fetch_all(app, start_at:, end_at:)
45
47
  pages = []
46
48
  cursor = end_at
47
49
 
48
50
  loop do
49
51
  uri = build_uri(app, start_at:, before: cursor, limit: PAGE_SIZE)
50
- response = get(uri, token)
52
+ response = get(uri)
51
53
  page, has_more = extract_page(handle_response(response))
52
54
 
53
55
  break if page.empty?
@@ -74,12 +76,6 @@ module Captive
74
76
  end
75
77
  end
76
78
 
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
79
  def build_uri(app, start_at: nil, end_at: nil, before: nil, limit:)
84
80
  params = { limit: }
85
81
  params[:start_at] = start_at.utc.iso8601(3) if start_at
@@ -90,21 +86,25 @@ module Captive
90
86
  uri
91
87
  end
92
88
 
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")
89
+ def get(uri)
90
+ @session.authorize do |token|
91
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
92
+ open_timeout: 5, read_timeout: 30) do |http|
93
+ http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
94
+ end
97
95
  end
98
96
  end
99
97
 
100
98
  # 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")
99
+ def post(uri, body)
100
+ @session.authorize do |token|
101
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
102
+ open_timeout: 2, read_timeout: 5) do |http|
103
+ http.post(uri.request_uri, JSON.generate(body),
104
+ "Authorization" => "Bearer #{token}",
105
+ "Accept" => "application/json",
106
+ "Content-Type" => "application/json")
107
+ end
108
108
  end
109
109
  end
110
110
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "errors"
5
+ require_relative "validation_message"
6
+
7
+ module Captive
8
+ # Traduit une réponse HTTP du dashboard : le corps JSON si elle réussit, sinon l'erreur
9
+ # que l'utilisateur peut comprendre.
10
+ class DashboardResponse
11
+ ERRORS = {
12
+ 401 => [ AuthError, "Token invalide ou expiré. Lance : captive login" ],
13
+ 403 => [ ForbiddenError, "Accès refusé : tu n'es pas Membre de l'Organization de cette app." ],
14
+ 404 => [ NotFoundError, "App ou ressource introuvable" ],
15
+ 503 => [ ServiceUnavailableError, "Service indisponible, réessaie" ],
16
+ }.freeze
17
+
18
+ def initialize(response)
19
+ @code = response.code.to_i
20
+ @body = response.body.to_s
21
+ end
22
+
23
+ def parse
24
+ return (@body.strip.empty? ? nil : JSON.parse(@body)) if @code.between?(200, 299)
25
+ raise ValidationError, ValidationMessage.new(json_body).to_s if @code == 422
26
+
27
+ klass, message = ERRORS.fetch(@code) { [ DashboardError, "Erreur dashboard (HTTP #{@code})" ] }
28
+ raise klass, message
29
+ end
30
+
31
+ private
32
+
33
+ def json_body
34
+ JSON.parse(@body)
35
+ rescue JSON::ParserError
36
+ {}
37
+ end
38
+ end
39
+ end
@@ -4,13 +4,24 @@ module Captive
4
4
  class NoPodFound < StandardError; end
5
5
  class UnknownAppType < StandardError; end
6
6
  class PollTimeout < StandardError; end
7
- class ImageYamlNotFound < StandardError; end
8
- class WorkflowDispatchNotConfigured < StandardError; end
9
7
  class RestoreError < StandardError; end
10
8
  class DumpError < StandardError; end
11
9
  class ExecError < StandardError; end
12
10
  class AuthError < StandardError; end
13
11
  class NotAuthenticatedError < StandardError; end
12
+
13
+ # Le refresh_token a expiré ou GitHub le refuse : seul un nouveau `captive login` rétablit l'accès.
14
+ class SessionExpiredError < AuthError
15
+ def initialize(message = "Session expirée. Lance : captive login") = super
16
+ end
14
17
  class NoTtyError < StandardError; end
15
- class PlatformRootNotFound < StandardError; end
18
+ class MissingDependencyError < StandardError; end
19
+
20
+ # Réponses de l'API du dashboard qui ont un sens pour l'utilisateur : rattrapées par
21
+ # CLI.start et affichées sans trace.
22
+ class DashboardError < StandardError; end
23
+ class ForbiddenError < DashboardError; end
24
+ class NotFoundError < DashboardError; end
25
+ class ValidationError < DashboardError; end
26
+ class ServiceUnavailableError < DashboardError; end
16
27
  end
data/lib/captive/flux.rb CHANGED
@@ -1,15 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require_relative "errors"
4
5
 
5
6
  module Captive
6
7
  class Flux
7
8
  def suspend_kustomization(name)
8
- Open3.capture3("flux", "suspend", "kustomization", name)
9
+ capture("suspend", "kustomization", name)
9
10
  end
10
11
 
11
12
  def resume_kustomization(name)
12
- Open3.capture3("flux", "resume", "kustomization", name)
13
+ capture("resume", "kustomization", name)
14
+ end
15
+
16
+ private
17
+
18
+ def capture(*args)
19
+ Open3.capture3("flux", *args)
20
+ rescue Errno::ENOENT
21
+ raise MissingDependencyError, "flux introuvable. Installe-le : brew install flux"
13
22
  end
14
23
  end
15
24
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "http_client"
4
+ require_relative "credentials"
4
5
 
5
6
  module Captive
6
7
  class GithubDeviceFlow
7
8
  CLIENT_ID = "Iv23liHr9n6eK4XhwtnP"
8
9
 
9
- def initialize(http: HttpClient.new)
10
- @http = http
10
+ def initialize(http: HttpClient.new, clock: -> { Time.now })
11
+ @http = http
12
+ @clock = clock
11
13
  end
12
14
 
13
15
  RETRIABLE_ERRORS = %w[authorization_pending slow_down].freeze
@@ -19,7 +21,7 @@ module Captive
19
21
  data = @http.post("https://github.com/login/oauth/access_token",
20
22
  client_id: CLIENT_ID, device_code: device_code,
21
23
  grant_type: "urn:ietf:params:oauth:grant-type:device_code")
22
- return data["access_token"] if data["access_token"]
24
+ return Credentials.from_github(data, now: @clock.call) if data["access_token"]
23
25
  raise DeviceFlowError, data["error"] unless RETRIABLE_ERRORS.include?(data["error"])
24
26
  end
25
27
  end
@@ -237,12 +237,6 @@ module Captive
237
237
  raise
238
238
  end
239
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
240
  def get_secret_password(namespace, secret_name)
247
241
  encoded = run("get", "secret", secret_name, "-n", namespace,
248
242
  "-o", "jsonpath={.data.password}").strip
@@ -291,10 +285,6 @@ module Captive
291
285
  status.success? ? out : nil
292
286
  end
293
287
 
294
- def restart_deployment(namespace, name)
295
- run("rollout", "restart", "deployment", "-n", namespace, name)
296
- end
297
-
298
288
  def run(*args)
299
289
  out, err, status = Open3.capture3("kubectl", *kubeconfig_args, *args)
300
290
  raise AuthError, "Votre session a expiré. Reconnectez-vous avec : captive login" if err.include?("Unauthorized")
@@ -6,14 +6,18 @@ require "pastel"
6
6
  require "tty-prompt"
7
7
 
8
8
  require_relative "kubectl"
9
- require_relative "cluster_apps"
10
9
  require_relative "log_noise_filter"
11
10
  require_relative "presenter/ansi_stripper"
12
11
  require_relative "null_log_filter"
13
12
  require_relative "one_off_spec"
14
13
  require_relative "session_id"
15
14
  require_relative "poller"
16
- require_relative "gh_cli"
15
+ require_relative "dashboard_api"
16
+ require_relative "dashboard/apps_resource"
17
+ require_relative "dashboard/env_vars_resource"
18
+ require_relative "dashboard/scheduled_tasks_resource"
19
+ require_relative "presenter/app_info"
20
+ require_relative "presenter/scheduled_task"
17
21
  require_relative "activity_trail"
18
22
  require_relative "errors"
19
23
  require_relative "ui/spinner"
@@ -23,9 +27,7 @@ require_relative "commands/console"
23
27
  require_relative "commands/one_off_session"
24
28
  require_relative "commands/run"
25
29
  require_relative "commands/logs"
26
- require_relative "commands/apps_info"
27
30
  require_relative "commands/apps_restart"
28
- require_relative "commands/apps_rebuild"
29
31
  require_relative "commands/db_dump"
30
32
  require_relative "commands/db_restore"
31
33
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Presenter
5
+ # La fiche d'une app telle que `captive apps info` l'affiche, à partir des lectures du
6
+ # dashboard : l'app, ses workloads, ses datastores et ses domaines.
7
+ class AppInfo
8
+ def initialize(paint:)
9
+ @paint = paint
10
+ end
11
+
12
+ def lines(app:, workloads:, datastores:, domains:)
13
+ [ @paint.bold(app["name"]),
14
+ " Stage : #{app["stage"] || "–"}",
15
+ " Statut : #{app["status"] || "–"}",
16
+ " URL : #{app["app_url"] || "–"}",
17
+ *section("Processus", workloads.map { |w| workload(w) }),
18
+ *section("Datastores", datastores.map { |d| "#{d["kind"]} #{d["name"]}" }),
19
+ *section("Domaines", domains.map { |d| domain(d) }), ]
20
+ end
21
+
22
+ private
23
+
24
+ def section(title, items)
25
+ [ " #{title} :", *(items.empty? ? [ @paint.dim(" –") ] : items.map { |item| " #{item}" }) ]
26
+ end
27
+
28
+ def workload(entry)
29
+ ready = "#{entry["ready"]}/#{entry["total"]}"
30
+ ready = @paint.yellow(ready) unless entry["ready"] == entry["total"]
31
+ "#{entry["name"]} #{ready} prêt(s), tier #{entry["tier"] || "–"}"
32
+ end
33
+
34
+ def domain(entry)
35
+ flags = [ ("principal" if entry["primary"]), ("en attente" if entry["pending"]) ].compact
36
+ flags.empty? ? entry["hostname"] : "#{entry["hostname"]} (#{flags.join(", ")})"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Presenter
5
+ # Une tâche planifiée sur une ligne, puis sa dernière exécution si elle a tourné.
6
+ class ScheduledTask
7
+ def initialize(paint:)
8
+ @paint = paint
9
+ end
10
+
11
+ def lines(task)
12
+ head = "#{@paint.bold(task["name"])} #{task["schedule"]} (#{task["timezone"]}) " \
13
+ "#{task["mode"]} #{task["target"]}"
14
+ head += " #{@paint.yellow("[suspendue]")}" if task["suspended"]
15
+ [ head, last_run(task["last_run"]) ]
16
+ end
17
+
18
+ # Le cluster ne garde qu'une exécution réussie, cinq minutes : seule la dernière se lit.
19
+ def last_run(run)
20
+ return @paint.dim(" aucune exécution récente") unless run
21
+
22
+ " dernière : #{run["started_at"]} → #{run["finished_at"] || "en cours"} #{outcome(run["outcome"])}"
23
+ end
24
+
25
+ private
26
+
27
+ def outcome(value)
28
+ case value
29
+ when "succeeded" then @paint.green(value)
30
+ when "failed", "timeout" then @paint.red(value)
31
+ else value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end