kitsune-kit 0.4.1 → 0.6.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +112 -135
  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/decisions/0007-compose-customization.md +30 -0
  14. data/docs/architecture/provider-adapters.md +49 -0
  15. data/docs/architecture.md +93 -0
  16. data/docs/commands.md +224 -0
  17. data/docs/configuration.md +226 -0
  18. data/docs/getting-started.md +133 -0
  19. data/docs/providers/digitalocean.md +52 -0
  20. data/docs/releasing.md +43 -0
  21. data/docs/roadmap.md +46 -0
  22. data/docs/security-audit.md +130 -0
  23. data/docs/security.md +116 -0
  24. data/docs/services/compose.md +164 -0
  25. data/docs/services/postgres.md +78 -0
  26. data/docs/services/redis.md +53 -0
  27. data/docs/testing.md +200 -0
  28. data/docs/troubleshooting.md +123 -0
  29. data/docs/tui.md +57 -0
  30. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  31. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  32. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  34. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  35. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  36. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  37. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  38. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  39. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  40. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  41. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  42. data/lib/kitsune/kit/application.rb +170 -0
  43. data/lib/kitsune/kit/cancellation.rb +21 -0
  44. data/lib/kitsune/kit/cli.rb +879 -64
  45. data/lib/kitsune/kit/clock.rb +11 -0
  46. data/lib/kitsune/kit/configuration.rb +577 -0
  47. data/lib/kitsune/kit/errors.rb +98 -0
  48. data/lib/kitsune/kit/events.rb +55 -0
  49. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  50. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  51. data/lib/kitsune/kit/operations/ensure_service.rb +302 -0
  52. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  53. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  54. data/lib/kitsune/kit/operations/service_files.rb +131 -0
  55. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  56. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  57. data/lib/kitsune/kit/plan.rb +72 -0
  58. data/lib/kitsune/kit/reporters/human.rb +89 -0
  59. data/lib/kitsune/kit/reporters/json.rb +65 -0
  60. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  61. data/lib/kitsune/kit/result.rb +28 -0
  62. data/lib/kitsune/kit/run_journal.rb +102 -0
  63. data/lib/kitsune/kit/run_logger.rb +37 -0
  64. data/lib/kitsune/kit/scripts/docker.sh +142 -0
  65. data/lib/kitsune/kit/scripts/firewall.sh +210 -0
  66. data/lib/kitsune/kit/scripts/metrics.sh +59 -0
  67. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  68. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  69. data/lib/kitsune/kit/scripts/unattended.sh +86 -0
  70. data/lib/kitsune/kit/scripts/user.sh +114 -0
  71. data/lib/kitsune/kit/secret_filter.rb +54 -0
  72. data/lib/kitsune/kit/secret_store.rb +32 -0
  73. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  74. data/lib/kitsune/kit/service_compose.rb +272 -0
  75. data/lib/kitsune/kit/state_store.rb +158 -0
  76. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  77. data/lib/kitsune/kit/tui/actions.rb +72 -0
  78. data/lib/kitsune/kit/tui/application.rb +35 -0
  79. data/lib/kitsune/kit/tui/controller.rb +162 -0
  80. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  81. data/lib/kitsune/kit/tui/state.rb +18 -0
  82. data/lib/kitsune/kit/tui/store.rb +88 -0
  83. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  84. data/lib/kitsune/kit/version.rb +1 -1
  85. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  86. data/lib/kitsune/kit/workflows/base.rb +31 -0
  87. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  88. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  89. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  90. data/lib/kitsune/kit/workflows/eject_compose.rb +82 -0
  91. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  92. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  93. data/lib/kitsune/kit/workflows/initialize_project.rb +129 -0
  94. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  95. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  96. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  97. data/lib/kitsune/kit.rb +42 -2
  98. metadata +125 -79
  99. data/.rspec +0 -3
  100. data/Rakefile +0 -8
  101. data/kitsune-kit-logo.jpg +0 -0
  102. data/lib/kitsune/blueprints/.env.template +0 -31
  103. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  104. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  105. data/lib/kitsune/blueprints/kit.env.template +0 -1
  106. data/lib/kitsune/kit/ansi_color.rb +0 -78
  107. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  108. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  109. data/lib/kitsune/kit/commands/dns.rb +0 -112
  110. data/lib/kitsune/kit/commands/init.rb +0 -148
  111. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  112. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  113. data/lib/kitsune/kit/commands/provision.rb +0 -43
  114. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  115. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  116. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  117. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  118. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  119. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  120. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  121. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  122. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  123. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  124. data/lib/kitsune/kit/defaults.rb +0 -91
  125. data/lib/kitsune/kit/env_loader.rb +0 -41
  126. data/lib/kitsune/kit/options_builder.rb +0 -26
  127. data/lib/kitsune/kit/provisioner.rb +0 -107
  128. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ class Clock
6
+ def now = Time.now.utc
7
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+ def sleep(seconds) = Kernel.sleep(seconds)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,577 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "pathname"
5
+ require "yaml"
6
+ require_relative "errors"
7
+
8
+ module Kitsune
9
+ module Kit
10
+ module Configuration
11
+ SCHEMA_VERSION = 1
12
+ SUPPORTED_PROVIDERS = ["digitalocean"].freeze
13
+ SUPPORTED_IMAGES = %w[ubuntu-22-04-x64 ubuntu-24-04-x64].freeze
14
+
15
+ class Provider < Data.define(:name, :token_env)
16
+ def initialize(name: "digitalocean", token_env: "DO_API_TOKEN")
17
+ super(name: name.to_s, token_env: token_env.to_s)
18
+ end
19
+ end
20
+
21
+ class Server < Data.define(:name, :region, :size, :image, :ssh_key_id, :tags)
22
+ def initialize(name:, region:, size:, image:, ssh_key_id:, tags: [])
23
+ super(
24
+ name: name.to_s,
25
+ region: region.to_s,
26
+ size: size.to_s,
27
+ image: image.to_s,
28
+ ssh_key_id: ssh_key_id.to_s,
29
+ tags: Array(tags).map(&:to_s).freeze
30
+ )
31
+ end
32
+ end
33
+
34
+ class Ssh < Data.define(:user, :port, :key_path, :allowed_cidrs)
35
+ def initialize(user: "deploy", port: 22, key_path: "~/.ssh/id_ed25519", allowed_cidrs: [])
36
+ super(
37
+ user: user.to_s,
38
+ port: Integer(port),
39
+ key_path: Pathname(key_path.to_s).expand_path.to_s,
40
+ allowed_cidrs: Array(allowed_cidrs).map(&:to_s).freeze
41
+ )
42
+ rescue ArgumentError, TypeError
43
+ raise Errors::ConfigurationError, "ssh.port must be an integer"
44
+ end
45
+ end
46
+
47
+ class Compose < Data.define(:mode, :file, :allow_unsafe)
48
+ MODES = %w[generated overlay custom].freeze
49
+
50
+ def initialize(mode: "generated", file: nil, allow_unsafe: false, root: Dir.pwd)
51
+ expanded_file = file.to_s.empty? ? nil : Pathname(file.to_s).expand_path(root).to_s
52
+ super(mode: mode.to_s, file: expanded_file, allow_unsafe: boolean(allow_unsafe))
53
+ end
54
+
55
+ private
56
+
57
+ def boolean(value)
58
+ return value if [true, false].include?(value)
59
+ return true if value.to_s.casecmp("true").zero?
60
+ return false if value.to_s.casecmp("false").zero?
61
+
62
+ raise Errors::ConfigurationError, "expected a boolean, got #{value.inspect}"
63
+ end
64
+ end
65
+
66
+ class Service < Data.define(:enabled, :mode, :host, :image, :publish, :bind, :allowed_cidrs, :port,
67
+ :password_env, :compose)
68
+ def initialize(**attributes)
69
+ super(
70
+ enabled: boolean(attributes.fetch(:enabled)),
71
+ mode: attributes.fetch(:mode, "managed").to_s,
72
+ host: attributes[:host]&.to_s,
73
+ image: attributes.fetch(:image).to_s,
74
+ publish: boolean(attributes.fetch(:publish)),
75
+ bind: attributes.fetch(:bind).to_s,
76
+ allowed_cidrs: Array(attributes.fetch(:allowed_cidrs)).map(&:to_s).freeze,
77
+ port: Integer(attributes.fetch(:port)),
78
+ password_env: attributes.fetch(:password_env).to_s,
79
+ compose: attributes.fetch(:compose, Compose.new)
80
+ )
81
+ rescue ArgumentError, TypeError
82
+ raise Errors::ConfigurationError, "service port must be an integer"
83
+ end
84
+
85
+ private
86
+
87
+ def boolean(value)
88
+ return value if [true, false].include?(value)
89
+ return true if value.to_s.casecmp("true").zero?
90
+ return false if value.to_s.casecmp("false").zero?
91
+
92
+ raise Errors::ConfigurationError, "expected a boolean, got #{value.inspect}"
93
+ end
94
+ end
95
+
96
+ class Services < Data.define(:postgres, :redis); end
97
+
98
+ class System < Data.define(:swap_size_gb, :swap_swappiness, :unattended_upgrades, :metrics,
99
+ :metrics_installer_sha256)
100
+ def initialize(swap_size_gb:, swap_swappiness:, unattended_upgrades:, metrics:,
101
+ metrics_installer_sha256: nil)
102
+ super(
103
+ swap_size_gb: Integer(swap_size_gb),
104
+ swap_swappiness: Integer(swap_swappiness),
105
+ unattended_upgrades: boolean(unattended_upgrades),
106
+ metrics: boolean(metrics),
107
+ metrics_installer_sha256: metrics_installer_sha256&.to_s
108
+ )
109
+ rescue ArgumentError, TypeError
110
+ raise Errors::ConfigurationError, "system numeric values must be integers"
111
+ end
112
+
113
+ private
114
+
115
+ def boolean(value)
116
+ return value if [true, false].include?(value)
117
+
118
+ raise Errors::ConfigurationError, "expected a boolean, got #{value.inspect}"
119
+ end
120
+ end
121
+
122
+ class Dns < Data.define(:domains, :ttl)
123
+ def initialize(domains:, ttl:)
124
+ super(domains: Array(domains).map(&:to_s).freeze, ttl: Integer(ttl))
125
+ rescue ArgumentError, TypeError
126
+ raise Errors::ConfigurationError, "dns.ttl must be an integer"
127
+ end
128
+ end
129
+
130
+ class Config < Data.define(:version, :environment, :provider, :server, :ssh, :services, :system, :dns)
131
+ def initialize(version:, environment:, provider:, server:, ssh:, services:, system:, dns:)
132
+ super(
133
+ version: Integer(version),
134
+ environment: environment.to_s,
135
+ provider: provider,
136
+ server: server,
137
+ ssh: ssh,
138
+ services: services,
139
+ system: system,
140
+ dns: dns
141
+ )
142
+ end
143
+ end
144
+
145
+ DEFAULTS = {
146
+ "version" => SCHEMA_VERSION,
147
+ "provider" => { "name" => "digitalocean", "token_env" => "DO_API_TOKEN" },
148
+ "server" => {
149
+ "region" => "sfo3",
150
+ "size" => "s-1vcpu-1gb",
151
+ "image" => "ubuntu-24-04-x64",
152
+ "tags" => ["kitsune-managed"]
153
+ },
154
+ "ssh" => { "user" => "deploy", "port" => 22, "key_path" => "~/.ssh/id_ed25519", "allowed_cidrs" => [] },
155
+ "services" => {
156
+ "postgres" => {
157
+ "enabled" => false,
158
+ "mode" => "managed",
159
+ "host" => nil,
160
+ "image" => "postgres:17",
161
+ "publish" => false,
162
+ "bind" => "127.0.0.1",
163
+ "allowed_cidrs" => [],
164
+ "port" => 5432,
165
+ "password_env" => "POSTGRES_PASSWORD",
166
+ "compose" => { "mode" => "generated", "file" => nil, "allow_unsafe" => false }
167
+ },
168
+ "redis" => {
169
+ "enabled" => false,
170
+ "mode" => "managed",
171
+ "host" => nil,
172
+ "image" => "redis:7.2",
173
+ "publish" => false,
174
+ "bind" => "127.0.0.1",
175
+ "allowed_cidrs" => [],
176
+ "port" => 6379,
177
+ "password_env" => "REDIS_PASSWORD",
178
+ "compose" => { "mode" => "generated", "file" => nil, "allow_unsafe" => false }
179
+ }
180
+ },
181
+ "system" => {
182
+ "swap_size_gb" => 2,
183
+ "swap_swappiness" => 10,
184
+ "unattended_upgrades" => true,
185
+ "metrics" => false,
186
+ "metrics_installer_sha256" => nil
187
+ },
188
+ "dns" => { "domains" => [], "ttl" => 3600 }
189
+ }.freeze
190
+
191
+ ENV_PATHS = {
192
+ "KITSUNE_PROVIDER" => %w[provider name],
193
+ "KITSUNE_SERVER_NAME" => %w[server name],
194
+ "KITSUNE_REGION" => %w[server region],
195
+ "KITSUNE_SIZE" => %w[server size],
196
+ "KITSUNE_IMAGE" => %w[server image],
197
+ "KITSUNE_SSH_KEY_ID" => %w[server ssh_key_id],
198
+ "KITSUNE_SSH_USER" => %w[ssh user],
199
+ "KITSUNE_SSH_PORT" => %w[ssh port],
200
+ "KITSUNE_SSH_KEY_PATH" => %w[ssh key_path],
201
+ "KITSUNE_METRICS_INSTALLER_SHA256" => %w[system metrics_installer_sha256]
202
+ }.freeze
203
+
204
+ SCHEMA_KEYS = {
205
+ [] => %w[version provider server ssh services system dns],
206
+ %w[provider] => %w[name token_env],
207
+ %w[server] => %w[name region size image ssh_key_id tags],
208
+ %w[ssh] => %w[user port key_path allowed_cidrs],
209
+ %w[services] => %w[postgres redis],
210
+ %w[services postgres] => %w[enabled mode host image publish bind allowed_cidrs port password_env compose],
211
+ %w[services redis] => %w[enabled mode host image publish bind allowed_cidrs port password_env compose],
212
+ %w[services postgres compose] => %w[mode file allow_unsafe],
213
+ %w[services redis compose] => %w[mode file allow_unsafe],
214
+ %w[system] => %w[swap_size_gb swap_swappiness unattended_upgrades metrics metrics_installer_sha256],
215
+ %w[dns] => %w[domains ttl]
216
+ }.freeze
217
+
218
+ class Loader
219
+ def initialize(root: Dir.pwd, env: ENV, config_path: nil, validate_secrets: true)
220
+ @root = Pathname(root).expand_path
221
+ @env = env
222
+ @config_path = config_path ? Pathname(config_path).expand_path : @root.join(".kitsune/config.yml")
223
+ @validate_secrets = validate_secrets
224
+ end
225
+
226
+ def load(environment: nil, overrides: {})
227
+ selected_environment = (environment || @env["KITSUNE_ENV"] || selected_environment_from_file ||
228
+ "development").to_s
229
+ unless selected_environment.match?(/\A[a-z0-9][a-z0-9_-]*\z/)
230
+ raise Errors::ConfigurationError, "environment name has an invalid format"
231
+ end
232
+
233
+ project = load_yaml(@config_path, required: true)
234
+ environment_config = load_yaml(@root.join(".kitsune/environments/#{selected_environment}.yml"),
235
+ required: false)
236
+ combined = deep_merge(DEFAULTS, project)
237
+ combined = deep_merge(combined, environment_config)
238
+ combined = apply_environment(combined)
239
+ combined = deep_merge(combined, stringify_keys(overrides))
240
+ build(combined, selected_environment)
241
+ end
242
+
243
+ private
244
+
245
+ def selected_environment_from_file
246
+ path = @root.join(".kitsune/environment")
247
+ return unless path.file?
248
+
249
+ path.read.strip
250
+ end
251
+
252
+ def load_yaml(path, required:)
253
+ unless path.file?
254
+ return {} unless required
255
+
256
+ raise Errors::ConfigurationError.new(
257
+ "configuration file not found: #{path}",
258
+ hint: "Run `kit init` in the project root."
259
+ )
260
+ end
261
+
262
+ content = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false)
263
+ return {} if content.nil?
264
+ raise Errors::ConfigurationError, "#{path} must contain a YAML mapping" unless content.is_a?(Hash)
265
+
266
+ stringify_keys(content)
267
+ rescue Psych::Exception => e
268
+ raise Errors::ConfigurationError.new("invalid YAML in #{path}: #{e.message}",
269
+ hint: "Correct the YAML syntax.")
270
+ end
271
+
272
+ def apply_environment(config)
273
+ ENV_PATHS.each_with_object(deep_copy(config)) do |(name, path), result|
274
+ set_path(result, path, @env[name]) if @env.key?(name)
275
+ end
276
+ end
277
+
278
+ def build(data, environment)
279
+ validate_structure!(data)
280
+ validate_schema_version!(data["version"])
281
+ config = Config.new(
282
+ version: data.fetch("version"),
283
+ environment: environment,
284
+ provider: Provider.new(**symbolize(data.fetch("provider"))),
285
+ server: Server.new(**symbolize(data.fetch("server"))),
286
+ ssh: Ssh.new(**symbolize(data.fetch("ssh"))),
287
+ services: Services.new(
288
+ postgres: build_service(data.dig("services", "postgres")),
289
+ redis: build_service(data.dig("services", "redis"))
290
+ ),
291
+ system: System.new(**symbolize(data.fetch("system"))),
292
+ dns: Dns.new(**symbolize(data.fetch("dns")))
293
+ )
294
+ Validator.new(config, env: @env, root: @root, validate_secrets: @validate_secrets).validate!
295
+ config
296
+ rescue KeyError => e
297
+ raise Errors::ConfigurationError.new("missing configuration value: #{e.key}",
298
+ hint: "Run `kit doctor` for details.")
299
+ end
300
+
301
+ def validate_structure!(data)
302
+ errors = SCHEMA_KEYS.each_with_object([]) do |(path, known), findings|
303
+ value = path.reduce(data) { |current, key| current.is_a?(Hash) ? current[key] : nil }
304
+ label = path.empty? ? "configuration" : path.join(".")
305
+ unless value.is_a?(Hash)
306
+ findings << "#{label} must be a mapping"
307
+ next
308
+ end
309
+
310
+ unknown = value.keys - known
311
+ findings << "#{label} contains unknown keys: #{unknown.join(', ')}" if unknown.any?
312
+ end
313
+ return if errors.empty?
314
+
315
+ raise Errors::ConfigurationError.new(
316
+ "invalid configuration structure:\n- #{errors.join("\n- ")}",
317
+ hint: "Remove unknown keys and restore every documented YAML mapping."
318
+ )
319
+ end
320
+
321
+ def build_service(data)
322
+ attributes = symbolize(data)
323
+ compose = Compose.new(**symbolize(data.fetch("compose")), root: @root)
324
+ Service.new(**attributes, compose: compose)
325
+ end
326
+
327
+ def validate_schema_version!(version)
328
+ return if version == SCHEMA_VERSION
329
+
330
+ raise Errors::ConfigurationError.new(
331
+ "unsupported configuration schema version #{version.inspect}; expected #{SCHEMA_VERSION}",
332
+ hint: "Use a Kitsune Kit version that supports this schema or migrate a backup with documented tooling."
333
+ )
334
+ end
335
+
336
+ def deep_merge(left, right)
337
+ left.merge(right) do |_key, old_value, new_value|
338
+ old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
339
+ end
340
+ end
341
+
342
+ def deep_copy(value) = Marshal.load(Marshal.dump(value))
343
+
344
+ def set_path(hash, path, value)
345
+ path[0...-1].reduce(hash) { |current, key| current[key] ||= {} }[path.last] = value
346
+ end
347
+
348
+ def stringify_keys(value)
349
+ case value
350
+ when Hash
351
+ value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
352
+ when Array
353
+ value.map { |item| stringify_keys(item) }
354
+ else
355
+ value
356
+ end
357
+ end
358
+
359
+ def symbolize(hash) = hash.to_h { |key, value| [key.to_sym, value] }
360
+ end
361
+
362
+ class Validator
363
+ RESOURCE_NAME = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/
364
+ ENV_NAME = /\A[A-Z][A-Z0-9_]*\z/
365
+ SSH_USER = /\A[a-z_][a-z0-9_-]{0,31}\z/
366
+ IMAGE = %r{\A[a-z0-9]+(?:[._/-][a-z0-9]+)*(?::[a-zA-Z0-9][a-zA-Z0-9._-]*)?(?:@sha256:[a-f0-9]{64})?\z}
367
+ INSECURE_SECRETS = %w[password postgres redis changeme change-me secret admin].freeze
368
+
369
+ def initialize(config, env: ENV, root: Dir.pwd, validate_secrets: true)
370
+ @config = config
371
+ @env = env
372
+ @root = Pathname(root).expand_path
373
+ @validate_secrets = validate_secrets
374
+ @errors = []
375
+ end
376
+
377
+ def validate!
378
+ validate_provider
379
+ validate_server
380
+ validate_ssh
381
+ validate_service("postgres", @config.services.postgres)
382
+ validate_service("redis", @config.services.redis)
383
+ validate_system
384
+ validate_dns
385
+ return @config if @errors.empty?
386
+
387
+ raise Errors::ConfigurationError.new(
388
+ "invalid configuration:\n- #{@errors.join("\n- ")}",
389
+ hint: "Correct .kitsune/config.yml or the selected environment file."
390
+ )
391
+ end
392
+
393
+ private
394
+
395
+ def validate_provider
396
+ unless SUPPORTED_PROVIDERS.include?(@config.provider.name)
397
+ @errors << "provider.name must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
398
+ end
399
+ return if @config.provider.token_env.match?(ENV_NAME)
400
+
401
+ @errors << "provider.token_env must be an environment variable name"
402
+ end
403
+
404
+ def validate_server
405
+ server = @config.server
406
+ @errors << "server.name is required" if server.name.empty?
407
+ @errors << "server.name has an invalid format" unless server.name.match?(RESOURCE_NAME)
408
+ @errors << "server.region is required" if server.region.empty?
409
+ @errors << "server.size is required" if server.size.empty?
410
+ @errors << "server.image is unsupported" unless SUPPORTED_IMAGES.include?(server.image)
411
+ @errors << "server.ssh_key_id is required" if server.ssh_key_id.empty?
412
+ end
413
+
414
+ def validate_ssh
415
+ ssh = @config.ssh
416
+ @errors << "ssh.user has an invalid format" unless ssh.user.match?(SSH_USER)
417
+ @errors << "ssh.port must be between 1 and 65535" unless (1..65_535).cover?(ssh.port)
418
+ validate_private_key(ssh.key_path)
419
+ ssh.allowed_cidrs.each { |cidr| validate_cidr("ssh.allowed_cidrs", cidr) }
420
+ end
421
+
422
+ def validate_private_key(path)
423
+ unless File.file?(path)
424
+ @errors << "ssh.key_path does not exist or is not a regular file: #{path}"
425
+ return
426
+ end
427
+
428
+ mode = File.stat(path).mode & 0o777
429
+ @errors << format("ssh.key_path permissions must be 0600 or stricter (currently %<mode>04o)", mode: mode) if
430
+ mode.anybits?(0o077)
431
+ rescue SystemCallError => e
432
+ @errors << "ssh.key_path cannot be inspected: #{e.class}"
433
+ end
434
+
435
+ def validate_service(name, service)
436
+ @errors << "services.#{name}.port must be between 1 and 65535" unless (1..65_535).cover?(service.port)
437
+ @errors << "services.#{name}.password_env has an invalid format" unless service.password_env.match?(ENV_NAME)
438
+ unless %w[managed external].include?(service.mode)
439
+ @errors << "services.#{name}.mode must be managed or external"
440
+ end
441
+ if service.mode == "external"
442
+ validate_external_service(name, service)
443
+ else
444
+ validate_managed_service(name, service)
445
+ end
446
+ validate_compose(name, service)
447
+ validate_service_secret(name, service) if service.enabled && @validate_secrets
448
+ end
449
+
450
+ def validate_compose(name, service)
451
+ compose = service.compose
452
+ return invalid_compose_mode(name) unless Compose::MODES.include?(compose.mode)
453
+
454
+ validate_compose_ownership(name, service)
455
+ return validate_generated_compose(name, compose) if compose.mode == "generated"
456
+ return missing_compose_file(name, compose) unless compose.file
457
+
458
+ validate_compose_file(name, Pathname(compose.file))
459
+ rescue SystemCallError => e
460
+ @errors << "services.#{name}.compose.file cannot be inspected: #{e.class}"
461
+ end
462
+
463
+ def invalid_compose_mode(name)
464
+ @errors << "services.#{name}.compose.mode must be generated, overlay or custom"
465
+ end
466
+
467
+ def validate_compose_ownership(name, service)
468
+ return unless service.mode == "external" && service.compose.mode != "generated"
469
+
470
+ @errors << "services.#{name}.compose is only available in managed mode"
471
+ end
472
+
473
+ def validate_generated_compose(name, compose)
474
+ @errors << "services.#{name}.compose.file must be empty in generated mode" if compose.file
475
+ return unless compose.allow_unsafe
476
+
477
+ @errors << "services.#{name}.compose.allow_unsafe must be false in generated mode"
478
+ end
479
+
480
+ def missing_compose_file(name, compose)
481
+ @errors << "services.#{name}.compose.file is required in #{compose.mode} mode"
482
+ end
483
+
484
+ def validate_compose_file(name, path)
485
+ root_prefix = "#{@root}#{File::SEPARATOR}"
486
+ return @errors << "services.#{name}.compose.file must stay inside the project root" unless
487
+ path.to_s.start_with?(root_prefix)
488
+
489
+ if path.symlink?
490
+ @errors << "services.#{name}.compose.file must not be a symbolic link"
491
+ elsif !path.file?
492
+ @errors << "services.#{name}.compose.file does not exist or is not a regular file: #{path}"
493
+ elsif !path.realpath.to_s.start_with?("#{@root.realpath}#{File::SEPARATOR}")
494
+ @errors << "services.#{name}.compose.file must not resolve outside the project root"
495
+ elsif path.size > 262_144
496
+ @errors << "services.#{name}.compose.file must not exceed 256 KiB"
497
+ end
498
+ end
499
+
500
+ def validate_managed_service(name, service)
501
+ @errors << "services.#{name}.image has an invalid format" unless service.image.match?(IMAGE)
502
+ @errors << "services.#{name}.host is only valid in external mode" unless service.host.to_s.empty?
503
+ validate_bind(name, service.bind)
504
+ service.allowed_cidrs.each { |cidr| validate_cidr("services.#{name}.allowed_cidrs", cidr) }
505
+ return unless service.publish && service.allowed_cidrs.empty?
506
+
507
+ @errors << "services.#{name}.allowed_cidrs is required when publish is true"
508
+ end
509
+
510
+ def validate_external_service(name, service)
511
+ unless valid_endpoint_host?(service.host)
512
+ @errors << "services.#{name}.host must be a valid hostname or IP address in external mode"
513
+ end
514
+ @errors << "services.#{name}.publish must be false in external mode" if service.publish
515
+ return if service.allowed_cidrs.empty?
516
+
517
+ @errors << "services.#{name}.allowed_cidrs must be empty in external mode"
518
+ end
519
+
520
+ def validate_service_secret(name, service)
521
+ secret = @env.fetch(service.password_env, "")
522
+ return @errors << "#{service.password_env} is required when services.#{name}.enabled is true" if
523
+ secret.empty?
524
+ return @errors << "#{service.password_env} uses a known insecure default value" if
525
+ INSECURE_SECRETS.include?(secret.downcase)
526
+ return unless secret.bytesize < 12
527
+
528
+ @errors << "#{service.password_env} must contain at least 12 bytes"
529
+ end
530
+
531
+ def validate_bind(name, bind)
532
+ address = IPAddr.new(bind)
533
+ @errors << "services.#{name}.bind must be an IPv4 address" unless address.ipv4?
534
+ rescue IPAddr::InvalidAddressError
535
+ @errors << "services.#{name}.bind must be an IP address"
536
+ end
537
+
538
+ def valid_endpoint_host?(host)
539
+ return false if host.to_s.empty?
540
+
541
+ IPAddr.new(host)
542
+ true
543
+ rescue IPAddr::InvalidAddressError
544
+ valid_hostname?(host)
545
+ end
546
+
547
+ def validate_system
548
+ system = @config.system
549
+ @errors << "system.swap_size_gb must be between 0 and 64" unless (0..64).cover?(system.swap_size_gb)
550
+ @errors << "system.swap_swappiness must be between 0 and 100" unless (0..100).cover?(system.swap_swappiness)
551
+ return unless system.metrics && !system.metrics_installer_sha256.to_s.match?(/\A[a-f0-9]{64}\z/)
552
+
553
+ @errors << "system.metrics_installer_sha256 must be a lowercase SHA-256 when metrics is enabled"
554
+ end
555
+
556
+ def validate_dns
557
+ @errors << "dns.ttl must be between 30 and 86400" unless (30..86_400).cover?(@config.dns.ttl)
558
+ @config.dns.domains.each do |domain|
559
+ @errors << "invalid DNS name: #{domain}" unless valid_hostname?(domain)
560
+ end
561
+ end
562
+
563
+ def validate_cidr(field, cidr)
564
+ IPAddr.new(cidr)
565
+ rescue IPAddr::InvalidAddressError
566
+ @errors << "#{field} contains an invalid CIDR: #{cidr}"
567
+ end
568
+
569
+ def valid_hostname?(hostname)
570
+ return false if hostname.length > 253
571
+
572
+ hostname.split(".").all? { |label| label.match?(RESOURCE_NAME) }
573
+ end
574
+ end
575
+ end
576
+ end
577
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Errors
6
+ class Error < StandardError
7
+ attr_reader :code, :hint, :context, :retryable
8
+
9
+ def initialize(message, code:, hint: nil, context: {}, retryable: false)
10
+ super(message)
11
+ @code = code
12
+ @hint = hint
13
+ @context = context.freeze
14
+ @retryable = retryable
15
+ end
16
+ end
17
+
18
+ class ConfigurationError < Error
19
+ DEFAULT_HINT = "Review the selected configuration and run `kit doctor`."
20
+
21
+ def initialize(message, hint: DEFAULT_HINT, **)
22
+ super(message, code: "configuration_error", hint: hint, **)
23
+ end
24
+ end
25
+
26
+ class AuthenticationError < Error
27
+ DEFAULT_HINT = "Check the configured credential variable, scope and provider account."
28
+
29
+ def initialize(message, hint: DEFAULT_HINT, **)
30
+ super(message, code: "authentication_error", hint: hint, **)
31
+ end
32
+ end
33
+
34
+ class ProviderError < Error
35
+ DEFAULT_HINT = "Check provider status, permissions, quotas and connectivity, then retry."
36
+
37
+ def initialize(message, hint: DEFAULT_HINT, **)
38
+ super(message, code: "provider_error", hint: hint, **)
39
+ end
40
+ end
41
+
42
+ class ConnectionError < Error
43
+ DEFAULT_HINT = "Verify the server address, SSH key, host fingerprint and firewall route."
44
+
45
+ def initialize(message, hint: DEFAULT_HINT, **)
46
+ super(message, code: "connection_error", hint: hint, **)
47
+ end
48
+ end
49
+
50
+ class RemoteCommandError < Error
51
+ DEFAULT_HINT = "Inspect the redacted remote error, correct the server condition and resume."
52
+
53
+ def initialize(message, hint: DEFAULT_HINT, **)
54
+ super(message, code: "remote_command_error", hint: hint, **)
55
+ end
56
+ end
57
+
58
+ class VerificationError < Error
59
+ DEFAULT_HINT = "Do not assume the change succeeded; inspect state and run `kit doctor` before retrying."
60
+
61
+ def initialize(message, hint: DEFAULT_HINT, **)
62
+ super(message, code: "verification_error", hint: hint, **)
63
+ end
64
+ end
65
+
66
+ class UnsafeOperationError < Error
67
+ DEFAULT_HINT = "Review the exact target and use the documented explicit confirmation option."
68
+
69
+ def initialize(message, hint: DEFAULT_HINT, **)
70
+ super(message, code: "unsafe_operation", hint: hint, **)
71
+ end
72
+ end
73
+
74
+ class TimeoutError < Error
75
+ DEFAULT_HINT = "Inspect the last confirmed step and retry or resume with an appropriate positive timeout."
76
+
77
+ def initialize(message, hint: DEFAULT_HINT, **)
78
+ super(message, code: "timeout", hint: hint, retryable: true, **)
79
+ end
80
+ end
81
+
82
+ EXIT_STATUS = {
83
+ "configuration_error" => 3,
84
+ "authentication_error" => 4,
85
+ "provider_error" => 5,
86
+ "connection_error" => 6,
87
+ "remote_command_error" => 7,
88
+ "verification_error" => 8,
89
+ "unsafe_operation" => 9,
90
+ "timeout" => 10
91
+ }.freeze
92
+
93
+ def self.exit_status(error)
94
+ EXIT_STATUS.fetch(error.respond_to?(:code) ? error.code : nil, 1)
95
+ end
96
+ end
97
+ end
98
+ end