kitsune-kit 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -2
- data/CONTRIBUTING.md +83 -0
- data/README.md +112 -135
- data/SECURITY.md +25 -0
- data/bin/kit +4 -1
- data/docs/architecture/decisions/0001-product-boundary.md +23 -0
- data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
- data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
- data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
- data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
- data/docs/architecture/decisions/0006-tui-backend.md +21 -0
- data/docs/architecture/decisions/0007-compose-customization.md +30 -0
- data/docs/architecture/provider-adapters.md +49 -0
- data/docs/architecture.md +93 -0
- data/docs/commands.md +224 -0
- data/docs/configuration.md +226 -0
- data/docs/getting-started.md +133 -0
- data/docs/providers/digitalocean.md +52 -0
- data/docs/releasing.md +43 -0
- data/docs/roadmap.md +46 -0
- data/docs/security-audit.md +130 -0
- data/docs/security.md +116 -0
- data/docs/services/compose.md +164 -0
- data/docs/services/postgres.md +78 -0
- data/docs/services/redis.md +53 -0
- data/docs/testing.md +200 -0
- data/docs/troubleshooting.md +123 -0
- data/docs/tui.md +57 -0
- data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
- data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
- data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
- data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
- data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
- data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
- data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
- data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
- data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
- data/lib/kitsune/kit/adapters/provider.rb +34 -0
- data/lib/kitsune/kit/adapters/transport.rb +22 -0
- data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
- data/lib/kitsune/kit/application.rb +170 -0
- data/lib/kitsune/kit/cancellation.rb +21 -0
- data/lib/kitsune/kit/cli.rb +879 -64
- data/lib/kitsune/kit/clock.rb +11 -0
- data/lib/kitsune/kit/configuration.rb +577 -0
- data/lib/kitsune/kit/errors.rb +98 -0
- data/lib/kitsune/kit/events.rb +55 -0
- data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
- data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
- data/lib/kitsune/kit/operations/ensure_service.rb +302 -0
- data/lib/kitsune/kit/operations/remote_script.rb +214 -0
- data/lib/kitsune/kit/operations/service_backup.rb +71 -0
- data/lib/kitsune/kit/operations/service_files.rb +131 -0
- data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
- data/lib/kitsune/kit/operations/service_state.rb +59 -0
- data/lib/kitsune/kit/plan.rb +72 -0
- data/lib/kitsune/kit/reporters/human.rb +89 -0
- data/lib/kitsune/kit/reporters/json.rb +65 -0
- data/lib/kitsune/kit/reporters/reporter.rb +11 -0
- data/lib/kitsune/kit/result.rb +28 -0
- data/lib/kitsune/kit/run_journal.rb +102 -0
- data/lib/kitsune/kit/run_logger.rb +37 -0
- data/lib/kitsune/kit/scripts/docker.sh +142 -0
- data/lib/kitsune/kit/scripts/firewall.sh +210 -0
- data/lib/kitsune/kit/scripts/metrics.sh +59 -0
- data/lib/kitsune/kit/scripts/ssh.sh +95 -0
- data/lib/kitsune/kit/scripts/swap.sh +86 -0
- data/lib/kitsune/kit/scripts/unattended.sh +86 -0
- data/lib/kitsune/kit/scripts/user.sh +114 -0
- data/lib/kitsune/kit/secret_filter.rb +54 -0
- data/lib/kitsune/kit/secret_store.rb +32 -0
- data/lib/kitsune/kit/secret_stores/store.rb +12 -0
- data/lib/kitsune/kit/service_compose.rb +272 -0
- data/lib/kitsune/kit/state_store.rb +158 -0
- data/lib/kitsune/kit/state_stores/store.rb +15 -0
- data/lib/kitsune/kit/tui/actions.rb +72 -0
- data/lib/kitsune/kit/tui/application.rb +35 -0
- data/lib/kitsune/kit/tui/controller.rb +162 -0
- data/lib/kitsune/kit/tui/renderer.rb +134 -0
- data/lib/kitsune/kit/tui/state.rb +18 -0
- data/lib/kitsune/kit/tui/store.rb +88 -0
- data/lib/kitsune/kit/tui/terminal.rb +95 -0
- data/lib/kitsune/kit/version.rb +1 -1
- data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
- data/lib/kitsune/kit/workflows/base.rb +31 -0
- data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
- data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
- data/lib/kitsune/kit/workflows/doctor.rb +225 -0
- data/lib/kitsune/kit/workflows/eject_compose.rb +82 -0
- data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
- data/lib/kitsune/kit/workflows/import_server.rb +100 -0
- data/lib/kitsune/kit/workflows/initialize_project.rb +129 -0
- data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
- data/lib/kitsune/kit/workflows/rollback.rb +54 -0
- data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
- data/lib/kitsune/kit.rb +42 -2
- metadata +125 -79
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/kitsune-kit-logo.jpg +0 -0
- data/lib/kitsune/blueprints/.env.template +0 -31
- data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
- data/lib/kitsune/blueprints/docker/redis.yml +0 -23
- data/lib/kitsune/blueprints/kit.env.template +0 -1
- data/lib/kitsune/kit/ansi_color.rb +0 -78
- data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
- data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
- data/lib/kitsune/kit/commands/dns.rb +0 -112
- data/lib/kitsune/kit/commands/init.rb +0 -148
- data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
- data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
- data/lib/kitsune/kit/commands/provision.rb +0 -43
- data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
- data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
- data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
- data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
- data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
- data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
- data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
- data/lib/kitsune/kit/commands/setup_user.rb +0 -189
- data/lib/kitsune/kit/commands/ssh.rb +0 -46
- data/lib/kitsune/kit/commands/switch_env.rb +0 -42
- data/lib/kitsune/kit/defaults.rb +0 -91
- data/lib/kitsune/kit/env_loader.rb +0 -41
- data/lib/kitsune/kit/options_builder.rb +0 -26
- data/lib/kitsune/kit/provisioner.rb +0 -107
- data/sig/kitsune/kit.rbs +0 -6
|
@@ -0,0 +1,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
|