kitsune-kit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +108 -136
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/provider-adapters.md +49 -0
  14. data/docs/architecture.md +89 -0
  15. data/docs/commands.md +210 -0
  16. data/docs/configuration.md +210 -0
  17. data/docs/getting-started.md +133 -0
  18. data/docs/providers/digitalocean.md +52 -0
  19. data/docs/releasing.md +43 -0
  20. data/docs/roadmap.md +46 -0
  21. data/docs/security-audit.md +130 -0
  22. data/docs/security.md +116 -0
  23. data/docs/services/postgres.md +78 -0
  24. data/docs/services/redis.md +53 -0
  25. data/docs/testing.md +200 -0
  26. data/docs/troubleshooting.md +123 -0
  27. data/docs/tui.md +57 -0
  28. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  29. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  30. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  31. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  32. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  34. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  35. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  36. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  37. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  38. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  39. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  40. data/lib/kitsune/kit/application.rb +170 -0
  41. data/lib/kitsune/kit/cancellation.rb +21 -0
  42. data/lib/kitsune/kit/cli.rb +802 -64
  43. data/lib/kitsune/kit/clock.rb +11 -0
  44. data/lib/kitsune/kit/configuration.rb +493 -0
  45. data/lib/kitsune/kit/errors.rb +98 -0
  46. data/lib/kitsune/kit/events.rb +55 -0
  47. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  48. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  49. data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
  50. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  51. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  52. data/lib/kitsune/kit/operations/service_files.rb +127 -0
  53. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  54. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  55. data/lib/kitsune/kit/plan.rb +72 -0
  56. data/lib/kitsune/kit/reporters/human.rb +89 -0
  57. data/lib/kitsune/kit/reporters/json.rb +65 -0
  58. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  59. data/lib/kitsune/kit/result.rb +28 -0
  60. data/lib/kitsune/kit/run_journal.rb +102 -0
  61. data/lib/kitsune/kit/run_logger.rb +37 -0
  62. data/lib/kitsune/kit/scripts/docker.sh +137 -0
  63. data/lib/kitsune/kit/scripts/firewall.sh +205 -0
  64. data/lib/kitsune/kit/scripts/metrics.sh +54 -0
  65. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  66. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  67. data/lib/kitsune/kit/scripts/unattended.sh +81 -0
  68. data/lib/kitsune/kit/scripts/user.sh +114 -0
  69. data/lib/kitsune/kit/secret_filter.rb +54 -0
  70. data/lib/kitsune/kit/secret_store.rb +32 -0
  71. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  72. data/lib/kitsune/kit/service_compose.rb +72 -0
  73. data/lib/kitsune/kit/state_store.rb +158 -0
  74. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  75. data/lib/kitsune/kit/tui/actions.rb +72 -0
  76. data/lib/kitsune/kit/tui/application.rb +35 -0
  77. data/lib/kitsune/kit/tui/controller.rb +162 -0
  78. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  79. data/lib/kitsune/kit/tui/state.rb +18 -0
  80. data/lib/kitsune/kit/tui/store.rb +88 -0
  81. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  82. data/lib/kitsune/kit/version.rb +1 -1
  83. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  84. data/lib/kitsune/kit/workflows/base.rb +31 -0
  85. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  86. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  87. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  88. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  89. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  90. data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
  91. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  92. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  93. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  94. data/lib/kitsune/kit.rb +41 -2
  95. metadata +122 -79
  96. data/.rspec +0 -3
  97. data/Rakefile +0 -8
  98. data/kitsune-kit-logo.jpg +0 -0
  99. data/lib/kitsune/blueprints/.env.template +0 -31
  100. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  101. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  102. data/lib/kitsune/blueprints/kit.env.template +0 -1
  103. data/lib/kitsune/kit/ansi_color.rb +0 -78
  104. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  105. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  106. data/lib/kitsune/kit/commands/dns.rb +0 -112
  107. data/lib/kitsune/kit/commands/init.rb +0 -148
  108. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  109. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  110. data/lib/kitsune/kit/commands/provision.rb +0 -43
  111. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  112. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  113. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  114. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  115. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  116. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  117. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  118. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  119. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  120. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  121. data/lib/kitsune/kit/defaults.rb +0 -91
  122. data/lib/kitsune/kit/env_loader.rb +0 -41
  123. data/lib/kitsune/kit/options_builder.rb +0 -26
  124. data/lib/kitsune/kit/provisioner.rb +0 -107
  125. data/sig/kitsune/kit.rbs +0 -6
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