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
|
@@ -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,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Captive
|
|
4
4
|
module ShellCompletions
|
|
5
|
-
ROOT_COMMANDS = %w[apps env db login whoami console run logs version completions].freeze
|
|
5
|
+
ROOT_COMMANDS = %w[apps env crons db login whoami console run logs version completions].freeze
|
|
6
6
|
|
|
7
7
|
SUBCOMMANDS = {
|
|
8
8
|
"apps" => %w[list info restart rebuild],
|
|
9
|
-
"env" => %w[list],
|
|
9
|
+
"env" => %w[list set unset],
|
|
10
|
+
"crons" => %w[list add edit rm suspend resume trigger runs],
|
|
10
11
|
"db" => %w[restore],
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
@@ -21,6 +22,7 @@ module Captive
|
|
|
21
22
|
case "$prev" in
|
|
22
23
|
apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
|
|
23
24
|
env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
|
|
25
|
+
crons) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["crons"].join(" ")}" -- "$cur")); return ;;
|
|
24
26
|
db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
|
|
25
27
|
esac
|
|
26
28
|
|
|
@@ -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
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Captive
|
|
4
|
+
# Les refus de validation, un par ligne : `errors` est une liste de {field, message}, un
|
|
5
|
+
# objet champ → messages, ou absent (seul `error` dit alors ce qui ne va pas).
|
|
6
|
+
class ValidationMessage
|
|
7
|
+
def initialize(body)
|
|
8
|
+
@body = body.is_a?(Hash) ? body : {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
([ "Requête refusée :" ] + lines.map { |line| " - #{line}" }).join("\n")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def lines
|
|
18
|
+
errors = @body["errors"]
|
|
19
|
+
case errors
|
|
20
|
+
when Array then errors.map { |e| e.is_a?(Hash) ? "#{e["field"]} : #{e["message"]}" : e.to_s }
|
|
21
|
+
when Hash then errors.map { |field, messages| "#{field} : #{Array(messages).join(", ")}" }
|
|
22
|
+
else [ @body.fetch("error", "invalide").to_s ]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Captive Studio
|
|
@@ -106,29 +106,36 @@ files:
|
|
|
106
106
|
- lib/captive/activity_trail.rb
|
|
107
107
|
- lib/captive/cli.rb
|
|
108
108
|
- lib/captive/cli/dependencies.rb
|
|
109
|
-
- lib/captive/cluster_apps.rb
|
|
110
109
|
- lib/captive/cluster_endpoint.rb
|
|
111
110
|
- lib/captive/commands/apps_commands.rb
|
|
112
|
-
- lib/captive/commands/apps_info.rb
|
|
113
|
-
- lib/captive/commands/apps_rebuild.rb
|
|
114
111
|
- lib/captive/commands/apps_restart.rb
|
|
112
|
+
- lib/captive/commands/auth_commands.rb
|
|
113
|
+
- lib/captive/commands/auth_token.rb
|
|
115
114
|
- lib/captive/commands/console.rb
|
|
115
|
+
- lib/captive/commands/cron_declaration.rb
|
|
116
|
+
- lib/captive/commands/crons_commands.rb
|
|
116
117
|
- lib/captive/commands/db_commands.rb
|
|
117
118
|
- lib/captive/commands/db_dump.rb
|
|
118
119
|
- lib/captive/commands/db_exec.rb
|
|
119
120
|
- lib/captive/commands/db_psql.rb
|
|
120
121
|
- lib/captive/commands/db_restore.rb
|
|
121
122
|
- lib/captive/commands/db_target.rb
|
|
122
|
-
- lib/captive/commands/
|
|
123
|
-
- lib/captive/commands/
|
|
123
|
+
- lib/captive/commands/env_commands.rb
|
|
124
|
+
- lib/captive/commands/env_set.rb
|
|
124
125
|
- lib/captive/commands/logs.rb
|
|
125
126
|
- lib/captive/commands/one_off_session.rb
|
|
126
127
|
- lib/captive/commands/run.rb
|
|
127
128
|
- lib/captive/commands/user_login.rb
|
|
129
|
+
- lib/captive/credentials.rb
|
|
130
|
+
- lib/captive/credentials_store.rb
|
|
131
|
+
- lib/captive/dashboard/apps_resource.rb
|
|
132
|
+
- lib/captive/dashboard/env_vars_resource.rb
|
|
133
|
+
- lib/captive/dashboard/scheduled_tasks_resource.rb
|
|
134
|
+
- lib/captive/dashboard_api.rb
|
|
128
135
|
- lib/captive/dashboard_client.rb
|
|
136
|
+
- lib/captive/dashboard_response.rb
|
|
129
137
|
- lib/captive/errors.rb
|
|
130
138
|
- lib/captive/flux.rb
|
|
131
|
-
- lib/captive/gh_cli.rb
|
|
132
139
|
- lib/captive/github_device_flow.rb
|
|
133
140
|
- lib/captive/http_client.rb
|
|
134
141
|
- lib/captive/kubectl.rb
|
|
@@ -136,17 +143,21 @@ files:
|
|
|
136
143
|
- lib/captive/log_noise_filter.rb
|
|
137
144
|
- lib/captive/null_log_filter.rb
|
|
138
145
|
- lib/captive/one_off_spec.rb
|
|
139
|
-
- lib/captive/platform_root.rb
|
|
140
146
|
- lib/captive/poller.rb
|
|
141
147
|
- lib/captive/presenter/ansi_stripper.rb
|
|
148
|
+
- lib/captive/presenter/app_info.rb
|
|
142
149
|
- lib/captive/presenter/log_colorizer.rb
|
|
143
150
|
- lib/captive/presenter/log_line.rb
|
|
151
|
+
- lib/captive/presenter/scheduled_task.rb
|
|
144
152
|
- lib/captive/session_id.rb
|
|
153
|
+
- lib/captive/session_token.rb
|
|
145
154
|
- lib/captive/shell_completions.rb
|
|
146
155
|
- lib/captive/terminal.rb
|
|
156
|
+
- lib/captive/token_refresher.rb
|
|
147
157
|
- lib/captive/ui/banner.rb
|
|
148
158
|
- lib/captive/ui/spinner.rb
|
|
149
159
|
- lib/captive/update_checker.rb
|
|
160
|
+
- lib/captive/validation_message.rb
|
|
150
161
|
- lib/captive/version.rb
|
|
151
162
|
homepage: https://github.com/captive-studio/captive-platform
|
|
152
163
|
licenses:
|
data/lib/captive/cluster_apps.rb
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Captive
|
|
6
|
-
# Ce que le cluster sait des apps déployées. Le CLI développeur décrit l'état réellement
|
|
7
|
-
# servi, pas l'état désiré dans le repo GitOps — auquel il n'a pas accès.
|
|
8
|
-
class ClusterApps
|
|
9
|
-
def initialize(kubectl:)
|
|
10
|
-
@kubectl = kubectl
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def all
|
|
14
|
-
web_deployments.map do |deployment|
|
|
15
|
-
namespace = deployment.dig("metadata", "namespace")
|
|
16
|
-
{ name: namespace, stage: stage_of(namespace), domains: domains_by_namespace[namespace] || [] }
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def domains(app) = domains_by_namespace[app] || []
|
|
21
|
-
|
|
22
|
-
def stage(app) = stage_of(app)
|
|
23
|
-
|
|
24
|
-
def tier(app) = web_of(app)&.dig("metadata", "annotations", "captive.dev/tier")
|
|
25
|
-
|
|
26
|
-
def workers(app) = of_type(app, "worker").map { |d| d.dig("metadata", "name") }
|
|
27
|
-
|
|
28
|
-
# L'add-on peut ne pas être posé, ou la CRD absente du cluster : les deux se lisent
|
|
29
|
-
# comme « pas d'IP fixe », pas comme une panne.
|
|
30
|
-
def egress_ip(app)
|
|
31
|
-
first_item("egresspolicy", app)&.dig("spec", "egressIP", "ipv4")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def postgres(app)
|
|
35
|
-
spec = first_item("cluster", app)&.fetch("spec", nil) or return nil
|
|
36
|
-
|
|
37
|
-
{ instances: spec["instances"],
|
|
38
|
-
memory: spec.dig("resources", "limits", "memory"),
|
|
39
|
-
storage: spec.dig("storage", "size"), }.compact
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
def first_item(kind, app)
|
|
45
|
-
items(@kubectl.run("get", kind, "-n", app, "-o", "json")).first
|
|
46
|
-
rescue RuntimeError
|
|
47
|
-
nil
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def web_of(app) = of_type(app, "web").first
|
|
51
|
-
|
|
52
|
-
def of_type(app, type)
|
|
53
|
-
workloads(app).select { |d| d.dig("metadata", "labels", "captive.dev/type") == type }
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def workloads(app)
|
|
57
|
-
@workloads ||= {}
|
|
58
|
-
@workloads[app] ||= items(@kubectl.run("get", "deployment", "-n", app, "-o", "json"))
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# Une app est un Deployment portant le type web : le même label que les composants de
|
|
63
|
-
# tier ciblent, et que la conformité exige.
|
|
64
|
-
def web_deployments
|
|
65
|
-
items(@kubectl.run("get", "deployment", "-A", "-l", "captive.dev/type=web", "-o", "json"))
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def domains_by_namespace
|
|
69
|
-
@domains_by_namespace ||= items(@kubectl.run("get", "httproute", "-A", "-o", "json"))
|
|
70
|
-
.group_by { |route| route.dig("metadata", "namespace") }
|
|
71
|
-
.transform_values { |routes| routes.flat_map { |r| Array(r.dig("spec", "hostnames")) } }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def items(json) = JSON.parse(json).fetch("items", [])
|
|
75
|
-
|
|
76
|
-
def stage_of(namespace)
|
|
77
|
-
return "production" if namespace.end_with?("-production")
|
|
78
|
-
|
|
79
|
-
"staging" if namespace.end_with?("-staging")
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Captive
|
|
4
|
-
module Commands
|
|
5
|
-
# L'état d'une app tel que le cluster le sert. Le CLI développeur n'a pas accès au repo
|
|
6
|
-
# GitOps : tout se lit sur les ressources vivantes, ce qui décrit l'état réel plutôt que
|
|
7
|
-
# l'état désiré — un déploiement en cours s'y voit, un manifeste poussé mais non
|
|
8
|
-
# réconcilié ne s'y voit pas encore.
|
|
9
|
-
class AppsInfo
|
|
10
|
-
TIER_RESOURCES = {
|
|
11
|
-
"xs" => { cpu: "100m → 500m", memory: "256Mi → 512Mi" },
|
|
12
|
-
"s" => { cpu: "250m → 1", memory: "512Mi → 1Gi" },
|
|
13
|
-
"m" => { cpu: "500m → 2", memory: "1Gi → 2Gi" },
|
|
14
|
-
"l" => { cpu: "1 → 4", memory: "2Gi → 4Gi" },
|
|
15
|
-
}.freeze
|
|
16
|
-
|
|
17
|
-
def initialize(kubectl:, cluster_apps: nil)
|
|
18
|
-
@kubectl = kubectl
|
|
19
|
-
@apps = cluster_apps || ClusterApps.new(kubectl: kubectl)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def gather(app)
|
|
23
|
-
tier = @apps.tier(app)
|
|
24
|
-
postgres = @apps.postgres(app)
|
|
25
|
-
egress = @apps.egress_ip(app)
|
|
26
|
-
{
|
|
27
|
-
pod_status: pod_status(app),
|
|
28
|
-
replicas: replicas(app),
|
|
29
|
-
postgres: !postgres.nil?,
|
|
30
|
-
redis: redis?(app),
|
|
31
|
-
egress_ip: !egress.nil?,
|
|
32
|
-
tier: tier,
|
|
33
|
-
resources: TIER_RESOURCES[tier],
|
|
34
|
-
domains: @apps.domains(app),
|
|
35
|
-
stage: @apps.stage(app),
|
|
36
|
-
workers: @apps.workers(app),
|
|
37
|
-
postgres_details: postgres,
|
|
38
|
-
redis_details: redis_details(app),
|
|
39
|
-
egress_ip_details: egress && { ip: egress },
|
|
40
|
-
}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def pod_status(app)
|
|
46
|
-
@kubectl.run("get", "pod", "-n", app, "-l", "app=#{app}",
|
|
47
|
-
"-o", "jsonpath={.items[0].status.phase}").strip
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def replicas(app)
|
|
51
|
-
@kubectl.run("get", "deployment", app, "-n", app,
|
|
52
|
-
"-o", "jsonpath={.spec.replicas}").strip.to_i
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def redis?(app)
|
|
56
|
-
!@kubectl.run("get", "deployment", "-n", app, "-l", "app=redis",
|
|
57
|
-
"--no-headers", "-o", "name").strip.empty?
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Redis est un Deployment ordinaire : sa taille se lit sur le conteneur, pas sur une CRD.
|
|
61
|
-
def redis_details(app)
|
|
62
|
-
limits = @kubectl.run(
|
|
63
|
-
"get", "deployment", "-n", app, "-l", "app=redis",
|
|
64
|
-
"-o", "jsonpath={.items[0].spec.template.spec.containers[0].resources.limits.memory}"
|
|
65
|
-
).strip
|
|
66
|
-
limits.empty? ? nil : { memory: limits }
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../errors"
|
|
4
|
-
require_relative "../platform_root"
|
|
5
|
-
|
|
6
|
-
module Captive
|
|
7
|
-
module Commands
|
|
8
|
-
class AppsRebuild
|
|
9
|
-
# Dernière commande du CLI développeur qui lit le repo GitOps, contre l'ADR 0016.
|
|
10
|
-
# Elle déduit le repo GitHub de l'image, or les images valent
|
|
11
|
-
# ghcr.io/captive-studio/captive-platform/<app> : la déduction rend trois segments, qui
|
|
12
|
-
# ne désignent aucun repo. Rien dans le service ni sur le cluster n'enregistre le repo
|
|
13
|
-
# d'origine, donc la commande n'est pas portable en l'état — voir la note de l'étape 9.
|
|
14
|
-
# Elle exige donc un clone de captive-platform (PlatformRoot), jusqu'à passer par l'API
|
|
15
|
-
# du dashboard.
|
|
16
|
-
def initialize(gh:, repo_root: nil)
|
|
17
|
-
@gh = gh
|
|
18
|
-
@repo_root = repo_root
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def run(app)
|
|
22
|
-
repo = extract_repo(app)
|
|
23
|
-
version = current_version(app)
|
|
24
|
-
fields = version ? { version: version } : {}
|
|
25
|
-
@gh.trigger_workflow(repo, ref: "main", fields: fields)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def repo_root = @repo_root ||= PlatformRoot.find
|
|
31
|
-
|
|
32
|
-
def extract_repo(app)
|
|
33
|
-
image_file = File.join(repo_root, "services", app, "image.yaml")
|
|
34
|
-
raise ImageYamlNotFound, "image.yaml introuvable pour '#{app}'" unless File.exist?(image_file)
|
|
35
|
-
|
|
36
|
-
content = File.read(image_file)
|
|
37
|
-
image = content.match(/image:\s+ghcr\.io\/([^\s:]+)/)&.captures&.first
|
|
38
|
-
raise ImageYamlNotFound, "image introuvable dans image.yaml de '#{app}'" unless image
|
|
39
|
-
|
|
40
|
-
image
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def current_version(app)
|
|
44
|
-
deploy_file = File.join(repo_root, "services", app, "#{app}.yaml")
|
|
45
|
-
return nil unless File.exist?(deploy_file)
|
|
46
|
-
|
|
47
|
-
tag = File.read(deploy_file).match(/:(v\d+\.\d+\.\d+(?:-\d{14})?)/)&.captures&.first
|
|
48
|
-
tag&.sub(/-\d{14}$/, "")
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "env_list_kube"
|
|
4
|
-
|
|
5
|
-
module Captive
|
|
6
|
-
module Commands
|
|
7
|
-
class EnvKubeCommands < Thor
|
|
8
|
-
include CLIDependencies
|
|
9
|
-
|
|
10
|
-
def self.exit_on_failure? = true
|
|
11
|
-
|
|
12
|
-
desc "list APP", "Liste les variables d'environnement de l'app depuis K8s"
|
|
13
|
-
long_desc <<~DESC
|
|
14
|
-
Lit directement le secret <app>-env depuis le cluster Kubernetes.
|
|
15
|
-
|
|
16
|
-
\x05Exemple :
|
|
17
|
-
\x05 captive env list my-app-staging
|
|
18
|
-
DESC
|
|
19
|
-
def list(app)
|
|
20
|
-
vars = EnvListKube.new(kubectl: kubectl).run(app)
|
|
21
|
-
vars.sort.each { |v| puts v }
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Captive
|
|
4
|
-
module Commands
|
|
5
|
-
class EnvListKube
|
|
6
|
-
def initialize(kubectl:)
|
|
7
|
-
@kubectl = kubectl
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def run(app)
|
|
11
|
-
data = @kubectl.get_secret_data(app, "#{app}-env")
|
|
12
|
-
data.map { |k, v| "#{k}=#{v}" }
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
data/lib/captive/gh_cli.rb
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "open3"
|
|
5
|
-
require_relative "errors"
|
|
6
|
-
|
|
7
|
-
module Captive
|
|
8
|
-
class GhCLI
|
|
9
|
-
def trigger_workflow(repo, workflow: "ci.yml", ref: "main", fields: {})
|
|
10
|
-
field_args = fields.flat_map { |k, v| [ "--field", "#{k}=#{v}" ] }
|
|
11
|
-
run("workflow", "run", workflow, "--repo", repo, "--ref", ref, *field_args)
|
|
12
|
-
rescue RuntimeError => e
|
|
13
|
-
raise WorkflowDispatchNotConfigured,
|
|
14
|
-
"Le workflow '#{workflow}' de '#{repo}' n'a pas de trigger workflow_dispatch.\n" \
|
|
15
|
-
" Regénère le ci.yml : captive apps launch <app> dans le repo de l'app." \
|
|
16
|
-
if e.message.include?("workflow_dispatch")
|
|
17
|
-
raise
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def workflow_exists?(repo)
|
|
21
|
-
%w[deploy.yml ci.yml].any? do |file|
|
|
22
|
-
run("api", "/repos/#{repo}/contents/.github/workflows/#{file}") && true
|
|
23
|
-
rescue RuntimeError
|
|
24
|
-
false
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def latest_run_status(repo)
|
|
29
|
-
output = run("run", "list", "--repo", repo, "--limit", "1",
|
|
30
|
-
"--json", "status,conclusion", "--jq", ".[0]")
|
|
31
|
-
data = JSON.parse(output)
|
|
32
|
-
return :success if data["status"] == "completed" && data["conclusion"] == "success"
|
|
33
|
-
return :failure if data["status"] == "completed"
|
|
34
|
-
|
|
35
|
-
:in_progress
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def image_exists?(repo, tag)
|
|
39
|
-
output = run("api", "/repos/#{repo}/packages/container/#{repo.split("/").last}/versions",
|
|
40
|
-
"--jq", ".[].metadata.container.tags[]")
|
|
41
|
-
output.split("\n").include?(tag)
|
|
42
|
-
rescue RuntimeError => e
|
|
43
|
-
return false if e.message.include?("404") || e.message.include?("Not Found")
|
|
44
|
-
|
|
45
|
-
raise
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def app_installed?(repo)
|
|
49
|
-
run("api", "repos/#{repo}/installation")
|
|
50
|
-
true
|
|
51
|
-
rescue RuntimeError
|
|
52
|
-
false
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def detect_type(repo)
|
|
56
|
-
data = JSON.parse(run("api", "repos/#{repo}"))
|
|
57
|
-
case data["language"]
|
|
58
|
-
when "Ruby" then "rails-app"
|
|
59
|
-
when "JavaScript", "TypeScript" then "node-app"
|
|
60
|
-
else raise UnknownAppType, "language non supportée : #{data["language"]}"
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
PLATFORM_REPO = "captive-studio/captive-platform"
|
|
65
|
-
|
|
66
|
-
def dispatch_build(repo)
|
|
67
|
-
repo_name = repo.split("/").last.downcase
|
|
68
|
-
sha = run("api", "repos/#{repo}/git/ref/heads/main", "--jq", ".object.sha").strip
|
|
69
|
-
body = JSON.generate(
|
|
70
|
-
event_type: "build-app",
|
|
71
|
-
client_payload: { repo: repo, repo_name: repo_name, sha: sha, ref: "refs/heads/main" }
|
|
72
|
-
)
|
|
73
|
-
run_with_input("api", "repos/#{PLATFORM_REPO}/dispatches", "-X", "POST", "--input", "-",
|
|
74
|
-
input: body)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def run(*args, input: nil)
|
|
78
|
-
opts = input ? { stdin_data: input } : {}
|
|
79
|
-
out, err, status = Open3.capture3("gh", *args, **opts)
|
|
80
|
-
raise "gh #{args.join(" ")} failed: #{err}" unless status.success?
|
|
81
|
-
|
|
82
|
-
out
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def run_with_input(*args, input:)
|
|
86
|
-
run(*args, input: input)
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "pathname"
|
|
4
|
-
require_relative "errors"
|
|
5
|
-
|
|
6
|
-
module Captive
|
|
7
|
-
# Où est le clone de captive-platform. Installé en gem, le CLI ne vit plus dans le monorepo :
|
|
8
|
-
# la racine se trouve en remontant depuis le dossier courant, comme git trouve .git, ou se
|
|
9
|
-
# désigne par CAPTIVE_PLATFORM_ROOT. Seul `apps rebuild` en a encore besoin (ADR 0018).
|
|
10
|
-
module PlatformRoot
|
|
11
|
-
ENV_VAR = "CAPTIVE_PLATFORM_ROOT"
|
|
12
|
-
|
|
13
|
-
def self.find(start: Dir.pwd, env: ENV)
|
|
14
|
-
env[ENV_VAR] || ascend(start) ||
|
|
15
|
-
raise(PlatformRootNotFound, "Clone de captive-platform introuvable depuis #{start}. " \
|
|
16
|
-
"Lance la commande depuis le clone ou définis #{ENV_VAR}.")
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def self.ascend(start)
|
|
20
|
-
Pathname.new(start).realpath.ascend
|
|
21
|
-
.find { |dir| dir.join("services").directory? && dir.join(".sops.yaml").file? }&.to_s
|
|
22
|
-
end
|
|
23
|
-
private_class_method :ascend
|
|
24
|
-
end
|
|
25
|
-
end
|