captive-cli 1.0.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/exe/captive +6 -0
  3. data/lib/captive/activity_trail.rb +38 -0
  4. data/lib/captive/cli/dependencies.rb +42 -0
  5. data/lib/captive/cli.rb +170 -0
  6. data/lib/captive/cluster_apps.rb +82 -0
  7. data/lib/captive/cluster_endpoint.rb +23 -0
  8. data/lib/captive/commands/apps_commands.rb +104 -0
  9. data/lib/captive/commands/apps_info.rb +70 -0
  10. data/lib/captive/commands/apps_rebuild.rb +52 -0
  11. data/lib/captive/commands/apps_restart.rb +24 -0
  12. data/lib/captive/commands/console.rb +32 -0
  13. data/lib/captive/commands/db_commands.rb +105 -0
  14. data/lib/captive/commands/db_dump.rb +33 -0
  15. data/lib/captive/commands/db_exec.rb +24 -0
  16. data/lib/captive/commands/db_psql.rb +20 -0
  17. data/lib/captive/commands/db_restore.rb +145 -0
  18. data/lib/captive/commands/db_target.rb +19 -0
  19. data/lib/captive/commands/env_kube_commands.rb +25 -0
  20. data/lib/captive/commands/env_list_kube.rb +16 -0
  21. data/lib/captive/commands/logs.rb +65 -0
  22. data/lib/captive/commands/one_off_session.rb +76 -0
  23. data/lib/captive/commands/run.rb +24 -0
  24. data/lib/captive/commands/user_login.rb +64 -0
  25. data/lib/captive/dashboard_client.rb +119 -0
  26. data/lib/captive/errors.rb +16 -0
  27. data/lib/captive/flux.rb +15 -0
  28. data/lib/captive/gh_cli.rb +89 -0
  29. data/lib/captive/github_device_flow.rb +39 -0
  30. data/lib/captive/http_client.rb +31 -0
  31. data/lib/captive/kubectl.rb +330 -0
  32. data/lib/captive/loader.rb +33 -0
  33. data/lib/captive/log_noise_filter.rb +17 -0
  34. data/lib/captive/null_log_filter.rb +8 -0
  35. data/lib/captive/one_off_spec.rb +144 -0
  36. data/lib/captive/platform_root.rb +25 -0
  37. data/lib/captive/poller.rb +35 -0
  38. data/lib/captive/presenter/ansi_stripper.rb +22 -0
  39. data/lib/captive/presenter/log_colorizer.rb +54 -0
  40. data/lib/captive/presenter/log_line.rb +40 -0
  41. data/lib/captive/session_id.rb +20 -0
  42. data/lib/captive/shell_completions.rb +61 -0
  43. data/lib/captive/terminal.rb +10 -0
  44. data/lib/captive/ui/banner.rb +37 -0
  45. data/lib/captive/ui/spinner.rb +23 -0
  46. data/lib/captive/update_checker.rb +65 -0
  47. data/lib/captive/version.rb +5 -0
  48. metadata +172 -0
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Écarte le bruit d'infrastructure des logs applicatifs.
5
+ #
6
+ # La readinessProbe frappe /up toutes les 3s : Rails tait la requête
7
+ # (silence_healthcheck_path) mais rack-timeout logge son propre couple
8
+ # ready/completed, soit ~40 lignes/minute et par pod qui noient les lignes
9
+ # utiles. Les états timed_out et expired, eux, signalent un incident réel et
10
+ # restent affichés.
11
+ class LogNoiseFilter
12
+ SILENCED_STATES = %w[ready active completed].freeze
13
+ RACK_TIMEOUT_STATE = /source=rack-timeout\b.*\bstate=(?:#{SILENCED_STATES.join("|")})\b/
14
+
15
+ def noise?(line) = RACK_TIMEOUT_STATE.match?(line)
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Filtre neutre : --raw affiche le flux kubectl tel quel.
5
+ class NullLogFilter
6
+ def noise?(_line) = false
7
+ end
8
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Construit le manifeste Job one-off à partir du Deployment web courant.
5
+ # Labels app: <app>-run pour ne jamais entrer dans le Service web (app=<app>).
6
+ #
7
+ # Toujours un Job, même pour la console interactive : ttlSecondsAfterFinished
8
+ # laisse vivre le pod terminé — donc son log — quelques minutes après la
9
+ # sortie, ce qu'un Pod nu ne sait pas faire (pas de TTL sur un Pod).
10
+ class OneOffSpec
11
+ CONTAINER_NAME = "one-off"
12
+ CONSOLE_DEADLINE_SECONDS = 4 * 3600
13
+ RUN_DEADLINE_SECONDS = 3600
14
+ TTL_SECONDS = 600
15
+
16
+ PRODUCTION_NODE_AFFINITY = {
17
+ "nodeAffinity" => {
18
+ "requiredDuringSchedulingIgnoredDuringExecution" => {
19
+ "nodeSelectorTerms" => [
20
+ {
21
+ "matchExpressions" => [
22
+ {
23
+ "key" => "captive.dev/workload",
24
+ "operator" => "In",
25
+ "values" => [ "production" ],
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ },
31
+ },
32
+ }.freeze
33
+
34
+ def initialize(app:, deployment:, command:, interactive: false, session: nil)
35
+ @app = app
36
+ @deployment = deployment
37
+ @command = Array(command)
38
+ @interactive = interactive
39
+ @session = session
40
+ end
41
+
42
+ def to_h = job_manifest
43
+
44
+ def self.run_app_label(app)
45
+ "#{app}-run"
46
+ end
47
+
48
+ def self.live_logs_selector(app)
49
+ "app in (#{app},#{run_app_label(app)})"
50
+ end
51
+
52
+ private
53
+
54
+ def job_manifest
55
+ {
56
+ "apiVersion" => "batch/v1",
57
+ "kind" => "Job",
58
+ "metadata" => metadata,
59
+ "spec" => {
60
+ "backoffLimit" => 0,
61
+ "ttlSecondsAfterFinished" => TTL_SECONDS,
62
+ "activeDeadlineSeconds" => deadline,
63
+ "template" => {
64
+ "metadata" => { "labels" => labels },
65
+ "spec" => pod_spec,
66
+ },
67
+ },
68
+ }
69
+ end
70
+
71
+ def deadline = @interactive ? CONSOLE_DEADLINE_SECONDS : RUN_DEADLINE_SECONDS
72
+
73
+ # Nommé, et non `generateName`, dès qu'une session est donnée : le pod hérite du nom du
74
+ # Job en préfixe, ce qui rend la session retrouvable dans Loki sur le seul label `pod`.
75
+ # Sans session on garde `generateName`, qui ne suppose rien de l'appelant.
76
+ def metadata
77
+ base = { "namespace" => @app, "labels" => labels }
78
+ return base.merge("generateName" => "#{run_label}-") unless @session
79
+
80
+ base.merge("name" => "#{run_label}-#{@session}")
81
+ end
82
+
83
+ def labels
84
+ {
85
+ "app" => run_label,
86
+ "captive.dev/type" => "one-off",
87
+ }
88
+ end
89
+
90
+ def run_label
91
+ self.class.run_app_label(@app)
92
+ end
93
+
94
+ def pod_spec
95
+ template = @deployment.dig("spec", "template", "spec") || {}
96
+ source = primary_container(template)
97
+
98
+ spec = {
99
+ "restartPolicy" => "Never",
100
+ "imagePullSecrets" => image_pull_secrets(template),
101
+ "containers" => [ container(source) ],
102
+ "affinity" => node_affinity(template),
103
+ }
104
+ priority = template["priorityClassName"]
105
+ spec["priorityClassName"] = priority if priority
106
+ spec
107
+ end
108
+
109
+ def primary_container(template)
110
+ containers = template["containers"] || []
111
+ containers.first || raise(ArgumentError, "Deployment '#{@app}' sans container")
112
+ end
113
+
114
+ def container(source)
115
+ c = {
116
+ "name" => CONTAINER_NAME,
117
+ "image" => source.fetch("image"),
118
+ "command" => @command,
119
+ }
120
+ c["env"] = source["env"] if source["env"]
121
+ c["envFrom"] = source["envFrom"] if source["envFrom"]
122
+ c["resources"] = source["resources"] if source["resources"]
123
+ if @interactive
124
+ c["stdin"] = true
125
+ c["tty"] = true
126
+ end
127
+ c
128
+ end
129
+
130
+ def image_pull_secrets(template)
131
+ secrets = template["imagePullSecrets"]
132
+ return secrets if secrets && !secrets.empty?
133
+
134
+ [ { "name" => "ghcr-auth" } ]
135
+ end
136
+
137
+ def node_affinity(template)
138
+ existing = template.dig("affinity", "nodeAffinity")
139
+ return { "nodeAffinity" => existing } if existing
140
+
141
+ PRODUCTION_NODE_AFFINITY
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Captive
6
+ class Poller
7
+ def initialize(interval:, timeout:, sleeper: method(:sleep), clock: -> { Time.now })
8
+ @interval = interval
9
+ @timeout = timeout
10
+ @sleeper = sleeper
11
+ @clock = clock
12
+ end
13
+
14
+ def poll_until(context: nil, on_tick: nil, &block)
15
+ start = @clock.call
16
+ deadline = start + @timeout
17
+ loop do
18
+ return true if block.call
19
+
20
+ now = @clock.call
21
+ raise PollTimeout, timeout_message(context) if now >= deadline
22
+
23
+ on_tick&.call(now - start)
24
+ @sleeper.call(@interval)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def timeout_message(context)
31
+ msg = "timeout après #{@timeout}s"
32
+ context ? "#{msg} — #{context}" : msg
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module Presenter
5
+ # Retire les séquences d'échappement terminal d'une ligne de log.
6
+ #
7
+ # Une console one-off est attachée à un TTY : IRB redessine son prompt à
8
+ # chaque frappe et le log du container capture tout (CSI, OSC, requêtes de
9
+ # position du curseur, retours chariot). Sans ce nettoyage, une ligne de
10
+ # console est illisible. Le CLI recolorise lui-même, la couleur d'origine
11
+ # n'a donc rien à conserver.
12
+ class AnsiStripper
13
+ CSI = /\e\[[0-?]*[ -\/]*[@-~]/
14
+ OSC = /\e\][^\a\e]*(?:\a|\e\\)/
15
+ SINGLE = /\e[@-Z\\-_]/
16
+ CARRIAGE_RETURN = /\r/
17
+ SEQUENCES = Regexp.union(CSI, OSC, SINGLE, CARRIAGE_RETURN)
18
+
19
+ def call(message) = message.gsub(SEQUENCES, "")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "time"
5
+ require "captive/log_utils"
6
+ require_relative "ansi_stripper"
7
+
8
+ module Captive
9
+ module Presenter
10
+ # Renders a log line as a colorized terminal string using Pastel and LogUtils.
11
+ class LogColorizer
12
+ TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%N %z %Z"
13
+
14
+ PASTEL_COLORS = {
15
+ "blue" => :blue,
16
+ "green" => :green,
17
+ "yellow" => :yellow,
18
+ "red" => :red,
19
+ "magenta" => :magenta,
20
+ "dim" => :bright_black,
21
+ }.freeze
22
+
23
+ def initialize(paint: Pastel.new, stripper: AnsiStripper.new)
24
+ @paint = paint
25
+ @stripper = stripper
26
+ end
27
+
28
+ def format_line(ts:, label:, message:)
29
+ "#{dim_time(ts)} #{badge(label)} #{colorize_message(message)}"
30
+ end
31
+
32
+ private
33
+
34
+ def dim_time(ts)
35
+ formatted = Time.iso8601(ts).localtime.strftime(TIME_FORMAT)
36
+ @paint.dim(formatted)
37
+ rescue ArgumentError
38
+ @paint.dim(ts)
39
+ end
40
+
41
+ def badge(label)
42
+ color = PASTEL_COLORS.fetch(LogUtils::Colors.for_process(label), :white)
43
+ @paint.decorate("[#{label}]", color)
44
+ end
45
+
46
+ def colorize_message(raw)
47
+ message = @stripper.call(raw)
48
+ level = LogUtils::LogLevelDetector.call(message)
49
+ color = PASTEL_COLORS[LogUtils::Colors.for_level(level)]
50
+ color ? @paint.decorate(message, color) : message
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "captive/log_utils"
4
+ require_relative "log_colorizer"
5
+
6
+ module Captive
7
+ module Presenter
8
+ # Parses a raw kubectl log line and renders it as a colorized terminal string.
9
+ class LogLine
10
+ BRACKET_RE = /\A\[(?:pod\/)?(?<pod>[^\/\]]+)(?:\/(?<container>[^\/\]]+))?\]\z/
11
+
12
+ def initialize(app:, paint: Pastel.new)
13
+ @app = app
14
+ @colorizer = LogColorizer.new(paint: paint)
15
+ end
16
+
17
+ def call(raw_line)
18
+ line = raw_line.chomp
19
+ first, second, message = line.split(" ", 3)
20
+ return line if first.nil? || second.nil? || message.nil?
21
+
22
+ bracket, ts = pick_bracket_and_timestamp(first, second)
23
+ return line unless bracket
24
+
25
+ label = LogUtils::ProcessLabel.for(pod: bracket[:pod], namespace: @app, container: bracket[:container])
26
+ @colorizer.format_line(ts: ts, label: label, message: message)
27
+ end
28
+
29
+ private
30
+
31
+ def pick_bracket_and_timestamp(first, second)
32
+ if (bracket = BRACKET_RE.match(first))
33
+ [ bracket, second ]
34
+ elsif (bracket = BRACKET_RE.match(second))
35
+ [ bracket, first ]
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Captive
6
+ # L'identifiant qu'une session one-off se donne avant d'exister.
7
+ #
8
+ # Il est tiré côté client, et non déduit du Job créé, parce que l'accès est inscrit au fil
9
+ # d'activité *avant* l'action : à cet instant le Job n'existe pas encore, et une console
10
+ # abandonnée doit quand même laisser une trace qu'on saura relier à son log.
11
+ #
12
+ # Il nomme ensuite le Job, dont le pod hérite le préfixe — c'est ce qui permet au dashboard
13
+ # de retrouver la session dans Loki par le seul label `pod`, sans que la collecte ait à
14
+ # promouvoir quoi que ce soit.
15
+ module SessionId
16
+ BYTES = 4
17
+
18
+ def self.generate = SecureRandom.hex(BYTES)
19
+ end
20
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ module ShellCompletions
5
+ ROOT_COMMANDS = %w[apps env db login whoami console run logs version completions].freeze
6
+
7
+ SUBCOMMANDS = {
8
+ "apps" => %w[list info restart rebuild],
9
+ "env" => %w[list],
10
+ "db" => %w[restore],
11
+ }.freeze
12
+
13
+ def self.bash
14
+ all = (ROOT_COMMANDS + SUBCOMMANDS.values.flatten).uniq.join(" ")
15
+ <<~BASH
16
+ _captive_completions() {
17
+ local cur prev words
18
+ cur="${COMP_WORDS[COMP_CWORD]}"
19
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
20
+
21
+ case "$prev" in
22
+ apps) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["apps"].join(" ")}" -- "$cur")); return ;;
23
+ env) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["env"].join(" ")}" -- "$cur")); return ;;
24
+ db) COMPREPLY=($(compgen -W "#{SUBCOMMANDS["db"].join(" ")}" -- "$cur")); return ;;
25
+ esac
26
+
27
+ COMPREPLY=($(compgen -W "#{ROOT_COMMANDS.join(" ")}" -- "$cur"))
28
+ }
29
+ complete -F _captive_completions captive
30
+ BASH
31
+ end
32
+
33
+ def self.zsh
34
+ sub_cases = SUBCOMMANDS.map do |cmd, subs|
35
+ " #{cmd}) compadd #{subs.join(" ")} ;;"
36
+ end.join("\n")
37
+
38
+ <<~ZSH
39
+ _captive() {
40
+ local state
41
+
42
+ _arguments \\
43
+ '1: :->command' \\
44
+ '*: :->args'
45
+
46
+ case $state in
47
+ command)
48
+ compadd #{ROOT_COMMANDS.join(" ")}
49
+ ;;
50
+ args)
51
+ case ${words[2]} in
52
+ #{sub_cases}
53
+ esac
54
+ ;;
55
+ esac
56
+ }
57
+ compdef _captive captive
58
+ ZSH
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ # Distingue un humain devant son terminal d'un agent, d'une CI ou d'un pipe.
5
+ module Terminal
6
+ def self.interactive?
7
+ $stdin.tty? && $stdout.tty?
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Captive
6
+ module UI
7
+ # L'enseigne posée en haut d'une session interactive — console ou shell.
8
+ #
9
+ # Elle ne sert à rien d'autre qu'à dire où l'on vient d'atterrir : une console distante
10
+ # ressemble à s'y méprendre à un terminal local, et on s'y croit chez soi. Le nom de l'app
11
+ # à côté de l'enseigne est le seul vrai renseignement de l'affichage.
12
+ class Banner
13
+ # Chaque lettre est tracée sur une grille de cinq lignes, pleins uniquement : les
14
+ # caractères de filet donnaient des angles bricolés à cette taille, là où un pixel plein
15
+ # n'a pas de forme à rater. Les deux signes du logo y sont — le `/` à la place du `I`,
16
+ # et le `A` sans barre, qui est le `V` retourné.
17
+ ART = <<~ART
18
+ ████ █ ████ █████ █ █ █ ████
19
+ █ █ █ █ █ █ █ █ █ █
20
+ █ █ █ ████ █ █ █ █ ███
21
+ █ █ █ █ █ █ █ █ █
22
+ ████ █ █ █ █ █ █ ████
23
+ ART
24
+
25
+ def initialize(output: $stdout, paint: Pastel.new)
26
+ @output = output
27
+ @paint = paint
28
+ end
29
+
30
+ def show(app)
31
+ @output.puts(@paint.cyan(ART))
32
+ @output.puts(@paint.dim(" #{app}"))
33
+ @output.puts
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-spinner"
4
+
5
+ module Captive
6
+ module UI
7
+ class Spinner
8
+ def initialize(label, tty_spinner: nil)
9
+ @tty_spinner = tty_spinner || TTY::Spinner.new(" [:spinner] #{label}", format: :dots)
10
+ end
11
+
12
+ def run(success_msg:)
13
+ @tty_spinner.auto_spin
14
+ result = yield
15
+ @tty_spinner.success(success_msg)
16
+ result
17
+ rescue => e
18
+ @tty_spinner.error
19
+ raise
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "http_client"
7
+ require_relative "version"
8
+
9
+ module Captive
10
+ class UpdateChecker
11
+ VERSION_ENDPOINT = "https://raw.githubusercontent.com/captive-studio/homebrew-tap/main/version.json"
12
+ CACHE_TTL = 86_400 # 24h en secondes
13
+
14
+ def initialize(http: HttpClient.new, cache_path: default_cache_path, current_version: VERSION)
15
+ @http = http
16
+ @cache_path = cache_path
17
+ @current_version = Gem::Version.new(current_version)
18
+ end
19
+
20
+ def check
21
+ data = fetch_version_data
22
+ latest = Gem::Version.new(data["latest"])
23
+ minimum = Gem::Version.new(data["minimum"])
24
+
25
+ return :blocked if @current_version < minimum
26
+ return :update_available if @current_version < latest
27
+
28
+ :up_to_date
29
+ rescue StandardError
30
+ :up_to_date
31
+ end
32
+
33
+ private
34
+
35
+ def fetch_version_data
36
+ cached = read_cache
37
+ return cached if cached
38
+
39
+ data = @http.get(VERSION_ENDPOINT)
40
+ write_cache(data)
41
+ data
42
+ end
43
+
44
+ def read_cache
45
+ return unless File.exist?(@cache_path)
46
+
47
+ raw = JSON.parse(File.read(@cache_path))
48
+ age = Time.now - Time.parse(raw["checked_at"])
49
+ raw if age < CACHE_TTL
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def write_cache(data)
55
+ FileUtils.mkdir_p(File.dirname(@cache_path))
56
+ File.write(@cache_path, JSON.generate(data.merge("checked_at" => Time.now.iso8601)))
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def default_cache_path
62
+ File.expand_path("~/.config/captive/last_update_check")
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Captive
4
+ VERSION = "1.0.0"
5
+ end