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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ec4e1f102b8ed8f597677d727876ad7a0be2996de6cd51de68cb1e4730953e2
4
- data.tar.gz: 2a7e112840ea957652e340a70d9586328a65e95cf154b6d5de1a7b3f518a400b
3
+ metadata.gz: 29616a82b79478784474098a994449670c0e5ba17c4ddbc10b773168adbd5480
4
+ data.tar.gz: 5dfa16e3181afafa0f3800bfbacce3b07ab0eac18f3863a785b58b66fa4726d4
5
5
  SHA512:
6
- metadata.gz: 84a84d1504ace4bc7ddfc544a930df455297e4f806509f387241371f6f0f256b7ad2d0aee1ed870fc494a9a93eed36ee33e601808f7ee4363b62908e241076f6
7
- data.tar.gz: c5ba6f27ac8b9efb857b62c2f241ade477b976717f5225e4e1423b4be3407a88cadb89dc151e220394efa8d5e09c2bae28e215b491258148ba367adc0f046046
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
- Kubectl.new(kubeconfig: active_kubeconfig) if binary_on_path?("kubectl")
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/env_kube_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::EnvKubeCommands
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'identité K8s courante"
109
+ desc "whoami", "Affiche l'utilisateur connecté au dashboard"
106
110
  def whoami
107
- puts kubectl.run("auth", "whoami")
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 toutes les apps déployées sur la plateforme"
12
+ desc "list", "Liste les apps auxquelles tu as accès"
11
13
  long_desc <<~DESC
12
- Liste les apps déployées sur le cluster, avec leurs domaines et leur stage.
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 = ClusterApps.new(kubectl: kubectl).all
20
+ apps = apps_api.list
19
21
  puts paint.bold("Apps (#{apps.size}) :")
20
22
  apps.each do |app|
21
- stage_badge = case app[:stage]
22
- when "staging" then " " + paint.yellow("[staging]")
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 l'état complet : pod, replicas, stage, tier, domaines, addons.
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
- i = AppsInfo.new(kubectl: kubectl).gather(app)
44
- puts paint.bold("\n#{app}")
45
- puts " Stage : #{i[:stage] || "–"}"
46
- puts " Tier : #{i[:tier] || "–"}"
47
- puts " Pod : #{i[:pod_status]} (#{i[:replicas]} replica(s))"
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 le deployment"
43
+ desc "restart APP", "Redémarre les processus de l'app"
60
44
  long_desc <<~DESC
61
- Rollout restart du deployment + attend pod Ready.
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(kubectl: kubectl).run(app)
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", "Déclenche un rebuild sans changer le code"
58
+ desc "rebuild APP", "Redéploie l'app sans changer le code"
79
59
  long_desc <<~DESC
80
- workflow_dispatch sur le repo GitHub. Utile après changement d'env vars.
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
- AppsRebuild.new(gh: gh_cli).run(app)
87
- puts paint.green(" ✓ Rebuild déclenché pour '#{app}'")
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 format_age(created_at)
95
- return "?" unless created_at
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(kubectl:, poller: nil)
10
- @kubectl = kubectl
11
- @poller = poller || Poller.new(interval: 5, timeout: 120)
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
- old_pod, = @kubectl.pod_info(app, app)
16
- @kubectl.restart_deployment(app, app)
17
- @poller.poll_until(context: "nouveau pod #{app} Running") do
18
- new_pod, = @kubectl.pod_info(app, app)
19
- new_pod && !new_pod.empty? && new_pod != old_pod
20
- end
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