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,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "public_suffix"
|
|
4
|
+
require_relative "../adapters/provider"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../plan"
|
|
7
|
+
|
|
8
|
+
module Kitsune
|
|
9
|
+
module Kit
|
|
10
|
+
module Operations
|
|
11
|
+
class EnsureDnsRecords
|
|
12
|
+
def initialize(config:, provider:, state_store:)
|
|
13
|
+
@config = config
|
|
14
|
+
@provider = provider
|
|
15
|
+
@state_store = state_store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def plan
|
|
19
|
+
server = find_server
|
|
20
|
+
desired_ip = server&.public_ip
|
|
21
|
+
entries = domains.map { |domain| plan_domain(domain, desired_ip) }
|
|
22
|
+
action = entries.all? { |entry| entry[:action] == "no_change" } ? "no_change" : "update"
|
|
23
|
+
Change.new(
|
|
24
|
+
resource: "dns",
|
|
25
|
+
action: action,
|
|
26
|
+
summary: summary_for(action, entries),
|
|
27
|
+
details: { records: entries, desired_ip: desired_ip, depends_on: desired_ip ? nil : "server" }
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def apply(change)
|
|
32
|
+
return [] unless change.changed?
|
|
33
|
+
|
|
34
|
+
server = find_server
|
|
35
|
+
unless server&.public_ip
|
|
36
|
+
raise Errors::VerificationError.new(
|
|
37
|
+
"server has no public IP for DNS records",
|
|
38
|
+
hint: "Wait for the server to become ready, then resume apply."
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
domains.map do |domain|
|
|
43
|
+
apply_domain(domain, server.public_ip).tap { |entry| save_record(entry) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def rollback
|
|
48
|
+
managed = state_store.read(config.environment).dig("resources", "dns")
|
|
49
|
+
return false unless managed
|
|
50
|
+
|
|
51
|
+
managed.each_value do |entry|
|
|
52
|
+
record = record_from_state(entry.fetch("record"))
|
|
53
|
+
previous = entry["previous"] && record_from_state(entry["previous"])
|
|
54
|
+
if previous
|
|
55
|
+
provider.upsert_dns_record(record: previous)
|
|
56
|
+
else
|
|
57
|
+
provider.delete_dns_record(id: record.id, zone: record.zone)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
state_store.update(config.environment) do |state|
|
|
61
|
+
state["resources"].delete("dns")
|
|
62
|
+
state["operations"] << { "resource" => resource, "action" => "rollback", "status" => "applied" }
|
|
63
|
+
state
|
|
64
|
+
end
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resource = "dns"
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
attr_reader :config, :provider, :state_store
|
|
73
|
+
|
|
74
|
+
def domains = config.dns.domains
|
|
75
|
+
|
|
76
|
+
def find_server
|
|
77
|
+
provider.find_server(name: config.server.name, tags: config.server.tags)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def plan_domain(domain, desired_ip)
|
|
81
|
+
zone, name = zone_and_name(domain)
|
|
82
|
+
existing = provider.find_dns_record(zone: zone, name: name, type: "A")
|
|
83
|
+
action = if existing && desired_ip && existing.data == desired_ip && existing.ttl == config.dns.ttl
|
|
84
|
+
"no_change"
|
|
85
|
+
elsif existing
|
|
86
|
+
"update"
|
|
87
|
+
else
|
|
88
|
+
"create"
|
|
89
|
+
end
|
|
90
|
+
{
|
|
91
|
+
domain: domain,
|
|
92
|
+
zone: zone,
|
|
93
|
+
name: name,
|
|
94
|
+
action: action,
|
|
95
|
+
current: existing&.to_h,
|
|
96
|
+
desired: { data: desired_ip || "pending-server-ip", ttl: config.dns.ttl }
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_domain(domain, ip)
|
|
101
|
+
zone, name = zone_and_name(domain)
|
|
102
|
+
existing = provider.find_dns_record(zone: zone, name: name, type: "A")
|
|
103
|
+
desired = Adapters::DnsRecord.new(
|
|
104
|
+
id: existing&.id,
|
|
105
|
+
zone: zone,
|
|
106
|
+
name: name,
|
|
107
|
+
type: "A",
|
|
108
|
+
data: ip,
|
|
109
|
+
ttl: config.dns.ttl
|
|
110
|
+
)
|
|
111
|
+
saved = if existing&.data == ip && existing.ttl == config.dns.ttl
|
|
112
|
+
existing
|
|
113
|
+
else
|
|
114
|
+
provider.upsert_dns_record(record: desired)
|
|
115
|
+
end
|
|
116
|
+
previous = prior_previous(domain, existing)
|
|
117
|
+
{ domain: domain, record: saved, previous: previous }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def zone_and_name(domain)
|
|
121
|
+
parsed = PublicSuffix.parse(domain)
|
|
122
|
+
zone = parsed.domain
|
|
123
|
+
prefix = domain.delete_suffix(zone).delete_suffix(".")
|
|
124
|
+
[zone, prefix.empty? ? "@" : prefix]
|
|
125
|
+
rescue PublicSuffix::Error
|
|
126
|
+
raise Errors::ConfigurationError.new(
|
|
127
|
+
"cannot determine the DNS zone for #{domain}",
|
|
128
|
+
hint: "Use a fully qualified public domain name."
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def save_record(entry)
|
|
133
|
+
state_store.update(config.environment) do |state|
|
|
134
|
+
state["resources"]["dns"] ||= {}
|
|
135
|
+
state["resources"]["dns"][entry[:domain]] = {
|
|
136
|
+
"record" => stringify(entry[:record].to_h),
|
|
137
|
+
"previous" => entry[:previous] && stringify(entry[:previous].to_h)
|
|
138
|
+
}
|
|
139
|
+
state["operations"] << { "resource" => resource, "action" => "update", "status" => "applied" }
|
|
140
|
+
state
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def prior_previous(domain, existing)
|
|
145
|
+
prior = state_store.read(config.environment).dig("resources", "dns", domain)
|
|
146
|
+
return existing unless prior
|
|
147
|
+
|
|
148
|
+
prior["previous"] && record_from_state(prior["previous"])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def stringify(hash) = hash.transform_keys(&:to_s)
|
|
152
|
+
|
|
153
|
+
def record_from_state(value)
|
|
154
|
+
Adapters::DnsRecord.new(**value.transform_keys(&:to_sym))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def summary_for(action, entries)
|
|
158
|
+
return "DNS records are current" if action == "no_change"
|
|
159
|
+
|
|
160
|
+
"Apply #{entries.count { |entry| entry[:action] != 'no_change' }} DNS record change(s)"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../plan"
|
|
5
|
+
|
|
6
|
+
module Kitsune
|
|
7
|
+
module Kit
|
|
8
|
+
module Operations
|
|
9
|
+
class EnsureServer
|
|
10
|
+
attr_reader :config
|
|
11
|
+
|
|
12
|
+
def initialize(config:, provider:, state_store:, maximum_timeout: nil)
|
|
13
|
+
if maximum_timeout && maximum_timeout <= 0
|
|
14
|
+
raise Errors::ConfigurationError, "timeout must be greater than zero"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@config = config
|
|
18
|
+
@provider = provider
|
|
19
|
+
@state_store = state_store
|
|
20
|
+
@ready_timeout = maximum_timeout || 180
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def plan
|
|
24
|
+
@provider.validate_server_spec!(spec: desired_spec)
|
|
25
|
+
existing = @provider.find_server(name: config.server.name, tags: config.server.tags)
|
|
26
|
+
return create_change unless existing
|
|
27
|
+
|
|
28
|
+
differences = differences_for(existing)
|
|
29
|
+
return unchanged_change(existing) if differences.empty?
|
|
30
|
+
|
|
31
|
+
Change.new(
|
|
32
|
+
resource: "server.#{config.server.name}",
|
|
33
|
+
action: "update",
|
|
34
|
+
summary: "Server exists with immutable configuration differences",
|
|
35
|
+
details: { id: existing.id, differences: differences, replacement_required: true },
|
|
36
|
+
destructive: true
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def apply(change)
|
|
41
|
+
return current_server(change) unless change.changed?
|
|
42
|
+
if change.action == "update"
|
|
43
|
+
raise Errors::UnsafeOperationError.new(
|
|
44
|
+
"server replacement is required for #{config.server.name}",
|
|
45
|
+
hint: "Destroy the managed server explicitly, then run apply again.",
|
|
46
|
+
context: change.details
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
created = @provider.find_server(name: config.server.name, tags: config.server.tags) ||
|
|
51
|
+
@provider.create_server(spec: desired_spec)
|
|
52
|
+
record_created(created)
|
|
53
|
+
ready = @provider.wait_until_ready(id: created.id, timeout: @ready_timeout)
|
|
54
|
+
record_ready(ready)
|
|
55
|
+
ready
|
|
56
|
+
rescue Errors::Error
|
|
57
|
+
raise
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
raise Errors::ProviderError.new(
|
|
60
|
+
"server operation failed for #{config.server.name}",
|
|
61
|
+
context: { cause: e.class.name },
|
|
62
|
+
retryable: true
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resource = "server.#{config.server.name}"
|
|
67
|
+
def state_key = "server"
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def desired_spec
|
|
72
|
+
{
|
|
73
|
+
name: config.server.name,
|
|
74
|
+
region: config.server.region,
|
|
75
|
+
size: config.server.size,
|
|
76
|
+
image: config.server.image,
|
|
77
|
+
ssh_key_id: config.server.ssh_key_id,
|
|
78
|
+
tags: config.server.tags
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def create_change
|
|
83
|
+
Change.new(
|
|
84
|
+
resource: resource,
|
|
85
|
+
action: "create",
|
|
86
|
+
summary: "Create server #{config.server.name}",
|
|
87
|
+
details: desired_spec.except(:ssh_key_id)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unchanged_change(existing)
|
|
92
|
+
Change.new(
|
|
93
|
+
resource: resource,
|
|
94
|
+
action: "no_change",
|
|
95
|
+
summary: "Server #{config.server.name} is ready",
|
|
96
|
+
details: { id: existing.id, public_ip: existing.public_ip, status: existing.status }
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def differences_for(existing)
|
|
101
|
+
{
|
|
102
|
+
region: [existing.region, config.server.region],
|
|
103
|
+
size: [existing.size, config.server.size],
|
|
104
|
+
image: [existing.image, config.server.image]
|
|
105
|
+
}.reject { |_key, (actual, desired)| actual.nil? || actual == desired }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def current_server(_change)
|
|
109
|
+
@provider.find_server(name: config.server.name, tags: config.server.tags) ||
|
|
110
|
+
raise(Errors::VerificationError, "server disappeared after planning")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_created(server)
|
|
114
|
+
@state_store.update(config.environment) do |state|
|
|
115
|
+
state["resources"]["server"] = state_record(server)
|
|
116
|
+
state["operations"] << { "resource" => resource, "action" => "create", "status" => "started" }
|
|
117
|
+
state
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def record_ready(server)
|
|
122
|
+
@state_store.update(config.environment) do |state|
|
|
123
|
+
state["resources"]["server"] = state_record(server)
|
|
124
|
+
state["operations"] << { "resource" => resource, "action" => "create", "status" => "applied" }
|
|
125
|
+
state
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def state_record(server)
|
|
130
|
+
server.to_h.transform_keys(&:to_s).merge("provider" => config.provider.name)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../plan"
|
|
6
|
+
require_relative "../service_compose"
|
|
7
|
+
require_relative "service_files"
|
|
8
|
+
require_relative "service_firewall"
|
|
9
|
+
require_relative "service_backup"
|
|
10
|
+
require_relative "service_state"
|
|
11
|
+
|
|
12
|
+
module Kitsune
|
|
13
|
+
module Kit
|
|
14
|
+
module Operations
|
|
15
|
+
class EnsureService
|
|
16
|
+
TYPES = %w[postgres redis].freeze
|
|
17
|
+
MARKER_DIRECTORY = "/var/lib/kitsune/state"
|
|
18
|
+
|
|
19
|
+
attr_reader :resource
|
|
20
|
+
|
|
21
|
+
def initialize(config:, type:, transport_factory:, state_store:, secret_store:, clock: -> { Time.now.utc })
|
|
22
|
+
@config = config
|
|
23
|
+
@type = type.to_s
|
|
24
|
+
raise ArgumentError, "unknown service type: #{@type}" unless TYPES.include?(@type)
|
|
25
|
+
|
|
26
|
+
@service = config.services.public_send(@type)
|
|
27
|
+
@transport_factory = transport_factory
|
|
28
|
+
@state_store = state_store
|
|
29
|
+
@secret_store = secret_store
|
|
30
|
+
@resource = "service.#{@type}"
|
|
31
|
+
@managed_state = ServiceState.new(state_store: state_store, environment: config.environment,
|
|
32
|
+
resource: resource)
|
|
33
|
+
@compose = ServiceCompose.new(config: config, type: @type, service: @service)
|
|
34
|
+
@clock = clock
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def plan
|
|
38
|
+
return pending_change unless transport_factory.server&.public_ip
|
|
39
|
+
|
|
40
|
+
actual = remote_fingerprint
|
|
41
|
+
files.ensure_managed_or_absent!(actual)
|
|
42
|
+
return unchanged_change if actual == fingerprint
|
|
43
|
+
|
|
44
|
+
Change.new(
|
|
45
|
+
resource: resource,
|
|
46
|
+
action: actual.empty? ? "create" : "update",
|
|
47
|
+
summary: "#{actual.empty? ? 'Install' : 'Update'} #{@type} service",
|
|
48
|
+
details: safe_details(actual)
|
|
49
|
+
)
|
|
50
|
+
rescue Errors::ConnectionError
|
|
51
|
+
pending_change
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def apply(change)
|
|
55
|
+
return true unless change.changed?
|
|
56
|
+
|
|
57
|
+
@firewall_changed = false
|
|
58
|
+
previous_state = managed_state
|
|
59
|
+
password = validated_secret
|
|
60
|
+
files.ensure_managed_or_absent!(remote_fingerprint)
|
|
61
|
+
prepare_backup
|
|
62
|
+
transport.execute("mkdir", arguments: ["-p", service_directory], timeout: 10).then do |result|
|
|
63
|
+
raise_remote!(result, "create service directory")
|
|
64
|
+
end
|
|
65
|
+
transport.upload(content: compose_content, remote_path: compose_path, mode: "0600")
|
|
66
|
+
transport.upload(content: env_content(password), remote_path: env_path, mode: "0600")
|
|
67
|
+
validate_compose
|
|
68
|
+
start_service
|
|
69
|
+
verify_service
|
|
70
|
+
firewall.reconcile(managed_state)
|
|
71
|
+
@firewall_changed = true
|
|
72
|
+
write_marker
|
|
73
|
+
record_state(change)
|
|
74
|
+
true
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
recover_failed_apply(previous_state, e)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def remove
|
|
80
|
+
return false unless managed?
|
|
81
|
+
|
|
82
|
+
compose("down", "--remove-orphans")
|
|
83
|
+
firewall.remove
|
|
84
|
+
remove_marker
|
|
85
|
+
@managed_state.record_remove
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def backup
|
|
90
|
+
backup_operation.call(managed_state)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rollback
|
|
94
|
+
previous = managed_state
|
|
95
|
+
return false unless previous
|
|
96
|
+
|
|
97
|
+
remove
|
|
98
|
+
if files.restore_previous(previous, marker_path: marker_path)
|
|
99
|
+
firewall.restore(previous["previous_state"] || {})
|
|
100
|
+
start_service if previous.dig("previous_state", "installed") != false
|
|
101
|
+
end
|
|
102
|
+
@managed_state.record_rollback(previous)
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def destroy_data
|
|
107
|
+
previous = managed_state
|
|
108
|
+
return false unless previous
|
|
109
|
+
|
|
110
|
+
compose("down", "--volumes", "--remove-orphans")
|
|
111
|
+
files.destroy(previous, marker_path: marker_path)
|
|
112
|
+
@managed_state.delete("destroy_data")
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
attr_reader :config, :service, :transport_factory, :state_store, :secret_store
|
|
119
|
+
|
|
120
|
+
def transport = @transport ||= transport_factory.deploy
|
|
121
|
+
def project_name = "kitsune-#{config.environment}-#{@type}"
|
|
122
|
+
def service_directory = files.directory
|
|
123
|
+
def compose_path = files.compose_path
|
|
124
|
+
def env_path = files.env_path
|
|
125
|
+
def marker_path = "#{MARKER_DIRECTORY}/service-#{@type}.sha256"
|
|
126
|
+
|
|
127
|
+
def fingerprint
|
|
128
|
+
@fingerprint ||= Digest::SHA256.hexdigest(
|
|
129
|
+
[compose_content, service.password_env, secret_digest, service.publish, service.bind,
|
|
130
|
+
*service.allowed_cidrs].join("\0")
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def secret_digest = Digest::SHA256.hexdigest(validated_secret)
|
|
135
|
+
|
|
136
|
+
def remote_fingerprint
|
|
137
|
+
result = transport.execute("sudo", arguments: ["cat", marker_path], timeout: 10)
|
|
138
|
+
result.success? ? result.stdout.strip : ""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validated_secret
|
|
142
|
+
value = secret_store.fetch(service.password_env)
|
|
143
|
+
if value.match?(/[\n\r\0]/)
|
|
144
|
+
raise Errors::ConfigurationError, "#{service.password_env} contains unsupported control characters"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def compose_content = @compose.content
|
|
151
|
+
def env_content(password) = @compose.env_content(password)
|
|
152
|
+
|
|
153
|
+
def validate_compose = compose("config", "--quiet")
|
|
154
|
+
def start_service = compose("up", "--detach", "--wait", "--wait-timeout", "120")
|
|
155
|
+
|
|
156
|
+
def verify_service
|
|
157
|
+
result = compose("ps", "--status", "running", "--quiet")
|
|
158
|
+
raise Errors::VerificationError, "#{@type} did not reach running state" if result.stdout.strip.empty?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def compose(*arguments)
|
|
162
|
+
result = transport.execute(
|
|
163
|
+
"docker",
|
|
164
|
+
arguments: ["compose", "--project-directory", service_directory, "--file", compose_path, *arguments],
|
|
165
|
+
timeout: 180
|
|
166
|
+
)
|
|
167
|
+
raise_remote!(result, "docker compose #{arguments.first}")
|
|
168
|
+
result
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def prepare_backup = @backed_up_files = managed? ? files.prepare_backup : []
|
|
172
|
+
|
|
173
|
+
def restore_after_failed_apply(previous_state)
|
|
174
|
+
return unless defined?(@backed_up_files)
|
|
175
|
+
|
|
176
|
+
firewall.restore_after_failure(previous_state) if @firewall_changed
|
|
177
|
+
safe_compose_down
|
|
178
|
+
files.restore_after_failure(@backed_up_files)
|
|
179
|
+
start_service unless @backed_up_files.empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def recover_failed_apply(previous_state, original_error)
|
|
183
|
+
restore_after_failed_apply(previous_state)
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
raise Errors::VerificationError.new(
|
|
186
|
+
"#{@type} apply failed and automatic recovery also failed",
|
|
187
|
+
hint: "Stop changes and inspect the service files, container, volume and firewall before retrying.",
|
|
188
|
+
context: { original_error: error_name(original_error), recovery_error: error_name(e) }
|
|
189
|
+
)
|
|
190
|
+
else
|
|
191
|
+
raise original_error
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def safe_compose_down
|
|
195
|
+
transport.execute(
|
|
196
|
+
"docker",
|
|
197
|
+
arguments: ["compose", "--project-directory", service_directory, "--file", compose_path,
|
|
198
|
+
"down", "--remove-orphans"],
|
|
199
|
+
timeout: 180
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def write_marker
|
|
204
|
+
transport.execute("sudo", arguments: ["install", "-d", "-m", "0755", MARKER_DIRECTORY], timeout: 10)
|
|
205
|
+
result = transport.execute("sudo", arguments: ["tee", marker_path], stdin: "#{fingerprint}\n", timeout: 10)
|
|
206
|
+
raise_remote!(result, "write service marker")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def remove_marker
|
|
210
|
+
result = transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
|
|
211
|
+
raise_remote!(result, "remove service marker")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def record_state(change)
|
|
215
|
+
@managed_state.record_apply(change.action, state_attributes(change))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def state_attributes(change)
|
|
219
|
+
{
|
|
220
|
+
"managed" => true, "installed" => true, "fingerprint" => fingerprint, "project" => project_name,
|
|
221
|
+
"directory" => service_directory, "published" => service.publish, "port" => service.port,
|
|
222
|
+
"firewall_rules_added" => firewall.owned_rules, "firewall_drop_added" => firewall.drop_owned,
|
|
223
|
+
"volume" => "#{project_name}_data",
|
|
224
|
+
"previous_fingerprint" => change.details[:actual_fingerprint] || change.details["actual_fingerprint"],
|
|
225
|
+
"backup_directory" => @backed_up_files ? files.backup_directory : nil,
|
|
226
|
+
"backed_up_files" => @backed_up_files || []
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def managed? = @managed_state.managed?
|
|
231
|
+
def managed_state = @managed_state.current
|
|
232
|
+
|
|
233
|
+
def files
|
|
234
|
+
@files ||= ServiceFiles.new(
|
|
235
|
+
config: config, type: @type, transport: transport, state_store: state_store,
|
|
236
|
+
resource: resource, fingerprint: -> { fingerprint }
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def firewall
|
|
241
|
+
@firewall ||= ServiceFirewall.new(
|
|
242
|
+
config: config, type: @type, service: @service, transport: transport,
|
|
243
|
+
state_store: state_store, resource: resource
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def backup_operation
|
|
248
|
+
@backup_operation ||= ServiceBackup.new(
|
|
249
|
+
config: config, type: @type, transport: transport, compose: method(:compose), clock: @clock
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def raise_remote!(result, action)
|
|
254
|
+
return if result.success?
|
|
255
|
+
|
|
256
|
+
raise Errors::RemoteCommandError.new(
|
|
257
|
+
"unable to #{action} for #{@type}",
|
|
258
|
+
context: { stderr: result.stderr, exit_status: result.exit_status }
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def pending_change
|
|
263
|
+
Change.new(resource: resource, action: "create", summary: "Install #{@type} after Docker is ready",
|
|
264
|
+
details: safe_details("").merge(depends_on: "docker"))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def unchanged_change
|
|
268
|
+
Change.new(resource: resource, action: "no_change", summary: "#{@type} is configured",
|
|
269
|
+
details: safe_details(fingerprint))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def safe_details(actual)
|
|
273
|
+
{
|
|
274
|
+
image: service.image, published: service.publish,
|
|
275
|
+
bind: service.publish ? service.bind : nil, port: service.publish ? service.port : nil,
|
|
276
|
+
fingerprint: fingerprint,
|
|
277
|
+
actual_fingerprint: actual.empty? ? nil : actual
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def error_name(error) = error.respond_to?(:code) ? error.code : error.class.name
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|