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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -2
- data/CONTRIBUTING.md +83 -0
- data/README.md +112 -135
- 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/decisions/0007-compose-customization.md +30 -0
- data/docs/architecture/provider-adapters.md +49 -0
- data/docs/architecture.md +93 -0
- data/docs/commands.md +224 -0
- data/docs/configuration.md +226 -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/compose.md +164 -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 +879 -64
- data/lib/kitsune/kit/clock.rb +11 -0
- data/lib/kitsune/kit/configuration.rb +577 -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 +302 -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 +131 -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 +142 -0
- data/lib/kitsune/kit/scripts/firewall.sh +210 -0
- data/lib/kitsune/kit/scripts/metrics.sh +59 -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 +86 -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 +272 -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/eject_compose.rb +82 -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 +129 -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 +42 -2
- metadata +125 -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,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,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
|