Class: Captive::Kubectl

Inherits:
Object
  • Object
show all
Defined in:
lib/captive/kubectl.rb

Constant Summary collapse

EXIT_SUCCESS =
0
EXIT_FAILURE =
1
TERMINAL_PHASES =
%w[Succeeded Failed].freeze
ADMIN_ROLE =

Rôle admin non-superuser sous lequel tourne le SQL utilisateur, sur le modèle du rôle postgres de Supabase hébergé et du neon_superuser de Neon. Les apps sans backend Supabase n'ont que le rôle propriétaire app.

"app_admin"
ADMIN_SECRET =
"supabase-app-admin"
OWNER_ROLE =
"app"

Instance Method Summary collapse

Constructor Details

#initialize(kubeconfig: nil) ⇒ Kubectl

Returns a new instance of Kubectl.



24
25
26
# File 'lib/captive/kubectl.rb', line 24

def initialize(kubeconfig: nil)
  @kubeconfig = kubeconfig
end

Instance Method Details

#admin_credentials(namespace, app) ⇒ Object



155
156
157
158
159
160
161
# File 'lib/captive/kubectl.rb', line 155

def admin_credentials(namespace, app)
  [ ADMIN_ROLE, get_secret_password(namespace, ADMIN_SECRET) ]
rescue RuntimeError => e
  raise unless e.message.include?("NotFound")

  [ OWNER_ROLE, postgres_secret_password(namespace, app) ]
end

#attach(namespace, pod) ⇒ Object



59
60
61
# File 'lib/captive/kubectl.rb', line 59

def attach(namespace, pod)
  run_interactive("attach", "-n", namespace, "-it", pod, "-c", OneOffSpec::CONTAINER_NAME)
end

#create_empty_secret_yaml(name, namespace) ⇒ Object



135
136
137
# File 'lib/captive/kubectl.rb', line 135

def create_empty_secret_yaml(name, namespace)
  run("create", "secret", "generic", name, "-n", namespace, "--dry-run=client", "-o", "yaml")
end

#create_from_resource(resource) ⇒ Object

Raises:



39
40
41
42
43
44
45
46
47
48
49
# File 'lib/captive/kubectl.rb', line 39

def create_from_resource(resource)
  json = JSON.generate(resource)
  out, err, status = Open3.capture3(
    "kubectl", *kubeconfig_args, "create", "-f", "-", "-o", "jsonpath={.metadata.name}",
    stdin_data: json
  )
  raise AuthError, "Votre session a expiré. Reconnectez-vous avec : captive login" if err.include?("Unauthorized")
  raise "kubectl create failed: #{err}" unless status.success?

  out.strip
end

#create_secret_yaml(name, vars, namespace: name) ⇒ Object



139
140
141
142
# File 'lib/captive/kubectl.rb', line 139

def create_secret_yaml(name, vars, namespace: name)
  literals = vars.map { |k, v| "--from-literal=#{k}=#{v}" }
  run("create", "secret", "generic", name, "-n", namespace, *literals, "--dry-run=client", "-o", "yaml")
end

#delete_job(namespace, job) ⇒ Object



86
87
88
# File 'lib/captive/kubectl.rb', line 86

def delete_job(namespace, job)
  run("delete", "job", job, "-n", namespace, "--ignore-not-found=true", "--wait=false")
end

#delete_pod(namespace, pod) ⇒ Object



63
64
65
# File 'lib/captive/kubectl.rb', line 63

def delete_pod(namespace, pod)
  run("delete", "pod", pod, "-n", namespace, "--ignore-not-found=true", "--wait=false")
end

#dump_from_pod(namespace, pod, local_path, cmd) ⇒ Object



269
270
271
# File 'lib/captive/kubectl.rb', line 269

def dump_from_pod(namespace, pod, local_path, cmd)
  system_pipe("#{kubectl_cmd} exec #{pod} -n #{namespace} -- bash -c '#{cmd}' > #{Shellwords.escape(local_path)}")
end

#exec_on_pod(namespace, pod, cmd) ⇒ Object



285
286
287
# File 'lib/captive/kubectl.rb', line 285

def exec_on_pod(namespace, pod, cmd)
  run_interactive("exec", pod, "-n", namespace, "--", "bash", "-c", cmd)
end

#exec_on_pod_output(namespace, pod, cmd) ⇒ Object



289
290
291
292
# File 'lib/captive/kubectl.rb', line 289

def exec_on_pod_output(namespace, pod, cmd)
  out, _err, status = Open3.capture3("kubectl", *kubeconfig_args, "exec", pod, "-n", namespace, "--", "bash", "-c", cmd)
  status.success? ? out : nil
end

#exec_with_file(namespace, pod, file, *cmd) ⇒ Object



173
174
175
# File 'lib/captive/kubectl.rb', line 173

def exec_with_file(namespace, pod, file, *cmd)
  system("kubectl", *kubeconfig_args, "exec", "-n", namespace, "-i", pod, "--", *cmd, in: File.open(file))
end

#follow_job_logs(namespace, job) ⇒ Object



90
91
92
93
94
95
# File 'lib/captive/kubectl.rb', line 90

def follow_job_logs(namespace, job)
  wait_job_pod(namespace, job)
  stream_logs("logs", "-n", namespace, "job/#{job}", "-c", OneOffSpec::CONTAINER_NAME, "-f") do |line|
    puts line
  end
end

#get_deployment_json(namespace, name) ⇒ Object



35
36
37
# File 'lib/captive/kubectl.rb', line 35

def get_deployment_json(namespace, name)
  JSON.parse(run("get", "deployment", name, "-n", namespace, "-o", "json"))
end

#get_pod(namespace, app) ⇒ Object

Raises:



28
29
30
31
32
33
# File 'lib/captive/kubectl.rb', line 28

def get_pod(namespace, app)
  name, = pod_info(namespace, app)
  raise NoPodFound, "aucun pod Running pour '#{app}' dans '#{namespace}'" if name.nil? || name.empty?

  name
end

#get_secret_data(namespace, secret_name) ⇒ Object



240
241
242
243
244
# File 'lib/captive/kubectl.rb', line 240

def get_secret_data(namespace, secret_name)
  json = run("get", "secret", secret_name, "-n", namespace, "-o", "json")
  data = JSON.parse(json).fetch("data", {})
  data.transform_values { |v| Base64.strict_decode64(v) }
end

#get_secret_password(namespace, secret_name) ⇒ Object



246
247
248
249
250
# File 'lib/captive/kubectl.rb', line 246

def get_secret_password(namespace, secret_name)
  encoded = run("get", "secret", secret_name, "-n", namespace,
                "-o", "jsonpath={.data.password}").strip
  Base64.strict_decode64(encoded)
end

#job_exit_code(namespace, job) ⇒ Object



97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/captive/kubectl.rb', line 97

def job_exit_code(namespace, job)
  json = JSON.parse(run("get", "job", job, "-n", namespace, "-o", "json"))
  return EXIT_SUCCESS if json.dig("status", "succeeded").to_i >= 1

  pod = job_pod_name(namespace, job)
  return EXIT_FAILURE if pod.nil?

  pod_json = JSON.parse(run("get", "pod", pod, "-n", namespace, "-o", "json"))
  statuses = pod_json.dig("status", "containerStatuses") || []
  statuses.first&.dig("state", "terminated", "exitCode") ||
    statuses.first&.dig("lastState", "terminated", "exitCode") ||
    EXIT_FAILURE
end

#job_pod_name(namespace, job) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
# File 'lib/captive/kubectl.rb', line 123

def job_pod_name(namespace, job)
  output = run(
    "get", "pod", "-n", namespace, "-l", "job-name=#{job}",
    "-o", "jsonpath={.items[0].metadata.name}"
  ).strip
  output.empty? ? nil : output
rescue RuntimeError => e
  return nil if e.message.include?("index out of bounds") || e.message.include?("array index")

  raise
end

#kubeconfig_args ⇒ Object



273
274
275
# File 'lib/captive/kubectl.rb', line 273

def kubeconfig_args
  @kubeconfig ? [ "--kubeconfig", @kubeconfig ] : []
end

#kubectl_cmd ⇒ Object



277
278
279
# File 'lib/captive/kubectl.rb', line 277

def kubectl_cmd
  ([ "kubectl" ] + kubeconfig_args).map { |a| Shellwords.escape(a) }.join(" ")
end

#logs(namespace, selector, follow: false, container: nil, previous: false, tail: 100, formatter: Presenter::LogLine.new(app: namespace), filter: LogNoiseFilter.new) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/captive/kubectl.rb', line 191

def logs(namespace, selector, follow: false, container: nil, previous: false, tail: 100,
         formatter: Presenter::LogLine.new(app: namespace), filter: LogNoiseFilter.new)
  args = [ "logs", "-n", namespace, "-l", selector, "--timestamps", "--prefix" ]
  args << "-f" if follow
  args << "-p" if previous
  args += [ "--tail", tail.to_s ] unless follow
  args += [ "-c", container ] if container
  success = print_logs(args, formatter, filter)
  return if success || !previous

  warn "⚠️  Logs du run précédent non disponibles (purgés par le runtime), affichage des logs actuels"
  print_logs(args - [ "-p" ], formatter, filter)
end

#pod_crashing?(namespace, selector) ⇒ Boolean

Returns:

  • (Boolean)


177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/captive/kubectl.rb', line 177

def pod_crashing?(namespace, selector)
  json = run("get", "pod", "-n", namespace, "-l", selector, "-o", "json")
  items = JSON.parse(json)["items"] || []
  items.any? do |pod|
    statuses = (pod.dig("status", "containerStatuses") || []) +
               (pod.dig("status", "initContainerStatuses") || [])
    statuses.any? do |s|
      s["restartCount"].to_i > 0 ||
        s.dig("state", "waiting", "reason")&.include?("CrashLoop") ||
        s.dig("lastState", "terminated", "exitCode").to_i != 0
    end
  end
end

#pod_info(namespace, app) ⇒ Object



227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/captive/kubectl.rb', line 227

def pod_info(namespace, app)
  output = run(
    "get", "pod", "-n", namespace, "-l", "app=#{app}",
    "--field-selector=status.phase=Running",
    "-o", "jsonpath={.items[0].metadata.name} {.items[0].metadata.creationTimestamp}"
  ).strip
  output.empty? ? [ nil, nil ] : output.split(" ", 2)
rescue RuntimeError => e
  return [ nil, nil ] if e.message.include?("index out of bounds") || e.message.include?("array index")

  raise
end

#pod_phase(namespace, pod) ⇒ Object



80
81
82
83
84
# File 'lib/captive/kubectl.rb', line 80

def pod_phase(namespace, pod)
  run("get", "pod", pod, "-n", namespace, "-o", "jsonpath={.status.phase}").strip
rescue RuntimeError
  ""
end

#pod_terminated?(namespace, pod, timeout: "10s") ⇒ Boolean

Attend que le pod atteigne une phase terminale. Sert à distinguer une session console sortie proprement (à laisser au TTL du Job) d’une session abandonnée (à supprimer tout de suite).

Returns:

  • (Boolean)


70
71
72
73
74
75
76
77
78
# File 'lib/captive/kubectl.rb', line 70

def pod_terminated?(namespace, pod, timeout: "10s")
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + parse_timeout(timeout)
  loop do
    return true if TERMINAL_PHASES.include?(pod_phase(namespace, pod))
    return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

    sleep 0.5
  end
end

#postgres_pod_name(namespace, app) ⇒ Object



258
259
260
261
262
263
# File 'lib/captive/kubectl.rb', line 258

def postgres_pod_name(namespace, app)
  with_cnpg_fallback("postgres-#{app}-1", "postgres-#{app}-cnpg-1") do |name|
    run("get", "pod", name, "-n", namespace, "-o", "jsonpath={.metadata.name}")
    name
  end
end

#postgres_secret_password(namespace, app) ⇒ Object



252
253
254
255
256
# File 'lib/captive/kubectl.rb', line 252

def postgres_secret_password(namespace, app)
  with_cnpg_fallback("postgres-#{app}-app", "postgres-#{app}-cnpg-app") do |name|
    get_secret_password(namespace, name)
  end
end


205
206
207
208
209
210
211
# File 'lib/captive/kubectl.rb', line 205

def print_logs(args, formatter, filter)
  stream_logs(*args) do |line|
    next if filter.noise?(line)

    puts formatter.call(line)
  end
end

#psql_file(namespace, pod, file, user:, password:, single_transaction: true) ⇒ Object



163
164
165
166
167
# File 'lib/captive/kubectl.rb', line 163

def psql_file(namespace, pod, file, user:, password:, single_transaction: true)
  flags = [ "-v", "ON_ERROR_STOP=1" ]
  flags << "--single-transaction" if single_transaction
  exec_with_file(namespace, pod, file, *psql_argv(user, password, flags))
end

#psql_interactive(namespace, pod, user:, password:) ⇒ Object



169
170
171
# File 'lib/captive/kubectl.rb', line 169

def psql_interactive(namespace, pod, user:, password:)
  run_interactive("exec", "-n", namespace, "-it", pod, "--", *psql_argv(user, password))
end

#restart_deployment(namespace, name) ⇒ Object



294
295
296
# File 'lib/captive/kubectl.rb', line 294

def restart_deployment(namespace, name)
  run("rollout", "restart", "deployment", "-n", namespace, name)
end

#restore(namespace, pod, dump_file, format) ⇒ Object



148
149
150
151
152
153
# File 'lib/captive/kubectl.rb', line 148

def restore(namespace, pod, dump_file, format)
  cmd = format == :custom ?
    [ "pg_restore", "--clean", "--if-exists", "--no-owner", "--no-acl", "-d", "app" ] :
    [ "psql", "app" ]
  exec_with_file(namespace, pod, dump_file, *cmd)
end

#run(*args) ⇒ Object

Raises:



298
299
300
301
302
303
304
# File 'lib/captive/kubectl.rb', line 298

def run(*args)
  out, err, status = Open3.capture3("kubectl", *kubeconfig_args, *args)
  raise AuthError, "Votre session a expiré. Reconnectez-vous avec : captive login" if err.include?("Unauthorized")
  raise "kubectl #{args.join(" ")} failed: #{err}" unless status.success?

  out
end

#run_interactive(*args) ⇒ Object



213
214
215
# File 'lib/captive/kubectl.rb', line 213

def run_interactive(*args)
  system("kubectl", *kubeconfig_args, *args)
end

#scale(namespace, deployment, replicas) ⇒ Object



144
145
146
# File 'lib/captive/kubectl.rb', line 144

def scale(namespace, deployment, replicas)
  run("scale", "deployment", deployment, "-n", namespace, "--replicas=#{replicas}")
end

#stream_logs(*args) ⇒ Object

external_encoding forcé : sans LANG, Ruby lit en US-ASCII et les octets UTF-8 des séquences TTY d’une console one-off font planter la lecture.



219
220
221
222
223
224
225
# File 'lib/captive/kubectl.rb', line 219

def stream_logs(*args)
  IO.popen([ "kubectl", *kubeconfig_args, *args ],
           err: [ :child, :out ], external_encoding: Encoding::UTF_8) do |io|
    io.each_line { |line| yield line.scrub }
  end
  $?.success?
end

#system_pipe(cmd) ⇒ Object



281
282
283
# File 'lib/captive/kubectl.rb', line 281

def system_pipe(cmd)
  system("bash", "-c", cmd)
end

#transfer_file_to_pod(namespace, pod, stream_cmd, remote_path) ⇒ Object



265
266
267
# File 'lib/captive/kubectl.rb', line 265

def transfer_file_to_pod(namespace, pod, stream_cmd, remote_path)
  system_pipe("#{stream_cmd} | #{kubectl_cmd} exec -i #{pod} -n #{namespace} -- bash -c 'cat > #{remote_path}'")
end

#wait_job_complete(namespace, job, timeout: "3600s") ⇒ Object



55
56
57
# File 'lib/captive/kubectl.rb', line 55

def wait_job_complete(namespace, job, timeout: "3600s")
  run("wait", "--for=condition=complete", "job/#{job}", "-n", namespace, "--timeout=#{timeout}")
end

#wait_job_pod(namespace, job, timeout: "120s") ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
# File 'lib/captive/kubectl.rb', line 111

def wait_job_pod(namespace, job, timeout: "120s")
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + parse_timeout(timeout)
  loop do
    name = job_pod_name(namespace, job)
    return name if name

    raise "aucun pod pour le Job '#{job}' dans '#{namespace}'" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

    sleep 0.5
  end
end

#wait_pod_ready(namespace, pod, timeout: "120s") ⇒ Object



51
52
53
# File 'lib/captive/kubectl.rb', line 51

def wait_pod_ready(namespace, pod, timeout: "120s")
  run("wait", "--for=condition=Ready", "pod/#{pod}", "-n", namespace, "--timeout=#{timeout}")
end