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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../plan"
|
|
4
|
+
require_relative "../result"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
class BuildPlan < Base
|
|
11
|
+
def initialize(config:, operations:, event_bus: Events::NullBus.new, clock: Clock.new)
|
|
12
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
13
|
+
@operations = operations
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
started_at = monotonic_time
|
|
18
|
+
emit("run_started", command: "plan", environment: config.environment)
|
|
19
|
+
changes = @operations.map(&:plan)
|
|
20
|
+
plan = Plan.new(environment: config.environment, changes: changes)
|
|
21
|
+
emit(
|
|
22
|
+
"plan_built",
|
|
23
|
+
changed_count: plan.changed_count,
|
|
24
|
+
unchanged_count: plan.changes.length - plan.changed_count,
|
|
25
|
+
destructive: plan.destructive?
|
|
26
|
+
)
|
|
27
|
+
emit("run_finished", status: "success", duration_ms: elapsed_ms(started_at))
|
|
28
|
+
Result.success(plan, metadata: { run_id: run_id })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../result"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
class DestroyServer < Base
|
|
11
|
+
def initialize(config:, provider:, state_store:, dns_operation: nil, event_bus: Events::NullBus.new,
|
|
12
|
+
clock: Clock.new)
|
|
13
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
14
|
+
@provider = provider
|
|
15
|
+
@state_store = state_store
|
|
16
|
+
@dns_operation = dns_operation
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(confirmation:)
|
|
20
|
+
@state_store.with_execution_lock(config.environment) { call_unlocked(confirmation: confirmation) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def call_unlocked(confirmation:)
|
|
26
|
+
state = @state_store.read(config.environment)
|
|
27
|
+
managed = state.dig("resources", "server")
|
|
28
|
+
unless managed&.fetch("id", nil)
|
|
29
|
+
raise Errors::UnsafeOperationError.new(
|
|
30
|
+
"no managed server ID exists in state",
|
|
31
|
+
hint: "Import and verify the server before attempting destruction."
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
validate_confirmation!(confirmation, managed)
|
|
35
|
+
current = verify_exact_server!(managed)
|
|
36
|
+
started_at = monotonic_time
|
|
37
|
+
emit("run_started", command: "server.destroy", environment: config.environment)
|
|
38
|
+
@provider.delete_server(id: managed.fetch("id")) if current
|
|
39
|
+
@dns_operation&.rollback
|
|
40
|
+
record_destruction(managed.fetch("id"))
|
|
41
|
+
emit("run_finished", status: "success", duration_ms: elapsed_ms(started_at))
|
|
42
|
+
Result.success(managed.fetch("id"), metadata: { run_id: run_id })
|
|
43
|
+
rescue StandardError
|
|
44
|
+
emit("run_finished", status: "failure", duration_ms: 0) if defined?(started_at) && started_at
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_confirmation!(confirmation, managed)
|
|
49
|
+
return if confirmation == config.server.name
|
|
50
|
+
|
|
51
|
+
raise Errors::UnsafeOperationError.new(
|
|
52
|
+
"server destruction was not confirmed",
|
|
53
|
+
hint: "Pass --confirm-destroy #{config.server.name} after reviewing the environment.",
|
|
54
|
+
context: {
|
|
55
|
+
provider: config.provider.name, environment: config.environment,
|
|
56
|
+
server: config.server.name, provider_id: managed["id"], recoverable: false
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def verify_exact_server!(managed)
|
|
62
|
+
current = @provider.find_server_by_id(id: managed.fetch("id"))
|
|
63
|
+
return unless current
|
|
64
|
+
|
|
65
|
+
expected_tags = config.server.tags
|
|
66
|
+
return current if current.name == config.server.name && (expected_tags - current.tags).empty?
|
|
67
|
+
|
|
68
|
+
raise Errors::UnsafeOperationError.new(
|
|
69
|
+
"managed provider ID points to an unexpected server",
|
|
70
|
+
hint: "Do not delete by name. Reconcile or import state first."
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_destruction(id)
|
|
75
|
+
@state_store.update(config.environment) do |state|
|
|
76
|
+
state["resources"].clear
|
|
77
|
+
state["operations"] << { "resource" => "server", "action" => "destroy", "status" => "applied", "id" => id }
|
|
78
|
+
state
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "../result"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
Check = Data.define(:name, :status, :message, :hint) do
|
|
11
|
+
def to_h = { name: name, status: status, message: message, hint: hint }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Doctor < Base
|
|
15
|
+
SUPPORTED_UBUNTU = %w[22.04 24.04].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(config:, provider:, state_store:, transport_factory: nil, operations: [],
|
|
18
|
+
event_bus: Events::NullBus.new, clock: Clock.new)
|
|
19
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
20
|
+
@provider = provider
|
|
21
|
+
@state_store = state_store
|
|
22
|
+
@transport_factory = transport_factory
|
|
23
|
+
@operations = operations
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
started_at = monotonic_time
|
|
28
|
+
emit("run_started", command: "doctor", environment: config.environment)
|
|
29
|
+
checks = local_checks + infrastructure_checks
|
|
30
|
+
emit_findings(checks)
|
|
31
|
+
status = checks.any? { |check| check.status == "fail" } ? "failure" : "success"
|
|
32
|
+
emit("run_finished", status: status, duration_ms: elapsed_ms(started_at))
|
|
33
|
+
Result.new(status: status.to_sym, value: checks, metadata: { run_id: run_id })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def local_checks
|
|
39
|
+
[runtime_check, configuration_check, ssh_key_check, security_check, provider_check, state_check]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def infrastructure_checks
|
|
43
|
+
@server = find_server
|
|
44
|
+
values = [server_check]
|
|
45
|
+
return values unless @server
|
|
46
|
+
|
|
47
|
+
values << ssh_connection_check
|
|
48
|
+
return values unless @transport
|
|
49
|
+
|
|
50
|
+
values + [ubuntu_check, sudo_check, docker_check, compose_check, ports_check, drift_check]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emit_findings(checks)
|
|
54
|
+
checks.reject { |check| check.status == "pass" }.each do |check|
|
|
55
|
+
emit("warning_emitted", message: "#{check.name}: #{check.message}", hint: check.hint,
|
|
56
|
+
status: check.status)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def runtime_check
|
|
61
|
+
supported = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2.0")
|
|
62
|
+
check("Runtime", supported ? "pass" : "fail", "Ruby #{RUBY_VERSION} on #{RbConfig::CONFIG['host_os']}",
|
|
63
|
+
supported ? nil : "Install Ruby 3.2 or newer.")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def configuration_check = check("Configuration", "pass", "Configuration is valid")
|
|
67
|
+
|
|
68
|
+
def ssh_key_check
|
|
69
|
+
path = config.ssh.key_path
|
|
70
|
+
unless File.file?(path)
|
|
71
|
+
return check("SSH key", "fail", "Private key does not exist: #{path}",
|
|
72
|
+
"Set ssh.key_path to an existing private key.")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
mode = File.stat(path).mode & 0o777
|
|
76
|
+
if mode.anybits?(0o077)
|
|
77
|
+
return check("SSH key", "warn", format("Private key mode is %<mode>04o", mode: mode),
|
|
78
|
+
"Run `chmod 600 #{path}`.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
check("SSH key", "pass", "Private key permissions are restricted")
|
|
82
|
+
rescue SystemCallError => e
|
|
83
|
+
check("SSH key", "fail", e.message, "Check the configured path and permissions.")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def security_check
|
|
87
|
+
published = %w[postgres redis].select do |type|
|
|
88
|
+
service = config.services.public_send(type)
|
|
89
|
+
service.enabled && service.mode == "managed" && service.publish
|
|
90
|
+
end
|
|
91
|
+
return check("Security defaults", "pass", "Data services are private") if published.empty?
|
|
92
|
+
|
|
93
|
+
check("Security defaults", "warn", "Published services: #{published.join(', ')}",
|
|
94
|
+
"Confirm every allowed CIDR and prefer private Docker networking.")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def provider_check
|
|
98
|
+
@provider.validate_credentials!
|
|
99
|
+
check("Provider API", "pass", "DigitalOcean credentials and API access are valid")
|
|
100
|
+
rescue Errors::Error => e
|
|
101
|
+
check("Provider API", "fail", e.message, e.hint)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def state_check
|
|
105
|
+
@state = @state_store.read(config.environment)
|
|
106
|
+
check("State", "pass", "State is readable and schema-compatible")
|
|
107
|
+
rescue Errors::Error => e
|
|
108
|
+
check("State", "fail", e.message, e.hint)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def find_server
|
|
112
|
+
@provider.find_server(name: config.server.name, tags: config.server.tags)
|
|
113
|
+
rescue Errors::Error => e
|
|
114
|
+
@server_error = e
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def server_check
|
|
119
|
+
return check("Server", "fail", @server_error.message, @server_error.hint) if @server_error
|
|
120
|
+
return check("Server", "warn", "Server does not exist", "Run `kit plan`, then `kit apply`.") unless @server
|
|
121
|
+
|
|
122
|
+
managed_id = @state&.dig("resources", "server", "id")
|
|
123
|
+
unless managed_id
|
|
124
|
+
return check("Server", "warn", "Server exists but is not tracked in local state",
|
|
125
|
+
"Restore the state backup before changing or destroying this server.")
|
|
126
|
+
end
|
|
127
|
+
if managed_id.to_s != @server.id.to_s
|
|
128
|
+
return check("Server", "fail", "Provider server does not match the managed ID",
|
|
129
|
+
"Reconcile state without deleting either server by name.")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
status = @server.status == "active" ? "pass" : "warn"
|
|
133
|
+
check("Server", status, "#{@server.name} is #{@server.status}",
|
|
134
|
+
status == "pass" ? nil : "Wait for the server to become active.")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ssh_connection_check
|
|
138
|
+
unless @transport_factory
|
|
139
|
+
return check("SSH connection", "warn", "SSH adapter is unavailable", "Run doctor through the CLI.")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@transport = @transport_factory.bootstrap
|
|
143
|
+
check("SSH connection", "pass", "Host key and public-key login are verified")
|
|
144
|
+
rescue Errors::Error => e
|
|
145
|
+
check("SSH connection", "fail", e.message, e.hint)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ubuntu_check
|
|
149
|
+
result = @transport.execute("cat", arguments: ["/etc/os-release"], timeout: 15)
|
|
150
|
+
version = result.stdout[/^VERSION_ID="?([^"\n]+)"?$/, 1]
|
|
151
|
+
ubuntu = result.stdout.match?(/^ID=ubuntu$/)
|
|
152
|
+
supported = result.success? && ubuntu && SUPPORTED_UBUNTU.include?(version)
|
|
153
|
+
check("Remote OS", supported ? "pass" : "fail", "Ubuntu #{version || 'unknown'}",
|
|
154
|
+
supported ? nil : "Use Ubuntu #{SUPPORTED_UBUNTU.join(' or ')} LTS.")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def sudo_check = command_check("Sudo", "sudo", ["-n", "true"], "Passwordless sudo is available")
|
|
158
|
+
|
|
159
|
+
def docker_check
|
|
160
|
+
command_check("Docker", "docker", ["version", "--format", "{{.Server.Version}}"],
|
|
161
|
+
"Docker Engine is available", status: "warn", hint: "Run `kit docker install`.")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def compose_check
|
|
165
|
+
command_check("Docker Compose", "docker", ["compose", "version", "--short"],
|
|
166
|
+
"Docker Compose v2 is available", status: "warn", hint: "Run `kit docker install`.")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def ports_check
|
|
170
|
+
result = @transport.execute("sudo", arguments: ["ss", "-lntH"], timeout: 15)
|
|
171
|
+
return check("Ports", "fail", "Unable to inspect listening ports") unless result.success?
|
|
172
|
+
|
|
173
|
+
ssh_listening = result.stdout.match?(/:#{Regexp.escape(config.ssh.port.to_s)}\s/)
|
|
174
|
+
exposed = private_service_ports.select { |port| result.stdout.match?(/(?:0\.0\.0\.0|\[::\]):#{port}\s/) }
|
|
175
|
+
return check("Ports", "fail", "SSH port #{config.ssh.port} is not listening") unless ssh_listening
|
|
176
|
+
if exposed.any?
|
|
177
|
+
return check("Ports", "fail", "Private data ports are publicly listening: #{exposed.join(', ')}",
|
|
178
|
+
"Stop the service and inspect its Compose port bindings.")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
check("Ports", "pass", "SSH is listening and private data ports are not public")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def drift_check
|
|
185
|
+
state = @state || @state_store.read(config.environment)
|
|
186
|
+
planned = @operations.map { |operation| [operation, operation.plan] }
|
|
187
|
+
desired_resources = planned.map { |operation, change| state_key_for(operation, change) }
|
|
188
|
+
drifted = planned.filter_map do |operation, change|
|
|
189
|
+
change.resource if state.dig("resources", state_key_for(operation, change)) && change.changed?
|
|
190
|
+
end
|
|
191
|
+
orphaned = state["resources"].keys - desired_resources
|
|
192
|
+
findings = drifted + orphaned
|
|
193
|
+
return check("Drift", "pass", "Managed resources match the desired configuration") if findings.empty?
|
|
194
|
+
|
|
195
|
+
check("Drift", "warn", "Review managed-resource drift: #{findings.uniq.join(', ')}",
|
|
196
|
+
"Run `kit plan`; restore state before adopting or removing existing resources.")
|
|
197
|
+
rescue Errors::Error => e
|
|
198
|
+
check("Drift", "fail", e.message, e.hint)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def state_key_for(operation, change)
|
|
202
|
+
operation.respond_to?(:state_key) ? operation.state_key : change.resource
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def command_check(name, command, arguments, success_message, status: "fail", hint: nil)
|
|
206
|
+
result = @transport.execute(command, arguments: arguments, timeout: 15)
|
|
207
|
+
return check(name, "pass", success_message) if result.success?
|
|
208
|
+
|
|
209
|
+
check(name, status, "Command failed with status #{result.exit_status}", hint)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def private_service_ports
|
|
213
|
+
%w[postgres redis].filter_map do |type|
|
|
214
|
+
service = config.services.public_send(type)
|
|
215
|
+
service.port if service.enabled && service.mode == "managed" && !service.publish
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def check(name, status, message, hint = nil)
|
|
220
|
+
Check.new(name: name, status: status, message: message, hint: hint)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "../service_compose"
|
|
9
|
+
|
|
10
|
+
module Kitsune
|
|
11
|
+
module Kit
|
|
12
|
+
module Workflows
|
|
13
|
+
class EjectCompose
|
|
14
|
+
TYPES = %w[postgres redis].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(root:, config:, type:, config_path: nil)
|
|
17
|
+
@root = Pathname(root).expand_path
|
|
18
|
+
@config = config
|
|
19
|
+
@type = type.to_s
|
|
20
|
+
@config_path = config_path ? Pathname(config_path).expand_path(@root) : @root.join(".kitsune/config.yml")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(force: false)
|
|
24
|
+
@backup_created = false
|
|
25
|
+
validate_target!(force)
|
|
26
|
+
content = ServiceCompose.new(config: config, type: type, service: service).generated_content
|
|
27
|
+
FileUtils.mkdir_p(output_path.dirname, mode: 0o700)
|
|
28
|
+
FileUtils.cp(config_path, backup_path)
|
|
29
|
+
@backup_created = true
|
|
30
|
+
write_atomic(output_path, content, 0o644)
|
|
31
|
+
update_configuration
|
|
32
|
+
{ compose_file: output_path.to_s, config_file: config_path.to_s, backup_file: backup_path.to_s }
|
|
33
|
+
rescue StandardError
|
|
34
|
+
FileUtils.cp(backup_path, config_path) if @backup_created && backup_path.file?
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :root, :config, :type, :config_path
|
|
41
|
+
|
|
42
|
+
def service = config.services.public_send(type)
|
|
43
|
+
def output_path = root.join(".kitsune/compose/#{type}.yml")
|
|
44
|
+
def backup_path = Pathname("#{config_path}.backup")
|
|
45
|
+
|
|
46
|
+
def validate_target!(force)
|
|
47
|
+
raise Errors::ConfigurationError, "unknown service type: #{type}" unless TYPES.include?(type)
|
|
48
|
+
raise Errors::ConfigurationError, "configuration file does not exist: #{config_path}" unless config_path.file?
|
|
49
|
+
return if force || (!output_path.exist? && !backup_path.exist? && service.compose.mode == "generated")
|
|
50
|
+
|
|
51
|
+
raise Errors::UnsafeOperationError.new(
|
|
52
|
+
"refusing to replace an existing Compose customization or configuration backup",
|
|
53
|
+
hint: "Review #{output_path} and #{backup_path}, then repeat with --force if replacement is intentional."
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update_configuration
|
|
58
|
+
document = YAML.safe_load(config_path.read, permitted_classes: [], permitted_symbols: [], aliases: false)
|
|
59
|
+
service_config = document.fetch("services").fetch(type)
|
|
60
|
+
service_config["compose"] = {
|
|
61
|
+
"mode" => "custom",
|
|
62
|
+
"file" => ".kitsune/compose/#{type}.yml",
|
|
63
|
+
"allow_unsafe" => false
|
|
64
|
+
}
|
|
65
|
+
write_atomic(config_path, YAML.dump(document), 0o600)
|
|
66
|
+
rescue KeyError, Psych::Exception => e
|
|
67
|
+
raise Errors::ConfigurationError, "unable to update configuration for Compose ejection: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_atomic(path, content, mode)
|
|
71
|
+
Tempfile.create([path.basename.to_s, ".tmp"], path.dirname) do |file|
|
|
72
|
+
file.write(content)
|
|
73
|
+
file.flush
|
|
74
|
+
file.fsync
|
|
75
|
+
File.chmod(mode, file.path)
|
|
76
|
+
File.rename(file.path, path)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Kitsune
|
|
9
|
+
module Kit
|
|
10
|
+
module Workflows
|
|
11
|
+
class EnvironmentSelection
|
|
12
|
+
NAME = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
13
|
+
|
|
14
|
+
def initialize(root: Dir.pwd)
|
|
15
|
+
@root = Pathname(root).expand_path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def list
|
|
19
|
+
environments_directory.glob("*.yml").map { |path| path.basename(".yml").to_s }.sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current(env: ENV)
|
|
23
|
+
env["KITSUNE_ENV"] || selected_from_file || "development"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use(name)
|
|
27
|
+
validate_name!(name)
|
|
28
|
+
unless list.include?(name)
|
|
29
|
+
raise Errors::ConfigurationError.new(
|
|
30
|
+
"environment does not exist: #{name}",
|
|
31
|
+
hint: "Create .kitsune/environments/#{name}.yml, then retry."
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
FileUtils.mkdir_p(base_directory, mode: 0o700)
|
|
36
|
+
Tempfile.create(["environment", ".tmp"], base_directory.to_s, mode: 0o600) do |file|
|
|
37
|
+
file.write("#{name}\n")
|
|
38
|
+
file.flush
|
|
39
|
+
file.fsync
|
|
40
|
+
File.rename(file.path, selection_path)
|
|
41
|
+
end
|
|
42
|
+
File.chmod(0o600, selection_path)
|
|
43
|
+
name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :root
|
|
49
|
+
|
|
50
|
+
def base_directory = root.join(".kitsune")
|
|
51
|
+
def environments_directory = base_directory.join("environments")
|
|
52
|
+
def selection_path = base_directory.join("environment")
|
|
53
|
+
|
|
54
|
+
def selected_from_file
|
|
55
|
+
return unless selection_path.file?
|
|
56
|
+
|
|
57
|
+
value = selection_path.read.strip
|
|
58
|
+
validate_name!(value)
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_name!(name)
|
|
63
|
+
return if name.to_s.match?(NAME)
|
|
64
|
+
|
|
65
|
+
raise Errors::ConfigurationError, "environment name has an invalid format"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../result"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
class ImportServer < Base
|
|
11
|
+
def initialize(config:, provider:, state_store:, event_bus: Events::NullBus.new, clock: Clock.new)
|
|
12
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
13
|
+
@provider = provider
|
|
14
|
+
@state_store = state_store
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(provider_id:, confirmation:)
|
|
18
|
+
validate_inputs!(provider_id, confirmation)
|
|
19
|
+
@state_store.with_execution_lock(config.environment) do
|
|
20
|
+
import_locked(provider_id.to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_inputs!(provider_id, confirmation)
|
|
27
|
+
unless provider_id.to_s.match?(/\A[0-9]+\z/)
|
|
28
|
+
raise Errors::ConfigurationError.new(
|
|
29
|
+
"a numeric provider ID is required for server import",
|
|
30
|
+
hint: "Pass --provider-id after copying the exact Droplet ID from the DigitalOcean control panel."
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
return if confirmation == config.server.name
|
|
34
|
+
|
|
35
|
+
raise Errors::UnsafeOperationError.new(
|
|
36
|
+
"server import was not confirmed",
|
|
37
|
+
hint: "Pass --confirm-import #{config.server.name} only after checking the exact provider ID."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def import_locked(provider_id)
|
|
42
|
+
state = @state_store.read(config.environment)
|
|
43
|
+
existing_id = state.dig("resources", "server", "id")
|
|
44
|
+
return Result.success(existing_id, metadata: { unchanged: true }) if existing_id.to_s == provider_id
|
|
45
|
+
if existing_id
|
|
46
|
+
raise Errors::UnsafeOperationError.new(
|
|
47
|
+
"state already tracks a different server",
|
|
48
|
+
hint: "Restore/reconcile the existing state instead of replacing its provider ID."
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
server = @provider.find_server_by_id(id: provider_id)
|
|
53
|
+
unless server
|
|
54
|
+
raise Errors::ProviderError.new(
|
|
55
|
+
"server does not exist for provider ID #{provider_id}",
|
|
56
|
+
hint: "Copy the exact Droplet ID from DigitalOcean and retry."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
verify_identity!(server)
|
|
60
|
+
record(server)
|
|
61
|
+
Result.success(server, metadata: { imported: true })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def verify_identity!(server)
|
|
65
|
+
differences = {
|
|
66
|
+
name: [server.name, config.server.name],
|
|
67
|
+
region: [server.region, config.server.region],
|
|
68
|
+
size: [server.size, config.server.size],
|
|
69
|
+
image: [server.image, config.server.image],
|
|
70
|
+
tags: [server.tags, config.server.tags],
|
|
71
|
+
status: [server.status, "active"]
|
|
72
|
+
}
|
|
73
|
+
differences.delete_if do |field, (actual, desired)|
|
|
74
|
+
field == :tags ? (desired - Array(actual)).empty? : actual == desired
|
|
75
|
+
end
|
|
76
|
+
return if differences.empty? && server.public_ip
|
|
77
|
+
|
|
78
|
+
differences[:public_ip] = [server.public_ip, "required"] unless server.public_ip
|
|
79
|
+
raise Errors::UnsafeOperationError.new(
|
|
80
|
+
"provider server does not match the configured environment",
|
|
81
|
+
hint: "Do not import it. Correct configuration or recover the original state backup.",
|
|
82
|
+
context: { provider_id: server.id, differences: differences }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record(server)
|
|
87
|
+
@state_store.update(config.environment) do |state|
|
|
88
|
+
state["resources"]["server"] = server.to_h.transform_keys(&:to_s).merge(
|
|
89
|
+
"provider" => config.provider.name
|
|
90
|
+
)
|
|
91
|
+
state["operations"] << {
|
|
92
|
+
"resource" => "server.#{config.server.name}", "action" => "import", "status" => "applied"
|
|
93
|
+
}
|
|
94
|
+
state
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|