kitsune-kit 0.4.0 → 0.5.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +108 -136
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/provider-adapters.md +49 -0
  14. data/docs/architecture.md +89 -0
  15. data/docs/commands.md +210 -0
  16. data/docs/configuration.md +210 -0
  17. data/docs/getting-started.md +133 -0
  18. data/docs/providers/digitalocean.md +52 -0
  19. data/docs/releasing.md +43 -0
  20. data/docs/roadmap.md +46 -0
  21. data/docs/security-audit.md +130 -0
  22. data/docs/security.md +116 -0
  23. data/docs/services/postgres.md +78 -0
  24. data/docs/services/redis.md +53 -0
  25. data/docs/testing.md +200 -0
  26. data/docs/troubleshooting.md +123 -0
  27. data/docs/tui.md +57 -0
  28. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  29. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  30. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  31. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  32. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  34. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  35. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  36. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  37. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  38. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  39. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  40. data/lib/kitsune/kit/application.rb +170 -0
  41. data/lib/kitsune/kit/cancellation.rb +21 -0
  42. data/lib/kitsune/kit/cli.rb +802 -64
  43. data/lib/kitsune/kit/clock.rb +11 -0
  44. data/lib/kitsune/kit/configuration.rb +493 -0
  45. data/lib/kitsune/kit/errors.rb +98 -0
  46. data/lib/kitsune/kit/events.rb +55 -0
  47. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  48. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  49. data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
  50. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  51. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  52. data/lib/kitsune/kit/operations/service_files.rb +127 -0
  53. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  54. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  55. data/lib/kitsune/kit/plan.rb +72 -0
  56. data/lib/kitsune/kit/reporters/human.rb +89 -0
  57. data/lib/kitsune/kit/reporters/json.rb +65 -0
  58. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  59. data/lib/kitsune/kit/result.rb +28 -0
  60. data/lib/kitsune/kit/run_journal.rb +102 -0
  61. data/lib/kitsune/kit/run_logger.rb +37 -0
  62. data/lib/kitsune/kit/scripts/docker.sh +137 -0
  63. data/lib/kitsune/kit/scripts/firewall.sh +205 -0
  64. data/lib/kitsune/kit/scripts/metrics.sh +54 -0
  65. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  66. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  67. data/lib/kitsune/kit/scripts/unattended.sh +81 -0
  68. data/lib/kitsune/kit/scripts/user.sh +114 -0
  69. data/lib/kitsune/kit/secret_filter.rb +54 -0
  70. data/lib/kitsune/kit/secret_store.rb +32 -0
  71. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  72. data/lib/kitsune/kit/service_compose.rb +72 -0
  73. data/lib/kitsune/kit/state_store.rb +158 -0
  74. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  75. data/lib/kitsune/kit/tui/actions.rb +72 -0
  76. data/lib/kitsune/kit/tui/application.rb +35 -0
  77. data/lib/kitsune/kit/tui/controller.rb +162 -0
  78. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  79. data/lib/kitsune/kit/tui/state.rb +18 -0
  80. data/lib/kitsune/kit/tui/store.rb +88 -0
  81. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  82. data/lib/kitsune/kit/version.rb +1 -1
  83. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  84. data/lib/kitsune/kit/workflows/base.rb +31 -0
  85. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  86. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  87. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  88. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  89. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  90. data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
  91. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  92. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  93. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  94. data/lib/kitsune/kit.rb +41 -2
  95. metadata +122 -79
  96. data/.rspec +0 -3
  97. data/Rakefile +0 -8
  98. data/kitsune-kit-logo.jpg +0 -0
  99. data/lib/kitsune/blueprints/.env.template +0 -31
  100. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  101. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  102. data/lib/kitsune/blueprints/kit.env.template +0 -1
  103. data/lib/kitsune/kit/ansi_color.rb +0 -78
  104. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  105. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  106. data/lib/kitsune/kit/commands/dns.rb +0 -112
  107. data/lib/kitsune/kit/commands/init.rb +0 -148
  108. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  109. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  110. data/lib/kitsune/kit/commands/provision.rb +0 -43
  111. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  112. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  113. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  114. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  115. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  116. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  117. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  118. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  119. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  120. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  121. data/lib/kitsune/kit/defaults.rb +0 -91
  122. data/lib/kitsune/kit/env_loader.rb +0 -41
  123. data/lib/kitsune/kit/options_builder.rb +0 -26
  124. data/lib/kitsune/kit/provisioner.rb +0 -107
  125. data/sig/kitsune/kit.rbs +0 -6
@@ -1,95 +1,833 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "thor"
2
- require_relative "ansi_color"
3
- require_relative "env_loader"
4
- require_relative "commands/init"
5
- require_relative "commands/switch_env"
6
- require_relative "commands/provision"
7
- require_relative "commands/dns"
8
- require_relative "commands/setup_user"
9
- require_relative "commands/setup_firewall"
10
- require_relative "commands/setup_unattended"
11
- require_relative "commands/setup_swap"
12
- require_relative "commands/setup_do_metrics"
13
- require_relative "commands/bootstrap"
14
- require_relative "commands/setup_docker_prereqs"
15
- require_relative "commands/install_docker_engine"
16
- require_relative "commands/postinstall_docker"
17
- require_relative "commands/bootstrap_docker"
18
- require_relative "commands/setup_postgres_docker"
19
- require_relative "commands/setup_redis_docker"
20
- require_relative "commands/ssh"
4
+ require_relative "../kit"
21
5
 
22
6
  module Kitsune
23
7
  module Kit
24
8
  class CLI < Thor
25
- def self.dispatch(m, args, options, config)
26
- if args.include?("-v") || args.include?("--version")
27
- puts "Kitsune Kit v#{Kitsune::Kit::VERSION}"
28
- exit(0)
9
+ package_name "Kitsune Kit"
10
+
11
+ # Thor may add framework-level commands between minor releases. Keep the
12
+ # Keep the Kitsune Kit public surface intentional instead of inheriting those commands.
13
+ remove_task :tree if all_tasks.key?("tree")
14
+
15
+ class_option :env, type: :string, aliases: "-e", desc: "Environment name"
16
+ class_option :root, type: :string, default: Dir.pwd, desc: "Project root"
17
+ class_option :config, type: :string, desc: "Alternative project configuration file"
18
+ class_option :format, type: :string, default: "human", enum: %w[human json], desc: "Output format"
19
+ class_option :no_color, type: :boolean, default: false, desc: "Disable ANSI colors"
20
+ class_option :no_input, type: :boolean, default: false, desc: "Fail instead of prompting"
21
+ class_option :yes, type: :boolean, default: false, desc: "Approve non-destructive changes"
22
+ class_option :dry_run, type: :boolean, default: false, desc: "Build and display a plan without applying"
23
+ class_option :quiet, type: :boolean, default: false, desc: "Show only warnings, errors and final results"
24
+ class_option :verbose, type: :boolean, default: false, desc: "Show additional operational details"
25
+ class_option :debug, type: :boolean, default: false, desc: "Show technical error details"
26
+ class_option :log, type: :boolean, default: true, desc: "Write redacted local execution logs"
27
+ class_option :timeout, type: :numeric, desc: "Maximum seconds allowed for each remote step"
28
+ class_option :trust_host_key, type: :string, desc: "Trust this exact SHA256 SSH host-key fingerprint"
29
+
30
+ def self.exit_on_failure? = true
31
+
32
+ def self.start(given_args = ARGV, config = {})
33
+ config[:shell] ||= Thor::Base.shell.new
34
+ dispatch(nil, normalized_arguments(given_args), nil, config)
35
+ rescue Thor::Error => e
36
+ config[:debug] || ENV["THOR_DEBUG"] == "1" ? raise(e) : config[:shell].error(e.message)
37
+
38
+ exit(2)
39
+ rescue Errno::EPIPE
40
+ exit(0)
41
+ end
42
+
43
+ def self.normalized_arguments(arguments)
44
+ values = arguments.dup
45
+ return ["help", values.first] if values.length == 2 && values.last == "help" && tasks.key?(values.first)
46
+
47
+ values
48
+ end
49
+
50
+ desc "version", "Print the Kitsune Kit version"
51
+ def version
52
+ puts "Kitsune Kit #{VERSION}"
53
+ end
54
+
55
+ map %w[-v --version] => :version
56
+
57
+ desc "init", "Create a new .kitsune project configuration"
58
+ option :force, type: :boolean, default: false, desc: "Replace generated configuration files"
59
+ def init
60
+ execute do
61
+ files = Workflows::InitializeProject.new(root: options[:root]).call(force: options[:force])
62
+ if json?
63
+ flush_json(build_reporter(build_secret_filter), Result.success(files), "init", "development")
64
+ else
65
+ puts "Created Kitsune Kit configuration:"
66
+ files.each { |file| puts " #{file}" }
67
+ puts "Next: edit .kitsune/config.yml, export DO_API_TOKEN, then run `kit doctor`."
68
+ end
69
+ 0
70
+ end
71
+ end
72
+
73
+ desc "doctor", "Check configuration, credentials and managed resources without changing them"
74
+ def doctor
75
+ execute do
76
+ app, reporter = build_application
77
+ result = Workflows::Doctor.new(
78
+ config: app.config,
79
+ provider: app.provider,
80
+ state_store: app.state_store,
81
+ transport_factory: app.transport_factory,
82
+ operations: app.operations,
83
+ event_bus: app.event_bus
84
+ ).call
85
+ if json?
86
+ flush_json(reporter, result, "doctor", app.config.environment)
87
+ else
88
+ puts
89
+ print_checks(result.value)
90
+ end
91
+ result.success? ? 0 : 1
29
92
  end
30
-
31
- unless ["version", "init", "switch_env", "help", nil].include?(args.first)
32
- Kitsune::Kit::EnvLoader.load!
93
+ end
94
+
95
+ desc "plan", "Inspect infrastructure changes without applying them"
96
+ def plan
97
+ execute do
98
+ app, reporter = build_application
99
+ result = Workflows::BuildPlan.new(
100
+ config: app.config,
101
+ operations: app.operations,
102
+ event_bus: app.event_bus
103
+ ).call
104
+ json? ? flush_json(reporter, result, "plan", app.config.environment) : print_plan(result.value)
105
+ 0
33
106
  end
107
+ end
108
+
109
+ desc "apply", "Apply the desired infrastructure configuration"
110
+ def apply
111
+ execute do
112
+ app, reporter = build_application
113
+ plan_result = Workflows::BuildPlan.new(
114
+ config: app.config,
115
+ operations: app.operations,
116
+ event_bus: app.event_bus
117
+ ).call
118
+ plan = plan_result.value
119
+ print_plan(plan) unless json?
120
+
121
+ if options[:dry_run]
122
+ render_apply_terminal(reporter, plan_result, app, "Dry run: no changes were applied.")
123
+ next 0
124
+ end
34
125
 
35
- super
126
+ unless plan.changed?
127
+ result = Result.success([], metadata: { unchanged: true })
128
+ render_apply_terminal(reporter, result, app, "No changes to apply.")
129
+ next 0
130
+ end
131
+
132
+ confirm_apply!(plan)
133
+ result = Workflows::ApplyPlan.new(
134
+ config: app.config,
135
+ operations: app.operations,
136
+ state_store: app.state_store,
137
+ event_bus: app.event_bus
138
+ ).call(plan: plan)
139
+ render_apply_success(reporter, result, app)
140
+ 0
141
+ end
36
142
  end
37
143
 
38
- desc "version", "Show Kitsune Kit version"
39
- def version
40
- say "Kitsune Kit v#{Kitsune::Kit::VERSION}", :green
144
+ desc "resume [RUN_ID]", "Resume the latest failed or interrupted apply run"
145
+ def resume(run_id = nil)
146
+ execute do
147
+ app, reporter = build_application
148
+ confirm_resume!(run_id)
149
+ result = Workflows::ApplyPlan.new(
150
+ config: app.config,
151
+ operations: app.operations,
152
+ state_store: app.state_store,
153
+ event_bus: app.event_bus
154
+ ).call(resume_from: run_id || true)
155
+ flush_json(reporter, result, "resume", app.config.environment) if json?
156
+ 0
157
+ end
158
+ end
159
+
160
+ desc "status", "Show the current server and managed-resource state"
161
+ def status
162
+ execute do
163
+ app, reporter = build_application
164
+ result = Workflows::InspectEnvironment.new(
165
+ config: app.config,
166
+ provider: app.provider,
167
+ state_store: app.state_store,
168
+ event_bus: app.event_bus
169
+ ).call
170
+ if json?
171
+ flush_json(reporter, result, "status", app.config.environment)
172
+ else
173
+ print_environment_status(result.value)
174
+ end
175
+ 0
176
+ end
177
+ end
178
+
179
+ desc "rollback", "Restore managed configuration while preserving the server and service data"
180
+ def rollback
181
+ execute do
182
+ app, reporter = build_application
183
+ confirm_named_action!("rollback", app.config.environment, option: :yes)
184
+ result = Workflows::Rollback.new(
185
+ config: app.config,
186
+ operations: app.operations.drop(1),
187
+ state_store: app.state_store,
188
+ event_bus: app.event_bus
189
+ ).call
190
+ flush_json(reporter, result, "rollback", app.config.environment) if json?
191
+ 0
192
+ end
193
+ end
194
+
195
+ desc "server ACTION", "Manage the server: show, create, configure, ssh, import, destroy"
196
+ option :confirm_destroy, type: :string, desc: "Exact server name required for destruction"
197
+ option :provider_id, type: :string, desc: "Exact provider ID required for state recovery"
198
+ option :confirm_import, type: :string, desc: "Exact server name required for state import"
199
+ def server(action)
200
+ execute do
201
+ app, reporter = build_application
202
+ result = dispatch_server_action(app, action)
203
+ flush_json(reporter, result, "server.#{action}", app.config.environment) if json? && result.is_a?(Result)
204
+ 0
205
+ end
41
206
  end
42
207
 
43
- desc "init", "Initialize Kitsune Kit project structure"
44
- subcommand "init", Kitsune::Kit::Commands::Init
208
+ desc "service TYPE ACTION", "Manage postgres or redis: status, install, backup, remove, destroy-data"
209
+ option :confirm_destroy, type: :string, desc: "Exact TYPE@ENV value required for data destruction"
210
+ option :backup_before_destroy, type: :boolean, default: false,
211
+ desc: "Create a restricted remote data backup before destroy-data"
212
+ def service(type, action)
213
+ execute do
214
+ app, reporter = build_application
215
+ operation = find_service_operation(app, type, action)
216
+ result = dispatch_service_action(app, operation, type, action)
217
+ if json? && result.is_a?(Result)
218
+ flush_json(reporter, result, "service.#{type}.#{action}", app.config.environment)
219
+ end
220
+ 0
221
+ end
222
+ end
223
+
224
+ desc "dns ACTION", "Manage configured DNS records: list, plan, apply, remove"
225
+ def dns(action)
226
+ execute do
227
+ app, reporter = build_application
228
+ operation = app.operations.find { |candidate| candidate.resource == "dns" }
229
+ raise Errors::ConfigurationError, "no DNS domains are configured" unless operation
230
+
231
+ result = dispatch_resource_action(app, operation, action)
232
+ flush_json(reporter, result, "dns.#{action}", app.config.environment) if json? && result.is_a?(Result)
233
+ 0
234
+ end
235
+ end
236
+
237
+ desc "env ACTION [NAME]", "Manage environments: list, current, use NAME"
238
+ def env(action, name = nil)
239
+ execute do
240
+ selection = Workflows::EnvironmentSelection.new(root: options[:root])
241
+ value = case action
242
+ when "list" then selection.list
243
+ when "current" then selection.current
244
+ when "use" then selection.use(name)
245
+ else raise Errors::ConfigurationError, "unknown environment action: #{action}"
246
+ end
247
+ if json?
248
+ flush_json(
249
+ build_reporter(build_secret_filter), Result.success(value), "env.#{action}", selection.current
250
+ )
251
+ elsif value.is_a?(Array)
252
+ value.each { |environment| puts environment }
253
+ else
254
+ puts value
255
+ end
256
+ 0
257
+ end
258
+ end
259
+
260
+ desc "support ACTION", "Create local diagnostics: bundle"
261
+ def support(action)
262
+ execute do
263
+ raise Errors::ConfigurationError, "unknown support action: #{action}" unless action == "bundle"
264
+
265
+ app, = build_application
266
+ filter = configured_secret_filter(app.config)
267
+ doctor = Workflows::Doctor.new(
268
+ config: app.config,
269
+ provider: app.provider,
270
+ state_store: app.state_store,
271
+ transport_factory: app.transport_factory,
272
+ operations: app.operations
273
+ )
274
+ path = Workflows::SupportBundle.new(
275
+ root: options[:root], config: app.config, state_store: app.state_store, doctor: doctor,
276
+ secret_filter: filter
277
+ ).call
278
+ if json?
279
+ flush_json(
280
+ build_reporter(filter), Result.success({ path: path }), "support.bundle", app.config.environment
281
+ )
282
+ else
283
+ puts "Support bundle created: #{path}"
284
+ puts "\nRedacted contents (inspect before sharing):"
285
+ puts File.read(path)
286
+ puts "Kitsune Kit did not upload anything."
287
+ end
288
+ 0
289
+ end
290
+ end
291
+
292
+ desc "docker ACTION", "Manage Docker: status, install, uninstall"
293
+ def docker(action)
294
+ execute do
295
+ app, reporter = build_application
296
+ operation = app.operations.find { |candidate| candidate.resource == "docker" }
297
+ result = dispatch_docker_action(app, operation, action)
298
+ flush_json(reporter, result, "docker.#{action}", app.config.environment) if json? && result.is_a?(Result)
299
+ 0
300
+ end
301
+ end
302
+
303
+ desc "ui", "Open the interactive terminal interface"
304
+ def ui
305
+ execute do
306
+ build_tui.call
307
+ end
308
+ end
309
+
310
+ default_task :help
311
+
312
+ no_commands do
313
+ def execute
314
+ status = yield
315
+ exit(status) if status.is_a?(Integer) && !status.zero?
316
+ status
317
+ rescue Errors::Error => e
318
+ render_error(e)
319
+ exit(Errors.exit_status(e))
320
+ rescue Interrupt
321
+ warn "Cancelled."
322
+ exit(130)
323
+ rescue StandardError => e
324
+ render_unexpected_error(e)
325
+ exit(1)
326
+ ensure
327
+ puts "Log: #{@run_log_path}" if @run_log_path && !json?
328
+ end
329
+
330
+ def build_application
331
+ filter = @secret_filter = build_secret_filter
332
+ bus = Events::Bus.new
333
+ reporter = build_reporter(filter)
334
+ bus.subscribe(reporter)
335
+ subscribe_logger(bus, filter)
336
+ app = Application.build(
337
+ root: options[:root],
338
+ environment: options[:env],
339
+ config_path: options[:config],
340
+ event_bus: bus,
341
+ host_key_confirmation: method(:confirm_host_key),
342
+ maximum_timeout: options[:timeout]
343
+ )
344
+ register_configured_secrets(filter, app.config)
345
+ [app, reporter]
346
+ end
347
+
348
+ def build_tui
349
+ unless $stdin.tty? && $stdout.tty?
350
+ raise Errors::ConfigurationError.new(
351
+ "the TUI requires an interactive terminal",
352
+ hint: "Use `kit doctor`, `kit plan`, `kit apply`, or --format json in automation."
353
+ )
354
+ end
355
+
356
+ filter = @secret_filter = build_secret_filter
357
+ bus = Events::Bus.new
358
+ loader = Configuration::Loader.new(root: options[:root], env: ENV, config_path: options[:config])
359
+ config = loader.load(environment: options[:env])
360
+ store = Tui::Store.new(environment: config.environment, secret_filter: filter)
361
+ bus.subscribe(store)
362
+ subscribe_logger(bus, filter)
363
+ app = Application.build(
364
+ root: options[:root], environment: options[:env], config_path: options[:config], event_bus: bus,
365
+ host_key_confirmation: method(:confirm_host_key), maximum_timeout: options[:timeout]
366
+ )
367
+ register_configured_secrets(filter, app.config)
368
+ controller = Tui::Controller.new(store: store, actions: Tui::Actions.new(application: app))
369
+ Tui::Application.new(store: store, controller: controller)
370
+ end
371
+
372
+ def build_secret_filter
373
+ SecretFilter.new([ENV["DO_API_TOKEN"], ENV["POSTGRES_PASSWORD"], ENV["REDIS_PASSWORD"]])
374
+ end
375
+
376
+ def configured_secret_filter(config)
377
+ build_secret_filter.tap { |filter| register_configured_secrets(filter, config) }
378
+ end
379
+
380
+ def build_reporter(filter)
381
+ return Reporters::Json.new(secret_filter: filter) if json?
382
+
383
+ Reporters::Human.new(color: !options[:no_color] && !ENV.key?("NO_COLOR"), quiet: options[:quiet],
384
+ verbose: options[:verbose], secret_filter: filter)
385
+ end
386
+
387
+ def flush_json(reporter, result, command, environment)
388
+ reporter.flush(result: result, command: command, environment: environment)
389
+ end
390
+
391
+ def render_apply_terminal(reporter, result, app, human_message)
392
+ return flush_json(reporter, result, "apply", app.config.environment) if json?
393
+
394
+ puts human_message
395
+ end
396
+
397
+ def render_apply_success(reporter, result, app)
398
+ return flush_json(reporter, result, "apply", app.config.environment) if json?
399
+
400
+ puts "Next: run `kit doctor`; a subsequent `kit plan` should report zero changes."
401
+ end
402
+
403
+ def subscribe_logger(bus, filter)
404
+ return unless options[:log]
405
+
406
+ logger = RunLogger.new(root: options[:root], run_id: "session-#{SecureRandom.uuid}",
407
+ secret_filter: filter)
408
+ @run_log_path = logger.path
409
+ bus.subscribe(logger)
410
+ end
411
+
412
+ def register_configured_secrets(filter, config)
413
+ filter.register(ENV[config.provider.token_env])
414
+ %w[postgres redis].each do |type|
415
+ filter.register(ENV[config.services.public_send(type).password_env])
416
+ end
417
+ end
418
+
419
+ def confirm_apply!(plan)
420
+ if plan.destructive?
421
+ raise Errors::UnsafeOperationError.new(
422
+ "the plan contains destructive changes",
423
+ hint: "Resolve or explicitly perform the required destructive operation first."
424
+ )
425
+ end
426
+ return if options[:yes]
427
+ if options[:no_input] || !$stdin.tty?
428
+ raise Errors::UnsafeOperationError.new(
429
+ "apply requires confirmation",
430
+ hint: "Review `kit plan`, then pass --yes for a non-interactive apply."
431
+ )
432
+ end
433
+ return if yes?("Apply #{plan.changed_count} change(s)? [y/N]")
434
+
435
+ raise Errors::UnsafeOperationError, "apply was not confirmed"
436
+ end
437
+
438
+ def confirm_resume!(run_id)
439
+ return if options[:yes]
440
+
441
+ label = run_id || "the latest incomplete run"
442
+ if options[:no_input] || !$stdin.tty?
443
+ raise Errors::UnsafeOperationError.new(
444
+ "resume requires confirmation",
445
+ hint: "Inspect `kit status`, then pass --yes to resume #{label}."
446
+ )
447
+ end
448
+ return if yes?("Resume #{label}? [y/N]")
449
+
450
+ raise Errors::UnsafeOperationError, "resume was not confirmed"
451
+ end
452
+
453
+ def dispatch_server_action(app, action)
454
+ case action
455
+ when "show", "status" then inspect_environment(app)
456
+ when "create" then apply_operations(app, [app.operations.first])
457
+ when "configure" then apply_operations(app, app.operations.drop(1))
458
+ when "import" then import_server(app)
459
+ when "destroy" then destroy_server(app)
460
+ when "ssh" then open_ssh(app)
461
+ else raise Errors::ConfigurationError, "unknown server action: #{action}"
462
+ end
463
+ end
464
+
465
+ def dispatch_service_action(app, operation, type, action)
466
+ case action
467
+ when "status" then service_status(app, type)
468
+ when "install" then apply_operations(app, [operation])
469
+ when "backup" then with_mutation_lock(app) { service_backup(operation) }
470
+ when "remove"
471
+ confirm_named_action!("remove #{type}", "#{type}@#{app.config.environment}", option: :yes)
472
+ with_mutation_lock(app) { Result.success(operation.remove) }
473
+ when "destroy-data" then destroy_service_data(app, operation, type)
474
+ else raise Errors::ConfigurationError, "unknown service action: #{action}"
475
+ end
476
+ end
477
+
478
+ def destroy_service_data(app, operation, type)
479
+ expected = "#{type}@#{app.config.environment}"
480
+ state = app.state_store.read(app.config.environment).dig("resources", "service.#{type}")
481
+ unless state
482
+ raise Errors::UnsafeOperationError.new(
483
+ "no managed #{type} data exists in #{app.config.environment}",
484
+ hint: "Run `kit service #{type} status`; restore state before attempting data destruction."
485
+ )
486
+ end
487
+ context = { environment: app.config.environment, resource: "service.#{type}",
488
+ volume: state.fetch("volume", nil), recoverable: false }
489
+ print_destructive_summary(
490
+ environment: context[:environment], provider: app.config.provider.name, resource: context[:resource],
491
+ identifier: context[:volume], recoverable: context[:recoverable]
492
+ )
493
+ backup_path = create_pre_destroy_backup?(type) ? operation.backup : nil
494
+ confirmation = options[:confirm_destroy]
495
+ confirmation ||= ask("Type #{expected} to permanently destroy its data:") unless options[:no_input]
496
+ confirm_exact!(confirmation, expected, "service data destruction", context: context)
497
+ destroyed = with_mutation_lock(app) { operation.destroy_data }
498
+ Result.success({ destroyed: destroyed, backup: backup_path })
499
+ end
500
+
501
+ def create_pre_destroy_backup?(type)
502
+ return true if options[:backup_before_destroy]
503
+ return false if options[:no_input] || !$stdin.tty?
504
+
505
+ yes?("Create a #{type} data backup on the server before destruction? [y/N]")
506
+ end
507
+
508
+ def service_backup(operation)
509
+ path = operation.backup
510
+ puts "Backup created: #{path}" unless json?
511
+ Result.success({ path: path })
512
+ end
513
+
514
+ def dispatch_resource_action(app, operation, action)
515
+ case action
516
+ when "list" then dns_list(app)
517
+ when "plan" then build_plan(app, [operation])
518
+ when "apply" then apply_operations(app, [operation])
519
+ when "remove"
520
+ confirm_named_action!("remove DNS", app.config.environment, option: :yes)
521
+ with_mutation_lock(app) { Result.success(operation.rollback) }
522
+ else raise Errors::ConfigurationError, "unknown DNS action: #{action}"
523
+ end
524
+ end
525
+
526
+ def dns_list(app)
527
+ values = app.config.dns.domains
528
+ values.each { |domain| puts domain } unless json?
529
+ Result.success(values)
530
+ end
531
+
532
+ def dispatch_docker_action(app, operation, action)
533
+ case action
534
+ when "status" then resource_status(app, "docker")
535
+ when "install" then apply_operations(app, [operation])
536
+ when "uninstall"
537
+ active_services = app.state_store.read(app.config.environment)["resources"].keys.grep(/\Aservice\./)
538
+ unless active_services.empty?
539
+ raise Errors::UnsafeOperationError.new(
540
+ "Docker cannot be removed while managed services exist",
541
+ hint: "Remove services first: #{active_services.join(', ')}"
542
+ )
543
+ end
544
+ confirm_named_action!("uninstall Docker", app.config.environment, option: :yes)
545
+ with_mutation_lock(app) { Result.success(operation.rollback) }
546
+ else raise Errors::ConfigurationError, "unknown Docker action: #{action}"
547
+ end
548
+ end
549
+
550
+ def inspect_environment(app)
551
+ result = Workflows::InspectEnvironment.new(
552
+ config: app.config,
553
+ provider: app.provider,
554
+ state_store: app.state_store,
555
+ event_bus: app.event_bus
556
+ ).call
557
+ print_environment_status(result.value) unless json?
558
+ result
559
+ end
560
+
561
+ def destroy_server(app)
562
+ expected = app.config.server.name
563
+ managed = app.state_store.read(app.config.environment).dig("resources", "server")
564
+ unless managed&.fetch("id", nil)
565
+ raise Errors::UnsafeOperationError.new(
566
+ "no managed server ID exists in state",
567
+ hint: "Restore state or use the verified `kit server import` recovery flow first."
568
+ )
569
+ end
570
+ print_destructive_summary(
571
+ environment: app.config.environment, provider: app.config.provider.name,
572
+ resource: expected, identifier: managed&.fetch("id", nil), recoverable: false
573
+ )
574
+ confirmation = options[:confirm_destroy]
575
+ confirmation ||= ask("Type #{expected} to permanently destroy the server:") unless options[:no_input]
576
+ dns_operation = app.operations.find { |operation| operation.resource == "dns" }
577
+ Workflows::DestroyServer.new(
578
+ config: app.config,
579
+ provider: app.provider,
580
+ state_store: app.state_store,
581
+ dns_operation: dns_operation,
582
+ event_bus: app.event_bus
583
+ ).call(confirmation: confirmation)
584
+ end
585
+
586
+ def import_server(app)
587
+ result = Workflows::ImportServer.new(
588
+ config: app.config,
589
+ provider: app.provider,
590
+ state_store: app.state_store,
591
+ event_bus: app.event_bus
592
+ ).call(provider_id: options[:provider_id], confirmation: options[:confirm_import])
593
+ unless json?
594
+ if result.metadata[:unchanged]
595
+ puts "State already tracks provider ID #{result.value}."
596
+ else
597
+ puts "Imported server #{result.value.name} (provider ID #{result.value.id}) " \
598
+ "into #{app.config.environment}."
599
+ puts "Only the server identity was imported; run `kit doctor` before applying remote resources."
600
+ end
601
+ end
602
+ result
603
+ end
604
+
605
+ def open_ssh(app)
606
+ current = app.provider.find_server(name: app.config.server.name, tags: app.config.server.tags)
607
+ raise Errors::ConnectionError, "server has no public IP" unless current&.public_ip
608
+
609
+ known_hosts = File.expand_path(".kitsune/known_hosts", options[:root])
610
+ unless app.transport_factory.deploy.reachable?
611
+ raise Errors::ConnectionError, "the deploy SSH connection could not be verified"
612
+ end
613
+
614
+ Kernel.exec(
615
+ "ssh", "-i", app.config.ssh.key_path, "-p", app.config.ssh.port.to_s,
616
+ "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=#{known_hosts}",
617
+ "#{app.config.ssh.user}@#{current.public_ip}"
618
+ )
619
+ end
620
+
621
+ def confirm_host_key(host:, fingerprint:, key_type:)
622
+ expected = options[:trust_host_key]
623
+ if expected
624
+ return true if expected == fingerprint
625
+
626
+ raise Errors::UnsafeOperationError.new(
627
+ "SSH host-key fingerprint does not match --trust-host-key",
628
+ context: { host: host, expected: expected, actual: fingerprint }
629
+ )
630
+ end
631
+ if options[:no_input] || !$stdin.tty? || json?
632
+ raise Errors::UnsafeOperationError.new(
633
+ "SSH host key is not trusted",
634
+ hint: "Verify the provider-console fingerprint, then pass --trust-host-key #{fingerprint}.",
635
+ context: { host: host, fingerprint: fingerprint, key_type: key_type }
636
+ )
637
+ end
638
+
639
+ puts "SSH host key for #{host} (#{key_type}): #{fingerprint}"
640
+ yes?("Trust and save this host key? [y/N]")
641
+ end
642
+
643
+ def find_service_operation(app, type, action)
644
+ raise Errors::ConfigurationError, "unknown service type: #{type}" unless %w[postgres redis].include?(type)
645
+
646
+ service = app.config.services.public_send(type)
647
+ if service.enabled && service.mode == "external"
648
+ return nil if action == "status"
649
+
650
+ raise Errors::UnsafeOperationError.new(
651
+ "#{type} is configured as an external service",
652
+ hint: "Manage its lifecycle with its provider; Kitsune Kit only reports the configured endpoint."
653
+ )
654
+ end
655
+ enabled = service.enabled
656
+ return app.service_operation(type) if enabled || action != "install"
657
+
658
+ raise Errors::ConfigurationError.new(
659
+ "#{type} is not enabled in configuration",
660
+ hint: "Set services.#{type}.enabled to true and configure its secret."
661
+ )
662
+ end
45
663
 
46
- desc "switch_env SUBCOMMAND", "Switch the active Kitsune environment"
47
- subcommand "switch_env", Kitsune::Kit::Commands::SwitchEnv
664
+ def apply_operations(app, operations)
665
+ plan_result = build_plan(app, operations)
666
+ plan = plan_result.value
667
+ return Result.success([], metadata: { unchanged: true }) unless plan.changed?
668
+ return plan_result if options[:dry_run]
48
669
 
49
- desc "provision SUBCOMMAND", "Provisioning tasks"
50
- subcommand "provision", Kitsune::Kit::Commands::Provision
670
+ confirm_apply!(plan)
671
+ Workflows::ApplyPlan.new(
672
+ config: app.config,
673
+ operations: operations,
674
+ state_store: app.state_store,
675
+ event_bus: app.event_bus
676
+ ).call(plan: plan)
677
+ end
678
+
679
+ def build_plan(app, operations)
680
+ result = Workflows::BuildPlan.new(
681
+ config: app.config,
682
+ operations: operations,
683
+ event_bus: app.event_bus
684
+ ).call
685
+ print_plan(result.value) unless json?
686
+ result
687
+ end
51
688
 
52
- desc "dns SUBCOMMAND", "Manage DNS"
53
- subcommand "dns", Kitsune::Kit::Commands::Dns
689
+ def service_status(app, type)
690
+ service = app.config.services.public_send(type)
691
+ return resource_status(app, "service.#{type}") unless service.enabled && service.mode == "external"
692
+
693
+ value = { mode: "external", host: service.host, port: service.port, managed: false }
694
+ puts("service.#{type}: external (#{service.host}:#{service.port})") unless json?
695
+ Result.success(value)
696
+ end
54
697
 
55
- desc "setup_user SUBCOMMAND", "Create or rollback deploy user on remote server"
56
- subcommand "setup_user", Kitsune::Kit::Commands::SetupUser
698
+ def resource_status(app, resource)
699
+ value = app.state_store.read(app.config.environment).dig("resources", resource)
700
+ if json?
701
+ Result.success(value)
702
+ else
703
+ status = if !value
704
+ "not managed"
705
+ elsif value["installed"] == false
706
+ "removed (data preserved)"
707
+ else
708
+ "managed"
709
+ end
710
+ puts("#{resource}: #{status}")
711
+ end
712
+ Result.success(value)
713
+ end
714
+
715
+ def confirm_named_action!(action, expected, option:)
716
+ return if options[option]
717
+ if options[:no_input] || !$stdin.tty?
718
+ raise Errors::UnsafeOperationError.new(
719
+ "#{action} requires confirmation",
720
+ hint: "Pass --yes after reviewing the affected environment (#{expected})."
721
+ )
722
+ end
723
+ return if yes?("#{action.capitalize} in #{expected}? [y/N]")
724
+
725
+ raise Errors::UnsafeOperationError, "#{action} was not confirmed"
726
+ end
57
727
 
58
- desc "setup_firewall SUBCOMMAND", "Configure or rollback UFW firewall rules"
59
- subcommand "setup_firewall", Kitsune::Kit::Commands::SetupFirewall
728
+ def confirm_exact!(actual, expected, action, context: {})
729
+ return if actual == expected
60
730
 
61
- desc "setup_unattended SUBCOMMAND", "Configure or rollback unattended-upgrades"
62
- subcommand "setup_unattended", Kitsune::Kit::Commands::SetupUnattended
731
+ raise Errors::UnsafeOperationError.new(
732
+ "#{action} was not confirmed",
733
+ hint: "Pass --confirm-destroy #{expected} after creating a backup.",
734
+ context: context
735
+ )
736
+ end
63
737
 
64
- desc "setup_swap SUBCOMMAND", "Configure or rollback swap memory"
65
- subcommand "setup_swap", Kitsune::Kit::Commands::SetupSwap
738
+ def print_destructive_summary(environment:, provider:, resource:, identifier:, recoverable:)
739
+ return if json?
66
740
 
67
- desc "setup_do_metrics SUBCOMMAND", "Install or rollback DigitalOcean Metrics Agent"
68
- subcommand "setup_do_metrics", Kitsune::Kit::Commands::SetupDoMetrics
741
+ puts
742
+ puts "Permanent destruction"
743
+ puts " Environment: #{environment}"
744
+ puts " Provider: #{provider}"
745
+ puts " Resource: #{resource}"
746
+ puts " Identifier: #{identifier || 'not recorded'}"
747
+ puts " Recoverable: #{recoverable ? 'yes' : 'no; restore only from an independent backup'}"
748
+ puts
749
+ end
69
750
 
70
- desc "bootstrap SUBCOMMAND", "Run full server setup or rollback"
71
- subcommand "bootstrap", Kitsune::Kit::Commands::Bootstrap
751
+ def with_mutation_lock(app, &)
752
+ app.state_store.with_execution_lock(app.config.environment, &)
753
+ end
72
754
 
73
- desc "setup_docker_prereqs SUBCOMMAND", "Install or rollback docker prerequisites"
74
- subcommand "setup_docker_prereqs", Kitsune::Kit::Commands::SetupDockerPrereqs
755
+ def print_plan(plan)
756
+ puts
757
+ puts "Plan for environment: #{plan.environment}"
758
+ puts
759
+ if options[:quiet]
760
+ puts "#{plan.changed_count} change(s), #{plan.changes.length - plan.changed_count} unchanged"
761
+ return
762
+ end
75
763
 
76
- desc "install_docker_engine SUBCOMMAND", "Install or rollback Docker Engine"
77
- subcommand "install_docker_engine", Kitsune::Kit::Commands::InstallDockerEngine
764
+ plan.changes.each do |change|
765
+ marker = { "create" => "+", "update" => "~", "delete" => "-", "no_change" => "=" }.fetch(change.action)
766
+ puts " #{marker} #{change.summary}"
767
+ end
768
+ puts
769
+ puts "#{plan.changed_count} change(s), #{plan.changes.length - plan.changed_count} unchanged"
770
+ end
78
771
 
79
- desc "postinstall_docker SUBCOMMAND", "Apply or rollback Docker post-installation tasks"
80
- subcommand "postinstall_docker", Kitsune::Kit::Commands::PostinstallDocker
772
+ def print_checks(checks)
773
+ checks.each do |check|
774
+ puts format("%<name>-24s %<status>-5s %<message>s",
775
+ name: check.name, status: check.status.upcase, message: check.message)
776
+ puts "#{' ' * 31}#{check.hint}" if check.hint
777
+ end
778
+ end
81
779
 
82
- desc "bootstrap_docker SUBCOMMAND", "Run full docker setup or rollback"
83
- subcommand "bootstrap_docker", Kitsune::Kit::Commands::BootstrapDocker
780
+ def print_environment_status(status)
781
+ puts
782
+ puts "Environment: #{status.environment}"
783
+ if status.server
784
+ puts "Server: #{status.server.name} (#{status.server.status}, #{status.server.public_ip || 'no IP'})"
785
+ else
786
+ puts "Server: not created"
787
+ end
788
+ puts "Managed resources:"
789
+ if status.managed_resources.empty?
790
+ puts " none"
791
+ else
792
+ status.managed_resources.each_key { |resource| puts " - #{resource}" }
793
+ end
794
+ end
84
795
 
85
- desc "setup_postgres_docker SUBCOMMAND", "Setup PostgreSQL via Docker Compose on remote server"
86
- subcommand "setup_postgres_docker", Kitsune::Kit::Commands::SetupPostgresDocker
796
+ def render_error(error)
797
+ filter = @secret_filter || build_secret_filter
798
+ safe_message = filter.filter(error.message)
799
+ safe_hint = filter.filter(error.hint)
800
+ safe_context = filter.filter(error.context)
801
+ if json?
802
+ warn ::JSON.generate(
803
+ schema_version: 1,
804
+ status: "failure",
805
+ error: { code: error.code, message: safe_message, hint: safe_hint, context: safe_context }
806
+ )
807
+ else
808
+ warn "Error [#{error.code}]: #{safe_message}"
809
+ warn "Hint: #{safe_hint}" if safe_hint
810
+ warn "Context: #{safe_context.inspect}" if options[:debug] && !safe_context.empty?
811
+ end
812
+ end
87
813
 
88
- desc "setup_redis_docker SUBCOMMAND", "Setup Redis via Docker Compose on remote server"
89
- subcommand "setup_redis_docker", Kitsune::Kit::Commands::SetupRedisDocker
814
+ def render_unexpected_error(error)
815
+ filter = @secret_filter || build_secret_filter
816
+ safe_message = filter.filter(error.message)
817
+ if json?
818
+ warn ::JSON.generate(
819
+ schema_version: 1,
820
+ status: "failure",
821
+ error: { code: "unexpected_error", message: safe_message }
822
+ )
823
+ else
824
+ warn "Unexpected error: #{safe_message}"
825
+ warn filter.filter(error.full_message) if options[:debug]
826
+ end
827
+ end
90
828
 
91
- desc "ssh connect", "SSH into the server"
92
- subcommand "ssh", Kitsune::Kit::Commands::Ssh
829
+ def json? = options[:format] == "json"
830
+ end
93
831
  end
94
832
  end
95
833
  end