captive-cli 1.0.0 → 1.2.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 +4 -4
- data/lib/captive/cli/dependencies.rb +12 -2
- data/lib/captive/cli.rb +10 -5
- data/lib/captive/commands/apps_commands.rb +25 -50
- data/lib/captive/commands/apps_restart.rb +23 -9
- data/lib/captive/commands/cron_declaration.rb +25 -0
- data/lib/captive/commands/crons_commands.rb +123 -0
- data/lib/captive/commands/env_commands.rb +66 -0
- data/lib/captive/commands/env_set.rb +40 -0
- data/lib/captive/dashboard/apps_resource.rb +26 -0
- data/lib/captive/dashboard/env_vars_resource.rb +27 -0
- data/lib/captive/dashboard/scheduled_tasks_resource.rb +32 -0
- data/lib/captive/dashboard_api.rb +56 -0
- data/lib/captive/dashboard_response.rb +39 -0
- data/lib/captive/errors.rb +9 -3
- data/lib/captive/flux.rb +11 -2
- data/lib/captive/kubectl.rb +0 -10
- data/lib/captive/loader.rb +6 -4
- data/lib/captive/presenter/app_info.rb +40 -0
- data/lib/captive/presenter/scheduled_task.rb +36 -0
- data/lib/captive/shell_completions.rb +4 -2
- data/lib/captive/token_file.rb +20 -0
- data/lib/captive/validation_message.rb +26 -0
- data/lib/captive/version.rb +1 -1
- metadata +14 -8
- data/lib/captive/cluster_apps.rb +0 -82
- data/lib/captive/commands/apps_info.rb +0 -70
- data/lib/captive/commands/apps_rebuild.rb +0 -52
- data/lib/captive/commands/env_kube_commands.rb +0 -25
- data/lib/captive/commands/env_list_kube.rb +0 -16
- data/lib/captive/gh_cli.rb +0 -89
- data/lib/captive/platform_root.rb +0 -25
|
@@ -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
|
data/lib/captive/errors.rb
CHANGED
|
@@ -4,13 +4,19 @@ 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
|
|
14
12
|
class NoTtyError < StandardError; end
|
|
15
|
-
class
|
|
13
|
+
class MissingDependencyError < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Réponses de l'API du dashboard qui ont un sens pour l'utilisateur : rattrapées par
|
|
16
|
+
# CLI.start et affichées sans trace.
|
|
17
|
+
class DashboardError < StandardError; end
|
|
18
|
+
class ForbiddenError < DashboardError; end
|
|
19
|
+
class NotFoundError < DashboardError; end
|
|
20
|
+
class ValidationError < DashboardError; end
|
|
21
|
+
class ServiceUnavailableError < DashboardError; end
|
|
16
22
|
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
|
-
|
|
9
|
+
capture("suspend", "kustomization", name)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def resume_kustomization(name)
|
|
12
|
-
|
|
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
|
data/lib/captive/kubectl.rb
CHANGED
|
@@ -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")
|
data/lib/captive/loader.rb
CHANGED
|
@@ -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 "
|
|
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
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Captive
|
|
4
4
|
module ShellCompletions
|
|
5
|
-
ROOT_COMMANDS = %w[apps env db login whoami console run logs version completions].freeze
|
|
5
|
+
ROOT_COMMANDS = %w[apps env crons db login whoami console run logs version completions].freeze
|
|
6
6
|
|
|
7
7
|
SUBCOMMANDS = {
|
|
8
8
|
"apps" => %w[list info restart rebuild],
|
|
9
|
-
"env" => %w[list],
|
|
9
|
+
"env" => %w[list set unset],
|
|
10
|
+
"crons" => %w[list add edit rm suspend resume trigger runs],
|
|
10
11
|
"db" => %w[restore],
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
@@ -21,6 +22,7 @@ module Captive
|
|
|
21
22
|
case "$prev" in
|
|
22
23
|
apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
|
|
23
24
|
env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
|
|
25
|
+
crons) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["crons"].join(" ")}" -- "$cur")); return ;;
|
|
24
26
|
db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
|
|
25
27
|
esac
|
|
26
28
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
# Le jeton GitHub écrit par `captive login`, qui authentifie toutes les requêtes au dashboard.
|
|
7
|
+
class TokenFile
|
|
8
|
+
PATH = File.expand_path("~/.config/captive/token")
|
|
9
|
+
|
|
10
|
+
def initialize(path: PATH)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read
|
|
15
|
+
raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(@path)
|
|
16
|
+
|
|
17
|
+
File.read(@path).strip
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
# Les refus de validation, un par ligne : `errors` est une liste de {field, message}, un
|
|
5
|
+
# objet champ → messages, ou absent (seul `error` dit alors ce qui ne va pas).
|
|
6
|
+
class ValidationMessage
|
|
7
|
+
def initialize(body)
|
|
8
|
+
@body = body.is_a?(Hash) ? body : {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
([ "Requête refusée :" ] + lines.map { |line| " - #{line}" }).join("\n")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def lines
|
|
18
|
+
errors = @body["errors"]
|
|
19
|
+
case errors
|
|
20
|
+
when Array then errors.map { |e| e.is_a?(Hash) ? "#{e["field"]} : #{e["message"]}" : e.to_s }
|
|
21
|
+
when Hash then errors.map { |field, messages| "#{field} : #{Array(messages).join(", ")}" }
|
|
22
|
+
else [ @body.fetch("error", "invalide").to_s ]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/captive/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: captive-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Captive Studio
|
|
@@ -106,29 +106,32 @@ files:
|
|
|
106
106
|
- lib/captive/activity_trail.rb
|
|
107
107
|
- lib/captive/cli.rb
|
|
108
108
|
- lib/captive/cli/dependencies.rb
|
|
109
|
-
- lib/captive/cluster_apps.rb
|
|
110
109
|
- lib/captive/cluster_endpoint.rb
|
|
111
110
|
- lib/captive/commands/apps_commands.rb
|
|
112
|
-
- lib/captive/commands/apps_info.rb
|
|
113
|
-
- lib/captive/commands/apps_rebuild.rb
|
|
114
111
|
- lib/captive/commands/apps_restart.rb
|
|
115
112
|
- lib/captive/commands/console.rb
|
|
113
|
+
- lib/captive/commands/cron_declaration.rb
|
|
114
|
+
- lib/captive/commands/crons_commands.rb
|
|
116
115
|
- lib/captive/commands/db_commands.rb
|
|
117
116
|
- lib/captive/commands/db_dump.rb
|
|
118
117
|
- lib/captive/commands/db_exec.rb
|
|
119
118
|
- lib/captive/commands/db_psql.rb
|
|
120
119
|
- lib/captive/commands/db_restore.rb
|
|
121
120
|
- lib/captive/commands/db_target.rb
|
|
122
|
-
- lib/captive/commands/
|
|
123
|
-
- lib/captive/commands/
|
|
121
|
+
- lib/captive/commands/env_commands.rb
|
|
122
|
+
- lib/captive/commands/env_set.rb
|
|
124
123
|
- lib/captive/commands/logs.rb
|
|
125
124
|
- lib/captive/commands/one_off_session.rb
|
|
126
125
|
- lib/captive/commands/run.rb
|
|
127
126
|
- lib/captive/commands/user_login.rb
|
|
127
|
+
- lib/captive/dashboard/apps_resource.rb
|
|
128
|
+
- lib/captive/dashboard/env_vars_resource.rb
|
|
129
|
+
- lib/captive/dashboard/scheduled_tasks_resource.rb
|
|
130
|
+
- lib/captive/dashboard_api.rb
|
|
128
131
|
- lib/captive/dashboard_client.rb
|
|
132
|
+
- lib/captive/dashboard_response.rb
|
|
129
133
|
- lib/captive/errors.rb
|
|
130
134
|
- lib/captive/flux.rb
|
|
131
|
-
- lib/captive/gh_cli.rb
|
|
132
135
|
- lib/captive/github_device_flow.rb
|
|
133
136
|
- lib/captive/http_client.rb
|
|
134
137
|
- lib/captive/kubectl.rb
|
|
@@ -136,17 +139,20 @@ files:
|
|
|
136
139
|
- lib/captive/log_noise_filter.rb
|
|
137
140
|
- lib/captive/null_log_filter.rb
|
|
138
141
|
- lib/captive/one_off_spec.rb
|
|
139
|
-
- lib/captive/platform_root.rb
|
|
140
142
|
- lib/captive/poller.rb
|
|
141
143
|
- lib/captive/presenter/ansi_stripper.rb
|
|
144
|
+
- lib/captive/presenter/app_info.rb
|
|
142
145
|
- lib/captive/presenter/log_colorizer.rb
|
|
143
146
|
- lib/captive/presenter/log_line.rb
|
|
147
|
+
- lib/captive/presenter/scheduled_task.rb
|
|
144
148
|
- lib/captive/session_id.rb
|
|
145
149
|
- lib/captive/shell_completions.rb
|
|
146
150
|
- lib/captive/terminal.rb
|
|
151
|
+
- lib/captive/token_file.rb
|
|
147
152
|
- lib/captive/ui/banner.rb
|
|
148
153
|
- lib/captive/ui/spinner.rb
|
|
149
154
|
- lib/captive/update_checker.rb
|
|
155
|
+
- lib/captive/validation_message.rb
|
|
150
156
|
- lib/captive/version.rb
|
|
151
157
|
homepage: https://github.com/captive-studio/captive-platform
|
|
152
158
|
licenses:
|
data/lib/captive/cluster_apps.rb
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Captive
|
|
6
|
-
# Ce que le cluster sait des apps déployées. Le CLI développeur décrit l'état réellement
|
|
7
|
-
# servi, pas l'état désiré dans le repo GitOps — auquel il n'a pas accès.
|
|
8
|
-
class ClusterApps
|
|
9
|
-
def initialize(kubectl:)
|
|
10
|
-
@kubectl = kubectl
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def all
|
|
14
|
-
web_deployments.map do |deployment|
|
|
15
|
-
namespace = deployment.dig("metadata", "namespace")
|
|
16
|
-
{ name: namespace, stage: stage_of(namespace), domains: domains_by_namespace[namespace] || [] }
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def domains(app) = domains_by_namespace[app] || []
|
|
21
|
-
|
|
22
|
-
def stage(app) = stage_of(app)
|
|
23
|
-
|
|
24
|
-
def tier(app) = web_of(app)&.dig("metadata", "annotations", "captive.dev/tier")
|
|
25
|
-
|
|
26
|
-
def workers(app) = of_type(app, "worker").map { |d| d.dig("metadata", "name") }
|
|
27
|
-
|
|
28
|
-
# L'add-on peut ne pas être posé, ou la CRD absente du cluster : les deux se lisent
|
|
29
|
-
# comme « pas d'IP fixe », pas comme une panne.
|
|
30
|
-
def egress_ip(app)
|
|
31
|
-
first_item("egresspolicy", app)&.dig("spec", "egressIP", "ipv4")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def postgres(app)
|
|
35
|
-
spec = first_item("cluster", app)&.fetch("spec", nil) or return nil
|
|
36
|
-
|
|
37
|
-
{ instances: spec["instances"],
|
|
38
|
-
memory: spec.dig("resources", "limits", "memory"),
|
|
39
|
-
storage: spec.dig("storage", "size"), }.compact
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
def first_item(kind, app)
|
|
45
|
-
items(@kubectl.run("get", kind, "-n", app, "-o", "json")).first
|
|
46
|
-
rescue RuntimeError
|
|
47
|
-
nil
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def web_of(app) = of_type(app, "web").first
|
|
51
|
-
|
|
52
|
-
def of_type(app, type)
|
|
53
|
-
workloads(app).select { |d| d.dig("metadata", "labels", "captive.dev/type") == type }
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def workloads(app)
|
|
57
|
-
@workloads ||= {}
|
|
58
|
-
@workloads[app] ||= items(@kubectl.run("get", "deployment", "-n", app, "-o", "json"))
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# Une app est un Deployment portant le type web : le même label que les composants de
|
|
63
|
-
# tier ciblent, et que la conformité exige.
|
|
64
|
-
def web_deployments
|
|
65
|
-
items(@kubectl.run("get", "deployment", "-A", "-l", "captive.dev/type=web", "-o", "json"))
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def domains_by_namespace
|
|
69
|
-
@domains_by_namespace ||= items(@kubectl.run("get", "httproute", "-A", "-o", "json"))
|
|
70
|
-
.group_by { |route| route.dig("metadata", "namespace") }
|
|
71
|
-
.transform_values { |routes| routes.flat_map { |r| Array(r.dig("spec", "hostnames")) } }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def items(json) = JSON.parse(json).fetch("items", [])
|
|
75
|
-
|
|
76
|
-
def stage_of(namespace)
|
|
77
|
-
return "production" if namespace.end_with?("-production")
|
|
78
|
-
|
|
79
|
-
"staging" if namespace.end_with?("-staging")
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Captive
|
|
4
|
-
module Commands
|
|
5
|
-
# L'état d'une app tel que le cluster le sert. Le CLI développeur n'a pas accès au repo
|
|
6
|
-
# GitOps : tout se lit sur les ressources vivantes, ce qui décrit l'état réel plutôt que
|
|
7
|
-
# l'état désiré — un déploiement en cours s'y voit, un manifeste poussé mais non
|
|
8
|
-
# réconcilié ne s'y voit pas encore.
|
|
9
|
-
class AppsInfo
|
|
10
|
-
TIER_RESOURCES = {
|
|
11
|
-
"xs" => { cpu: "100m → 500m", memory: "256Mi → 512Mi" },
|
|
12
|
-
"s" => { cpu: "250m → 1", memory: "512Mi → 1Gi" },
|
|
13
|
-
"m" => { cpu: "500m → 2", memory: "1Gi → 2Gi" },
|
|
14
|
-
"l" => { cpu: "1 → 4", memory: "2Gi → 4Gi" },
|
|
15
|
-
}.freeze
|
|
16
|
-
|
|
17
|
-
def initialize(kubectl:, cluster_apps: nil)
|
|
18
|
-
@kubectl = kubectl
|
|
19
|
-
@apps = cluster_apps || ClusterApps.new(kubectl: kubectl)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def gather(app)
|
|
23
|
-
tier = @apps.tier(app)
|
|
24
|
-
postgres = @apps.postgres(app)
|
|
25
|
-
egress = @apps.egress_ip(app)
|
|
26
|
-
{
|
|
27
|
-
pod_status: pod_status(app),
|
|
28
|
-
replicas: replicas(app),
|
|
29
|
-
postgres: !postgres.nil?,
|
|
30
|
-
redis: redis?(app),
|
|
31
|
-
egress_ip: !egress.nil?,
|
|
32
|
-
tier: tier,
|
|
33
|
-
resources: TIER_RESOURCES[tier],
|
|
34
|
-
domains: @apps.domains(app),
|
|
35
|
-
stage: @apps.stage(app),
|
|
36
|
-
workers: @apps.workers(app),
|
|
37
|
-
postgres_details: postgres,
|
|
38
|
-
redis_details: redis_details(app),
|
|
39
|
-
egress_ip_details: egress && { ip: egress },
|
|
40
|
-
}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def pod_status(app)
|
|
46
|
-
@kubectl.run("get", "pod", "-n", app, "-l", "app=#{app}",
|
|
47
|
-
"-o", "jsonpath={.items[0].status.phase}").strip
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def replicas(app)
|
|
51
|
-
@kubectl.run("get", "deployment", app, "-n", app,
|
|
52
|
-
"-o", "jsonpath={.spec.replicas}").strip.to_i
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def redis?(app)
|
|
56
|
-
!@kubectl.run("get", "deployment", "-n", app, "-l", "app=redis",
|
|
57
|
-
"--no-headers", "-o", "name").strip.empty?
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Redis est un Deployment ordinaire : sa taille se lit sur le conteneur, pas sur une CRD.
|
|
61
|
-
def redis_details(app)
|
|
62
|
-
limits = @kubectl.run(
|
|
63
|
-
"get", "deployment", "-n", app, "-l", "app=redis",
|
|
64
|
-
"-o", "jsonpath={.items[0].spec.template.spec.containers[0].resources.limits.memory}"
|
|
65
|
-
).strip
|
|
66
|
-
limits.empty? ? nil : { memory: limits }
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../errors"
|
|
4
|
-
require_relative "../platform_root"
|
|
5
|
-
|
|
6
|
-
module Captive
|
|
7
|
-
module Commands
|
|
8
|
-
class AppsRebuild
|
|
9
|
-
# Dernière commande du CLI développeur qui lit le repo GitOps, contre l'ADR 0016.
|
|
10
|
-
# Elle déduit le repo GitHub de l'image, or les images valent
|
|
11
|
-
# ghcr.io/captive-studio/captive-platform/<app> : la déduction rend trois segments, qui
|
|
12
|
-
# ne désignent aucun repo. Rien dans le service ni sur le cluster n'enregistre le repo
|
|
13
|
-
# d'origine, donc la commande n'est pas portable en l'état — voir la note de l'étape 9.
|
|
14
|
-
# Elle exige donc un clone de captive-platform (PlatformRoot), jusqu'à passer par l'API
|
|
15
|
-
# du dashboard.
|
|
16
|
-
def initialize(gh:, repo_root: nil)
|
|
17
|
-
@gh = gh
|
|
18
|
-
@repo_root = repo_root
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def run(app)
|
|
22
|
-
repo = extract_repo(app)
|
|
23
|
-
version = current_version(app)
|
|
24
|
-
fields = version ? { version: version } : {}
|
|
25
|
-
@gh.trigger_workflow(repo, ref: "main", fields: fields)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def repo_root = @repo_root ||= PlatformRoot.find
|
|
31
|
-
|
|
32
|
-
def extract_repo(app)
|
|
33
|
-
image_file = File.join(repo_root, "services", app, "image.yaml")
|
|
34
|
-
raise ImageYamlNotFound, "image.yaml introuvable pour '#{app}'" unless File.exist?(image_file)
|
|
35
|
-
|
|
36
|
-
content = File.read(image_file)
|
|
37
|
-
image = content.match(/image:\s+ghcr\.io\/([^\s:]+)/)&.captures&.first
|
|
38
|
-
raise ImageYamlNotFound, "image introuvable dans image.yaml de '#{app}'" unless image
|
|
39
|
-
|
|
40
|
-
image
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def current_version(app)
|
|
44
|
-
deploy_file = File.join(repo_root, "services", app, "#{app}.yaml")
|
|
45
|
-
return nil unless File.exist?(deploy_file)
|
|
46
|
-
|
|
47
|
-
tag = File.read(deploy_file).match(/:(v\d+\.\d+\.\d+(?:-\d{14})?)/)&.captures&.first
|
|
48
|
-
tag&.sub(/-\d{14}$/, "")
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
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
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|