captive-cli 1.3.0 → 1.4.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: f79f7992c692e80419a6da77317d0e3d50d72ee4b48d41ea6001fa3e8a968400
4
- data.tar.gz: 43220b3bb3e0b9cdaae4bf96de10d80eb3145af3f52584bff1c73fb45d4e7349
3
+ metadata.gz: 377f77798282f817bcd1632482c5075ca10f8c65f1b5ce9abc4e7d3f1aeae2ee
4
+ data.tar.gz: ef1dc980b18c772c703a7b94ee35a3c35049b22d84ffeca742ee5ce569a32f7e
5
5
  SHA512:
6
- metadata.gz: cec86c2467edb73bb4c46c520c2fb793c1c4b67800213d5ef68348aa2ddae4c91b25e85d82fe73e599fc7cb188aee96b63feb5674b10885e7aa7100027f240ac
7
- data.tar.gz: a69f88f24623b2329515daff60be1df8d3092046c51cd20b995cb6a0ba58a176dc29898640a07cdc6d9904315b10a321b7d7f6cc9cb7c301e768e991d55a3980
6
+ metadata.gz: e058db4108f3da4be9dd9c6e5699f1cdb5419463cfe93300cd96dbd8bf612894f23bf97fab095480edc22e70a9c98f4e172e56edb78e8c1108bac3c66b1918be
7
+ data.tar.gz: 8f199adf232216645878442735570e5ee04a70dd4a8f43031bd0e24d67c12f7af7f5ab8ba7e4de2accfb6143a5801f09fa5b173cf80ed88a9e43ceff5d31a3f2
@@ -24,6 +24,7 @@ module Captive
24
24
  def apps_api = @apps_api ||= Dashboard::AppsResource.new(api: dashboard)
25
25
  def env_api = @env_api ||= Dashboard::EnvVarsResource.new(api: dashboard)
26
26
  def crons_api = @crons_api ||= Dashboard::ScheduledTasksResource.new(api: dashboard)
27
+ def domains_api = @domains_api ||= Dashboard::DomainsResource.new(api: dashboard)
27
28
 
28
29
  def active_kubeconfig
29
30
  env_kc = ENV["KUBECONFIG"]
data/lib/captive/cli.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "cli/dependencies"
6
6
  require_relative "commands/apps_commands"
7
7
  require_relative "commands/env_commands"
8
8
  require_relative "commands/crons_commands"
9
+ require_relative "commands/domains_commands"
9
10
  require_relative "commands/db_commands"
10
11
  require_relative "commands/user_login"
11
12
  require_relative "commands/auth_commands"
@@ -62,6 +63,9 @@ module Captive
62
63
  desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
63
64
  subcommand "crons", Commands::CronsCommands
64
65
 
66
+ desc "domains SUBCOMMAND", "Domaines d'une app"
67
+ subcommand "domains", Commands::DomainsCommands
68
+
65
69
  desc "auth SUBCOMMAND", "Jeton pour kubectl", hide: true
66
70
  subcommand "auth", Commands::AuthCommands
67
71
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Captive
6
+ module Commands
7
+ # Dit ce qu'est devenu un geste sur un Domaine. Sans attente, la demande est seulement
8
+ # acceptée ; avec une attente, le bloc la reçoit et rend le Domaine appliqué.
9
+ class DomainAnnouncer
10
+ def initialize(paint:, presenter:, waiter: nil)
11
+ @paint = paint
12
+ @presenter = presenter
13
+ @waiter = waiter
14
+ end
15
+
16
+ def call(message)
17
+ return puts(@paint.green(" ✓ #{message} : #{CLIDependencies::ASYNC_NOTICE}")) unless @waiter
18
+
19
+ applied(message, yield(@waiter))
20
+ rescue PollTimeout => timeout
21
+ abort @paint.red("✗ Timeout : #{timeout.message}")
22
+ end
23
+
24
+ private
25
+
26
+ def applied(message, entry)
27
+ puts @paint.green(" ✓ #{message} : appliqué")
28
+ puts " #{@presenter.line(entry)}" if entry
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../poller"
4
+ require_relative "../errors"
5
+
6
+ module Captive
7
+ module Commands
8
+ # Attend que le cluster reflète un geste sur un Domaine : ni en attente ni échoué, et
9
+ # présent (absent après une suppression). Le DNS n'entre pas en compte : il dépend du
10
+ # propriétaire du Domaine, pas de Captive.
11
+ class DomainWait
12
+ def initialize(domains:, poller: nil, timeout: 600)
13
+ @domains = domains
14
+ @poller = poller || Poller.new(interval: 10, timeout: timeout)
15
+ end
16
+
17
+ # Rend le Domaine tel que le cluster le montre une fois le geste appliqué.
18
+ def until_applied(app, hostname)
19
+ entry = nil
20
+ poll(app, hostname) { |found| (entry = found) && !found["pending"] }
21
+ entry
22
+ end
23
+
24
+ def until_gone(app, hostname)
25
+ poll(app, hostname, &:nil?)
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def poll(app, hostname)
32
+ @poller.poll_until(context: "#{hostname} sur #{app}") do
33
+ yield check(hostname, @domains.find(app, hostname))
34
+ end
35
+ end
36
+
37
+ def check(hostname, entry)
38
+ return entry unless entry&.fetch("failed", false)
39
+
40
+ raise DomainChangeFailed, "#{hostname} : #{entry["failure_reason"] || "échec"}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Commands
5
+ # Les Domaines d'une app via le dashboard. Chaque geste est appliqué plus tard par
6
+ # captive-platform ; --wait attend que le cluster le reflète.
7
+ class DomainsCommands < Thor
8
+ include CLIDependencies
9
+
10
+ def self.exit_on_failure? = true
11
+
12
+ WAIT_OPTIONS = {
13
+ wait: { type: :boolean, desc: "Attend que le changement soit appliqué" },
14
+ timeout: { type: :numeric, default: 600, desc: "Attente max en secondes avec --wait" },
15
+ }.freeze
16
+
17
+ def self.wait_options
18
+ WAIT_OPTIONS.each { |name, settings| method_option(name, **settings) }
19
+ end
20
+
21
+ desc "list APP", "Liste les domaines de l'app et l'état de leur DNS"
22
+ long_desc <<~DESC
23
+ \x05Exemple :
24
+ \x05 captive domains list my-app
25
+ \x05 captive domains list my-app --refresh
26
+ DESC
27
+ method_option :refresh, type: :boolean, desc: "Vérifie à nouveau le DNS sans attendre le cache"
28
+ def list(app)
29
+ entries = options[:refresh] ? domains_api.recheck(app) : domains_api.list(app)
30
+ return puts(paint.dim("Aucun domaine.")) if entries.empty?
31
+
32
+ entries.each { |entry| puts presenter.line(entry) }
33
+ end
34
+
35
+ desc "add APP DOMAINE", "Ajoute un domaine à l'app"
36
+ long_desc <<~DESC
37
+ L'enregistrement DNS à créer s'affiche ensuite dans `captive domains list`.
38
+
39
+ \x05Exemple :
40
+ \x05 captive domains add my-app www.example.fr --wait
41
+ DESC
42
+ wait_options
43
+ def add(app, hostname)
44
+ domains_api.add(app, hostname)
45
+ announcer.call("#{hostname} ajouté") { |wait| wait.until_applied(app, hostname) }
46
+ puts paint.dim(" DNS à créer : captive domains list #{app}") unless options[:wait]
47
+ end
48
+
49
+ desc "rm APP DOMAINE", "Supprime un domaine de l'app"
50
+ long_desc <<~DESC
51
+ Le domaine cesse aussitôt de servir l'app : il faut le retaper pour confirmer.
52
+
53
+ \x05Exemple :
54
+ \x05 captive domains rm my-app www.example.fr
55
+ \x05 captive domains rm my-app www.example.fr --yes
56
+ DESC
57
+ method_option :yes, type: :boolean, desc: "Supprime sans demander de confirmation"
58
+ wait_options
59
+ def rm(app, hostname)
60
+ confirm_removal(hostname)
61
+ domains_api.remove(app, hostname)
62
+ announcer.call("#{hostname} supprimé") { |wait| wait.until_gone(app, hostname) }
63
+ end
64
+
65
+ desc "redirect APP DOMAINE", "Redirige un domaine vers un autre domaine de l'app"
66
+ long_desc <<~DESC
67
+ \x05Exemple :
68
+ \x05 captive domains redirect my-app example.fr --to www.example.fr
69
+ DESC
70
+ method_option :to, type: :string, required: true, desc: "Domaine servi vers lequel rediriger"
71
+ method_option :status, type: :numeric, default: 301, enum: [ 301, 302 ], desc: "Code HTTP de redirection"
72
+ wait_options
73
+ def redirect(app, hostname)
74
+ target = options[:to]
75
+ domains_api.redirect(app, hostname, { to: target, status: options[:status] })
76
+ announcer.call("#{hostname} redirigé vers #{target}") { |wait| wait.until_applied(app, hostname) }
77
+ end
78
+
79
+ desc "serve APP DOMAINE", "Repasse un domaine redirigé en domaine servi"
80
+ long_desc <<~DESC
81
+ \x05Exemple :
82
+ \x05 captive domains serve my-app example.fr
83
+ DESC
84
+ wait_options
85
+ def serve(app, hostname)
86
+ domains_api.serve(app, hostname)
87
+ announcer.call("#{hostname} servi") { |wait| wait.until_applied(app, hostname) }
88
+ end
89
+
90
+ private
91
+
92
+ def presenter = Presenter::Domain.new(paint: paint)
93
+ def domain_wait = DomainWait.new(domains: domains_api, timeout: options[:timeout])
94
+
95
+ def announcer
96
+ DomainAnnouncer.new(paint: paint, presenter: presenter, waiter: (domain_wait if options[:wait]))
97
+ end
98
+
99
+ def confirm_removal(hostname)
100
+ return if options[:yes]
101
+ return if ask("Retape #{hostname} pour confirmer la suppression :").to_s.strip == hostname
102
+
103
+ warn paint.red("Abandon : rien n'a été supprimé.")
104
+ exit 1
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Le chemin d'une collection rattachée à une app (`/apps/:app/<collection>`), et d'un de
6
+ # ses éléments, chaque segment encodé.
7
+ AppPath = Data.define(:collection) do
8
+ def call(app, key = nil)
9
+ base = "/apps/#{DashboardApi.segment(app)}/#{collection}"
10
+ key ? "#{base}/#{DashboardApi.segment(key)}" : base
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Dashboard
5
+ # Les Domaines d'une app : lus du cluster, modifiés via captive-platform (réponse 202).
6
+ # `recheck` relit le DNS sans attendre le cache ; une redirection est `{ to:, status: }`.
7
+ class DomainsResource
8
+ PATH = AppPath.new("domains")
9
+
10
+ def initialize(api:)
11
+ @api = api
12
+ end
13
+
14
+ def list(app) = @api.get(path(app))
15
+ def recheck(app) = @api.get("#{path(app)}?refresh=1")
16
+ def find(app, hostname) = list(app).find { |domain| domain["hostname"] == hostname }
17
+ def add(app, hostname) = @api.post(path(app), { hostname: hostname })
18
+ def remove(app, hostname) = @api.delete(path(app, hostname))
19
+ def serve(app, hostname) = @api.patch(path(app, hostname), { mode: "serve" })
20
+ def redirect(app, hostname, rule) = @api.patch(path(app, hostname), rule)
21
+
22
+ private
23
+
24
+ def path(...) = PATH.call(...)
25
+ end
26
+ end
27
+ end
@@ -4,6 +4,8 @@ module Captive
4
4
  module Dashboard
5
5
  # Les variables d'environnement d'une app : lues du Secret, écrites par set-env.
6
6
  class EnvVarsResource
7
+ PATH = AppPath.new("env_vars")
8
+
7
9
  def initialize(api:)
8
10
  @api = api
9
11
  end
@@ -18,10 +20,7 @@ module Captive
18
20
 
19
21
  private
20
22
 
21
- def path(app, key = nil)
22
- base = "/apps/#{DashboardApi.segment(app)}/env_vars"
23
- key ? "#{base}/#{DashboardApi.segment(key)}" : base
24
- end
23
+ def path(...) = PATH.call(...)
25
24
  end
26
25
  end
27
26
  end
@@ -4,6 +4,8 @@ module Captive
4
4
  module Dashboard
5
5
  # Les tâches planifiées d'une app : lues du cluster, déclarées via captive-platform.
6
6
  class ScheduledTasksResource
7
+ PATH = AppPath.new("scheduled_tasks")
8
+
7
9
  def initialize(api:)
8
10
  @api = api
9
11
  end
@@ -23,10 +25,7 @@ module Captive
23
25
 
24
26
  private
25
27
 
26
- def path(app, name = nil)
27
- base = "/apps/#{DashboardApi.segment(app)}/scheduled_tasks"
28
- name ? "#{base}/#{DashboardApi.segment(name)}" : base
29
- end
28
+ def path(...) = PATH.call(...)
30
29
  end
31
30
  end
32
31
  end
@@ -24,4 +24,5 @@ module Captive
24
24
  class NotFoundError < DashboardError; end
25
25
  class ValidationError < DashboardError; end
26
26
  class ServiceUnavailableError < DashboardError; end
27
+ class DomainChangeFailed < DashboardError; end
27
28
  end
@@ -13,11 +13,14 @@ require_relative "one_off_spec"
13
13
  require_relative "session_id"
14
14
  require_relative "poller"
15
15
  require_relative "dashboard_api"
16
+ require_relative "dashboard/app_path"
16
17
  require_relative "dashboard/apps_resource"
17
18
  require_relative "dashboard/env_vars_resource"
18
19
  require_relative "dashboard/scheduled_tasks_resource"
20
+ require_relative "dashboard/domains_resource"
19
21
  require_relative "presenter/app_info"
20
22
  require_relative "presenter/scheduled_task"
23
+ require_relative "presenter/domain"
21
24
  require_relative "activity_trail"
22
25
  require_relative "errors"
23
26
  require_relative "ui/spinner"
@@ -28,6 +31,8 @@ require_relative "commands/one_off_session"
28
31
  require_relative "commands/run"
29
32
  require_relative "commands/logs"
30
33
  require_relative "commands/apps_restart"
34
+ require_relative "commands/domain_wait"
35
+ require_relative "commands/domain_announcer"
31
36
  require_relative "commands/db_dump"
32
37
  require_relative "commands/db_restore"
33
38
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Presenter
5
+ # Une ligne par Domaine : son nom, son état (servi, redirigé, en attente, échoué) et ce que
6
+ # dit le DNS. Un geste en cours masque le diagnostic, qui ne vaut que pour l'état atteint.
7
+ class Domain
8
+ HEALTH = { "certificate_pending" => "certificat en cours", "invalid" => "route refusée" }.freeze
9
+
10
+ def initialize(paint:)
11
+ @paint = paint
12
+ end
13
+
14
+ def line(entry)
15
+ change = change(entry)
16
+ [ entry["hostname"], change || reached(entry), (health(entry) unless change) ].compact.join(" ")
17
+ end
18
+
19
+ private
20
+
21
+ # Un geste en attente ou échoué ; nil quand le Domaine est dans l'état demandé.
22
+ def change(entry)
23
+ return failure(entry) if entry["failed"]
24
+
25
+ @paint.yellow("en attente (#{entry["pending_change"]})") if entry["pending"]
26
+ end
27
+
28
+ def reached(entry)
29
+ redirect = entry["redirect"]
30
+ return "→ #{redirect["to"]} (#{redirect["status"]})" if redirect
31
+
32
+ entry["primary"] ? "servi, principal" : "servi"
33
+ end
34
+
35
+ def failure(entry)
36
+ reason = entry["failure_reason"]
37
+ @paint.red(reason ? "échoué : #{reason}" : "échoué")
38
+ end
39
+
40
+ def health(entry)
41
+ status = entry["health"]
42
+ return @paint.green("✓") if status == "valid"
43
+
44
+ expected = entry.dig("dns", "expected")
45
+ return @paint.red("DNS à créer : #{expected.values_at("type", "name").join(" ")} → #{expected["value"]}") if expected
46
+
47
+ @paint.yellow(HEALTH.fetch(status, status.to_s))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Captive
4
4
  module ShellCompletions
5
- ROOT_COMMANDS = %w[apps env crons db login whoami console run logs version completions].freeze
5
+ ROOT_COMMANDS = %w[apps env crons domains db login whoami console run logs version completions].freeze
6
6
 
7
7
  SUBCOMMANDS = {
8
8
  "apps" => %w[list info restart rebuild],
9
9
  "env" => %w[list set unset],
10
10
  "crons" => %w[list add edit rm suspend resume trigger runs],
11
+ "domains" => %w[list add rm redirect serve],
11
12
  "db" => %w[restore],
12
13
  }.freeze
13
14
 
@@ -23,6 +24,7 @@ module Captive
23
24
  apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
24
25
  env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
25
26
  crons) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["crons"].join(" ")}" -- "$cur")); return ;;
27
+ domains) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["domains"].join(" ")}" -- "$cur")); return ;;
26
28
  db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
27
29
  esac
28
30
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Captive
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
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.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Captive Studio
@@ -120,6 +120,9 @@ files:
120
120
  - lib/captive/commands/db_psql.rb
121
121
  - lib/captive/commands/db_restore.rb
122
122
  - lib/captive/commands/db_target.rb
123
+ - lib/captive/commands/domain_announcer.rb
124
+ - lib/captive/commands/domain_wait.rb
125
+ - lib/captive/commands/domains_commands.rb
123
126
  - lib/captive/commands/env_commands.rb
124
127
  - lib/captive/commands/env_set.rb
125
128
  - lib/captive/commands/logs.rb
@@ -128,7 +131,9 @@ files:
128
131
  - lib/captive/commands/user_login.rb
129
132
  - lib/captive/credentials.rb
130
133
  - lib/captive/credentials_store.rb
134
+ - lib/captive/dashboard/app_path.rb
131
135
  - lib/captive/dashboard/apps_resource.rb
136
+ - lib/captive/dashboard/domains_resource.rb
132
137
  - lib/captive/dashboard/env_vars_resource.rb
133
138
  - lib/captive/dashboard/scheduled_tasks_resource.rb
134
139
  - lib/captive/dashboard_api.rb
@@ -146,6 +151,7 @@ files:
146
151
  - lib/captive/poller.rb
147
152
  - lib/captive/presenter/ansi_stripper.rb
148
153
  - lib/captive/presenter/app_info.rb
154
+ - lib/captive/presenter/domain.rb
149
155
  - lib/captive/presenter/log_colorizer.rb
150
156
  - lib/captive/presenter/log_line.rb
151
157
  - lib/captive/presenter/scheduled_task.rb