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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ class SecretFilter
8
+ REDACTED = "[REDACTED]"
9
+ SENSITIVE_KEY = /(token|secret|password|private_key|authorization)/i
10
+ PRIVATE_KEY_BLOCK = /-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----/m
11
+
12
+ def initialize(values = [])
13
+ @values = []
14
+ values.each { |value| register(value) }
15
+ end
16
+
17
+ def register(value)
18
+ string = value.to_s
19
+ @values << string unless string.empty? || @values.include?(string)
20
+ self
21
+ end
22
+
23
+ def filter(value)
24
+ case value
25
+ when Hash
26
+ value.each_with_object({}) do |(key, item), result|
27
+ result[key] = key.to_s.match?(SENSITIVE_KEY) ? REDACTED : filter(item)
28
+ end
29
+ when Array
30
+ value.map { |item| filter(item) }
31
+ when String
32
+ filter_string(value)
33
+ else
34
+ value
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def filter_string(value)
41
+ filtered = @values.sort_by { |secret| -secret.length }.reduce(value.dup) do |text, secret|
42
+ text.gsub(secret, REDACTED)
43
+ end
44
+ redact_private_key(redact_uri_password(filtered))
45
+ end
46
+
47
+ def redact_uri_password(value)
48
+ value.gsub(%r{([a-z][a-z0-9+.-]*://[^:/@\s]+):[^@\s/]*@}i, "\\1:#{REDACTED}@")
49
+ end
50
+
51
+ def redact_private_key(value) = value.gsub(PRIVATE_KEY_BLOCK, REDACTED)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "secret_stores/store"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module SecretStores
9
+ class Environment < Store
10
+ def initialize(env: ENV, filter: nil)
11
+ super()
12
+ @env = env
13
+ @filter = filter
14
+ end
15
+
16
+ def fetch(name, required: true)
17
+ value = @env.fetch(name.to_s, "")
18
+ if required && value.empty?
19
+ raise Errors::ConfigurationError.new(
20
+ "required secret #{name} is not set",
21
+ hint: "Export #{name} before running this command."
22
+ )
23
+ end
24
+ @filter&.register(value)
25
+ value
26
+ end
27
+
28
+ def configured?(name) = !@env.fetch(name.to_s, "").empty?
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module SecretStores
6
+ class Store
7
+ def fetch(name, required: true) = raise NotImplementedError
8
+ def configured?(name) = raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "pathname"
6
+ require "yaml"
7
+ require_relative "errors"
8
+
9
+ module Kitsune
10
+ module Kit
11
+ class ServiceCompose
12
+ Document = Data.define(:filename, :content, :source)
13
+ MAX_FILE_SIZE = 262_144
14
+ MODES = %w[generated overlay custom].freeze
15
+ SENSITIVE_KEY = /(?:password|passwd|secret|token|api[_-]?key)/i
16
+ ENV_REFERENCE = /\A\$\{[A-Z][A-Z0-9_]*\}\z/
17
+
18
+ attr_reader :mode, :security_findings
19
+
20
+ def initialize(config:, type:, service:)
21
+ @config = config
22
+ @type = type.to_s
23
+ @service = service
24
+ @mode = service.compose.mode
25
+ @security_findings = []
26
+ validate!
27
+ end
28
+
29
+ def content = documents.first.content
30
+ def generated_content = YAML.dump(generated_document)
31
+ def env_content(password) = "#{@service.password_env}=#{JSON.generate(password)}\n"
32
+ def filenames = documents.map(&:filename)
33
+
34
+ def fingerprint
35
+ Digest::SHA256.hexdigest([mode, *documents.flat_map { |item| [item.filename, item.content] }].join("\0"))
36
+ end
37
+
38
+ def documents
39
+ @documents ||= case mode
40
+ when "generated"
41
+ [Document.new(filename: "compose.yml", content: generated_content, source: "generated")]
42
+ when "overlay"
43
+ [
44
+ Document.new(filename: "compose.yml", content: generated_content, source: "generated"),
45
+ Document.new(filename: "compose.override.yml", content: user_content,
46
+ source: @service.compose.file)
47
+ ]
48
+ when "custom"
49
+ [Document.new(filename: "compose.yml", content: user_content, source: @service.compose.file)]
50
+ else
51
+ raise Errors::ConfigurationError, "unsupported Compose mode: #{mode}"
52
+ end
53
+ end
54
+
55
+ def display
56
+ documents.map do |document|
57
+ "# --- #{document.filename} (#{document.source}) ---\n#{document.content}"
58
+ end.join("\n")
59
+ end
60
+
61
+ def metadata
62
+ {
63
+ mode: mode,
64
+ files: documents.map { |document| { filename: document.filename, source: document.source } },
65
+ allow_unsafe: @service.compose.allow_unsafe,
66
+ security_findings: security_findings
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def validate!
73
+ raise Errors::ConfigurationError, "unsupported Compose mode: #{mode}" unless MODES.include?(mode)
74
+ return if mode == "generated"
75
+
76
+ document = user_document
77
+ if mode == "custom"
78
+ services = document["services"]
79
+ unless services.is_a?(Hash) && services[@type].is_a?(Hash)
80
+ raise Errors::ConfigurationError.new(
81
+ "custom Compose file must define services.#{@type}",
82
+ hint: "Run `kit service #{@type} compose eject` to create a valid starting point."
83
+ )
84
+ end
85
+ end
86
+ detect_inline_secrets(document)
87
+ inspect_unsafe_options(document)
88
+ return if security_findings.empty? || @service.compose.allow_unsafe
89
+
90
+ raise Errors::UnsafeOperationError.new(
91
+ "Compose customization contains unsafe options",
92
+ hint: "Remove the options or set services.#{@type}.compose.allow_unsafe to true after review.",
93
+ context: { service: @type, findings: security_findings }
94
+ )
95
+ end
96
+
97
+ def user_content
98
+ @user_content ||= Pathname(@service.compose.file).binread
99
+ rescue SystemCallError => e
100
+ raise Errors::ConfigurationError, "unable to read Compose customization: #{e.class}"
101
+ end
102
+
103
+ def user_document
104
+ @user_document ||= begin
105
+ if user_content.bytesize > MAX_FILE_SIZE
106
+ raise Errors::ConfigurationError, "Compose customization exceeds 256 KiB"
107
+ end
108
+
109
+ parsed = YAML.safe_load(user_content, permitted_classes: [], permitted_symbols: [], aliases: false)
110
+ unless parsed.is_a?(Hash)
111
+ raise Errors::ConfigurationError, "Compose customization must contain a YAML mapping"
112
+ end
113
+
114
+ stringify_keys(parsed)
115
+ rescue Psych::Exception => e
116
+ raise Errors::ConfigurationError, "invalid Compose customization YAML: #{e.message}"
117
+ end
118
+ end
119
+
120
+ def detect_inline_secrets(value, path = [])
121
+ case value
122
+ when Hash
123
+ value.each do |key, child|
124
+ if key.match?(SENSITIVE_KEY) && child.is_a?(String) && !child.match?(ENV_REFERENCE)
125
+ raise Errors::ConfigurationError.new(
126
+ "Compose customization contains an inline secret at #{(path + [key]).join('.')}",
127
+ hint: "Reference an environment variable such as ${#{@service.password_env}} instead."
128
+ )
129
+ end
130
+ detect_inline_secrets(child, path + [key])
131
+ end
132
+ when Array
133
+ value.each_with_index { |child, index| detect_inline_secrets(child, path + [index.to_s]) }
134
+ when String
135
+ detect_inline_environment_secret(value, path)
136
+ end
137
+ end
138
+
139
+ def detect_inline_environment_secret(value, path)
140
+ key, content = value.split("=", 2)
141
+ return unless content && key.match?(SENSITIVE_KEY) && !content.match?(ENV_REFERENCE)
142
+
143
+ raise Errors::ConfigurationError.new(
144
+ "Compose customization contains an inline secret at #{path.join('.')}",
145
+ hint: "Use mapping syntax and reference ${#{@service.password_env}} instead."
146
+ )
147
+ end
148
+
149
+ def inspect_unsafe_options(document)
150
+ services = document["services"]
151
+ return unless services.is_a?(Hash)
152
+
153
+ services.each do |name, definition|
154
+ next unless definition.is_a?(Hash)
155
+
156
+ inspect_service(name, definition)
157
+ end
158
+ end
159
+
160
+ def inspect_service(name, definition)
161
+ prefix = "services.#{name}"
162
+ finding("#{prefix}.privileged", "privileged containers") if definition["privileged"] == true
163
+ inspect_namespaces(prefix, definition)
164
+ inspect_capabilities(prefix, definition)
165
+ inspect_ports(prefix, name, definition)
166
+ finding("#{prefix}.devices", "host device access") if definition.key?("devices")
167
+ finding("#{prefix}.build", "remote image builds") if definition.key?("build")
168
+ finding("#{prefix}.env_file", "unmanaged environment files") if definition.key?("env_file")
169
+ Array(definition["volumes"]).each do |volume|
170
+ finding("#{prefix}.volumes", "host or Docker socket mount") if unsafe_volume?(volume)
171
+ end
172
+ end
173
+
174
+ def inspect_namespaces(prefix, definition)
175
+ %w[network_mode pid ipc].each do |key|
176
+ finding("#{prefix}.#{key}", "host namespace access") if definition[key].to_s == "host"
177
+ end
178
+ end
179
+
180
+ def inspect_capabilities(prefix, definition)
181
+ capabilities = Array(definition["cap_add"]).map { |item| item.to_s.upcase }
182
+ return unless capabilities.intersect?(%w[ALL SYS_ADMIN SYS_PTRACE NET_ADMIN])
183
+
184
+ finding("#{prefix}.cap_add", "elevated Linux capabilities")
185
+ end
186
+
187
+ def inspect_ports(prefix, name, definition)
188
+ return unless definition.key?("ports")
189
+ return if name == @type && Array(definition["ports"]) == managed_ports
190
+
191
+ finding("#{prefix}.ports", "ports outside the managed firewall model")
192
+ end
193
+
194
+ def managed_ports
195
+ return [] unless @service.publish
196
+
197
+ ["#{@service.bind}:#{@service.port}:#{container_port}"]
198
+ end
199
+
200
+ def unsafe_volume?(volume)
201
+ source = case volume
202
+ when String then volume.split(":", 2).first
203
+ when Hash then volume["source"] || volume["src"]
204
+ end
205
+ source.to_s.start_with?("/") || source.to_s.include?("docker.sock")
206
+ end
207
+
208
+ def finding(path, message)
209
+ security_findings << { path: path, message: message }
210
+ end
211
+
212
+ def generated_document
213
+ {
214
+ "name" => project_name,
215
+ "services" => { @type => generated_service_definition },
216
+ "volumes" => { "data" => nil },
217
+ "networks" => { "private" => { "external" => true, "name" => "kitsune-private" } }
218
+ }
219
+ end
220
+
221
+ def generated_service_definition
222
+ definition = @type == "postgres" ? postgres : redis
223
+ definition["ports"] = ["#{@service.bind}:#{@service.port}:#{container_port}"] if @service.publish
224
+ definition
225
+ end
226
+
227
+ def postgres
228
+ {
229
+ "image" => @service.image,
230
+ "restart" => "unless-stopped",
231
+ "environment" => {
232
+ "POSTGRES_DB" => database,
233
+ "POSTGRES_USER" => "postgres",
234
+ "POSTGRES_PASSWORD" => interpolation
235
+ },
236
+ "volumes" => ["data:/var/lib/postgresql/data"],
237
+ "networks" => ["private"],
238
+ "healthcheck" => healthcheck("pg_isready -U postgres -d #{database}")
239
+ }
240
+ end
241
+
242
+ def redis
243
+ {
244
+ "image" => @service.image,
245
+ "restart" => "unless-stopped",
246
+ "environment" => { "REDIS_PASSWORD" => interpolation },
247
+ "command" => ["sh", "-ec", 'exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"'],
248
+ "volumes" => ["data:/data"],
249
+ "networks" => ["private"],
250
+ "healthcheck" => healthcheck('REDISCLI_AUTH="$${REDIS_PASSWORD}" redis-cli ping | grep -Fxq PONG')
251
+ }
252
+ end
253
+
254
+ def healthcheck(command)
255
+ { "test" => ["CMD-SHELL", command], "interval" => "10s", "timeout" => "5s", "retries" => 12 }
256
+ end
257
+
258
+ def stringify_keys(value)
259
+ case value
260
+ when Hash then value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
261
+ when Array then value.map { |item| stringify_keys(item) }
262
+ else value
263
+ end
264
+ end
265
+
266
+ def project_name = "kitsune-#{@config.environment}-#{@type}"
267
+ def database = "app_#{@config.environment.tr('-', '_')}"
268
+ def container_port = @type == "postgres" ? 5432 : 6379
269
+ def interpolation = "${#{@service.password_env}}"
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+ require "tempfile"
7
+ require "time"
8
+ require_relative "errors"
9
+ require_relative "state_stores/store"
10
+
11
+ module Kitsune
12
+ module Kit
13
+ class StateStore < StateStores::Store
14
+ SCHEMA_VERSION = 1
15
+
16
+ def initialize(root: Dir.pwd)
17
+ super()
18
+ @directory = Pathname(root).expand_path.join(".kitsune/state")
19
+ end
20
+
21
+ def read(environment)
22
+ path = state_path(environment)
23
+ return empty_state(environment) unless path.file?
24
+
25
+ with_lock(environment, shared: true) do
26
+ parsed = JSON.parse(path.read)
27
+ validate!(parsed, environment)
28
+ parsed
29
+ end
30
+ rescue JSON::ParserError => e
31
+ raise Errors::ConfigurationError.new(
32
+ "state file is invalid JSON: #{path}",
33
+ hint: "Restore the state backup or import the environment again.",
34
+ context: { cause: e.message }
35
+ )
36
+ end
37
+
38
+ def update(environment)
39
+ with_lock(environment) do
40
+ current = read_without_lock(environment)
41
+ updated = yield(deep_copy(current))
42
+ updated["updated_at"] = Time.now.utc.iso8601(6)
43
+ validate!(updated, environment)
44
+ write_atomically(state_path(environment), JSON.pretty_generate(updated) << "\n")
45
+ deep_copy(updated)
46
+ end
47
+ end
48
+
49
+ def write(environment, state)
50
+ update(environment) { state }
51
+ end
52
+
53
+ def delete(environment)
54
+ with_lock(environment) do
55
+ next unless state_path(environment).file?
56
+
57
+ state_path(environment).delete
58
+ true
59
+ end
60
+ end
61
+
62
+ def with_execution_lock(environment)
63
+ FileUtils.mkdir_p(@directory, mode: 0o700)
64
+ path = @directory.join("#{safe_environment(environment)}.operation.lock")
65
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
66
+ locked = file.flock(File::LOCK_EX | File::LOCK_NB)
67
+ unless locked
68
+ raise Errors::UnsafeOperationError.new(
69
+ "another mutating operation is active for #{environment}",
70
+ hint: "Wait for it to finish or stop it safely before retrying."
71
+ )
72
+ end
73
+ yield
74
+ ensure
75
+ file.flock(File::LOCK_UN) if locked
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def empty_state(environment)
82
+ {
83
+ "version" => SCHEMA_VERSION,
84
+ "environment" => environment.to_s,
85
+ "updated_at" => nil,
86
+ "resources" => {},
87
+ "operations" => [],
88
+ "runs" => {}
89
+ }
90
+ end
91
+
92
+ def state_path(environment) = @directory.join("#{safe_environment(environment)}.json")
93
+ def lock_path(environment) = @directory.join("#{safe_environment(environment)}.lock")
94
+
95
+ def safe_environment(environment)
96
+ value = environment.to_s
97
+ return value if value.match?(/\A[a-zA-Z0-9_-]+\z/)
98
+
99
+ raise Errors::ConfigurationError, "environment name has an invalid format"
100
+ end
101
+
102
+ def with_lock(environment, shared: false)
103
+ FileUtils.mkdir_p(@directory, mode: 0o700)
104
+ File.open(lock_path(environment), File::RDWR | File::CREAT, 0o600) do |file|
105
+ file.flock(shared ? File::LOCK_SH : File::LOCK_EX)
106
+ yield
107
+ ensure
108
+ file.flock(File::LOCK_UN)
109
+ end
110
+ end
111
+
112
+ def read_without_lock(environment)
113
+ path = state_path(environment)
114
+ return empty_state(environment) unless path.file?
115
+
116
+ parsed = JSON.parse(path.read)
117
+ validate!(parsed, environment)
118
+ parsed
119
+ rescue JSON::ParserError => e
120
+ raise Errors::ConfigurationError.new("state file is invalid JSON: #{path}", context: { cause: e.message })
121
+ end
122
+
123
+ def validate!(state, environment)
124
+ raise Errors::ConfigurationError, "state must be an object" unless state.is_a?(Hash)
125
+ unless state["version"] == SCHEMA_VERSION
126
+ raise Errors::ConfigurationError.new(
127
+ "unsupported state schema version #{state['version'].inspect}; expected #{SCHEMA_VERSION}",
128
+ hint: "Use a compatible Kitsune Kit version; do not edit IDs manually. Preserve this file and its backup."
129
+ )
130
+ end
131
+ raise Errors::ConfigurationError, "state environment mismatch" unless state["environment"] == environment.to_s
132
+ raise Errors::ConfigurationError, "state resources must be an object" unless state["resources"].is_a?(Hash)
133
+ raise Errors::ConfigurationError, "state operations must be an array" unless state["operations"].is_a?(Array)
134
+ raise Errors::ConfigurationError, "state runs must be an object" unless state["runs"].is_a?(Hash)
135
+
136
+ state
137
+ end
138
+
139
+ def write_atomically(path, content)
140
+ FileUtils.mkdir_p(path.dirname, mode: 0o700)
141
+ backup = Pathname("#{path}.backup")
142
+ if path.file?
143
+ FileUtils.cp(path, backup)
144
+ File.chmod(0o600, backup)
145
+ end
146
+ Tempfile.create([path.basename.to_s, ".tmp"], path.dirname.to_s, mode: 0o600) do |temp|
147
+ temp.write(content)
148
+ temp.flush
149
+ temp.fsync
150
+ File.rename(temp.path, path)
151
+ File.chmod(0o600, path)
152
+ end
153
+ end
154
+
155
+ def deep_copy(value) = Marshal.load(Marshal.dump(value))
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module StateStores
6
+ class Store
7
+ def read(environment) = raise NotImplementedError
8
+ def update(environment) = raise NotImplementedError
9
+ def write(environment, state) = raise NotImplementedError
10
+ def delete(environment) = raise NotImplementedError
11
+ def with_execution_lock(environment) = raise NotImplementedError
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Tui
6
+ class Actions
7
+ def initialize(application:, cancellation: Cancellation.new)
8
+ @application = application
9
+ @cancellation = cancellation
10
+ @last_plan = nil
11
+ end
12
+
13
+ def status
14
+ Workflows::InspectEnvironment.new(
15
+ config: app.config, provider: app.provider, state_store: app.state_store, event_bus: app.event_bus
16
+ ).call
17
+ end
18
+
19
+ def plan
20
+ workflow = Workflows::BuildPlan.new(config: app.config, operations: app.operations,
21
+ event_bus: app.event_bus)
22
+ workflow.call.tap do |result|
23
+ @last_plan = result.value
24
+ end
25
+ end
26
+
27
+ def doctor
28
+ Workflows::Doctor.new(
29
+ config: app.config,
30
+ provider: app.provider,
31
+ state_store: app.state_store,
32
+ transport_factory: app.transport_factory,
33
+ operations: app.operations,
34
+ event_bus: app.event_bus
35
+ ).call
36
+ end
37
+
38
+ def apply
39
+ @last_plan ||= plan.value
40
+ raise Errors::UnsafeOperationError, "the plan contains destructive changes" if @last_plan.destructive?
41
+ return Result.success([], metadata: { unchanged: true }) unless @last_plan.changed?
42
+
43
+ Workflows::ApplyPlan.new(
44
+ config: app.config, operations: app.operations, state_store: app.state_store, event_bus: app.event_bus,
45
+ cancellation: @cancellation
46
+ ).call(plan: @last_plan)
47
+ ensure
48
+ @last_plan = nil
49
+ reset_cancellation
50
+ end
51
+
52
+ def resume
53
+ Workflows::ApplyPlan.new(
54
+ config: app.config, operations: app.operations, state_store: app.state_store, event_bus: app.event_bus,
55
+ cancellation: @cancellation
56
+ ).call(resume_from: true)
57
+ ensure
58
+ reset_cancellation
59
+ end
60
+
61
+ def cancel = @cancellation.cancel!
62
+
63
+ private
64
+
65
+ attr_reader :application
66
+ alias app application
67
+
68
+ def reset_cancellation = @cancellation = Cancellation.new
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Tui
6
+ class Application
7
+ def initialize(store:, controller:, renderer: Renderer.new, terminal: Terminal.new)
8
+ @store = store
9
+ @controller = controller
10
+ @renderer = renderer
11
+ @terminal = terminal
12
+ end
13
+
14
+ def call
15
+ terminal.run do
16
+ controller.start
17
+ until controller.exit_requested
18
+ width, height = terminal.size
19
+ store.resize(width, height)
20
+ terminal.draw(renderer.render(store.snapshot))
21
+ controller.handle(terminal.read_key)
22
+ end
23
+ end
24
+ 0
25
+ ensure
26
+ controller.wait
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :store, :controller, :renderer, :terminal
32
+ end
33
+ end
34
+ end
35
+ end