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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -2
- data/CONTRIBUTING.md +83 -0
- data/README.md +108 -136
- data/SECURITY.md +25 -0
- data/bin/kit +4 -1
- data/docs/architecture/decisions/0001-product-boundary.md +23 -0
- data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
- data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
- data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
- data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
- data/docs/architecture/decisions/0006-tui-backend.md +21 -0
- data/docs/architecture/provider-adapters.md +49 -0
- data/docs/architecture.md +89 -0
- data/docs/commands.md +210 -0
- data/docs/configuration.md +210 -0
- data/docs/getting-started.md +133 -0
- data/docs/providers/digitalocean.md +52 -0
- data/docs/releasing.md +43 -0
- data/docs/roadmap.md +46 -0
- data/docs/security-audit.md +130 -0
- data/docs/security.md +116 -0
- data/docs/services/postgres.md +78 -0
- data/docs/services/redis.md +53 -0
- data/docs/testing.md +200 -0
- data/docs/troubleshooting.md +123 -0
- data/docs/tui.md +57 -0
- data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
- data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
- data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
- data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
- data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
- data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
- data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
- data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
- data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
- data/lib/kitsune/kit/adapters/provider.rb +34 -0
- data/lib/kitsune/kit/adapters/transport.rb +22 -0
- data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
- data/lib/kitsune/kit/application.rb +170 -0
- data/lib/kitsune/kit/cancellation.rb +21 -0
- data/lib/kitsune/kit/cli.rb +802 -64
- data/lib/kitsune/kit/clock.rb +11 -0
- data/lib/kitsune/kit/configuration.rb +493 -0
- data/lib/kitsune/kit/errors.rb +98 -0
- data/lib/kitsune/kit/events.rb +55 -0
- data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
- data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
- data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
- data/lib/kitsune/kit/operations/remote_script.rb +214 -0
- data/lib/kitsune/kit/operations/service_backup.rb +71 -0
- data/lib/kitsune/kit/operations/service_files.rb +127 -0
- data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
- data/lib/kitsune/kit/operations/service_state.rb +59 -0
- data/lib/kitsune/kit/plan.rb +72 -0
- data/lib/kitsune/kit/reporters/human.rb +89 -0
- data/lib/kitsune/kit/reporters/json.rb +65 -0
- data/lib/kitsune/kit/reporters/reporter.rb +11 -0
- data/lib/kitsune/kit/result.rb +28 -0
- data/lib/kitsune/kit/run_journal.rb +102 -0
- data/lib/kitsune/kit/run_logger.rb +37 -0
- data/lib/kitsune/kit/scripts/docker.sh +137 -0
- data/lib/kitsune/kit/scripts/firewall.sh +205 -0
- data/lib/kitsune/kit/scripts/metrics.sh +54 -0
- data/lib/kitsune/kit/scripts/ssh.sh +95 -0
- data/lib/kitsune/kit/scripts/swap.sh +86 -0
- data/lib/kitsune/kit/scripts/unattended.sh +81 -0
- data/lib/kitsune/kit/scripts/user.sh +114 -0
- data/lib/kitsune/kit/secret_filter.rb +54 -0
- data/lib/kitsune/kit/secret_store.rb +32 -0
- data/lib/kitsune/kit/secret_stores/store.rb +12 -0
- data/lib/kitsune/kit/service_compose.rb +72 -0
- data/lib/kitsune/kit/state_store.rb +158 -0
- data/lib/kitsune/kit/state_stores/store.rb +15 -0
- data/lib/kitsune/kit/tui/actions.rb +72 -0
- data/lib/kitsune/kit/tui/application.rb +35 -0
- data/lib/kitsune/kit/tui/controller.rb +162 -0
- data/lib/kitsune/kit/tui/renderer.rb +134 -0
- data/lib/kitsune/kit/tui/state.rb +18 -0
- data/lib/kitsune/kit/tui/store.rb +88 -0
- data/lib/kitsune/kit/tui/terminal.rb +95 -0
- data/lib/kitsune/kit/version.rb +1 -1
- data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
- data/lib/kitsune/kit/workflows/base.rb +31 -0
- data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
- data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
- data/lib/kitsune/kit/workflows/doctor.rb +225 -0
- data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
- data/lib/kitsune/kit/workflows/import_server.rb +100 -0
- data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
- data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
- data/lib/kitsune/kit/workflows/rollback.rb +54 -0
- data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
- data/lib/kitsune/kit.rb +41 -2
- metadata +122 -79
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/kitsune-kit-logo.jpg +0 -0
- data/lib/kitsune/blueprints/.env.template +0 -31
- data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
- data/lib/kitsune/blueprints/docker/redis.yml +0 -23
- data/lib/kitsune/blueprints/kit.env.template +0 -1
- data/lib/kitsune/kit/ansi_color.rb +0 -78
- data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
- data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
- data/lib/kitsune/kit/commands/dns.rb +0 -112
- data/lib/kitsune/kit/commands/init.rb +0 -148
- data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
- data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
- data/lib/kitsune/kit/commands/provision.rb +0 -43
- data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
- data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
- data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
- data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
- data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
- data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
- data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
- data/lib/kitsune/kit/commands/setup_user.rb +0 -189
- data/lib/kitsune/kit/commands/ssh.rb +0 -46
- data/lib/kitsune/kit/commands/switch_env.rb +0 -42
- data/lib/kitsune/kit/defaults.rb +0 -91
- data/lib/kitsune/kit/env_loader.rb +0 -41
- data/lib/kitsune/kit/options_builder.rb +0 -26
- data/lib/kitsune/kit/provisioner.rb +0 -107
- data/sig/kitsune/kit.rbs +0 -6
|
@@ -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
|