captive-cli 1.2.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: 29616a82b79478784474098a994449670c0e5ba17c4ddbc10b773168adbd5480
4
- data.tar.gz: 5dfa16e3181afafa0f3800bfbacce3b07ab0eac18f3863a785b58b66fa4726d4
3
+ metadata.gz: 377f77798282f817bcd1632482c5075ca10f8c65f1b5ce9abc4e7d3f1aeae2ee
4
+ data.tar.gz: ef1dc980b18c772c703a7b94ee35a3c35049b22d84ffeca742ee5ce569a32f7e
5
5
  SHA512:
6
- metadata.gz: 66462246f725f169bdd2c74e41bf896774fc821961afd12171455b1c4ce2ef94006c3f091203bb7495f8ba70e97e15a6c194ac21100a38b3072c8c6f960f8955
7
- data.tar.gz: 4c78529a60c79c0021dcb53a8f124b9fd4a706caa05cb22bb65a4f3a25687b0c288f329301c51628c6f9b216822d7ddbc3e745698f609c2834dc181d93eaef12
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,8 +6,11 @@ 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"
12
+ require_relative "commands/auth_commands"
13
+ require_relative "credentials_store"
11
14
  require_relative "dashboard_client"
12
15
  require_relative "github_device_flow"
13
16
  require_relative "cluster_endpoint"
@@ -23,13 +26,7 @@ module Captive
23
26
  def self.start(args = ARGV, config = {})
24
27
  args = [ "help", args.first ] if args.length == 2 && %w[--help -h].include?(args.last)
25
28
 
26
- case UpdateChecker.new.check
27
- when :blocked
28
- warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
29
- exit 1
30
- when :update_available
31
- warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
32
- end
29
+ check_for_update unless args.first(2) == %w[auth token]
33
30
  super
34
31
  rescue AuthError, NotAuthenticatedError, NoTtyError, MissingDependencyError, DashboardError => e
35
32
  warn e.message
@@ -40,6 +37,19 @@ module Captive
40
37
  exit 1
41
38
  end
42
39
 
40
+ # kubectl appelle `captive auth token` à chaque commande : ni requête réseau en plus, ni
41
+ # avertissement à afficher.
42
+ def self.check_for_update
43
+ case UpdateChecker.new.check
44
+ when :blocked
45
+ warn "Version #{VERSION} n'est plus supportée. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
46
+ exit 1
47
+ when :update_available
48
+ warn "⚠ Nouvelle version disponible. Mettez à jour : brew upgrade captive-studio/tap/captive-cli"
49
+ end
50
+ end
51
+ private_class_method :check_for_update
52
+
43
53
  # ── Subcommands ─────────────────────────────────────────────────────────
44
54
  desc "apps SUBCOMMAND", "Gestion des apps"
45
55
  subcommand "apps", Commands::AppsCommands
@@ -53,6 +63,12 @@ module Captive
53
63
  desc "crons SUBCOMMAND", "Tâches planifiées d'une app"
54
64
  subcommand "crons", Commands::CronsCommands
55
65
 
66
+ desc "domains SUBCOMMAND", "Domaines d'une app"
67
+ subcommand "domains", Commands::DomainsCommands
68
+
69
+ desc "auth SUBCOMMAND", "Jeton pour kubectl", hide: true
70
+ subcommand "auth", Commands::AuthCommands
71
+
56
72
  # ── Root commands ────────────────────────────────────────────────────────
57
73
  map "--version" => :version
58
74
  desc "version", "Affiche la version de la CLI"
@@ -69,7 +85,7 @@ module Captive
69
85
  Commands::UserLogin.new(
70
86
  github: GithubDeviceFlow.new,
71
87
  cluster: ClusterEndpoint.new,
72
- token_path: File.expand_path("~/.config/captive/token"),
88
+ store: CredentialsStore.new,
73
89
  kubeconfig_path: File.expand_path("~/.config/captive/kubeconfig")
74
90
  ).call
75
91
  puts "Authentifié avec succès."
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_token"
4
+ require_relative "../session_token"
5
+
6
+ module Captive
7
+ module Commands
8
+ # `captive auth` : ce dont kubectl a besoin pour s'authentifier, masqué de l'aide.
9
+ class AuthCommands < Thor
10
+ def self.exit_on_failure? = true
11
+
12
+ desc "token", "Imprime un ExecCredential pour kubectl (appelé par le kubeconfig)"
13
+ def token = AuthToken.new(session: SessionToken.new).call
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Captive
7
+ module Commands
8
+ # Le plugin d'authentification exec de kubectl : un ExecCredential sur stdout, et rien d'autre.
9
+ class AuthToken
10
+ def initialize(session:, out: $stdout)
11
+ @session = session
12
+ @out = out
13
+ end
14
+
15
+ def call
16
+ @out.puts JSON.generate(exec_credential(@session.current))
17
+ end
18
+
19
+ private
20
+
21
+ def exec_credential(credentials)
22
+ expires_at = credentials.expires_at
23
+ status = { token: credentials.access_token }
24
+ status[:expirationTimestamp] = expires_at.utc.iso8601 if expires_at
25
+ { apiVersion: "client.authentication.k8s.io/v1", kind: "ExecCredential", status: status }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -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
@@ -9,36 +9,36 @@ module Captive
9
9
  def open(url) = system(open_cmd, url)
10
10
  end
11
11
 
12
- def initialize(github:, cluster:, token_path:, kubeconfig_path:,
12
+ def initialize(github:, cluster:, store:, kubeconfig_path:,
13
13
  ui: method(:puts), browser: Browser.new("open"))
14
14
  @github = github
15
15
  @cluster = cluster
16
- @token_path = token_path
16
+ @store = store
17
17
  @kubeconfig_path = kubeconfig_path
18
18
  @ui = ui
19
19
  @browser = browser
20
20
  end
21
21
 
22
22
  def call
23
- ensure_dirs
24
23
  device = @github.request_device_code
25
24
  @browser.open(device[:verification_uri])
26
25
  @ui.call("Entre le code sur #{device[:verification_uri]} : #{device[:user_code]}")
27
- token = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
28
- File.write(@token_path, token)
29
- File.chmod(0o600, @token_path)
30
- File.write(@kubeconfig_path, kubeconfig_content(token))
31
- File.chmod(0o600, @kubeconfig_path)
26
+ credentials = @github.poll_token(device_code: device[:device_code], interval: device[:interval])
27
+ @store.write(credentials)
28
+ write_kubeconfig(@cluster.fetch(credentials.access_token))
32
29
  end
33
30
 
34
31
  private
35
32
 
36
- def ensure_dirs
37
- [ @token_path, @kubeconfig_path ].each { |p| FileUtils.mkdir_p(File.dirname(p)) }
33
+ def write_kubeconfig(endpoint)
34
+ FileUtils.mkdir_p(File.dirname(@kubeconfig_path))
35
+ File.write(@kubeconfig_path, kubeconfig_content(endpoint))
36
+ File.chmod(0o600, @kubeconfig_path)
38
37
  end
39
38
 
40
- def kubeconfig_content(token)
41
- endpoint = @cluster.fetch(token)
39
+ # Pas de jeton dans le kubeconfig : kubectl demande à `captive auth token` un jeton
40
+ # renouvelé quand il le faut.
41
+ def kubeconfig_content(endpoint)
42
42
  <<~YAML
43
43
  apiVersion: v1
44
44
  kind: Config
@@ -50,7 +50,14 @@ module Captive
50
50
  users:
51
51
  - name: captive-user
52
52
  user:
53
- token: #{token}
53
+ exec:
54
+ apiVersion: client.authentication.k8s.io/v1
55
+ command: captive
56
+ args:
57
+ - auth
58
+ - token
59
+ interactiveMode: Never
60
+ provideClusterInfo: false
54
61
  contexts:
55
62
  - name: captive
56
63
  context:
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Captive
6
+ # Les jetons GitHub de l'utilisateur et leurs échéances. Un jeton sans `expires_at` vient d'une
7
+ # GitHub App sans expiration (ou de l'ancien fichier `token`) : il n'est jamais renouvelé.
8
+ Credentials = Data.define(:access_token, :refresh_token, :expires_at, :refresh_token_expires_at) do
9
+ def self.from_github(data, now:)
10
+ new(access_token: data.fetch("access_token"),
11
+ refresh_token: data["refresh_token"],
12
+ expires_at: after(now, data["expires_in"]),
13
+ refresh_token_expires_at: after(now, data["refresh_token_expires_in"]))
14
+ end
15
+
16
+ def self.from_json_hash(hash)
17
+ new(access_token: hash.fetch("access_token"),
18
+ refresh_token: hash["refresh_token"],
19
+ expires_at: parse_time(hash["expires_at"]),
20
+ refresh_token_expires_at: parse_time(hash["refresh_token_expires_at"]))
21
+ end
22
+
23
+ def self.after(now, seconds) = seconds && (now + seconds.to_i)
24
+ def self.parse_time(value) = value && Time.iso8601(value)
25
+ private_class_method :after, :parse_time
26
+
27
+ def initialize(access_token:, refresh_token: nil, expires_at: nil, refresh_token_expires_at: nil) = super
28
+
29
+ # Moins de `margin` secondes de validité : on renouvelle avant qu'une requête n'échoue.
30
+ def expiring?(now, margin: 300) = expirable? && expires_at - now < margin
31
+ def expirable? = !expires_at.nil?
32
+ def refreshable?(now) = !refresh_token.nil? && (refresh_token_expires_at.nil? || refresh_token_expires_at > now)
33
+
34
+ def to_json_hash
35
+ {
36
+ "access_token" => access_token,
37
+ "refresh_token" => refresh_token,
38
+ "expires_at" => expires_at&.utc&.iso8601,
39
+ "refresh_token_expires_at" => refresh_token_expires_at&.utc&.iso8601,
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "credentials"
7
+ require_relative "errors"
8
+
9
+ module Captive
10
+ # Où vivent les jetons : `credentials.json` (0600, écrit atomiquement), et en repli l'ancien
11
+ # fichier `token` des postes qui ne se sont pas reconnectés depuis l'arrivée du renouvellement.
12
+ class CredentialsStore
13
+ DIR = File.expand_path("~/.config/captive")
14
+
15
+ def initialize(dir: DIR)
16
+ @path = File.join(dir, "credentials.json")
17
+ @legacy_path = File.join(dir, "token")
18
+ @lock_path = File.join(dir, "credentials.lock")
19
+ end
20
+
21
+ def read
22
+ return Credentials.from_json_hash(JSON.parse(File.read(@path))) if File.exist?(@path)
23
+ return Credentials.new(access_token: File.read(@legacy_path).strip) if File.exist?(@legacy_path)
24
+
25
+ raise NotAuthenticatedError, "Pas de session active. Lance : captive login"
26
+ end
27
+
28
+ # Un fichier temporaire renommé : un lecteur concurrent voit l'ancien contenu ou le nouveau,
29
+ # jamais un fichier à moitié écrit.
30
+ def write(credentials)
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ tmp = "#{@path}.#{SecureRandom.hex(4)}.tmp"
33
+ File.write(tmp, JSON.pretty_generate(credentials.to_json_hash), perm: 0o600)
34
+ File.rename(tmp, @path)
35
+ end
36
+
37
+ # Verrou inter-processus : deux commandes (ou kubectl et une commande) ne renouvellent pas en
38
+ # même temps un refresh_token à usage unique.
39
+ def exclusively
40
+ FileUtils.mkdir_p(File.dirname(@lock_path))
41
+ File.open(@lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
42
+ lock.flock(File::LOCK_EX)
43
+ yield
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,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
@@ -3,11 +3,12 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
- require_relative "token_file"
6
+ require_relative "session_token"
7
7
  require_relative "dashboard_response"
8
8
 
9
9
  module Captive
10
- # Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer, timeouts courts, et
10
+ # Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer (rejoué une fois renouvelé
11
+ # sur un 401), timeouts courts, et
11
12
  # erreurs HTTP traduites par DashboardResponse. Les chemins sont relatifs à /api.
12
13
  class DashboardApi
13
14
  BASE_URL = "https://dashboard.captive.dev"
@@ -17,7 +18,7 @@ module Captive
17
18
 
18
19
  def self.segment(value) = URI.encode_uri_component(value.to_s)
19
20
 
20
- def initialize(token: TokenFile.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
21
+ def initialize(token: SessionToken.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
21
22
  read_timeout: 15)
22
23
  @token = token
23
24
  @base_url = base_url
@@ -34,17 +35,18 @@ module Captive
34
35
 
35
36
  def request(verb, path, body = nil)
36
37
  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)
38
+ response = @token.authorize do |token|
39
+ @starter.call(uri.host, uri.port, use_ssl: uri.scheme == "https",
40
+ open_timeout: 5, read_timeout: @read_timeout) do |http|
41
+ http.request(build(verb, uri, body, token))
42
+ end
41
43
  end
42
44
  DashboardResponse.new(response).parse
43
45
  end
44
46
 
45
- def build(verb, uri, body)
47
+ def build(verb, uri, body, token)
46
48
  req = VERBS.fetch(verb).new(uri.request_uri)
47
- req["Authorization"] = "Bearer #{@token.read}"
49
+ req["Authorization"] = "Bearer #{token}"
48
50
  req["Accept"] = "application/json"
49
51
  if body
50
52
  req["Content-Type"] = "application/json"
@@ -5,22 +5,24 @@ require "uri"
5
5
  require "json"
6
6
  require "time"
7
7
  require_relative "errors"
8
+ require_relative "session_token"
8
9
 
9
10
  module Captive
10
11
  class DashboardClient
11
12
  DASHBOARD_URL = "https://dashboard.captive.dev"
12
- TOKEN_PATH = File.expand_path("~/.config/captive/token")
13
13
 
14
14
  PAGE_SIZE = 500
15
15
 
16
- def logs(app, start_at: nil, end_at: nil, limit: nil)
17
- token = read_token
16
+ def initialize(session: SessionToken.new)
17
+ @session = session
18
+ end
18
19
 
20
+ def logs(app, start_at: nil, end_at: nil, limit: nil)
19
21
  if start_at || end_at
20
- fetch_all(app, token, start_at:, end_at:)
22
+ fetch_all(app, start_at:, end_at:)
21
23
  else
22
24
  uri = build_uri(app, limit: limit || PAGE_SIZE)
23
- response = get(uri, token)
25
+ response = get(uri)
24
26
  page, _ = extract_page(handle_response(response))
25
27
  page
26
28
  end
@@ -34,20 +36,20 @@ module Captive
34
36
  body[:command] = command if command
35
37
  body[:session] = session if session
36
38
 
37
- post(uri, read_token, body)
39
+ post(uri, body)
38
40
  end
39
41
 
40
42
  private
41
43
 
42
44
  # Pagine en remontant depuis end_at vers start_at (direction backward côté Loki).
43
45
  # Chaque page utilise le ts du log le plus ancien comme prochain curseur `before`.
44
- def fetch_all(app, token, start_at:, end_at:)
46
+ def fetch_all(app, start_at:, end_at:)
45
47
  pages = []
46
48
  cursor = end_at
47
49
 
48
50
  loop do
49
51
  uri = build_uri(app, start_at:, before: cursor, limit: PAGE_SIZE)
50
- response = get(uri, token)
52
+ response = get(uri)
51
53
  page, has_more = extract_page(handle_response(response))
52
54
 
53
55
  break if page.empty?
@@ -74,12 +76,6 @@ module Captive
74
76
  end
75
77
  end
76
78
 
77
- def read_token
78
- raise NotAuthenticatedError, "Pas de session active. Lance : captive login" unless File.exist?(TOKEN_PATH)
79
-
80
- File.read(TOKEN_PATH).strip
81
- end
82
-
83
79
  def build_uri(app, start_at: nil, end_at: nil, before: nil, limit:)
84
80
  params = { limit: }
85
81
  params[:start_at] = start_at.utc.iso8601(3) if start_at
@@ -90,21 +86,25 @@ module Captive
90
86
  uri
91
87
  end
92
88
 
93
- def get(uri, token)
94
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
95
- open_timeout: 5, read_timeout: 30) do |http|
96
- http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
89
+ def get(uri)
90
+ @session.authorize do |token|
91
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
92
+ open_timeout: 5, read_timeout: 30) do |http|
93
+ http.get(uri.request_uri, "Authorization" => "Bearer #{token}", "Accept" => "application/json")
94
+ end
97
95
  end
98
96
  end
99
97
 
100
98
  # Timeouts courts : une console ne doit pas attendre un dashboard qui ne répond pas.
101
- def post(uri, token, body)
102
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
103
- open_timeout: 2, read_timeout: 5) do |http|
104
- http.post(uri.request_uri, JSON.generate(body),
105
- "Authorization" => "Bearer #{token}",
106
- "Accept" => "application/json",
107
- "Content-Type" => "application/json")
99
+ def post(uri, body)
100
+ @session.authorize do |token|
101
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
102
+ open_timeout: 2, read_timeout: 5) do |http|
103
+ http.post(uri.request_uri, JSON.generate(body),
104
+ "Authorization" => "Bearer #{token}",
105
+ "Accept" => "application/json",
106
+ "Content-Type" => "application/json")
107
+ end
108
108
  end
109
109
  end
110
110
 
@@ -9,6 +9,11 @@ module Captive
9
9
  class ExecError < StandardError; end
10
10
  class AuthError < StandardError; end
11
11
  class NotAuthenticatedError < StandardError; end
12
+
13
+ # Le refresh_token a expiré ou GitHub le refuse : seul un nouveau `captive login` rétablit l'accès.
14
+ class SessionExpiredError < AuthError
15
+ def initialize(message = "Session expirée. Lance : captive login") = super
16
+ end
12
17
  class NoTtyError < StandardError; end
13
18
  class MissingDependencyError < StandardError; end
14
19
 
@@ -19,4 +24,5 @@ module Captive
19
24
  class NotFoundError < DashboardError; end
20
25
  class ValidationError < DashboardError; end
21
26
  class ServiceUnavailableError < DashboardError; end
27
+ class DomainChangeFailed < DashboardError; end
22
28
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "http_client"
4
+ require_relative "credentials"
4
5
 
5
6
  module Captive
6
7
  class GithubDeviceFlow
7
8
  CLIENT_ID = "Iv23liHr9n6eK4XhwtnP"
8
9
 
9
- def initialize(http: HttpClient.new)
10
- @http = http
10
+ def initialize(http: HttpClient.new, clock: -> { Time.now })
11
+ @http = http
12
+ @clock = clock
11
13
  end
12
14
 
13
15
  RETRIABLE_ERRORS = %w[authorization_pending slow_down].freeze
@@ -19,7 +21,7 @@ module Captive
19
21
  data = @http.post("https://github.com/login/oauth/access_token",
20
22
  client_id: CLIENT_ID, device_code: device_code,
21
23
  grant_type: "urn:ietf:params:oauth:grant-type:device_code")
22
- return data["access_token"] if data["access_token"]
24
+ return Credentials.from_github(data, now: @clock.call) if data["access_token"]
23
25
  raise DeviceFlowError, data["error"] unless RETRIABLE_ERRORS.include?(data["error"])
24
26
  end
25
27
  end
@@ -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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "credentials_store"
4
+ require_relative "token_refresher"
5
+ require_relative "errors"
6
+
7
+ module Captive
8
+ # La source unique du jeton envoyé au dashboard et à kubectl : renouvelé quand il expire dans
9
+ # moins de 5 minutes, ou quand le dashboard le refuse (401).
10
+ class SessionToken
11
+ INVALID = "Token invalide ou expiré. Lance : captive login"
12
+
13
+ def initialize(store: CredentialsStore.new, refresher: TokenRefresher.new, clock: -> { Time.now })
14
+ @store = store
15
+ @refresher = refresher
16
+ @clock = clock
17
+ end
18
+
19
+ def current
20
+ now = @clock.call
21
+ credentials = @store.read
22
+ return credentials unless credentials.expiring?(now)
23
+
24
+ renew { |fresh| fresh.expiring?(now) }
25
+ end
26
+
27
+ # Après un 401 sur `stale_token` : renouvelle, sauf si un autre processus l'a déjà fait.
28
+ def refresh(stale_token)
29
+ renew { |fresh| fresh.access_token == stale_token }.access_token
30
+ end
31
+
32
+ # Pour les transports : la requête est rejouée une fois avec un jeton renouvelé sur un 401.
33
+ def authorize
34
+ token = current.access_token
35
+ response = yield token
36
+ return response unless response.code.to_i == 401
37
+
38
+ yield refresh(token)
39
+ end
40
+
41
+ private
42
+
43
+ # Sous verrou, on relit : si un autre processus a renouvelé entre-temps, son jeton suffit.
44
+ def renew(&stale)
45
+ @store.exclusively do
46
+ fresh = @store.read
47
+ next fresh unless stale.call(fresh)
48
+
49
+ @refresher.refresh(refreshable(fresh).refresh_token).tap { |credentials| @store.write(credentials) }
50
+ end
51
+ end
52
+
53
+ def refreshable(credentials)
54
+ return credentials if credentials.refreshable?(@clock.call)
55
+ raise SessionExpiredError if credentials.expirable?
56
+
57
+ raise AuthError, INVALID
58
+ end
59
+ end
60
+ 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
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_client"
4
+ require_relative "credentials"
5
+ require_relative "errors"
6
+ require_relative "github_device_flow"
7
+
8
+ module Captive
9
+ # Échange un refresh_token contre de nouveaux jetons. Jeton issu du device flow : pas de
10
+ # client_secret. L'échange est à usage unique et invalide l'ancien couple de jetons.
11
+ class TokenRefresher
12
+ URL = "https://github.com/login/oauth/access_token"
13
+
14
+ def initialize(http: HttpClient.new, clock: -> { Time.now })
15
+ @http = http
16
+ @clock = clock
17
+ end
18
+
19
+ def refresh(refresh_token)
20
+ data = @http.post(URL, client_id: GithubDeviceFlow::CLIENT_ID, grant_type: "refresh_token",
21
+ refresh_token: refresh_token)
22
+ raise SessionExpiredError unless data["access_token"]
23
+
24
+ Credentials.from_github(data, now: @clock.call)
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Captive
4
- VERSION = "1.2.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.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Captive Studio
@@ -109,6 +109,8 @@ files:
109
109
  - lib/captive/cluster_endpoint.rb
110
110
  - lib/captive/commands/apps_commands.rb
111
111
  - lib/captive/commands/apps_restart.rb
112
+ - lib/captive/commands/auth_commands.rb
113
+ - lib/captive/commands/auth_token.rb
112
114
  - lib/captive/commands/console.rb
113
115
  - lib/captive/commands/cron_declaration.rb
114
116
  - lib/captive/commands/crons_commands.rb
@@ -118,13 +120,20 @@ files:
118
120
  - lib/captive/commands/db_psql.rb
119
121
  - lib/captive/commands/db_restore.rb
120
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
121
126
  - lib/captive/commands/env_commands.rb
122
127
  - lib/captive/commands/env_set.rb
123
128
  - lib/captive/commands/logs.rb
124
129
  - lib/captive/commands/one_off_session.rb
125
130
  - lib/captive/commands/run.rb
126
131
  - lib/captive/commands/user_login.rb
132
+ - lib/captive/credentials.rb
133
+ - lib/captive/credentials_store.rb
134
+ - lib/captive/dashboard/app_path.rb
127
135
  - lib/captive/dashboard/apps_resource.rb
136
+ - lib/captive/dashboard/domains_resource.rb
128
137
  - lib/captive/dashboard/env_vars_resource.rb
129
138
  - lib/captive/dashboard/scheduled_tasks_resource.rb
130
139
  - lib/captive/dashboard_api.rb
@@ -142,13 +151,15 @@ files:
142
151
  - lib/captive/poller.rb
143
152
  - lib/captive/presenter/ansi_stripper.rb
144
153
  - lib/captive/presenter/app_info.rb
154
+ - lib/captive/presenter/domain.rb
145
155
  - lib/captive/presenter/log_colorizer.rb
146
156
  - lib/captive/presenter/log_line.rb
147
157
  - lib/captive/presenter/scheduled_task.rb
148
158
  - lib/captive/session_id.rb
159
+ - lib/captive/session_token.rb
149
160
  - lib/captive/shell_completions.rb
150
161
  - lib/captive/terminal.rb
151
- - lib/captive/token_file.rb
162
+ - lib/captive/token_refresher.rb
152
163
  - lib/captive/ui/banner.rb
153
164
  - lib/captive/ui/spinner.rb
154
165
  - lib/captive/update_checker.rb
@@ -1,20 +0,0 @@
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