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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +108 -136
  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/provider-adapters.md +49 -0
  14. data/docs/architecture.md +89 -0
  15. data/docs/commands.md +210 -0
  16. data/docs/configuration.md +210 -0
  17. data/docs/getting-started.md +133 -0
  18. data/docs/providers/digitalocean.md +52 -0
  19. data/docs/releasing.md +43 -0
  20. data/docs/roadmap.md +46 -0
  21. data/docs/security-audit.md +130 -0
  22. data/docs/security.md +116 -0
  23. data/docs/services/postgres.md +78 -0
  24. data/docs/services/redis.md +53 -0
  25. data/docs/testing.md +200 -0
  26. data/docs/troubleshooting.md +123 -0
  27. data/docs/tui.md +57 -0
  28. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  29. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  30. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  31. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  32. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  34. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  35. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  36. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  37. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  38. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  39. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  40. data/lib/kitsune/kit/application.rb +170 -0
  41. data/lib/kitsune/kit/cancellation.rb +21 -0
  42. data/lib/kitsune/kit/cli.rb +802 -64
  43. data/lib/kitsune/kit/clock.rb +11 -0
  44. data/lib/kitsune/kit/configuration.rb +493 -0
  45. data/lib/kitsune/kit/errors.rb +98 -0
  46. data/lib/kitsune/kit/events.rb +55 -0
  47. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  48. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  49. data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
  50. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  51. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  52. data/lib/kitsune/kit/operations/service_files.rb +127 -0
  53. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  54. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  55. data/lib/kitsune/kit/plan.rb +72 -0
  56. data/lib/kitsune/kit/reporters/human.rb +89 -0
  57. data/lib/kitsune/kit/reporters/json.rb +65 -0
  58. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  59. data/lib/kitsune/kit/result.rb +28 -0
  60. data/lib/kitsune/kit/run_journal.rb +102 -0
  61. data/lib/kitsune/kit/run_logger.rb +37 -0
  62. data/lib/kitsune/kit/scripts/docker.sh +137 -0
  63. data/lib/kitsune/kit/scripts/firewall.sh +205 -0
  64. data/lib/kitsune/kit/scripts/metrics.sh +54 -0
  65. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  66. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  67. data/lib/kitsune/kit/scripts/unattended.sh +81 -0
  68. data/lib/kitsune/kit/scripts/user.sh +114 -0
  69. data/lib/kitsune/kit/secret_filter.rb +54 -0
  70. data/lib/kitsune/kit/secret_store.rb +32 -0
  71. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  72. data/lib/kitsune/kit/service_compose.rb +72 -0
  73. data/lib/kitsune/kit/state_store.rb +158 -0
  74. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  75. data/lib/kitsune/kit/tui/actions.rb +72 -0
  76. data/lib/kitsune/kit/tui/application.rb +35 -0
  77. data/lib/kitsune/kit/tui/controller.rb +162 -0
  78. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  79. data/lib/kitsune/kit/tui/state.rb +18 -0
  80. data/lib/kitsune/kit/tui/store.rb +88 -0
  81. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  82. data/lib/kitsune/kit/version.rb +1 -1
  83. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  84. data/lib/kitsune/kit/workflows/base.rb +31 -0
  85. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  86. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  87. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  88. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  89. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  90. data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
  91. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  92. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  93. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  94. data/lib/kitsune/kit.rb +41 -2
  95. metadata +122 -79
  96. data/.rspec +0 -3
  97. data/Rakefile +0 -8
  98. data/kitsune-kit-logo.jpg +0 -0
  99. data/lib/kitsune/blueprints/.env.template +0 -31
  100. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  101. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  102. data/lib/kitsune/blueprints/kit.env.template +0 -1
  103. data/lib/kitsune/kit/ansi_color.rb +0 -78
  104. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  105. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  106. data/lib/kitsune/kit/commands/dns.rb +0 -112
  107. data/lib/kitsune/kit/commands/init.rb +0 -148
  108. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  109. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  110. data/lib/kitsune/kit/commands/provision.rb +0 -43
  111. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  112. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  113. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  114. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  115. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  116. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  117. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  118. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  119. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  120. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  121. data/lib/kitsune/kit/defaults.rb +0 -91
  122. data/lib/kitsune/kit/env_loader.rb +0 -41
  123. data/lib/kitsune/kit/options_builder.rb +0 -26
  124. data/lib/kitsune/kit/provisioner.rb +0 -107
  125. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "provider"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Adapters
9
+ class FakeProvider < Provider
10
+ attr_reader :calls, :servers, :dns_records
11
+
12
+ def initialize(servers: [], dns_records: [], failures: {})
13
+ super()
14
+ @servers = servers.dup
15
+ @dns_records = dns_records.dup
16
+ @failures = failures
17
+ @calls = []
18
+ end
19
+
20
+ def validate_credentials!
21
+ record(:validate_credentials)
22
+ fail_if_requested!(:validate_credentials)
23
+ true
24
+ end
25
+
26
+ def validate_server_spec!(spec:)
27
+ record(:validate_server_spec, spec: spec)
28
+ fail_if_requested!(:validate_server_spec)
29
+ true
30
+ end
31
+
32
+ def find_server(name:, tags: [])
33
+ record(:find_server, name: name, tags: tags)
34
+ fail_if_requested!(:find_server)
35
+ @servers.find { |server| server.name == name && (tags.empty? || (tags - server.tags).empty?) }
36
+ end
37
+
38
+ def find_server_by_id(id:)
39
+ record(:find_server_by_id, id: id)
40
+ fail_if_requested!(:find_server_by_id)
41
+ @servers.find { |server| server.id.to_s == id.to_s }
42
+ end
43
+
44
+ def create_server(spec:)
45
+ record(:create_server, spec: spec)
46
+ fail_if_requested!(:create_server)
47
+ server = ServerRecord.new(
48
+ id: SecureRandom.uuid,
49
+ name: spec.fetch(:name),
50
+ status: "new",
51
+ public_ip: nil,
52
+ region: spec.fetch(:region),
53
+ size: spec.fetch(:size),
54
+ image: spec.fetch(:image),
55
+ tags: spec.fetch(:tags, [])
56
+ )
57
+ @servers << server
58
+ server
59
+ end
60
+
61
+ def wait_until_ready(id:, timeout:)
62
+ record(:wait_until_ready, id: id, timeout: timeout)
63
+ fail_if_requested!(:wait_until_ready)
64
+ server = @servers.find { |candidate| candidate.id == id }
65
+ raise KeyError, "server not found: #{id}" unless server
66
+
67
+ ready = server.with(status: "active", public_ip: server.public_ip || "203.0.113.10")
68
+ @servers[@servers.index(server)] = ready
69
+ ready
70
+ end
71
+
72
+ def delete_server(id:)
73
+ record(:delete_server, id: id)
74
+ fail_if_requested!(:delete_server)
75
+ !!@servers.reject! { |server| server.id == id }
76
+ end
77
+
78
+ def find_dns_record(zone:, name:, type:)
79
+ record(:find_dns_record, zone: zone, name: name, type: type)
80
+ @dns_records.find { |record| record.zone == zone && record.name == name && record.type == type }
81
+ end
82
+
83
+ def upsert_dns_record(record:)
84
+ record(:upsert_dns_record, record: record)
85
+ existing = find_dns_record(zone: record.zone, name: record.name, type: record.type)
86
+ saved = record.with(id: existing&.id || SecureRandom.uuid)
87
+ existing ? @dns_records[@dns_records.index(existing)] = saved : @dns_records << saved
88
+ saved
89
+ end
90
+
91
+ def delete_dns_record(id:, zone:)
92
+ record(:delete_dns_record, id: id, zone: zone)
93
+ !!@dns_records.reject! { |record| record.id == id && record.zone == zone }
94
+ end
95
+
96
+ private
97
+
98
+ def record(name, **arguments) = @calls << [name, arguments]
99
+
100
+ def fail_if_requested!(name)
101
+ failure = @failures[name]
102
+ raise failure if failure
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reporters/reporter"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Adapters
8
+ # Deterministic event sink for exercising complete workflows without a terminal.
9
+ class FakeReporter < Reporters::Reporter
10
+ def initialize
11
+ super
12
+ @events = []
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def handle(event)
17
+ @mutex.synchronize { @events << event }
18
+ event
19
+ end
20
+
21
+ def events = @mutex.synchronize { @events.dup }
22
+
23
+ def events_of(type)
24
+ @mutex.synchronize { @events.select { |event| event.type == type.to_s } }
25
+ end
26
+
27
+ def clear
28
+ @mutex.synchronize { @events.clear }
29
+ self
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
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 Adapters
9
+ class FakeSecretStore < SecretStores::Store
10
+ attr_reader :fetches
11
+
12
+ def initialize(values = {})
13
+ super()
14
+ @values = values.transform_keys(&:to_s)
15
+ @fetches = []
16
+ end
17
+
18
+ def fetch(name, required: true)
19
+ key = name.to_s
20
+ @fetches << key
21
+ value = @values.fetch(key, "")
22
+ if required && value.empty?
23
+ raise Errors::ConfigurationError.new(
24
+ "required secret #{key} is not set", hint: "Provide #{key} through the configured secret store."
25
+ )
26
+ end
27
+ value
28
+ end
29
+
30
+ def configured?(name) = !@values.fetch(name.to_s, "").empty?
31
+ def set(name, value) = @values[name.to_s] = value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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