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,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
|