captive-cli 1.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/captive/cli/dependencies.rb +12 -2
- data/lib/captive/cli.rb +30 -13
- data/lib/captive/commands/apps_commands.rb +25 -50
- data/lib/captive/commands/apps_restart.rb +23 -9
- data/lib/captive/commands/auth_commands.rb +16 -0
- data/lib/captive/commands/auth_token.rb +29 -0
- data/lib/captive/commands/cron_declaration.rb +25 -0
- data/lib/captive/commands/crons_commands.rb +123 -0
- data/lib/captive/commands/env_commands.rb +66 -0
- data/lib/captive/commands/env_set.rb +40 -0
- data/lib/captive/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/apps_resource.rb +26 -0
- data/lib/captive/dashboard/env_vars_resource.rb +27 -0
- data/lib/captive/dashboard/scheduled_tasks_resource.rb +32 -0
- data/lib/captive/dashboard_api.rb +58 -0
- data/lib/captive/dashboard_client.rb +25 -25
- data/lib/captive/dashboard_response.rb +39 -0
- data/lib/captive/errors.rb +14 -3
- data/lib/captive/flux.rb +11 -2
- data/lib/captive/github_device_flow.rb +5 -3
- data/lib/captive/kubectl.rb +0 -10
- data/lib/captive/loader.rb +6 -4
- data/lib/captive/presenter/app_info.rb +40 -0
- data/lib/captive/presenter/scheduled_task.rb +36 -0
- data/lib/captive/session_token.rb +60 -0
- data/lib/captive/shell_completions.rb +4 -2
- data/lib/captive/token_refresher.rb +27 -0
- data/lib/captive/validation_message.rb +26 -0
- data/lib/captive/version.rb +1 -1
- metadata +19 -8
- data/lib/captive/cluster_apps.rb +0 -82
- data/lib/captive/commands/apps_info.rb +0 -70
- data/lib/captive/commands/apps_rebuild.rb +0 -52
- data/lib/captive/commands/env_kube_commands.rb +0 -25
- data/lib/captive/commands/env_list_kube.rb +0 -16
- data/lib/captive/gh_cli.rb +0 -89
- data/lib/captive/platform_root.rb +0 -25
|
@@ -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,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Les apps vues par le dashboard : leur fiche, ce qui tourne, et les gestes sur l'app.
|
|
6
|
+
class AppsResource
|
|
7
|
+
def initialize(api:)
|
|
8
|
+
@api = api
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def list = @api.get("/apps")
|
|
12
|
+
def show(app) = @api.get(path(app))
|
|
13
|
+
def datastores(app) = @api.get("#{path(app)}/datastores").fetch("datastores", [])
|
|
14
|
+
def domains(app) = @api.get("#{path(app)}/domains")
|
|
15
|
+
def restart(app) = @api.post("#{path(app)}/restart")
|
|
16
|
+
def redeploy(app) = @api.post("#{path(app)}/deployments/redeploy")
|
|
17
|
+
|
|
18
|
+
# Le dashboard répond null quand le namespace n'a encore rien déployé.
|
|
19
|
+
def workloads(app) = @api.get("#{path(app)}/workloads") || {}
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def path(app) = "/apps/#{DashboardApi.segment(app)}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Les variables d'environnement d'une app : lues du Secret, écrites par set-env.
|
|
6
|
+
class EnvVarsResource
|
|
7
|
+
def initialize(api:)
|
|
8
|
+
@api = api
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def list(app) = @api.get(path(app))
|
|
12
|
+
|
|
13
|
+
def create(app, key, value) = @api.post(path(app), { env_var: { key: key, value: value } })
|
|
14
|
+
|
|
15
|
+
def update(app, key, value) = @api.patch(path(app, key), { env_var: { value: value } })
|
|
16
|
+
|
|
17
|
+
def delete(app, key) = @api.delete(path(app, key))
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def path(app, key = nil)
|
|
22
|
+
base = "/apps/#{DashboardApi.segment(app)}/env_vars"
|
|
23
|
+
key ? "#{base}/#{DashboardApi.segment(key)}" : base
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Les tâches planifiées d'une app : lues du cluster, déclarées via captive-platform.
|
|
6
|
+
class ScheduledTasksResource
|
|
7
|
+
def initialize(api:)
|
|
8
|
+
@api = api
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def list(app) = @api.get(path(app))
|
|
12
|
+
def create(app, declaration) = @api.post(path(app), { scheduled_task: declaration })
|
|
13
|
+
def update(app, name, declaration) = @api.patch(path(app, name), { scheduled_task: declaration })
|
|
14
|
+
def delete(app, name) = @api.delete(path(app, name))
|
|
15
|
+
def suspend(app, name) = @api.post("#{path(app, name)}/suspension")
|
|
16
|
+
def resume(app, name) = @api.delete("#{path(app, name)}/suspension")
|
|
17
|
+
def trigger(app, name) = @api.post("#{path(app, name)}/runs")
|
|
18
|
+
|
|
19
|
+
def find(app, name)
|
|
20
|
+
list(app).find { |task| task["name"] == name } ||
|
|
21
|
+
raise(NotFoundError, "Tâche planifiée « #{name} » introuvable sur #{app}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def path(app, name = nil)
|
|
27
|
+
base = "/apps/#{DashboardApi.segment(app)}/scheduled_tasks"
|
|
28
|
+
name ? "#{base}/#{DashboardApi.segment(name)}" : base
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "session_token"
|
|
7
|
+
require_relative "dashboard_response"
|
|
8
|
+
|
|
9
|
+
module Captive
|
|
10
|
+
# Le transport JSON vers l'API du dashboard : jeton GitHub en Bearer (rejoué une fois renouvelé
|
|
11
|
+
# sur un 401), timeouts courts, et
|
|
12
|
+
# erreurs HTTP traduites par DashboardResponse. Les chemins sont relatifs à /api.
|
|
13
|
+
class DashboardApi
|
|
14
|
+
BASE_URL = "https://dashboard.captive.dev"
|
|
15
|
+
VERBS = {
|
|
16
|
+
get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete,
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.segment(value) = URI.encode_uri_component(value.to_s)
|
|
20
|
+
|
|
21
|
+
def initialize(token: SessionToken.new, base_url: BASE_URL, starter: Net::HTTP.method(:start),
|
|
22
|
+
read_timeout: 15)
|
|
23
|
+
@token = token
|
|
24
|
+
@base_url = base_url
|
|
25
|
+
@starter = starter
|
|
26
|
+
@read_timeout = read_timeout
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(path) = request(:get, path)
|
|
30
|
+
def post(path, body = nil) = request(:post, path, body)
|
|
31
|
+
def patch(path, body = nil) = request(:patch, path, body)
|
|
32
|
+
def delete(path) = request(:delete, path)
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def request(verb, path, body = nil)
|
|
37
|
+
uri = URI.parse("#{@base_url}/api#{path}")
|
|
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
|
|
43
|
+
end
|
|
44
|
+
DashboardResponse.new(response).parse
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build(verb, uri, body, token)
|
|
48
|
+
req = VERBS.fetch(verb).new(uri.request_uri)
|
|
49
|
+
req["Authorization"] = "Bearer #{token}"
|
|
50
|
+
req["Accept"] = "application/json"
|
|
51
|
+
if body
|
|
52
|
+
req["Content-Type"] = "application/json"
|
|
53
|
+
req.body = JSON.generate(body)
|
|
54
|
+
end
|
|
55
|
+
req
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -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
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "validation_message"
|
|
6
|
+
|
|
7
|
+
module Captive
|
|
8
|
+
# Traduit une réponse HTTP du dashboard : le corps JSON si elle réussit, sinon l'erreur
|
|
9
|
+
# que l'utilisateur peut comprendre.
|
|
10
|
+
class DashboardResponse
|
|
11
|
+
ERRORS = {
|
|
12
|
+
401 => [ AuthError, "Token invalide ou expiré. Lance : captive login" ],
|
|
13
|
+
403 => [ ForbiddenError, "Accès refusé : tu n'es pas Membre de l'Organization de cette app." ],
|
|
14
|
+
404 => [ NotFoundError, "App ou ressource introuvable" ],
|
|
15
|
+
503 => [ ServiceUnavailableError, "Service indisponible, réessaie" ],
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(response)
|
|
19
|
+
@code = response.code.to_i
|
|
20
|
+
@body = response.body.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse
|
|
24
|
+
return (@body.strip.empty? ? nil : JSON.parse(@body)) if @code.between?(200, 299)
|
|
25
|
+
raise ValidationError, ValidationMessage.new(json_body).to_s if @code == 422
|
|
26
|
+
|
|
27
|
+
klass, message = ERRORS.fetch(@code) { [ DashboardError, "Erreur dashboard (HTTP #{@code})" ] }
|
|
28
|
+
raise klass, message
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def json_body
|
|
34
|
+
JSON.parse(@body)
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
{}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/captive/errors.rb
CHANGED
|
@@ -4,13 +4,24 @@ module Captive
|
|
|
4
4
|
class NoPodFound < StandardError; end
|
|
5
5
|
class UnknownAppType < StandardError; end
|
|
6
6
|
class PollTimeout < StandardError; end
|
|
7
|
-
class ImageYamlNotFound < StandardError; end
|
|
8
|
-
class WorkflowDispatchNotConfigured < StandardError; end
|
|
9
7
|
class RestoreError < StandardError; end
|
|
10
8
|
class DumpError < StandardError; end
|
|
11
9
|
class ExecError < StandardError; end
|
|
12
10
|
class AuthError < StandardError; end
|
|
13
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
|
|
14
17
|
class NoTtyError < StandardError; end
|
|
15
|
-
class
|
|
18
|
+
class MissingDependencyError < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Réponses de l'API du dashboard qui ont un sens pour l'utilisateur : rattrapées par
|
|
21
|
+
# CLI.start et affichées sans trace.
|
|
22
|
+
class DashboardError < StandardError; end
|
|
23
|
+
class ForbiddenError < DashboardError; end
|
|
24
|
+
class NotFoundError < DashboardError; end
|
|
25
|
+
class ValidationError < DashboardError; end
|
|
26
|
+
class ServiceUnavailableError < DashboardError; end
|
|
16
27
|
end
|
data/lib/captive/flux.rb
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require_relative "errors"
|
|
4
5
|
|
|
5
6
|
module Captive
|
|
6
7
|
class Flux
|
|
7
8
|
def suspend_kustomization(name)
|
|
8
|
-
|
|
9
|
+
capture("suspend", "kustomization", name)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def resume_kustomization(name)
|
|
12
|
-
|
|
13
|
+
capture("resume", "kustomization", name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def capture(*args)
|
|
19
|
+
Open3.capture3("flux", *args)
|
|
20
|
+
rescue Errno::ENOENT
|
|
21
|
+
raise MissingDependencyError, "flux introuvable. Installe-le : brew install flux"
|
|
13
22
|
end
|
|
14
23
|
end
|
|
15
24
|
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/kubectl.rb
CHANGED
|
@@ -237,12 +237,6 @@ module Captive
|
|
|
237
237
|
raise
|
|
238
238
|
end
|
|
239
239
|
|
|
240
|
-
def get_secret_data(namespace, secret_name)
|
|
241
|
-
json = run("get", "secret", secret_name, "-n", namespace, "-o", "json")
|
|
242
|
-
data = JSON.parse(json).fetch("data", {})
|
|
243
|
-
data.transform_values { |v| Base64.strict_decode64(v) }
|
|
244
|
-
end
|
|
245
|
-
|
|
246
240
|
def get_secret_password(namespace, secret_name)
|
|
247
241
|
encoded = run("get", "secret", secret_name, "-n", namespace,
|
|
248
242
|
"-o", "jsonpath={.data.password}").strip
|
|
@@ -291,10 +285,6 @@ module Captive
|
|
|
291
285
|
status.success? ? out : nil
|
|
292
286
|
end
|
|
293
287
|
|
|
294
|
-
def restart_deployment(namespace, name)
|
|
295
|
-
run("rollout", "restart", "deployment", "-n", namespace, name)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
288
|
def run(*args)
|
|
299
289
|
out, err, status = Open3.capture3("kubectl", *kubeconfig_args, *args)
|
|
300
290
|
raise AuthError, "Votre session a expiré. Reconnectez-vous avec : captive login" if err.include?("Unauthorized")
|
data/lib/captive/loader.rb
CHANGED
|
@@ -6,14 +6,18 @@ require "pastel"
|
|
|
6
6
|
require "tty-prompt"
|
|
7
7
|
|
|
8
8
|
require_relative "kubectl"
|
|
9
|
-
require_relative "cluster_apps"
|
|
10
9
|
require_relative "log_noise_filter"
|
|
11
10
|
require_relative "presenter/ansi_stripper"
|
|
12
11
|
require_relative "null_log_filter"
|
|
13
12
|
require_relative "one_off_spec"
|
|
14
13
|
require_relative "session_id"
|
|
15
14
|
require_relative "poller"
|
|
16
|
-
require_relative "
|
|
15
|
+
require_relative "dashboard_api"
|
|
16
|
+
require_relative "dashboard/apps_resource"
|
|
17
|
+
require_relative "dashboard/env_vars_resource"
|
|
18
|
+
require_relative "dashboard/scheduled_tasks_resource"
|
|
19
|
+
require_relative "presenter/app_info"
|
|
20
|
+
require_relative "presenter/scheduled_task"
|
|
17
21
|
require_relative "activity_trail"
|
|
18
22
|
require_relative "errors"
|
|
19
23
|
require_relative "ui/spinner"
|
|
@@ -23,9 +27,7 @@ require_relative "commands/console"
|
|
|
23
27
|
require_relative "commands/one_off_session"
|
|
24
28
|
require_relative "commands/run"
|
|
25
29
|
require_relative "commands/logs"
|
|
26
|
-
require_relative "commands/apps_info"
|
|
27
30
|
require_relative "commands/apps_restart"
|
|
28
|
-
require_relative "commands/apps_rebuild"
|
|
29
31
|
require_relative "commands/db_dump"
|
|
30
32
|
require_relative "commands/db_restore"
|
|
31
33
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Presenter
|
|
5
|
+
# La fiche d'une app telle que `captive apps info` l'affiche, à partir des lectures du
|
|
6
|
+
# dashboard : l'app, ses workloads, ses datastores et ses domaines.
|
|
7
|
+
class AppInfo
|
|
8
|
+
def initialize(paint:)
|
|
9
|
+
@paint = paint
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def lines(app:, workloads:, datastores:, domains:)
|
|
13
|
+
[ @paint.bold(app["name"]),
|
|
14
|
+
" Stage : #{app["stage"] || "–"}",
|
|
15
|
+
" Statut : #{app["status"] || "–"}",
|
|
16
|
+
" URL : #{app["app_url"] || "–"}",
|
|
17
|
+
*section("Processus", workloads.map { |w| workload(w) }),
|
|
18
|
+
*section("Datastores", datastores.map { |d| "#{d["kind"]} #{d["name"]}" }),
|
|
19
|
+
*section("Domaines", domains.map { |d| domain(d) }), ]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def section(title, items)
|
|
25
|
+
[ " #{title} :", *(items.empty? ? [ @paint.dim(" –") ] : items.map { |item| " #{item}" }) ]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def workload(entry)
|
|
29
|
+
ready = "#{entry["ready"]}/#{entry["total"]}"
|
|
30
|
+
ready = @paint.yellow(ready) unless entry["ready"] == entry["total"]
|
|
31
|
+
"#{entry["name"]} #{ready} prêt(s), tier #{entry["tier"] || "–"}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def domain(entry)
|
|
35
|
+
flags = [ ("principal" if entry["primary"]), ("en attente" if entry["pending"]) ].compact
|
|
36
|
+
flags.empty? ? entry["hostname"] : "#{entry["hostname"]} (#{flags.join(", ")})"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
module Presenter
|
|
5
|
+
# Une tâche planifiée sur une ligne, puis sa dernière exécution si elle a tourné.
|
|
6
|
+
class ScheduledTask
|
|
7
|
+
def initialize(paint:)
|
|
8
|
+
@paint = paint
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def lines(task)
|
|
12
|
+
head = "#{@paint.bold(task["name"])} #{task["schedule"]} (#{task["timezone"]}) " \
|
|
13
|
+
"#{task["mode"]} #{task["target"]}"
|
|
14
|
+
head += " #{@paint.yellow("[suspendue]")}" if task["suspended"]
|
|
15
|
+
[ head, last_run(task["last_run"]) ]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Le cluster ne garde qu'une exécution réussie, cinq minutes : seule la dernière se lit.
|
|
19
|
+
def last_run(run)
|
|
20
|
+
return @paint.dim(" aucune exécution récente") unless run
|
|
21
|
+
|
|
22
|
+
" dernière : #{run["started_at"]} → #{run["finished_at"] || "en cours"} #{outcome(run["outcome"])}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def outcome(value)
|
|
28
|
+
case value
|
|
29
|
+
when "succeeded" then @paint.green(value)
|
|
30
|
+
when "failed", "timeout" then @paint.red(value)
|
|
31
|
+
else value.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|