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 +4 -4
- data/lib/captive/cli/dependencies.rb +1 -0
- data/lib/captive/cli.rb +24 -8
- data/lib/captive/commands/auth_commands.rb +16 -0
- data/lib/captive/commands/auth_token.rb +29 -0
- data/lib/captive/commands/domain_announcer.rb +32 -0
- data/lib/captive/commands/domain_wait.rb +44 -0
- data/lib/captive/commands/domains_commands.rb +108 -0
- data/lib/captive/commands/user_login.rb +20 -13
- data/lib/captive/credentials.rb +43 -0
- data/lib/captive/credentials_store.rb +47 -0
- data/lib/captive/dashboard/app_path.rb +14 -0
- data/lib/captive/dashboard/domains_resource.rb +27 -0
- data/lib/captive/dashboard/env_vars_resource.rb +3 -4
- data/lib/captive/dashboard/scheduled_tasks_resource.rb +3 -4
- data/lib/captive/dashboard_api.rb +11 -9
- data/lib/captive/dashboard_client.rb +25 -25
- data/lib/captive/errors.rb +6 -0
- data/lib/captive/github_device_flow.rb +5 -3
- data/lib/captive/loader.rb +5 -0
- data/lib/captive/presenter/domain.rb +51 -0
- data/lib/captive/session_token.rb +60 -0
- data/lib/captive/shell_completions.rb +3 -1
- data/lib/captive/token_refresher.rb +27 -0
- data/lib/captive/version.rb +1 -1
- metadata +13 -2
- data/lib/captive/token_file.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 377f77798282f817bcd1632482c5075ca10f8c65f1b5ce9abc4e7d3f1aeae2ee
|
|
4
|
+
data.tar.gz: ef1dc980b18c772c703a7b94ee35a3c35049b22d84ffeca742ee5ce569a32f7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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:,
|
|
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
|
-
@
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 "
|
|
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
|
|
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:
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 #{
|
|
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
|
|
17
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
data/lib/captive/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
data/lib/captive/loader.rb
CHANGED
|
@@ -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
|
data/lib/captive/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: captive-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.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/
|
|
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
|
data/lib/captive/token_file.rb
DELETED
|
@@ -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
|