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,123 @@
1
+ # Troubleshooting
2
+
3
+ Start with read-only evidence:
4
+
5
+ ```bash
6
+ kit doctor --debug
7
+ kit status
8
+ kit plan
9
+ ```
10
+
11
+ Do not delete `.kitsune/state/` or provider resources just to retry. The state is what prevents duplicate creation and unsafe deletion.
12
+
13
+ If the primary state file and `.backup` are both unavailable, recover only an independently verified server identity with:
14
+
15
+ ```bash
16
+ kit server import \
17
+ --provider-id EXACT_NUMERIC_DROPLET_ID \
18
+ --confirm-import CONFIGURED_SERVER_NAME
19
+ kit doctor
20
+ ```
21
+
22
+ Import checks the complete configured identity and records no SSH, firewall, Docker, service or DNS ownership. `doctor` and `plan` must be reviewed afterward; do not assume pre-existing remote files belong to Kitsune Kit.
23
+
24
+ ## Configuration file not found
25
+
26
+ Run from the project root or pass `--root PATH`. Initialize once with `kit init`. If using `--config`, it must point to the base YAML file; relative environment overlays still come from the selected project root.
27
+
28
+ ## Invalid configuration
29
+
30
+ Kitsune Kit reports every validation finding together when possible. Common causes:
31
+
32
+ - placeholder `server.ssh_key_id` was not replaced;
33
+ - service enabled without its password environment variable;
34
+ - unsupported Ubuntu image slug;
35
+ - published service has no allowed CIDR;
36
+ - YAML boolean was quoted or malformed;
37
+ - metrics enabled without a verified lowercase SHA256;
38
+ - environment or resource name contains spaces/metacharacters.
39
+
40
+ Correct the source file/environment variable and rerun `doctor` and `plan`.
41
+
42
+ ## Provider authentication or API error
43
+
44
+ Confirm the environment variable named by `provider.token_env` is exported in the same process. Check token expiration/scopes, account/project, quota and region/size availability. Use `--debug` for the safe error class/context; Kitsune Kit intentionally does not print raw provider responses that might include sensitive details.
45
+
46
+ ## Server creation timed out
47
+
48
+ Run `kit status` before doing anything else. If state contains the provider ID, do not create another Droplet manually. Inspect that exact ID in DigitalOcean, resolve provider/network delays, then:
49
+
50
+ ```bash
51
+ kit resume
52
+ ```
53
+
54
+ ## SSH host key is not trusted
55
+
56
+ This is expected on first contact. Verify the displayed SHA256 fingerprint independently. Interactive mode can save it. For automation:
57
+
58
+ ```bash
59
+ kit doctor --no-input --trust-host-key SHA256:verified-value
60
+ ```
61
+
62
+ A changed key can indicate server replacement or interception. Compare provider ID/state and investigate; do not remove known-host state reflexively.
63
+
64
+ ## Neither deploy nor root can connect
65
+
66
+ Use DigitalOcean's console/recovery access and verify:
67
+
68
+ - the configured private key matches the uploaded key ID;
69
+ - deploy user's `authorized_keys` and permissions;
70
+ - configured SSH port and UFW rules;
71
+ - `sshd -t` and service status;
72
+ - your source IP remains in `ssh.allowed_cidrs`.
73
+
74
+ Kitsune Kit validates the deploy connection before disabling bootstrap assumptions, but out-of-band changes can still remove access.
75
+
76
+ ## Remote command or verification failed
77
+
78
+ The error identifies the resource and stable code. Inspect the redacted session log under `.kitsune/logs/`, repair the underlying condition and use `kit resume`. A failed operation is not marked successful; temporary uploads are cleaned best-effort.
79
+
80
+ For Docker failures, inspect disk space, conflicting packages, apt repository reachability, daemon status and `docker compose version`. For services, inspect Compose health/logs and the configured image architecture.
81
+
82
+ Kitsune Kit refuses to remove Ubuntu/community Docker packages (`docker.io`, legacy Compose, `podman-docker`, `containerd` or `runc`) automatically because doing so could disrupt an existing installation. Migrate/remove those packages deliberately, preserve any existing Docker data, then rerun the plan.
83
+
84
+ ## Plan always reports drift
85
+
86
+ `doctor` compares remote fingerprints and local ownership. Common causes are manual edits to managed files, missing markers, environment mismatch or restored server data with stale local state. Preserve `.kitsune/state/ENV.json` and its `.backup`; compare exact provider IDs and fingerprints. Do not adopt/delete by name without a deliberate recovery procedure.
87
+
88
+ ## PostgreSQL or Redis is publicly reachable
89
+
90
+ Treat this as urgent. Stop/remove the service or close the provider/network firewall, then inspect:
91
+
92
+ ```bash
93
+ kit doctor
94
+ kit service TYPE status
95
+ ```
96
+
97
+ Ensure `publish: false`, or narrow `bind`/`allowed_cidrs`. Inspect Docker's `DOCKER-USER` chain as well as UFW because published Docker ports can bypass ordinary UFW forwarding rules.
98
+
99
+ ## Apply/resume asks for confirmation in CI
100
+
101
+ Use both flags after reviewing the plan:
102
+
103
+ ```bash
104
+ kit apply --no-input --yes --format json
105
+ kit resume --no-input --yes --format json
106
+ ```
107
+
108
+ Permanent destruction still needs `--confirm-destroy`; this cannot be bypassed by `--yes`.
109
+
110
+ ## TUI does not open
111
+
112
+ The TUI requires interactive stdin and stdout. Redirected/piped sessions should use conventional commands. Explicit `kit ui` without a TTY returns a configuration error instead of emitting terminal escape sequences. Minimum display size is 70×18.
113
+
114
+ ## Create a support bundle
115
+
116
+ ```bash
117
+ kit support bundle
118
+ ```
119
+
120
+ Kitsune Kit writes the restricted JSON file and prints its redacted contents in human mode. Review that output before
121
+ sharing it; the bundle is never uploaded automatically. With `--format json`, inspect the returned local path.
122
+
123
+ Open the generated JSON and inspect it before sharing. It is permission-restricted and redacted, but Kitsune Kit never uploads it or asserts that arbitrary user content cannot contain sensitive business data.
data/docs/tui.md ADDED
@@ -0,0 +1,57 @@
1
+ # Optional terminal interface
2
+
3
+ The TUI is a full-screen convenience layer. It is not required to install or use Kitsune Kit, and it contains no provider, SSH or infrastructure logic.
4
+
5
+ ```text
6
+ kit Open the TUI only when stdin/stdout are interactive
7
+ kit ui Request the TUI explicitly
8
+ kit plan Conventional CLI, always available
9
+ kit apply --no-input Conventional non-interactive CLI
10
+ ```
11
+
12
+ When no TTY is available, bare `kit` prints help and `kit ui` returns a stable configuration error without writing alternate-screen escape sequences.
13
+
14
+ ## Screens
15
+
16
+ - Dashboard: environment, server/managed resources and recent operations.
17
+ - Plan: exact domain plan used by `kit plan`/`kit apply`.
18
+ - Doctor: checks and actionable hints.
19
+ - Logs: bounded, redacted event feedback.
20
+ - Help and confirmation modals.
21
+
22
+ Minimum usable terminal size is 70 columns by 18 rows. Smaller terminals show a resize message rather than corrupting layout.
23
+
24
+ ## Keys
25
+
26
+ | Key | Action | CLI equivalent |
27
+ | --- | --- | --- |
28
+ | `j`/`k`, arrows | Select resource | presentation only |
29
+ | `Tab` | Cycle screens | presentation only |
30
+ | `PgUp`/`PgDn` | Scroll logs | presentation only |
31
+ | `p` | Build/show plan | `kit plan` |
32
+ | `a` | Confirm and apply visible/fresh plan | `kit apply` |
33
+ | `d` | Run diagnostics | `kit doctor` |
34
+ | `r` | Confirm and resume latest run | `kit resume` |
35
+ | `l` | Show event logs | local log/output |
36
+ | `?` | Toggle help | `kit help` |
37
+ | `q` | Quit (confirmation while busy) | process exit |
38
+ | `Ctrl+C` | Request cooperative cancellation; quit when idle | `SIGINT` |
39
+
40
+ Apply/resume run in a worker thread so redraw/input remain responsive. Cancellation is cooperative between operations; the current remote command is still bounded by its timeout. After cancellation, a new token is used for the next action.
41
+
42
+ ## Functional parity
43
+
44
+ The TUI's action object calls `InspectEnvironment`, `BuildPlan`, `Doctor` and `ApplyPlan` directly—the same workflows as the CLI. It cannot expose an infrastructure capability that lacks a conventional command. Technical/rare commands may remain CLI-only.
45
+
46
+ Automated tests assert that CLI/TUI-facing planning returns the same `Plan#to_h`. Differences are limited to navigation, confirmation and rendering.
47
+
48
+ ## Implementation and testing
49
+
50
+ The initial backend is pure Ruby:
51
+
52
+ - `Tui::Store` converts domain events into immutable view state;
53
+ - `Tui::Renderer` turns state into a deterministic text buffer;
54
+ - `Tui::Controller` maps keys/modals/workers to shared actions;
55
+ - `Tui::Terminal` owns raw mode, alternate screen, resize and restoration.
56
+
57
+ Terminal restoration runs in `ensure`, including exceptions and `Ctrl+C`. Headless tests use fake terminals/events and snapshots; no real interactive terminal is needed. A future RatatuiRuby renderer can replace terminal/rendering components without changing the core or making the TUI mandatory.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/ssh/verifiers/always"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Adapters
8
+ class ConfirmingHostKeyVerifier < Net::SSH::Verifiers::Always
9
+ def initialize(&confirmation)
10
+ super()
11
+ @confirmation = confirmation || ->(**) { false }
12
+ end
13
+
14
+ def verify(arguments)
15
+ super
16
+ rescue Net::SSH::HostKeyUnknown => e
17
+ remember_if_confirmed(e, arguments)
18
+ end
19
+
20
+ def verify_signature(&)
21
+ super
22
+ rescue Net::SSH::HostKeyUnknown => e
23
+ remember_if_confirmed(e, e.data)
24
+ end
25
+
26
+ private
27
+
28
+ def remember_if_confirmed(error, arguments)
29
+ approved = @confirmation.call(
30
+ host: arguments.fetch(:session).host_keys.host,
31
+ fingerprint: arguments.fetch(:fingerprint),
32
+ key_type: arguments.fetch(:key).ssh_type
33
+ )
34
+ raise error unless approved
35
+
36
+ error.remember_host!
37
+ true
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "droplet_kit"
4
+ require_relative "../errors"
5
+ require_relative "provider"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ module Adapters
10
+ class DigitalOceanProvider < Provider
11
+ POLL_INTERVAL = 5
12
+
13
+ def initialize(token:, client: nil, sleeper: Kernel, maximum_timeout: nil)
14
+ super()
15
+ raise Errors::AuthenticationError, "DigitalOcean token is missing" if token.to_s.empty?
16
+ if maximum_timeout && maximum_timeout <= 0
17
+ raise Errors::ConfigurationError, "timeout must be greater than zero"
18
+ end
19
+
20
+ @client = client || build_client(token, maximum_timeout)
21
+ @sleeper = sleeper
22
+ end
23
+
24
+ def validate_credentials!
25
+ @client.account.info
26
+ true
27
+ rescue StandardError => e
28
+ raise_provider_error(
29
+ "DigitalOcean authentication failed",
30
+ e,
31
+ authentication: true,
32
+ hint: "Use a current token for the selected team and include the account:read scope."
33
+ )
34
+ end
35
+
36
+ def validate_server_spec!(spec:)
37
+ validate_region!(spec)
38
+ validate_size!(spec)
39
+ validate_image!(spec)
40
+ true
41
+ rescue Errors::ConfigurationError
42
+ raise
43
+ rescue StandardError => e
44
+ raise_provider_error("Unable to validate DigitalOcean server configuration", e)
45
+ end
46
+
47
+ def find_server(name:, tags: [])
48
+ tag = tags.first
49
+ droplets = tag ? @client.droplets.all(tag_name: tag) : @client.droplets.all
50
+ droplet = droplets.find do |candidate|
51
+ candidate.name == name && (tags - Array(candidate.tags)).empty?
52
+ end
53
+ droplet && server_record(droplet)
54
+ rescue StandardError => e
55
+ raise_provider_error("Unable to find DigitalOcean server #{name}", e)
56
+ end
57
+
58
+ def find_server_by_id(id:)
59
+ server_record(@client.droplets.find(id: id))
60
+ rescue StandardError => e
61
+ return nil if not_found?(e)
62
+
63
+ raise_provider_error("Unable to find DigitalOcean server #{id}", e)
64
+ end
65
+
66
+ def create_server(spec:)
67
+ droplet = DropletKit::Droplet.new(
68
+ name: spec.fetch(:name),
69
+ region: spec.fetch(:region),
70
+ size: spec.fetch(:size),
71
+ image: spec.fetch(:image),
72
+ ssh_keys: [spec.fetch(:ssh_key_id)],
73
+ tags: spec.fetch(:tags, [])
74
+ )
75
+ server_record(@client.droplets.create(droplet))
76
+ rescue StandardError => e
77
+ raise_provider_error("Unable to create DigitalOcean server #{spec[:name]}", e)
78
+ end
79
+
80
+ def wait_until_ready(id:, timeout: 180)
81
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
82
+ loop do
83
+ droplet = @client.droplets.find(id: id)
84
+ record = server_record(droplet)
85
+ return record if record.status == "active" && record.public_ip
86
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
87
+
88
+ @sleeper.sleep(POLL_INTERVAL)
89
+ end
90
+ raise Errors::TimeoutError.new(
91
+ "DigitalOcean server #{id} did not become ready within #{timeout} seconds",
92
+ hint: "Inspect the Droplet in DigitalOcean before retrying."
93
+ )
94
+ rescue Errors::Error
95
+ raise
96
+ rescue StandardError => e
97
+ raise_provider_error("Unable to wait for DigitalOcean server #{id}", e)
98
+ end
99
+
100
+ def delete_server(id:)
101
+ @client.droplets.delete(id: id)
102
+ true
103
+ rescue StandardError => e
104
+ raise_provider_error("Unable to delete DigitalOcean server #{id}", e)
105
+ end
106
+
107
+ def find_dns_record(zone:, name:, type:)
108
+ record = @client.domain_records.all(for_domain: zone).find do |candidate|
109
+ candidate.name == name && candidate.type == type
110
+ end
111
+ record && dns_record(zone, record)
112
+ rescue StandardError => e
113
+ raise_provider_error("Unable to inspect DNS record #{name}.#{zone}", e)
114
+ end
115
+
116
+ def upsert_dns_record(record:)
117
+ value = DropletKit::DomainRecord.new(type: record.type, name: record.name, data: record.data, ttl: record.ttl)
118
+ saved = if record.id
119
+ @client.domain_records.update(value, for_domain: record.zone, id: record.id)
120
+ else
121
+ @client.domain_records.create(value, for_domain: record.zone)
122
+ end
123
+ dns_record(record.zone, saved)
124
+ rescue StandardError => e
125
+ raise_provider_error("Unable to update DNS record #{record.name}.#{record.zone}", e)
126
+ end
127
+
128
+ def delete_dns_record(id:, zone:)
129
+ @client.domain_records.delete(for_domain: zone, id: id)
130
+ true
131
+ rescue StandardError => e
132
+ raise_provider_error("Unable to delete DNS record #{id} from #{zone}", e)
133
+ end
134
+
135
+ private
136
+
137
+ def build_client(token, maximum_timeout)
138
+ return DropletKit::Client.new(access_token: token) unless maximum_timeout
139
+
140
+ DropletKit::Client.new(
141
+ access_token: token, open_timeout: maximum_timeout, timeout: maximum_timeout
142
+ )
143
+ end
144
+
145
+ def server_record(droplet)
146
+ ip = droplet.networks&.v4&.find { |network| network.type == "public" }&.ip_address
147
+ ServerRecord.new(
148
+ id: droplet.id.to_s,
149
+ name: droplet.name,
150
+ status: droplet.status,
151
+ public_ip: ip,
152
+ region: droplet.region.respond_to?(:slug) ? droplet.region.slug : droplet.region.to_s,
153
+ size: droplet.size_slug || droplet.size&.slug,
154
+ image: droplet.image.respond_to?(:slug) ? droplet.image.slug : droplet.image.to_s,
155
+ tags: droplet.tags || []
156
+ )
157
+ end
158
+
159
+ def dns_record(zone, record)
160
+ DnsRecord.new(
161
+ id: record.id.to_s,
162
+ zone: zone,
163
+ name: record.name,
164
+ type: record.type,
165
+ data: record.data,
166
+ ttl: record.ttl
167
+ )
168
+ end
169
+
170
+ def raise_provider_error(message, cause, authentication: false, hint: nil)
171
+ error_class = authentication ? Errors::AuthenticationError : Errors::ProviderError
172
+ raise error_class.new(
173
+ message,
174
+ hint: hint || "Check provider credentials, connectivity and resource limits, then retry.",
175
+ context: { cause: cause.class.name },
176
+ retryable: !authentication
177
+ )
178
+ end
179
+
180
+ def not_found?(error)
181
+ error.respond_to?(:response) && error.response.respond_to?(:status) && error.response.status.to_i == 404
182
+ end
183
+
184
+ def validate_region!(spec)
185
+ region = @client.regions.all.find { |candidate| candidate.slug == spec.fetch(:region) }
186
+ return if region&.available
187
+
188
+ raise unavailable("region", spec[:region], "Choose an available DigitalOcean region.")
189
+ end
190
+
191
+ def validate_size!(spec)
192
+ size = @client.sizes.all.find { |candidate| candidate.slug == spec.fetch(:size) }
193
+ return if size&.available && Array(size.regions).include?(spec[:region])
194
+
195
+ raise unavailable("size", spec[:size], "Choose a size available in region #{spec[:region]}.")
196
+ end
197
+
198
+ def validate_image!(spec)
199
+ image = @client.images.all(type: "distribution").find do |candidate|
200
+ candidate.slug == spec.fetch(:image)
201
+ end
202
+ return if image && (Array(image.regions).empty? || Array(image.regions).include?(spec[:region]))
203
+
204
+ raise unavailable("image", spec[:image], "Choose a supported image available in #{spec[:region]}.")
205
+ end
206
+
207
+ def unavailable(field, value, hint)
208
+ Errors::ConfigurationError.new(
209
+ "DigitalOcean #{field} is unavailable: #{value}",
210
+ hint: hint,
211
+ context: { field: field, value: value }
212
+ )
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../clock"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Adapters
8
+ class FakeClock < Clock
9
+ attr_reader :now, :monotonic, :sleeps
10
+
11
+ def initialize(now: Time.utc(2026, 1, 1), monotonic: 0.0)
12
+ super()
13
+ @now = now.utc
14
+ @monotonic = Float(monotonic)
15
+ @sleeps = []
16
+ end
17
+
18
+ def sleep(seconds)
19
+ seconds = Float(seconds)
20
+ @sleeps << seconds
21
+ advance(seconds)
22
+ seconds
23
+ end
24
+
25
+ def advance(seconds)
26
+ seconds = Float(seconds)
27
+ @now += seconds
28
+ @monotonic += seconds
29
+ self
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -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