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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29616a82b79478784474098a994449670c0e5ba17c4ddbc10b773168adbd5480
|
|
4
|
+
data.tar.gz: 5dfa16e3181afafa0f3800bfbacce3b07ab0eac18f3863a785b58b66fa4726d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66462246f725f169bdd2c74e41bf896774fc821961afd12171455b1c4ce2ef94006c3f091203bb7495f8ba70e97e15a6c194ac21100a38b3072c8c6f960f8955
|
|
7
|
+
data.tar.gz: 4c78529a60c79c0021dcb53a8f124b9fd4a706caa05cb22bb65a4f3a25687b0c288f329301c51628c6f9b216822d7ddbc3e745698f609c2834dc181d93eaef12
|
|
@@ -2,18 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Captive
|
|
4
4
|
module CLIDependencies
|
|
5
|
+
# Les écritures du dashboard sont appliquées plus tard par captive-platform (réponse 202).
|
|
6
|
+
ASYNC_NOTICE = "demande envoyée, appliquée sous quelques minutes"
|
|
7
|
+
|
|
5
8
|
private
|
|
6
9
|
|
|
7
10
|
def kubectl
|
|
8
11
|
@kubectl ||= begin
|
|
9
12
|
raise NotAuthenticatedError, "Pas de session active. Lance : captive login" if active_kubeconfig.nil?
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
unless binary_on_path?("kubectl")
|
|
15
|
+
raise MissingDependencyError, "kubectl introuvable. Installe-le : brew install kubernetes-cli"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Kubectl.new(kubeconfig: active_kubeconfig)
|
|
12
19
|
end
|
|
13
20
|
end
|
|
14
|
-
def gh_cli = @gh_cli ||= GhCLI.new
|
|
15
21
|
def trail = @trail ||= ActivityTrail.new
|
|
16
22
|
def paint = @paint ||= Pastel.new
|
|
23
|
+
def dashboard = @dashboard ||= DashboardApi.new
|
|
24
|
+
def apps_api = @apps_api ||= Dashboard::AppsResource.new(api: dashboard)
|
|
25
|
+
def env_api = @env_api ||= Dashboard::EnvVarsResource.new(api: dashboard)
|
|
26
|
+
def crons_api = @crons_api ||= Dashboard::ScheduledTasksResource.new(api: dashboard)
|
|
17
27
|
|
|
18
28
|
def active_kubeconfig
|
|
19
29
|
env_kc = ENV["KUBECONFIG"]
|
data/lib/captive/cli.rb
CHANGED
|
@@ -4,7 +4,8 @@ require "time"
|
|
|
4
4
|
require_relative "loader"
|
|
5
5
|
require_relative "cli/dependencies"
|
|
6
6
|
require_relative "commands/apps_commands"
|
|
7
|
-
require_relative "commands/
|
|
7
|
+
require_relative "commands/env_commands"
|
|
8
|
+
require_relative "commands/crons_commands"
|
|
8
9
|
require_relative "commands/db_commands"
|
|
9
10
|
require_relative "commands/user_login"
|
|
10
11
|
require_relative "dashboard_client"
|
|
@@ -30,7 +31,7 @@ module Captive
|
|
|
30
31
|
warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
|
|
31
32
|
end
|
|
32
33
|
super
|
|
33
|
-
rescue AuthError, NotAuthenticatedError, NoTtyError => e
|
|
34
|
+
rescue AuthError, NotAuthenticatedError, NoTtyError, MissingDependencyError, DashboardError => e
|
|
34
35
|
warn e.message
|
|
35
36
|
exit 1
|
|
36
37
|
rescue StandardError => e
|
|
@@ -47,7 +48,10 @@ module Captive
|
|
|
47
48
|
subcommand "db", Commands::DbCommands
|
|
48
49
|
|
|
49
50
|
desc "env SUBCOMMAND", "Variables d'environnement d'une app"
|
|
50
|
-
subcommand "env", Commands::
|
|
51
|
+
subcommand "env", Commands::EnvCommands
|
|
52
|
+
|
|
53
|
+
desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
|
|
54
|
+
subcommand "crons", Commands::CronsCommands
|
|
51
55
|
|
|
52
56
|
# ── Root commands ────────────────────────────────────────────────────────
|
|
53
57
|
map "--version" => :version
|
|
@@ -102,9 +106,10 @@ module Captive
|
|
|
102
106
|
exit(exit_code) if exit_code && exit_code != 0
|
|
103
107
|
end
|
|
104
108
|
|
|
105
|
-
desc "whoami", "Affiche l'
|
|
109
|
+
desc "whoami", "Affiche l'utilisateur connecté au dashboard"
|
|
106
110
|
def whoami
|
|
107
|
-
|
|
111
|
+
me = dashboard.get("/me")
|
|
112
|
+
puts [ me["login"], ("(#{me["name"]})" if me["name"]) ].compact.join(" ")
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
desc "completions SHELL", "Génère le script de complétion shell (bash ou zsh)"
|
|
@@ -5,99 +5,74 @@ module Captive
|
|
|
5
5
|
class AppsCommands < Thor
|
|
6
6
|
include CLIDependencies
|
|
7
7
|
|
|
8
|
+
STAGE_BADGES = { "staging" => [ :yellow, "[staging]" ], "production" => [ :green, "[prod]" ] }.freeze
|
|
9
|
+
|
|
8
10
|
def self.exit_on_failure? = true
|
|
9
11
|
|
|
10
|
-
desc "list", "Liste
|
|
12
|
+
desc "list", "Liste les apps auxquelles tu as accès"
|
|
11
13
|
long_desc <<~DESC
|
|
12
|
-
Liste les apps
|
|
14
|
+
Liste les apps de tes Organizations, avec leur stage et leur URL (API du dashboard).
|
|
13
15
|
|
|
14
16
|
\x05Exemple :
|
|
15
17
|
\x05 captive apps list
|
|
16
18
|
DESC
|
|
17
19
|
def list
|
|
18
|
-
apps =
|
|
20
|
+
apps = apps_api.list
|
|
19
21
|
puts paint.bold("Apps (#{apps.size}) :")
|
|
20
22
|
apps.each do |app|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
when "production" then " " + paint.green("[prod]")
|
|
24
|
-
else ""
|
|
25
|
-
end
|
|
26
|
-
puts " - #{paint.bold(app[:name])}#{stage_badge}"
|
|
27
|
-
if app[:domains]&.any?
|
|
28
|
-
app[:domains].each { |d| puts " #{paint.cyan("https://#{d}")}" }
|
|
29
|
-
else
|
|
30
|
-
puts paint.dim(" <no domain>")
|
|
31
|
-
end
|
|
23
|
+
puts " - #{paint.bold(app["name"])}#{stage_badge(app["stage"])}"
|
|
24
|
+
puts app["app_url"] ? " #{paint.cyan(app["app_url"])}" : paint.dim(" <no domain>")
|
|
32
25
|
end
|
|
33
26
|
end
|
|
34
27
|
|
|
35
28
|
desc "info APP", "Affiche le statut et les infos de l'app"
|
|
36
29
|
long_desc <<~DESC
|
|
37
|
-
Affiche
|
|
30
|
+
Affiche stage, statut, URL, processus (replicas prêts, tier), datastores et domaines.
|
|
38
31
|
|
|
39
32
|
\x05Exemple :
|
|
40
33
|
\x05 captive apps info my-app
|
|
41
34
|
DESC
|
|
42
35
|
def info(app)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
puts
|
|
48
|
-
puts " Postgres : #{i[:postgres] ? paint.green("✓") : paint.dim("–")}"
|
|
49
|
-
puts " Redis : #{i[:redis] ? paint.green("✓") : paint.dim("–")}"
|
|
50
|
-
puts " IP fixe : #{i[:egress_ip] ? paint.green("✓") : paint.dim("–")}"
|
|
51
|
-
i[:domains]&.any? && i[:domains].each { |d| puts " → #{d}" }
|
|
52
|
-
i[:workers]&.any? && puts(" Workers : #{i[:workers].join(", ")}")
|
|
53
|
-
(pg = i[:postgres_details]) && puts(" Postgres : #{pg[:instances] || 1} instance(s), #{pg[:memory] || "–"} RAM")
|
|
54
|
-
(rd = i[:redis_details]) && puts(" Redis : #{rd[:memory] || "–"} RAM, #{rd[:storage] || "–"}")
|
|
55
|
-
(eg = i[:egress_ip_details]) && puts(" IP fixe : #{eg[:ip] || "–"}")
|
|
56
|
-
puts
|
|
36
|
+
lines = Presenter::AppInfo.new(paint: paint).lines(
|
|
37
|
+
app: apps_api.show(app), workloads: apps_api.workloads(app).fetch("workloads", []),
|
|
38
|
+
datastores: apps_api.datastores(app), domains: apps_api.domains(app)
|
|
39
|
+
)
|
|
40
|
+
puts lines
|
|
57
41
|
end
|
|
58
42
|
|
|
59
|
-
desc "restart APP", "Redémarre
|
|
43
|
+
desc "restart APP", "Redémarre les processus de l'app"
|
|
60
44
|
long_desc <<~DESC
|
|
61
|
-
|
|
45
|
+
Demande le redémarrage au dashboard, puis attend (120 s max) que tous les
|
|
46
|
+
processus soient de nouveau prêts.
|
|
62
47
|
|
|
63
48
|
\x05Exemple :
|
|
64
49
|
\x05 captive apps restart my-app
|
|
65
50
|
DESC
|
|
66
51
|
def restart(app)
|
|
67
|
-
AppsRestart.new(
|
|
68
|
-
pod_name, created_at = kubectl.pod_info(app, app)
|
|
69
|
-
age = format_age(created_at)
|
|
52
|
+
AppsRestart.new(apps: apps_api).run(app)
|
|
70
53
|
puts paint.green(" ✓ #{app} redémarré")
|
|
71
|
-
puts paint.dim(" pod: #{pod_name} (#{age})")
|
|
72
54
|
rescue PollTimeout => e
|
|
73
55
|
abort paint.red("✗ Timeout : #{e.message}")
|
|
74
|
-
rescue RuntimeError => e
|
|
75
|
-
abort paint.red("✗ #{e.message.split("failed: ").last}")
|
|
76
56
|
end
|
|
77
57
|
|
|
78
|
-
desc "rebuild APP", "
|
|
58
|
+
desc "rebuild APP", "Redéploie l'app sans changer le code"
|
|
79
59
|
long_desc <<~DESC
|
|
80
|
-
|
|
60
|
+
Relance le build et le déploiement de la version courante via le dashboard.
|
|
61
|
+
Utile après un changement de variables d'environnement.
|
|
81
62
|
|
|
82
63
|
\x05Exemple :
|
|
83
64
|
\x05 captive apps rebuild my-app-staging
|
|
84
65
|
DESC
|
|
85
66
|
def rebuild(app)
|
|
86
|
-
|
|
87
|
-
puts paint.green(" ✓
|
|
88
|
-
rescue WorkflowDispatchNotConfigured, ImageYamlNotFound, PlatformRootNotFound => e
|
|
89
|
-
abort paint.red("✗ #{e.message}")
|
|
67
|
+
apps_api.redeploy(app)
|
|
68
|
+
puts paint.green(" ✓ Redéploiement déclenché pour '#{app}'")
|
|
90
69
|
end
|
|
91
70
|
|
|
92
71
|
private
|
|
93
72
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
require "time"
|
|
98
|
-
"#{((Time.now - Time.parse(created_at)) / 60).round}s"
|
|
99
|
-
rescue ArgumentError
|
|
100
|
-
"?"
|
|
73
|
+
def stage_badge(stage)
|
|
74
|
+
color, label = STAGE_BADGES[stage]
|
|
75
|
+
color ? " #{paint.public_send(color, label)}" : ""
|
|
101
76
|
end
|
|
102
77
|
end
|
|
103
78
|
end
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
3
4
|
require_relative "../poller"
|
|
4
5
|
require_relative "../errors"
|
|
5
6
|
|
|
6
7
|
module Captive
|
|
7
8
|
module Commands
|
|
9
|
+
# Demande le redémarrage au dashboard, puis attend que chaque workload ait tous ses
|
|
10
|
+
# replicas prêts, sur des pods créés après la demande : juste après le POST, les anciens
|
|
11
|
+
# pods sont encore prêts et le redémarrage paraîtrait déjà fini.
|
|
8
12
|
class AppsRestart
|
|
9
|
-
def initialize(
|
|
10
|
-
@
|
|
11
|
-
@poller
|
|
13
|
+
def initialize(apps:, poller: nil)
|
|
14
|
+
@apps = apps
|
|
15
|
+
@poller = poller || Poller.new(interval: 5, timeout: 120)
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def run(app)
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
restarted_at = Time.parse(@apps.restart(app).fetch("restarted_at"))
|
|
20
|
+
@poller.poll_until(context: "redémarrage de #{app}") { restarted?(app, restarted_at) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def restarted?(app, restarted_at)
|
|
26
|
+
state = @apps.workloads(app)
|
|
27
|
+
workloads = state.fetch("workloads", [])
|
|
28
|
+
workloads.any? && workloads.all? { |w| w["ready"] == w["total"] } &&
|
|
29
|
+
state.fetch("pods", []).all? { |pod| fresh?(pod, restarted_at) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fresh?(pod, restarted_at)
|
|
33
|
+
created_at = pod["created_at"]
|
|
34
|
+
created_at.nil? || Time.parse(created_at.to_s) >= restarted_at
|
|
21
35
|
end
|
|
22
36
|
end
|
|
23
37
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Commands
|
|
5
|
+
# Le corps d'une tâche planifiée à déclarer : les options données sur la ligne de
|
|
6
|
+
# commande, posées sur une base (les défauts du dashboard pour un ajout, la tâche
|
|
7
|
+
# actuelle pour une édition).
|
|
8
|
+
class CronDeclaration
|
|
9
|
+
DEFAULTS = { "schedule" => "", "timezone" => "Etc/UTC", "mode" => "http", "target" => "",
|
|
10
|
+
"timeout_seconds" => 60, "suspended" => false, }.freeze
|
|
11
|
+
OPTIONS = { schedule: "schedule", timezone: "timezone", mode: "mode", target: "target",
|
|
12
|
+
timeout: "timeout_seconds", suspended: "suspended", }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(name, base: DEFAULTS)
|
|
15
|
+
@name = name
|
|
16
|
+
@base = base
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with(options)
|
|
20
|
+
given = OPTIONS.filter_map { |option, field| [ field, options[option] ] unless options[option].nil? }
|
|
21
|
+
DEFAULTS.merge(@base.slice(*DEFAULTS.keys)).merge(given.to_h).merge("name" => @name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cron_declaration"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
module Commands
|
|
7
|
+
class CronsCommands < Thor
|
|
8
|
+
include CLIDependencies
|
|
9
|
+
|
|
10
|
+
def self.exit_on_failure? = true
|
|
11
|
+
|
|
12
|
+
DECLARATION_OPTIONS = {
|
|
13
|
+
schedule: { type: :string, desc: "Planning cron à 5 champs, ex. \"0 3 * * *\"" },
|
|
14
|
+
target: { type: :string, desc: "URL (mode http) ou commande (mode command)" },
|
|
15
|
+
mode: { type: :string, enum: %w[http command], desc: "http (défaut) ou command" },
|
|
16
|
+
timezone: { type: :string, desc: "Fuseau IANA (défaut Etc/UTC)" },
|
|
17
|
+
timeout: { type: :numeric, desc: "Durée max en secondes, 1 à 3600 (défaut 60)" },
|
|
18
|
+
suspended: { type: :boolean, desc: "Déclare la tâche suspendue" },
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.declaration_options
|
|
22
|
+
DECLARATION_OPTIONS.each { |name, settings| method_option(name, **settings) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "list APP", "Liste les tâches planifiées de l'app"
|
|
26
|
+
long_desc <<~DESC
|
|
27
|
+
\x05Exemple :
|
|
28
|
+
\x05 captive crons list my-app
|
|
29
|
+
DESC
|
|
30
|
+
def list(app)
|
|
31
|
+
tasks = crons_api.list(app)
|
|
32
|
+
return puts(paint.dim("Aucune tâche planifiée.")) if tasks.empty?
|
|
33
|
+
|
|
34
|
+
tasks.each { |task| puts presenter.lines(task) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc "add APP NAME --schedule CRON --target CIBLE", "Déclare une tâche planifiée"
|
|
38
|
+
long_desc <<~DESC
|
|
39
|
+
\x05Exemple :
|
|
40
|
+
\x05 captive crons add my-app nettoyage --schedule "0 3 * * *" --target /cron/cleanup
|
|
41
|
+
\x05 captive crons add my-app purge --schedule "*/15 * * * *" --mode command --target "bin/rails purge"
|
|
42
|
+
DESC
|
|
43
|
+
declaration_options
|
|
44
|
+
def add(app, name)
|
|
45
|
+
crons_api.create(app, CronDeclaration.new(name).with(options.transform_keys(&:to_sym)))
|
|
46
|
+
accepted("#{name} déclarée")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc "edit APP NAME [options]", "Modifie une tâche planifiée"
|
|
50
|
+
long_desc <<~DESC
|
|
51
|
+
Les options absentes gardent la valeur actuelle de la tâche.
|
|
52
|
+
|
|
53
|
+
\x05Exemple :
|
|
54
|
+
\x05 captive crons edit my-app nettoyage --schedule "0 4 * * *"
|
|
55
|
+
DESC
|
|
56
|
+
declaration_options
|
|
57
|
+
def edit(app, name)
|
|
58
|
+
base = crons_api.find(app, name)
|
|
59
|
+
crons_api.update(app, name, CronDeclaration.new(name, base: base).with(options.transform_keys(&:to_sym)))
|
|
60
|
+
accepted("#{name} modifiée")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
desc "rm APP NAME", "Supprime une tâche planifiée"
|
|
64
|
+
long_desc <<~DESC
|
|
65
|
+
\x05Exemple :
|
|
66
|
+
\x05 captive crons rm my-app nettoyage
|
|
67
|
+
DESC
|
|
68
|
+
def rm(app, name)
|
|
69
|
+
crons_api.delete(app, name)
|
|
70
|
+
accepted("#{name} supprimée")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "suspend APP NAME", "Suspend une tâche planifiée"
|
|
74
|
+
long_desc <<~DESC
|
|
75
|
+
\x05Exemple :
|
|
76
|
+
\x05 captive crons suspend my-app nettoyage
|
|
77
|
+
DESC
|
|
78
|
+
def suspend(app, name)
|
|
79
|
+
crons_api.suspend(app, name)
|
|
80
|
+
accepted("#{name} suspendue")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc "resume APP NAME", "Reprend une tâche planifiée suspendue"
|
|
84
|
+
long_desc <<~DESC
|
|
85
|
+
\x05Exemple :
|
|
86
|
+
\x05 captive crons resume my-app nettoyage
|
|
87
|
+
DESC
|
|
88
|
+
def resume(app, name)
|
|
89
|
+
crons_api.resume(app, name)
|
|
90
|
+
accepted("#{name} reprise")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
desc "trigger APP NAME", "Lance une tâche planifiée maintenant"
|
|
94
|
+
long_desc <<~DESC
|
|
95
|
+
\x05Exemple :
|
|
96
|
+
\x05 captive crons trigger my-app nettoyage
|
|
97
|
+
DESC
|
|
98
|
+
def trigger(app, name)
|
|
99
|
+
job = crons_api.trigger(app, name).fetch("job_name")
|
|
100
|
+
puts paint.green(" ✓ #{name} lancée (job #{job})")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "runs APP NAME", "Affiche la dernière exécution d'une tâche"
|
|
104
|
+
long_desc <<~DESC
|
|
105
|
+
Le cluster ne garde que la dernière exécution (un succès disparaît après 5 minutes).
|
|
106
|
+
|
|
107
|
+
\x05Exemple :
|
|
108
|
+
\x05 captive crons runs my-app nettoyage
|
|
109
|
+
DESC
|
|
110
|
+
def runs(app, name)
|
|
111
|
+
puts presenter.last_run(crons_api.find(app, name)["last_run"]).strip
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def presenter = Presenter::ScheduledTask.new(paint: paint)
|
|
117
|
+
|
|
118
|
+
def accepted(message)
|
|
119
|
+
puts paint.green(" ✓ #{message} : #{ASYNC_NOTICE}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "env_set"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
module Commands
|
|
7
|
+
class EnvCommands < 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"
|
|
13
|
+
long_desc <<~DESC
|
|
14
|
+
Lit les variables via le dashboard. Une écriture pas encore appliquée est signalée.
|
|
15
|
+
|
|
16
|
+
\x05Exemple :
|
|
17
|
+
\x05 captive env list my-app-staging
|
|
18
|
+
DESC
|
|
19
|
+
def list(app)
|
|
20
|
+
env_api.list(app).sort_by { |var| var["key"] }.each do |var|
|
|
21
|
+
puts "#{var["key"]}=#{var["value"]}#{state(var)}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "set APP KEY=VALUE [KEY=VALUE...]", "Crée ou modifie des variables d'environnement"
|
|
26
|
+
long_desc <<~DESC
|
|
27
|
+
Crée les clés absentes, met à jour les autres. Appliqué par la plateforme
|
|
28
|
+
sous quelques minutes.
|
|
29
|
+
|
|
30
|
+
\x05Exemple :
|
|
31
|
+
\x05 captive env set my-app-staging FOO=bar BAZ=qux
|
|
32
|
+
DESC
|
|
33
|
+
def set(app, *pairs)
|
|
34
|
+
abort "Indique au moins une paire KEY=VALUE." if pairs.empty?
|
|
35
|
+
|
|
36
|
+
EnvSet.new(env_vars: env_api).run(app, pairs).each do |key, outcome|
|
|
37
|
+
puts paint.green(" ✓ #{key} #{outcome == :created ? "créée" : "modifiée"}")
|
|
38
|
+
end
|
|
39
|
+
puts paint.dim(" #{ASYNC_NOTICE}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc "unset APP KEY [KEY...]", "Supprime des variables d'environnement"
|
|
43
|
+
long_desc <<~DESC
|
|
44
|
+
\x05Exemple :
|
|
45
|
+
\x05 captive env unset my-app-staging FOO BAZ
|
|
46
|
+
DESC
|
|
47
|
+
def unset(app, *keys)
|
|
48
|
+
abort "Indique au moins une clé." if keys.empty?
|
|
49
|
+
|
|
50
|
+
keys.each do |key|
|
|
51
|
+
env_api.delete(app, key)
|
|
52
|
+
puts paint.green(" ✓ #{key} supprimée")
|
|
53
|
+
end
|
|
54
|
+
puts paint.dim(" #{ASYNC_NOTICE}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def state(var)
|
|
60
|
+
return paint.red(" [échec : #{var["failure_reason"] || "écriture refusée"}]") if var["failed"]
|
|
61
|
+
|
|
62
|
+
var["pending"] ? paint.yellow(" [en attente : #{var["pending_change"]}]") : ""
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Captive
|
|
6
|
+
module Commands
|
|
7
|
+
# Écrit des paires KEY=VALUE : une clé déjà présente se met à jour, une nouvelle se crée.
|
|
8
|
+
class EnvSet
|
|
9
|
+
def initialize(env_vars:)
|
|
10
|
+
@env_vars = env_vars
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Retourne [[clé, :created|:updated], …] dans l'ordre des paires.
|
|
14
|
+
def run(app, pairs)
|
|
15
|
+
parsed = pairs.map { |pair| parse(pair) }
|
|
16
|
+
existing = @env_vars.list(app).to_set { |var| var["key"] }
|
|
17
|
+
parsed.map { |key, value| write(app, key, value, existing.include?(key)) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def parse(pair)
|
|
23
|
+
key, separator, value = pair.partition("=")
|
|
24
|
+
raise ValidationError, "Paire invalide : #{pair.inspect}. Attendu KEY=VALUE" if separator.empty? || key.empty?
|
|
25
|
+
|
|
26
|
+
[ key, value ]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(app, key, value, exists)
|
|
30
|
+
if exists
|
|
31
|
+
@env_vars.update(app, key, value)
|
|
32
|
+
[ key, :updated ]
|
|
33
|
+
else
|
|
34
|
+
@env_vars.create(app, key, value)
|
|
35
|
+
[ key, :created ]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "token_file"
|
|
7
|
+
require_relative "dashboard_response"
|
|
8
|
+
|
|
9
|
+
module Captive
|
|
10
|
+
# Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer, timeouts courts, et
|
|
11
|
+
# erreurs HTTP traduites par DashboardResponse. Les chemins sont relatifs à /api.
|
|
12
|
+
class DashboardApi
|
|
13
|
+
BASE_URL = "https://dashboard.captive.dev"
|
|
14
|
+
VERBS = {
|
|
15
|
+
get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.segment(value) = URI.encode_uri_component(value.to_s)
|
|
19
|
+
|
|
20
|
+
def initialize(token: TokenFile.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
|
|
21
|
+
read_timeout: 15)
|
|
22
|
+
@token = token
|
|
23
|
+
@base_url = base_url
|
|
24
|
+
@starter = starter
|
|
25
|
+
@read_timeout = read_timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get(path) = request(:get, path)
|
|
29
|
+
def post(path, body = nil) = request(:post, path, body)
|
|
30
|
+
def patch(path, body = nil) = request(:patch, path, body)
|
|
31
|
+
def delete(path) = request(:delete, path)
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def request(verb, path, body = nil)
|
|
36
|
+
uri = URI.parse("#{@base_url}/api#{path}")
|
|
37
|
+
req = build(verb, uri, body)
|
|
38
|
+
response = @starter.call(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
39
|
+
open_timeout: 5, read_timeout: @read_timeout) do |http|
|
|
40
|
+
http.request(req)
|
|
41
|
+
end
|
|
42
|
+
DashboardResponse.new(response).parse
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build(verb, uri, body)
|
|
46
|
+
req = VERBS.fetch(verb).new(uri.request_uri)
|
|
47
|
+
req["Authorization"] = "Bearer #{@token.read}"
|
|
48
|
+
req["Accept"] = "application/json"
|
|
49
|
+
if body
|
|
50
|
+
req["Content-Type"] = "application/json"
|
|
51
|
+
req.body = JSON.generate(body)
|
|
52
|
+
end
|
|
53
|
+
req
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|