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
@@ -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,493 @@
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 Service < Data.define(:enabled, :mode, :host, :image, :publish, :bind, :allowed_cidrs, :port,
48
+ :password_env)
49
+ def initialize(**attributes)
50
+ super(
51
+ enabled: boolean(attributes.fetch(:enabled)),
52
+ mode: attributes.fetch(:mode, "managed").to_s,
53
+ host: attributes[:host]&.to_s,
54
+ image: attributes.fetch(:image).to_s,
55
+ publish: boolean(attributes.fetch(:publish)),
56
+ bind: attributes.fetch(:bind).to_s,
57
+ allowed_cidrs: Array(attributes.fetch(:allowed_cidrs)).map(&:to_s).freeze,
58
+ port: Integer(attributes.fetch(:port)),
59
+ password_env: attributes.fetch(:password_env).to_s
60
+ )
61
+ rescue ArgumentError, TypeError
62
+ raise Errors::ConfigurationError, "service port must be an integer"
63
+ end
64
+
65
+ private
66
+
67
+ def boolean(value)
68
+ return value if [true, false].include?(value)
69
+ return true if value.to_s.casecmp("true").zero?
70
+ return false if value.to_s.casecmp("false").zero?
71
+
72
+ raise Errors::ConfigurationError, "expected a boolean, got #{value.inspect}"
73
+ end
74
+ end
75
+
76
+ class Services < Data.define(:postgres, :redis); end
77
+
78
+ class System < Data.define(:swap_size_gb, :swap_swappiness, :unattended_upgrades, :metrics,
79
+ :metrics_installer_sha256)
80
+ def initialize(swap_size_gb:, swap_swappiness:, unattended_upgrades:, metrics:,
81
+ metrics_installer_sha256: nil)
82
+ super(
83
+ swap_size_gb: Integer(swap_size_gb),
84
+ swap_swappiness: Integer(swap_swappiness),
85
+ unattended_upgrades: boolean(unattended_upgrades),
86
+ metrics: boolean(metrics),
87
+ metrics_installer_sha256: metrics_installer_sha256&.to_s
88
+ )
89
+ rescue ArgumentError, TypeError
90
+ raise Errors::ConfigurationError, "system numeric values must be integers"
91
+ end
92
+
93
+ private
94
+
95
+ def boolean(value)
96
+ return value if [true, false].include?(value)
97
+
98
+ raise Errors::ConfigurationError, "expected a boolean, got #{value.inspect}"
99
+ end
100
+ end
101
+
102
+ class Dns < Data.define(:domains, :ttl)
103
+ def initialize(domains:, ttl:)
104
+ super(domains: Array(domains).map(&:to_s).freeze, ttl: Integer(ttl))
105
+ rescue ArgumentError, TypeError
106
+ raise Errors::ConfigurationError, "dns.ttl must be an integer"
107
+ end
108
+ end
109
+
110
+ class Config < Data.define(:version, :environment, :provider, :server, :ssh, :services, :system, :dns)
111
+ def initialize(version:, environment:, provider:, server:, ssh:, services:, system:, dns:)
112
+ super(
113
+ version: Integer(version),
114
+ environment: environment.to_s,
115
+ provider: provider,
116
+ server: server,
117
+ ssh: ssh,
118
+ services: services,
119
+ system: system,
120
+ dns: dns
121
+ )
122
+ end
123
+ end
124
+
125
+ DEFAULTS = {
126
+ "version" => SCHEMA_VERSION,
127
+ "provider" => { "name" => "digitalocean", "token_env" => "DO_API_TOKEN" },
128
+ "server" => {
129
+ "region" => "sfo3",
130
+ "size" => "s-1vcpu-1gb",
131
+ "image" => "ubuntu-24-04-x64",
132
+ "tags" => ["kitsune-managed"]
133
+ },
134
+ "ssh" => { "user" => "deploy", "port" => 22, "key_path" => "~/.ssh/id_ed25519", "allowed_cidrs" => [] },
135
+ "services" => {
136
+ "postgres" => {
137
+ "enabled" => false,
138
+ "mode" => "managed",
139
+ "host" => nil,
140
+ "image" => "postgres:17",
141
+ "publish" => false,
142
+ "bind" => "127.0.0.1",
143
+ "allowed_cidrs" => [],
144
+ "port" => 5432,
145
+ "password_env" => "POSTGRES_PASSWORD"
146
+ },
147
+ "redis" => {
148
+ "enabled" => false,
149
+ "mode" => "managed",
150
+ "host" => nil,
151
+ "image" => "redis:7.2",
152
+ "publish" => false,
153
+ "bind" => "127.0.0.1",
154
+ "allowed_cidrs" => [],
155
+ "port" => 6379,
156
+ "password_env" => "REDIS_PASSWORD"
157
+ }
158
+ },
159
+ "system" => {
160
+ "swap_size_gb" => 2,
161
+ "swap_swappiness" => 10,
162
+ "unattended_upgrades" => true,
163
+ "metrics" => false,
164
+ "metrics_installer_sha256" => nil
165
+ },
166
+ "dns" => { "domains" => [], "ttl" => 3600 }
167
+ }.freeze
168
+
169
+ ENV_PATHS = {
170
+ "KITSUNE_PROVIDER" => %w[provider name],
171
+ "KITSUNE_SERVER_NAME" => %w[server name],
172
+ "KITSUNE_REGION" => %w[server region],
173
+ "KITSUNE_SIZE" => %w[server size],
174
+ "KITSUNE_IMAGE" => %w[server image],
175
+ "KITSUNE_SSH_KEY_ID" => %w[server ssh_key_id],
176
+ "KITSUNE_SSH_USER" => %w[ssh user],
177
+ "KITSUNE_SSH_PORT" => %w[ssh port],
178
+ "KITSUNE_SSH_KEY_PATH" => %w[ssh key_path],
179
+ "KITSUNE_METRICS_INSTALLER_SHA256" => %w[system metrics_installer_sha256]
180
+ }.freeze
181
+
182
+ SCHEMA_KEYS = {
183
+ [] => %w[version provider server ssh services system dns],
184
+ %w[provider] => %w[name token_env],
185
+ %w[server] => %w[name region size image ssh_key_id tags],
186
+ %w[ssh] => %w[user port key_path allowed_cidrs],
187
+ %w[services] => %w[postgres redis],
188
+ %w[services postgres] => %w[enabled mode host image publish bind allowed_cidrs port password_env],
189
+ %w[services redis] => %w[enabled mode host image publish bind allowed_cidrs port password_env],
190
+ %w[system] => %w[swap_size_gb swap_swappiness unattended_upgrades metrics metrics_installer_sha256],
191
+ %w[dns] => %w[domains ttl]
192
+ }.freeze
193
+
194
+ class Loader
195
+ def initialize(root: Dir.pwd, env: ENV, config_path: nil)
196
+ @root = Pathname(root).expand_path
197
+ @env = env
198
+ @config_path = config_path ? Pathname(config_path).expand_path : @root.join(".kitsune/config.yml")
199
+ end
200
+
201
+ def load(environment: nil, overrides: {})
202
+ selected_environment = (environment || @env["KITSUNE_ENV"] || selected_environment_from_file ||
203
+ "development").to_s
204
+ unless selected_environment.match?(/\A[a-z0-9][a-z0-9_-]*\z/)
205
+ raise Errors::ConfigurationError, "environment name has an invalid format"
206
+ end
207
+
208
+ project = load_yaml(@config_path, required: true)
209
+ environment_config = load_yaml(@root.join(".kitsune/environments/#{selected_environment}.yml"),
210
+ required: false)
211
+ combined = deep_merge(DEFAULTS, project)
212
+ combined = deep_merge(combined, environment_config)
213
+ combined = apply_environment(combined)
214
+ combined = deep_merge(combined, stringify_keys(overrides))
215
+ build(combined, selected_environment)
216
+ end
217
+
218
+ private
219
+
220
+ def selected_environment_from_file
221
+ path = @root.join(".kitsune/environment")
222
+ return unless path.file?
223
+
224
+ path.read.strip
225
+ end
226
+
227
+ def load_yaml(path, required:)
228
+ unless path.file?
229
+ return {} unless required
230
+
231
+ raise Errors::ConfigurationError.new(
232
+ "configuration file not found: #{path}",
233
+ hint: "Run `kit init` in the project root."
234
+ )
235
+ end
236
+
237
+ content = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false)
238
+ return {} if content.nil?
239
+ raise Errors::ConfigurationError, "#{path} must contain a YAML mapping" unless content.is_a?(Hash)
240
+
241
+ stringify_keys(content)
242
+ rescue Psych::Exception => e
243
+ raise Errors::ConfigurationError.new("invalid YAML in #{path}: #{e.message}",
244
+ hint: "Correct the YAML syntax.")
245
+ end
246
+
247
+ def apply_environment(config)
248
+ ENV_PATHS.each_with_object(deep_copy(config)) do |(name, path), result|
249
+ set_path(result, path, @env[name]) if @env.key?(name)
250
+ end
251
+ end
252
+
253
+ def build(data, environment)
254
+ validate_structure!(data)
255
+ validate_schema_version!(data["version"])
256
+ config = Config.new(
257
+ version: data.fetch("version"),
258
+ environment: environment,
259
+ provider: Provider.new(**symbolize(data.fetch("provider"))),
260
+ server: Server.new(**symbolize(data.fetch("server"))),
261
+ ssh: Ssh.new(**symbolize(data.fetch("ssh"))),
262
+ services: Services.new(
263
+ postgres: Service.new(**symbolize(data.dig("services", "postgres"))),
264
+ redis: Service.new(**symbolize(data.dig("services", "redis")))
265
+ ),
266
+ system: System.new(**symbolize(data.fetch("system"))),
267
+ dns: Dns.new(**symbolize(data.fetch("dns")))
268
+ )
269
+ Validator.new(config, env: @env).validate!
270
+ config
271
+ rescue KeyError => e
272
+ raise Errors::ConfigurationError.new("missing configuration value: #{e.key}",
273
+ hint: "Run `kit doctor` for details.")
274
+ end
275
+
276
+ def validate_structure!(data)
277
+ errors = SCHEMA_KEYS.each_with_object([]) do |(path, known), findings|
278
+ value = path.reduce(data) { |current, key| current.is_a?(Hash) ? current[key] : nil }
279
+ label = path.empty? ? "configuration" : path.join(".")
280
+ unless value.is_a?(Hash)
281
+ findings << "#{label} must be a mapping"
282
+ next
283
+ end
284
+
285
+ unknown = value.keys - known
286
+ findings << "#{label} contains unknown keys: #{unknown.join(', ')}" if unknown.any?
287
+ end
288
+ return if errors.empty?
289
+
290
+ raise Errors::ConfigurationError.new(
291
+ "invalid configuration structure:\n- #{errors.join("\n- ")}",
292
+ hint: "Remove unknown keys and restore every documented YAML mapping."
293
+ )
294
+ end
295
+
296
+ def validate_schema_version!(version)
297
+ return if version == SCHEMA_VERSION
298
+
299
+ raise Errors::ConfigurationError.new(
300
+ "unsupported configuration schema version #{version.inspect}; expected #{SCHEMA_VERSION}",
301
+ hint: "Use a Kitsune Kit version that supports this schema or migrate a backup with documented tooling."
302
+ )
303
+ end
304
+
305
+ def deep_merge(left, right)
306
+ left.merge(right) do |_key, old_value, new_value|
307
+ old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
308
+ end
309
+ end
310
+
311
+ def deep_copy(value) = Marshal.load(Marshal.dump(value))
312
+
313
+ def set_path(hash, path, value)
314
+ path[0...-1].reduce(hash) { |current, key| current[key] ||= {} }[path.last] = value
315
+ end
316
+
317
+ def stringify_keys(value)
318
+ case value
319
+ when Hash
320
+ value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
321
+ when Array
322
+ value.map { |item| stringify_keys(item) }
323
+ else
324
+ value
325
+ end
326
+ end
327
+
328
+ def symbolize(hash) = hash.to_h { |key, value| [key.to_sym, value] }
329
+ end
330
+
331
+ class Validator
332
+ RESOURCE_NAME = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/
333
+ ENV_NAME = /\A[A-Z][A-Z0-9_]*\z/
334
+ SSH_USER = /\A[a-z_][a-z0-9_-]{0,31}\z/
335
+ IMAGE = %r{\A[a-z0-9]+(?:[._/-][a-z0-9]+)*(?::[a-zA-Z0-9][a-zA-Z0-9._-]*)?(?:@sha256:[a-f0-9]{64})?\z}
336
+ INSECURE_SECRETS = %w[password postgres redis changeme change-me secret admin].freeze
337
+
338
+ def initialize(config, env: ENV)
339
+ @config = config
340
+ @env = env
341
+ @errors = []
342
+ end
343
+
344
+ def validate!
345
+ validate_provider
346
+ validate_server
347
+ validate_ssh
348
+ validate_service("postgres", @config.services.postgres)
349
+ validate_service("redis", @config.services.redis)
350
+ validate_system
351
+ validate_dns
352
+ return @config if @errors.empty?
353
+
354
+ raise Errors::ConfigurationError.new(
355
+ "invalid configuration:\n- #{@errors.join("\n- ")}",
356
+ hint: "Correct .kitsune/config.yml or the selected environment file."
357
+ )
358
+ end
359
+
360
+ private
361
+
362
+ def validate_provider
363
+ unless SUPPORTED_PROVIDERS.include?(@config.provider.name)
364
+ @errors << "provider.name must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
365
+ end
366
+ return if @config.provider.token_env.match?(ENV_NAME)
367
+
368
+ @errors << "provider.token_env must be an environment variable name"
369
+ end
370
+
371
+ def validate_server
372
+ server = @config.server
373
+ @errors << "server.name is required" if server.name.empty?
374
+ @errors << "server.name has an invalid format" unless server.name.match?(RESOURCE_NAME)
375
+ @errors << "server.region is required" if server.region.empty?
376
+ @errors << "server.size is required" if server.size.empty?
377
+ @errors << "server.image is unsupported" unless SUPPORTED_IMAGES.include?(server.image)
378
+ @errors << "server.ssh_key_id is required" if server.ssh_key_id.empty?
379
+ end
380
+
381
+ def validate_ssh
382
+ ssh = @config.ssh
383
+ @errors << "ssh.user has an invalid format" unless ssh.user.match?(SSH_USER)
384
+ @errors << "ssh.port must be between 1 and 65535" unless (1..65_535).cover?(ssh.port)
385
+ validate_private_key(ssh.key_path)
386
+ ssh.allowed_cidrs.each { |cidr| validate_cidr("ssh.allowed_cidrs", cidr) }
387
+ end
388
+
389
+ def validate_private_key(path)
390
+ unless File.file?(path)
391
+ @errors << "ssh.key_path does not exist or is not a regular file: #{path}"
392
+ return
393
+ end
394
+
395
+ mode = File.stat(path).mode & 0o777
396
+ @errors << format("ssh.key_path permissions must be 0600 or stricter (currently %<mode>04o)", mode: mode) if
397
+ mode.anybits?(0o077)
398
+ rescue SystemCallError => e
399
+ @errors << "ssh.key_path cannot be inspected: #{e.class}"
400
+ end
401
+
402
+ def validate_service(name, service)
403
+ @errors << "services.#{name}.port must be between 1 and 65535" unless (1..65_535).cover?(service.port)
404
+ @errors << "services.#{name}.password_env has an invalid format" unless service.password_env.match?(ENV_NAME)
405
+ unless %w[managed external].include?(service.mode)
406
+ @errors << "services.#{name}.mode must be managed or external"
407
+ end
408
+ if service.mode == "external"
409
+ validate_external_service(name, service)
410
+ else
411
+ validate_managed_service(name, service)
412
+ end
413
+ validate_service_secret(name, service) if service.enabled
414
+ end
415
+
416
+ def validate_managed_service(name, service)
417
+ @errors << "services.#{name}.image has an invalid format" unless service.image.match?(IMAGE)
418
+ @errors << "services.#{name}.host is only valid in external mode" unless service.host.to_s.empty?
419
+ validate_bind(name, service.bind)
420
+ service.allowed_cidrs.each { |cidr| validate_cidr("services.#{name}.allowed_cidrs", cidr) }
421
+ return unless service.publish && service.allowed_cidrs.empty?
422
+
423
+ @errors << "services.#{name}.allowed_cidrs is required when publish is true"
424
+ end
425
+
426
+ def validate_external_service(name, service)
427
+ unless valid_endpoint_host?(service.host)
428
+ @errors << "services.#{name}.host must be a valid hostname or IP address in external mode"
429
+ end
430
+ @errors << "services.#{name}.publish must be false in external mode" if service.publish
431
+ return if service.allowed_cidrs.empty?
432
+
433
+ @errors << "services.#{name}.allowed_cidrs must be empty in external mode"
434
+ end
435
+
436
+ def validate_service_secret(name, service)
437
+ secret = @env.fetch(service.password_env, "")
438
+ return @errors << "#{service.password_env} is required when services.#{name}.enabled is true" if
439
+ secret.empty?
440
+ return @errors << "#{service.password_env} uses a known insecure default value" if
441
+ INSECURE_SECRETS.include?(secret.downcase)
442
+ return unless secret.bytesize < 12
443
+
444
+ @errors << "#{service.password_env} must contain at least 12 bytes"
445
+ end
446
+
447
+ def validate_bind(name, bind)
448
+ address = IPAddr.new(bind)
449
+ @errors << "services.#{name}.bind must be an IPv4 address" unless address.ipv4?
450
+ rescue IPAddr::InvalidAddressError
451
+ @errors << "services.#{name}.bind must be an IP address"
452
+ end
453
+
454
+ def valid_endpoint_host?(host)
455
+ return false if host.to_s.empty?
456
+
457
+ IPAddr.new(host)
458
+ true
459
+ rescue IPAddr::InvalidAddressError
460
+ valid_hostname?(host)
461
+ end
462
+
463
+ def validate_system
464
+ system = @config.system
465
+ @errors << "system.swap_size_gb must be between 0 and 64" unless (0..64).cover?(system.swap_size_gb)
466
+ @errors << "system.swap_swappiness must be between 0 and 100" unless (0..100).cover?(system.swap_swappiness)
467
+ return unless system.metrics && !system.metrics_installer_sha256.to_s.match?(/\A[a-f0-9]{64}\z/)
468
+
469
+ @errors << "system.metrics_installer_sha256 must be a lowercase SHA-256 when metrics is enabled"
470
+ end
471
+
472
+ def validate_dns
473
+ @errors << "dns.ttl must be between 30 and 86400" unless (30..86_400).cover?(@config.dns.ttl)
474
+ @config.dns.domains.each do |domain|
475
+ @errors << "invalid DNS name: #{domain}" unless valid_hostname?(domain)
476
+ end
477
+ end
478
+
479
+ def validate_cidr(field, cidr)
480
+ IPAddr.new(cidr)
481
+ rescue IPAddr::InvalidAddressError
482
+ @errors << "#{field} contains an invalid CIDR: #{cidr}"
483
+ end
484
+
485
+ def valid_hostname?(hostname)
486
+ return false if hostname.length > 253
487
+
488
+ hostname.split(".").all? { |label| label.match?(RESOURCE_NAME) }
489
+ end
490
+ end
491
+ end
492
+ end
493
+ 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
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Events
9
+ SCHEMA_VERSION = 1
10
+
11
+ class Event < Data.define(:id, :type, :time, :run_id, :data)
12
+ def self.build(type, run_id:, time: Time.now.utc, **data)
13
+ new(
14
+ id: SecureRandom.uuid,
15
+ type: type.to_s,
16
+ time: time.iso8601(6),
17
+ run_id: run_id,
18
+ data: data.freeze
19
+ )
20
+ end
21
+
22
+ def to_h
23
+ { schema_version: SCHEMA_VERSION, id: id, type: type, time: time, run_id: run_id, data: data }
24
+ end
25
+ end
26
+
27
+ class Bus
28
+ def initialize
29
+ @subscribers = []
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ def subscribe(subscriber = nil, &block)
34
+ target = subscriber || block
35
+ raise ArgumentError, "subscriber or block is required" unless target
36
+
37
+ @mutex.synchronize { @subscribers << target }
38
+ target
39
+ end
40
+
41
+ def publish(event)
42
+ @mutex.synchronize { @subscribers.dup }.each do |subscriber|
43
+ subscriber.respond_to?(:call) ? subscriber.call(event) : subscriber.handle(event)
44
+ end
45
+ event
46
+ end
47
+ end
48
+
49
+ class NullBus
50
+ def subscribe(*) = nil
51
+ def publish(event) = event
52
+ end
53
+ end
54
+ end
55
+ end