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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../errors"
5
+ require_relative "../state_stores/store"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ module Adapters
10
+ class FakeStateStore < StateStores::Store
11
+ SCHEMA_VERSION = 1
12
+
13
+ def initialize
14
+ super
15
+ @states = {}
16
+ @mutex = Mutex.new
17
+ @operation_mutexes = Hash.new { |hash, key| hash[key] = Mutex.new }
18
+ end
19
+
20
+ def read(environment)
21
+ name = safe_environment(environment)
22
+ @mutex.synchronize { copy(@states.fetch(name) { empty_state(name) }) }
23
+ end
24
+
25
+ def update(environment)
26
+ name = safe_environment(environment)
27
+ @mutex.synchronize do
28
+ updated = yield(copy(@states.fetch(name) { empty_state(name) }))
29
+ updated["updated_at"] = Time.now.utc.iso8601(6)
30
+ validate!(updated, name)
31
+ @states[name] = copy(updated)
32
+ end
33
+ read(name)
34
+ end
35
+
36
+ def write(environment, state) = update(environment) { state }
37
+
38
+ def delete(environment)
39
+ name = safe_environment(environment)
40
+ @mutex.synchronize { @states.delete(name) ? true : nil }
41
+ end
42
+
43
+ def with_execution_lock(environment)
44
+ name = safe_environment(environment)
45
+ lock = @mutex.synchronize { @operation_mutexes[name] }
46
+ unless lock.try_lock
47
+ raise Errors::UnsafeOperationError.new(
48
+ "another mutating operation is active for #{name}",
49
+ hint: "Wait for it to finish or stop it safely before retrying."
50
+ )
51
+ end
52
+ yield
53
+ ensure
54
+ lock&.unlock if lock&.owned?
55
+ end
56
+
57
+ private
58
+
59
+ def empty_state(environment)
60
+ {
61
+ "version" => SCHEMA_VERSION, "environment" => environment, "updated_at" => nil,
62
+ "resources" => {}, "operations" => [], "runs" => {}
63
+ }
64
+ end
65
+
66
+ def safe_environment(environment)
67
+ value = environment.to_s
68
+ return value if value.match?(/\A[a-zA-Z0-9_-]+\z/)
69
+
70
+ raise Errors::ConfigurationError, "environment name has an invalid format"
71
+ end
72
+
73
+ def validate!(state, environment)
74
+ raise Errors::ConfigurationError, "state must be an object" unless state.is_a?(Hash)
75
+ unless state["version"] == SCHEMA_VERSION
76
+ raise Errors::ConfigurationError,
77
+ "unsupported state schema version #{state['version'].inspect}; expected #{SCHEMA_VERSION}"
78
+ end
79
+ raise Errors::ConfigurationError, "state environment mismatch" unless state["environment"] == environment
80
+ raise Errors::ConfigurationError, "state resources must be an object" unless state["resources"].is_a?(Hash)
81
+ raise Errors::ConfigurationError, "state operations must be an array" unless state["operations"].is_a?(Array)
82
+ raise Errors::ConfigurationError, "state runs must be an object" unless state["runs"].is_a?(Hash)
83
+
84
+ state
85
+ end
86
+
87
+ def copy(value) = Marshal.load(Marshal.dump(value))
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Adapters
8
+ class FakeTransport < Transport
9
+ attr_reader :calls, :uploads
10
+
11
+ def initialize(reachable: true, failures: {})
12
+ super()
13
+ @reachable = reachable
14
+ @failures = failures
15
+ @stubs = {}
16
+ @calls = []
17
+ @uploads = {}
18
+ end
19
+
20
+ def stub(command, arguments: [], stdout: "", stderr: "", exit_status: 0, duration_ms: 1)
21
+ @stubs[[command, arguments]] = CommandResult.new(
22
+ stdout: stdout,
23
+ stderr: stderr,
24
+ exit_status: exit_status,
25
+ duration_ms: duration_ms
26
+ )
27
+ end
28
+
29
+ def reachable?
30
+ @calls << [:reachable, {}]
31
+ raise @failures[:reachable] if @failures[:reachable]
32
+
33
+ @reachable
34
+ end
35
+
36
+ def execute(command, arguments: [], timeout: 30, stdin: nil)
37
+ @calls << [:execute, { command: command, arguments: arguments, timeout: timeout, stdin: stdin }]
38
+ raise @failures[:execute] if @failures[:execute]
39
+
40
+ @stubs.fetch([command, arguments]) do
41
+ status = command == "test" ? 1 : 0
42
+ CommandResult.new(stdout: "", stderr: "", exit_status: status, duration_ms: 1)
43
+ end
44
+ end
45
+
46
+ def upload(content:, remote_path:, mode: "0600")
47
+ @calls << [:upload, { remote_path: remote_path, mode: mode }]
48
+ raise @failures[:upload] if @failures[:upload]
49
+
50
+ @uploads[remote_path] = { content: content, mode: mode }
51
+ true
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "net/ssh"
5
+ require "shellwords"
6
+ require "timeout"
7
+ require_relative "../errors"
8
+ require_relative "transport"
9
+
10
+ module Kitsune
11
+ module Kit
12
+ module Adapters
13
+ class NetSshTransport < Transport
14
+ def initialize(host:, user:, port:, key_path:, known_hosts: nil, verify_host_key: :always,
15
+ maximum_timeout: nil)
16
+ super()
17
+ @host = host
18
+ @user = user
19
+ @port = Integer(port)
20
+ @key_path = key_path
21
+ @known_hosts = known_hosts
22
+ @verify_host_key = verify_host_key
23
+ @maximum_timeout = maximum_timeout
24
+ end
25
+
26
+ def reachable?
27
+ with_connection { true }
28
+ rescue Errors::ConnectionError
29
+ false
30
+ end
31
+
32
+ def with_session
33
+ return yield if @persistent_session
34
+
35
+ open_connection do |ssh|
36
+ @persistent_session = ssh
37
+ yield
38
+ ensure
39
+ @persistent_session = nil
40
+ end
41
+ end
42
+
43
+ def execute(command, arguments: [], timeout: 30, stdin: nil)
44
+ validate_command!(command)
45
+ shell_command = ([command] + arguments.map { |argument| Shellwords.escape(argument.to_s) }).join(" ")
46
+ started_at = monotonic_time
47
+ stdout = +""
48
+ stderr = +""
49
+ exit_status = nil
50
+
51
+ effective_timeout = @maximum_timeout ? [timeout, @maximum_timeout].min : timeout
52
+ Timeout.timeout(effective_timeout) do
53
+ with_connection do |ssh|
54
+ ssh.open_channel do |channel|
55
+ channel.exec(shell_command) do |_exec_channel, success|
56
+ raise Errors::RemoteCommandError, "remote command could not be started" unless success
57
+
58
+ channel.send_data(stdin) if stdin
59
+ channel.eof! if stdin
60
+ channel.on_data { |_data_channel, data| stdout << data }
61
+ channel.on_extended_data { |_data_channel, _type, data| stderr << data }
62
+ channel.on_request("exit-status") { |_status_channel, data| exit_status = data.read_long }
63
+ end
64
+ end
65
+ ssh.loop
66
+ end
67
+ end
68
+
69
+ command_result(stdout, stderr, exit_status, started_at)
70
+ rescue Timeout::Error
71
+ raise Errors::TimeoutError.new("remote command timed out after #{effective_timeout || timeout} seconds",
72
+ context: { command: command })
73
+ end
74
+
75
+ def upload(content:, remote_path:, mode: "0600")
76
+ validate_remote_path!(remote_path)
77
+ validate_mode!(mode)
78
+ encoded = Base64.strict_encode64(content)
79
+ result = execute(
80
+ "sh",
81
+ arguments: ["-c", "base64 --decode > \"$1\" && chmod \"$2\" \"$1\"", "kitsune-upload", remote_path, mode],
82
+ stdin: encoded,
83
+ timeout: 30
84
+ )
85
+ raise_remote_failure!(result, "upload #{remote_path}") unless result.success?
86
+
87
+ true
88
+ end
89
+
90
+ private
91
+
92
+ def command_result(stdout, stderr, exit_status, started_at)
93
+ CommandResult.new(
94
+ stdout: stdout,
95
+ stderr: stderr,
96
+ exit_status: exit_status || 255,
97
+ duration_ms: ((monotonic_time - started_at) * 1000).round
98
+ )
99
+ end
100
+
101
+ def with_connection
102
+ return yield(@persistent_session) if @persistent_session
103
+
104
+ open_connection { |ssh| return yield(ssh) }
105
+ end
106
+
107
+ def open_connection
108
+ options = {
109
+ port: @port,
110
+ keys: [@key_path],
111
+ non_interactive: true,
112
+ timeout: @maximum_timeout ? [10, @maximum_timeout].min : 10,
113
+ verify_host_key: @verify_host_key
114
+ }
115
+ options[:user_known_hosts_file] = [@known_hosts] if @known_hosts
116
+ Net::SSH.start(@host, @user, **options) { |ssh| return yield(ssh) }
117
+ rescue Net::SSH::Exception, SystemCallError, SocketError => e
118
+ raise Errors::ConnectionError.new(
119
+ "SSH connection to #{@user}@#{@host}:#{@port} failed",
120
+ hint: "Verify the address, key, host fingerprint and firewall rules.",
121
+ context: { cause: e.class.name },
122
+ retryable: true
123
+ )
124
+ end
125
+
126
+ def validate_command!(command)
127
+ return if command.to_s.match?(%r{\A[\w./-]+\z})
128
+
129
+ raise Errors::ConfigurationError, "remote command has an invalid format"
130
+ end
131
+
132
+ def validate_remote_path!(path)
133
+ return if path.to_s.match?(%r{\A/[a-zA-Z0-9_./-]+\z}) && !path.to_s.include?("..")
134
+
135
+ raise Errors::ConfigurationError, "remote path has an invalid format"
136
+ end
137
+
138
+ def validate_mode!(mode)
139
+ return if mode.to_s.match?(/\A0[0-7]{3}\z/)
140
+
141
+ raise Errors::ConfigurationError, "file mode has an invalid format"
142
+ end
143
+
144
+ def raise_remote_failure!(result, operation)
145
+ raise Errors::RemoteCommandError.new(
146
+ "remote #{operation} failed with status #{result.exit_status}",
147
+ context: { stderr: result.stderr }
148
+ )
149
+ end
150
+
151
+ def monotonic_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Adapters
6
+ API_VERSION = 1
7
+
8
+ ServerRecord = Data.define(:id, :name, :status, :public_ip, :region, :size, :image, :tags) do
9
+ def to_h
10
+ members.to_h { |member| [member, public_send(member)] }
11
+ end
12
+ end
13
+
14
+ DnsRecord = Data.define(:id, :zone, :name, :type, :data, :ttl) do
15
+ def to_h
16
+ members.to_h { |member| [member, public_send(member)] }
17
+ end
18
+ end
19
+
20
+ class Provider
21
+ def validate_credentials! = raise NotImplementedError
22
+ def validate_server_spec!(spec:) = raise NotImplementedError
23
+ def find_server(name:, tags: []) = raise NotImplementedError
24
+ def find_server_by_id(id:) = raise NotImplementedError
25
+ def create_server(spec:) = raise NotImplementedError
26
+ def wait_until_ready(id:, timeout:) = raise NotImplementedError
27
+ def delete_server(id:) = raise NotImplementedError
28
+ def find_dns_record(zone:, name:, type:) = raise NotImplementedError
29
+ def upsert_dns_record(record:) = raise NotImplementedError
30
+ def delete_dns_record(id:, zone:) = raise NotImplementedError
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Adapters
6
+ CommandResult = Data.define(:stdout, :stderr, :exit_status, :duration_ms) do
7
+ def success? = exit_status.zero?
8
+
9
+ def to_h
10
+ { stdout: stdout, stderr: stderr, exit_status: exit_status, duration_ms: duration_ms }
11
+ end
12
+ end
13
+
14
+ class Transport
15
+ def with_session = yield
16
+ def reachable? = raise NotImplementedError
17
+ def execute(command, arguments: [], timeout: 30, stdin: nil) = raise NotImplementedError
18
+ def upload(content:, remote_path:, mode: "0600") = raise NotImplementedError
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../errors"
5
+ require_relative "net_ssh_transport"
6
+ require_relative "confirming_host_key_verifier"
7
+
8
+ module Kitsune
9
+ module Kit
10
+ module Adapters
11
+ class TransportFactory
12
+ BOOTSTRAP_TIMEOUT = 120
13
+ BOOTSTRAP_RETRY_INTERVAL = 5
14
+
15
+ def initialize(config:, provider:, root: Dir.pwd, host_key_confirmation: nil, maximum_timeout: nil,
16
+ sleeper: Kernel, monotonic_clock: nil, bootstrap_timeout: BOOTSTRAP_TIMEOUT)
17
+ if maximum_timeout && maximum_timeout <= 0
18
+ raise Errors::ConfigurationError, "timeout must be greater than zero"
19
+ end
20
+ raise Errors::ConfigurationError, "SSH bootstrap timeout must be greater than zero" if bootstrap_timeout <= 0
21
+
22
+ @config = config
23
+ @provider = provider
24
+ @known_hosts = File.expand_path(".kitsune/known_hosts", root)
25
+ @host_key_verifier = ConfirmingHostKeyVerifier.new(&host_key_confirmation)
26
+ @maximum_timeout = maximum_timeout
27
+ @bootstrap_timeout = [bootstrap_timeout, maximum_timeout].compact.min
28
+ @sleeper = sleeper
29
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
30
+ end
31
+
32
+ def server
33
+ @provider.find_server(name: @config.server.name, tags: @config.server.tags)
34
+ end
35
+
36
+ def for(user:)
37
+ current = server
38
+ unless current&.public_ip
39
+ raise Errors::ConnectionError.new(
40
+ "server is not ready for SSH",
41
+ hint: "Apply the server operation first, then resume."
42
+ )
43
+ end
44
+ FileUtils.mkdir_p(File.dirname(@known_hosts), mode: 0o700)
45
+ FileUtils.touch(@known_hosts)
46
+ File.chmod(0o600, @known_hosts)
47
+ NetSshTransport.new(
48
+ host: current.public_ip,
49
+ user: user,
50
+ port: @config.ssh.port,
51
+ key_path: @config.ssh.key_path,
52
+ known_hosts: @known_hosts,
53
+ verify_host_key: @host_key_verifier,
54
+ maximum_timeout: @maximum_timeout
55
+ )
56
+ end
57
+
58
+ def deploy = self.for(user: @config.ssh.user)
59
+ def root = self.for(user: "root")
60
+
61
+ def bootstrap
62
+ candidate = deploy
63
+ return candidate if candidate.reachable?
64
+
65
+ candidate = root
66
+ return candidate if candidate.reachable?
67
+
68
+ raise Errors::ConnectionError.new(
69
+ "neither #{@config.ssh.user} nor root can access the server",
70
+ hint: "Verify the SSH key and provider console before changing SSH policy."
71
+ )
72
+ end
73
+
74
+ def wait_for_bootstrap
75
+ deadline = monotonic_time + @bootstrap_timeout
76
+ loop do
77
+ return bootstrap
78
+ rescue Errors::ConnectionError
79
+ remaining = deadline - monotonic_time
80
+ break if remaining <= 0
81
+
82
+ @sleeper.sleep([BOOTSTRAP_RETRY_INTERVAL, remaining].min)
83
+ end
84
+
85
+ raise Errors::ConnectionError.new(
86
+ "SSH did not become reachable within #{@bootstrap_timeout} seconds",
87
+ hint: "Wait for Ubuntu to finish booting and verify that the uploaded public key matches ssh.key_path.",
88
+ context: { timeout: @bootstrap_timeout },
89
+ retryable: true
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ def monotonic_time = @monotonic_clock.call
96
+ end
97
+
98
+ class FakeTransportFactory
99
+ attr_reader :server_record
100
+
101
+ def initialize(transport:, server: nil, root_transport: nil, deploy_transport: nil)
102
+ @transport = transport
103
+ @root_transport = root_transport || transport
104
+ @deploy_transport = deploy_transport || transport
105
+ @server_record = server
106
+ end
107
+
108
+ def server = @server_record
109
+ def deploy = @deploy_transport
110
+ def root = @root_transport
111
+ def bootstrap = @deploy_transport.reachable? ? @deploy_transport : @root_transport
112
+ def wait_for_bootstrap = bootstrap
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/digitalocean_provider"
4
+ require_relative "adapters/transport_factory"
5
+ require_relative "configuration"
6
+ require_relative "events"
7
+ require_relative "operations/ensure_server"
8
+ require_relative "operations/ensure_dns_records"
9
+ require_relative "operations/ensure_service"
10
+ require_relative "state_store"
11
+
12
+ module Kitsune
13
+ module Kit
14
+ class Application
15
+ attr_reader :config, :event_bus, :provider, :state_store, :transport_factory, :secret_store
16
+
17
+ def self.build(root:, environment:, config_path: nil, overrides: {}, env: ENV, event_bus: Events::Bus.new,
18
+ host_key_confirmation: nil, maximum_timeout: nil)
19
+ config = Configuration::Loader.new(root: root, env: env, config_path: config_path)
20
+ .load(environment: environment, overrides: overrides)
21
+ token = env.fetch(config.provider.token_env, "")
22
+ provider = Adapters::DigitalOceanProvider.new(token: token, maximum_timeout: maximum_timeout)
23
+ state_store = StateStore.new(root: root)
24
+ transport_factory = Adapters::TransportFactory.new(
25
+ config: config,
26
+ provider: provider,
27
+ root: root,
28
+ host_key_confirmation: host_key_confirmation,
29
+ maximum_timeout: maximum_timeout
30
+ )
31
+ new(
32
+ config: config,
33
+ provider: provider,
34
+ state_store: state_store,
35
+ transport_factory: transport_factory,
36
+ secret_store: SecretStores::Environment.new(env: env),
37
+ event_bus: event_bus,
38
+ maximum_timeout: maximum_timeout
39
+ )
40
+ end
41
+
42
+ def initialize(config:, provider:, state_store:, transport_factory: nil, event_bus: Events::Bus.new,
43
+ secret_store: SecretStores::Environment.new, maximum_timeout: nil)
44
+ @config = config
45
+ @provider = provider
46
+ @state_store = state_store
47
+ @transport_factory = transport_factory
48
+ @secret_store = secret_store
49
+ @event_bus = event_bus
50
+ @maximum_timeout = maximum_timeout
51
+ end
52
+
53
+ def operations
54
+ values = [Operations::EnsureServer.new(
55
+ config: config, provider: provider, state_store: state_store, maximum_timeout: @maximum_timeout
56
+ )]
57
+ values.concat(remote_operations) if @transport_factory
58
+ values.concat(service_operations) if @transport_factory
59
+ if config.dns.domains.any?
60
+ values << Operations::EnsureDnsRecords.new(config: config, provider: provider, state_store: state_store)
61
+ end
62
+ values
63
+ end
64
+
65
+ def service_operation(type)
66
+ Operations::EnsureService.new(
67
+ config: config,
68
+ type: type,
69
+ transport_factory: @transport_factory,
70
+ state_store: state_store,
71
+ secret_store: secret_store
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def remote_operations
78
+ values = [user_operation, ssh_operation, firewall_operation]
79
+ if config.system.unattended_upgrades
80
+ values << remote(resource: "unattended_upgrades",
81
+ script: script("unattended.sh"))
82
+ end
83
+ values << swap_operation
84
+ values << docker_operation
85
+ if config.system.metrics
86
+ values << remote(resource: "metrics", script: script("metrics.sh"),
87
+ arguments: [config.system.metrics_installer_sha256])
88
+ end
89
+ values
90
+ end
91
+
92
+ def user_operation
93
+ remote(
94
+ resource: "user.#{config.ssh.user}",
95
+ script: script("user.sh"),
96
+ arguments: [config.ssh.user],
97
+ role: :wait_for_bootstrap,
98
+ rollback_role: :root,
99
+ post_verify: method(:verify_deploy_connection)
100
+ )
101
+ end
102
+
103
+ def ssh_operation
104
+ remote(
105
+ resource: "ssh.policy", script: script("ssh.sh"), arguments: [config.ssh.user],
106
+ post_verify: method(:verify_deploy_connection), finalize_action: "finalize", rollback_on_failure: true
107
+ )
108
+ end
109
+
110
+ def firewall_operation
111
+ remote(
112
+ resource: "firewall",
113
+ script: script("firewall.sh"),
114
+ arguments: [config.ssh.port, *config.ssh.allowed_cidrs],
115
+ post_verify: method(:verify_deploy_connection),
116
+ finalize_action: "finalize",
117
+ rollback_on_failure: true
118
+ )
119
+ end
120
+
121
+ def swap_operation
122
+ remote(
123
+ resource: "swap",
124
+ script: script("swap.sh"),
125
+ arguments: [config.system.swap_size_gb, config.system.swap_swappiness]
126
+ )
127
+ end
128
+
129
+ def docker_operation
130
+ remote(resource: "docker", script: script("docker.sh"), arguments: [config.ssh.user])
131
+ end
132
+
133
+ def verify_deploy_connection
134
+ return if @transport_factory.deploy.reachable?
135
+
136
+ raise Errors::VerificationError.new(
137
+ "the deploy user cannot open a second SSH connection",
138
+ hint: "Root access remains enabled; inspect authorized_keys and retry."
139
+ )
140
+ end
141
+
142
+ def script(name) = File.expand_path("scripts/#{name}", __dir__)
143
+
144
+ def remote(resource:, script:, arguments: [], role: :deploy, rollback_role: nil, post_verify: nil,
145
+ finalize_action: nil, rollback_on_failure: false)
146
+ Operations::RemoteScript.new(
147
+ config: config,
148
+ transport_factory: @transport_factory,
149
+ state_store: state_store,
150
+ resource: resource,
151
+ script_path: script,
152
+ arguments: arguments,
153
+ transport_roles: { apply: role, rollback: rollback_role || role },
154
+ verification: {
155
+ callback: post_verify, finalize_action: finalize_action, rollback_on_failure: rollback_on_failure
156
+ }
157
+ )
158
+ end
159
+
160
+ def service_operations
161
+ %w[postgres redis].filter_map do |type|
162
+ service = config.services.public_send(type)
163
+ next unless service.enabled && service.mode == "managed"
164
+
165
+ service_operation(type)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ class Cancellation
6
+ class Cancelled < StandardError; end
7
+
8
+ def initialize
9
+ @cancelled = false
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def cancel! = @mutex.synchronize { @cancelled = true }
14
+ def cancelled? = @mutex.synchronize { @cancelled }
15
+
16
+ def check!
17
+ raise Cancelled, "operation cancelled" if cancelled?
18
+ end
19
+ end
20
+ end
21
+ end