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
data/docs/testing.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Testing and quality gates
|
|
2
|
+
|
|
3
|
+
Kitsune Kit uses layers because no single test environment accurately represents provider APIs, SSH, systemd, UFW, Docker and public network exposure.
|
|
4
|
+
|
|
5
|
+
## Local commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bin/setup # install gems and run the local gate
|
|
9
|
+
bundle exec rake test # complete normal RSpec suite + coverage gate
|
|
10
|
+
bundle exec rake lint # RuboCop + ShellCheck
|
|
11
|
+
bundle exec rake security # Bundler Audit
|
|
12
|
+
bundle exec rake build # build the gem
|
|
13
|
+
bundle exec rake artifact_smoke # install the gem with fresh dependencies and verify its CLI
|
|
14
|
+
bundle exec rake ci # lint, tests and gem build
|
|
15
|
+
bundle exec rake integration # Docker-backed SSH/script suite
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Run an individual spec without applying whole-suite coverage thresholds:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle exec rspec spec/unit/configuration_spec.rb
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Set `KITSUNE_COVERAGE_GATE=1` to force the full threshold for a custom selection or `0` for diagnostic subsets. CI always runs the complete gate.
|
|
25
|
+
|
|
26
|
+
## Test layers
|
|
27
|
+
|
|
28
|
+
### Unit/domain
|
|
29
|
+
|
|
30
|
+
Fast tests cover configuration precedence/validation, plans, errors/exit codes, redaction, reporters, state locking/writes, run journals, operation idempotence, rollback calculations, workflow resume/failure and TUI state/rendering. No network is used.
|
|
31
|
+
|
|
32
|
+
### Fakes and contracts
|
|
33
|
+
|
|
34
|
+
`FakeProvider`, `FakeTransport`, `FakeStateStore` and `FakeReporter` are deterministic memory adapters with
|
|
35
|
+
recorded calls or captured events. Shared examples define provider, transport and state-store behavior. Real
|
|
36
|
+
adapters execute those same contracts against simulated SDK/SSH boundaries, including timeouts, exact IDs,
|
|
37
|
+
status channels and hostile arguments.
|
|
38
|
+
|
|
39
|
+
### CLI process tests
|
|
40
|
+
|
|
41
|
+
The executable runs as a subprocess to verify global/help/version behavior, invalid syntax, exit codes, JSON parsing, non-TTY behavior and environment selection. Core behavior is separately tested through workflows so subprocess coverage is not mistaken for domain coverage.
|
|
42
|
+
|
|
43
|
+
### Shell scripts
|
|
44
|
+
|
|
45
|
+
Every remote script passes ShellCheck and `bash -n`. CI mounts scripts read-only into both supported Ubuntu images to ensure the distributed Bash parses there.
|
|
46
|
+
|
|
47
|
+
A simple container does not prove systemd/UFW behavior. The project does not claim otherwise; those effects are checked on a VPS in the E2E layer.
|
|
48
|
+
|
|
49
|
+
### Ephemeral SSH integration
|
|
50
|
+
|
|
51
|
+
`rake integration` builds an Ubuntu OpenSSH container with a temporary Ed25519 key. It verifies real public-key authentication, stdout/stderr/status separation, safe quoting, exact-byte upload/mode, host-key rejection, authentication failure and command timeout. It also uses a pseudo-terminal to prove that the TUI restores the main screen and console mode after normal exit and `SIGINT`. CI runs it on Ubuntu 22.04 and 24.04 base images.
|
|
52
|
+
|
|
53
|
+
Docker must be installed and running. If unavailable, examples report why they were skipped locally; the CI integration job first requires `docker info`, so it cannot silently pass without Docker.
|
|
54
|
+
|
|
55
|
+
### DigitalOcean E2E
|
|
56
|
+
|
|
57
|
+
The E2E suite is billable and never runs from a normal test command. Required variables:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
KITSUNE_E2E=1
|
|
61
|
+
DO_API_TOKEN
|
|
62
|
+
KITSUNE_E2E_SSH_KEY_ID
|
|
63
|
+
KITSUNE_E2E_KEY_PATH
|
|
64
|
+
POSTGRES_PASSWORD (generated by the test workflow)
|
|
65
|
+
REDIS_PASSWORD (generated by the test workflow)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use a dedicated DigitalOcean token and SSH key pair for this suite. The private key must not have a passphrase
|
|
69
|
+
because the GitHub runner cannot answer an unlock prompt. Generate a test-only Ed25519 pair locally:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
ssh-keygen -t ed25519 -f ~/.ssh/kitsune_e2e_ed25519 -C kitsune-e2e -N ""
|
|
73
|
+
chmod 0600 ~/.ssh/kitsune_e2e_ed25519
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Upload only `~/.ssh/kitsune_e2e_ed25519.pub` in DigitalOcean under **Settings → Security → SSH keys → Add SSH
|
|
77
|
+
Key**. Never upload or paste the private file into DigitalOcean. `KITSUNE_E2E_SSH_KEY_ID` is the numeric ID
|
|
78
|
+
assigned to that uploaded public key, not its fingerprint or path. With `DO_API_TOKEN` already exported, list
|
|
79
|
+
the available IDs without printing the token:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
curl --fail --silent --show-error \
|
|
83
|
+
-H "Authorization: Bearer ${DO_API_TOKEN}" \
|
|
84
|
+
https://api.digitalocean.com/v2/account/keys |
|
|
85
|
+
ruby -rjson -e 'JSON.parse(STDIN.read).fetch("ssh_keys").each { |key| puts [key["id"], key["name"], key["fingerprint"]].join("\t") }'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The minimum custom token scopes used by this suite are `account:read`, `droplet:read`, `droplet:create`,
|
|
89
|
+
`droplet:delete`, `regions:read`, `sizes:read`, `actions:read`, `image:read`, `ssh_key:read`, `tag:read` and
|
|
90
|
+
`tag:create`. The dynamic expiry tag makes `tag:create` necessary. DNS scopes are not required by the current
|
|
91
|
+
E2E configuration. DigitalOcean does not allow changing an existing token's scopes; create a replacement token
|
|
92
|
+
if any permission is missing.
|
|
93
|
+
|
|
94
|
+
Before authorizing a billable run, confirm that the token can access the endpoint used by `doctor`:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
curl --output /dev/null --silent --show-error --write-out '%{http_code}\n' \
|
|
98
|
+
-H "Authorization: Bearer ${DO_API_TOKEN}" \
|
|
99
|
+
https://api.digitalocean.com/v2/account
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The expected status is `200`; `403` means the token lacks `account:read` or the selected team role does not grant
|
|
103
|
+
it.
|
|
104
|
+
|
|
105
|
+
For a local run, keep the token out of shell history and export the remaining values:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
printf 'DigitalOcean token: '
|
|
109
|
+
IFS= read -r -s DO_API_TOKEN
|
|
110
|
+
printf '\n'
|
|
111
|
+
export DO_API_TOKEN
|
|
112
|
+
export KITSUNE_E2E_SSH_KEY_ID="12345678"
|
|
113
|
+
export KITSUNE_E2E_KEY_PATH="$HOME/.ssh/kitsune_e2e_ed25519"
|
|
114
|
+
export KITSUNE_E2E=1
|
|
115
|
+
bundle exec rake e2e
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The optional `KITSUNE_E2E_REGION` and `KITSUNE_E2E_SIZE` variables override the defaults `sfo3` and
|
|
119
|
+
`s-1vcpu-1gb` when that combination is unavailable.
|
|
120
|
+
|
|
121
|
+
For GitHub Actions, add these repository secrets under **Settings → Secrets and variables → Actions**:
|
|
122
|
+
|
|
123
|
+
| GitHub secret | Value |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `DO_API_TOKEN` | Dedicated DigitalOcean token |
|
|
126
|
+
| `KITSUNE_E2E_SSH_KEY_ID` | Numeric ID of the uploaded public key |
|
|
127
|
+
| `KITSUNE_E2E_KEY` | Complete contents of the matching private key file |
|
|
128
|
+
|
|
129
|
+
GitHub writes `KITSUNE_E2E_KEY` to a restricted temporary file and sets `KITSUNE_E2E_KEY_PATH` itself. After
|
|
130
|
+
the workflow exists on GitHub, run **Actions → Real infrastructure → Run workflow**. It has no schedule and
|
|
131
|
+
therefore cannot create billable infrastructure unless someone dispatches it explicitly.
|
|
132
|
+
|
|
133
|
+
Run only in a dedicated account/project:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
KITSUNE_E2E=1 bundle exec rake e2e
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
It creates a unique TTL-tagged Droplet, applies the complete configuration including PostgreSQL/Redis, runs CLI doctor/plan, demands a zero-change second plan, checks data ports from outside, rolls back and destroys in `ensure`.
|
|
140
|
+
|
|
141
|
+
Before creating anything, the suite derives the public half of `KITSUNE_E2E_KEY_PATH` and compares it with the
|
|
142
|
+
public key returned by DigitalOcean for `KITSUNE_E2E_SSH_KEY_ID`, then validates account API access. A key
|
|
143
|
+
mismatch or missing `account:read` permission fails without creating a billable Droplet. After creation, Kitsune Kit
|
|
144
|
+
waits up to two minutes for Ubuntu SSH readiness because an `active` API status and public IP do not guarantee
|
|
145
|
+
that `sshd` has finished starting.
|
|
146
|
+
|
|
147
|
+
The manually dispatched workflow serializes E2E runs and has a 45-minute limit. Its always-run cleanup job invokes
|
|
148
|
+
`script/cleanup-ci-resources`, which is dry-run by default and only considers `kitsune-ci` Droplets with an expired
|
|
149
|
+
`kitsune-expires-EPOCH` tag:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
bundle exec ruby script/cleanup-ci-resources
|
|
153
|
+
bundle exec ruby script/cleanup-ci-resources --execute
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Unit tests inject a fake Droplet API into the cleanup class and prove that dry-run never deletes, untagged or
|
|
157
|
+
unexpired resources are ignored, and `--execute` uses only the filtered provider IDs.
|
|
158
|
+
|
|
159
|
+
## Coverage
|
|
160
|
+
|
|
161
|
+
SimpleCov records line and branch coverage. The gate is raised with the new core and must not be improved by excluding difficult production files. Destructive paths require explicit examples even when another path happens to cover the same line.
|
|
162
|
+
|
|
163
|
+
The complete suite enforces at least 80% line/50% branch coverage across all production code and 85% branch coverage in the domain core. For this gate, the core is `lib/kitsune/kit` excluding presentation (`cli.rb`, `tui/`, `reporters/`) and external adapters; operations/workflows/configuration/state remain included. `script/verify-core-coverage` calculates this directly from SimpleCov's current result.
|
|
164
|
+
|
|
165
|
+
Coverage is evidence of exercised control flow, not proof of provider/system correctness; contract, integration, security and E2E tests remain mandatory.
|
|
166
|
+
|
|
167
|
+
The dated manual source review, remediated findings and residual infrastructure risks are in
|
|
168
|
+
[Security audit](security-audit.md).
|
|
169
|
+
|
|
170
|
+
## CI jobs
|
|
171
|
+
|
|
172
|
+
Pull requests run:
|
|
173
|
+
|
|
174
|
+
- unit tests on every declared Ruby family;
|
|
175
|
+
- CLI process tests;
|
|
176
|
+
- adapter contracts;
|
|
177
|
+
- Ruby/shell lint;
|
|
178
|
+
- dependency audit;
|
|
179
|
+
- gem build, isolated installation and executable smoke test;
|
|
180
|
+
- Docker SSH/script integration for both supported Ubuntu releases.
|
|
181
|
+
|
|
182
|
+
The credential-gated, manually dispatched workflow runs the DigitalOcean lifecycle and cleanup. Secrets are
|
|
183
|
+
never available to untrusted pull-request jobs, and no calendar trigger creates billable infrastructure.
|
|
184
|
+
|
|
185
|
+
The artifact job deliberately resolves runtime dependencies without the repository lockfile, runs from outside
|
|
186
|
+
the checkout, verifies the exact public command set, and initializes a temporary project. This catches undeclared
|
|
187
|
+
standard-library gems and framework commands that a locked development bundle could hide.
|
|
188
|
+
|
|
189
|
+
## Writing a new operation test
|
|
190
|
+
|
|
191
|
+
At minimum cover:
|
|
192
|
+
|
|
193
|
+
1. absent resource plan/apply/verification;
|
|
194
|
+
2. already-correct no-change behavior;
|
|
195
|
+
3. allowed update and immutable/destructive drift;
|
|
196
|
+
4. failure before and after mutation;
|
|
197
|
+
5. resume after a persisted partial step;
|
|
198
|
+
6. rollback of owned state and refusal to touch pre-existing state;
|
|
199
|
+
7. timeout/cancellation;
|
|
200
|
+
8. hostile input and secret redaction.
|
|
@@ -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
|