Skip to content

Commit 38d1703

Browse files
authored
infra: Ubuntu support + integration CI on ephemeral droplets (#12)
1 parent 30385a1 commit 38d1703

14 files changed

Lines changed: 436 additions & 25 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ jobs:
2525
- name: Typecheck
2626
run: npm run typecheck
2727

28+
- name: ShellCheck
29+
run: |
30+
find bin/ setup.sh start.sh -type f \( -name '*.sh' -o -name 'hornet-safe-bash' -o -name 'hornet-docker' \) \
31+
| xargs shellcheck -s bash -S warning
32+
2833
test:
2934
runs-on: ubuntu-latest
3035
steps:

.github/workflows/integration.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Integration
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: integration-${{ github.event.pull_request.number || github.sha }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
integration:
16+
runs-on: ubuntu-latest
17+
strategy:
18+
fail-fast: false
19+
matrix:
20+
include:
21+
- distro: ubuntu
22+
image: ubuntu-24-04-x64
23+
setup_script: bin/ci/setup-ubuntu.sh
24+
# To add Arch (or other distros), add entries here:
25+
# - distro: arch
26+
# image: arch-linux-x64 # or a custom snapshot ID
27+
# setup_script: bin/ci/setup-arch.sh
28+
29+
name: ${{ matrix.distro }}
30+
timeout-minutes: 10
31+
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Generate ephemeral SSH key
36+
run: |
37+
mkdir -p ~/.ssh
38+
ssh-keygen -t ed25519 -f ~/.ssh/ci_key -N "" -q
39+
40+
- name: Create droplet
41+
id: droplet
42+
env:
43+
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
44+
run: |
45+
output=$(bash bin/ci/droplet.sh create \
46+
"ci-${{ matrix.distro }}-${{ github.run_id }}" \
47+
"${{ matrix.image }}" \
48+
~/.ssh/ci_key.pub)
49+
echo "$output" >> "$GITHUB_OUTPUT"
50+
echo "$output"
51+
52+
- name: Wait for SSH
53+
env:
54+
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
55+
run: |
56+
bash bin/ci/droplet.sh wait-ssh \
57+
"${{ steps.droplet.outputs.DROPLET_IP }}" \
58+
~/.ssh/ci_key
59+
60+
- name: Upload source
61+
run: |
62+
tar czf /tmp/hornet-src.tar.gz \
63+
--exclude=node_modules --exclude=.git .
64+
scp -o StrictHostKeyChecking=no -o BatchMode=yes \
65+
-i ~/.ssh/ci_key \
66+
/tmp/hornet-src.tar.gz \
67+
"root@${{ steps.droplet.outputs.DROPLET_IP }}:/tmp/hornet-src.tar.gz"
68+
69+
- name: Setup and test
70+
run: |
71+
bash bin/ci/droplet.sh run \
72+
"${{ steps.droplet.outputs.DROPLET_IP }}" \
73+
~/.ssh/ci_key \
74+
"${{ matrix.setup_script }}"
75+
76+
- name: Cleanup
77+
if: always()
78+
env:
79+
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
80+
run: |
81+
bash bin/ci/droplet.sh destroy \
82+
"${{ steps.droplet.outputs.DROPLET_ID }}" \
83+
"${{ steps.droplet.outputs.SSH_KEY_ID }}"

.pi/todos/20e26efc.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"id": "20e26efc",
3+
"title": "CI job: run setup + tests on fresh Ubuntu droplet per PR",
4+
"tags": [
5+
"infra",
6+
"ci",
7+
"ubuntu"
8+
],
9+
"status": "done",
10+
"created_at": "2026-02-17T02:30:52.375Z"
11+
}
12+
13+
## Result
14+
Implemented and verified end-to-end. Workflow will activate once PR merges to main (GH Actions limitation: new workflow files in PRs don't trigger until they exist on the base branch).
15+
16+
### What was built
17+
- **`bin/ci/droplet.sh`** — reusable DO droplet lifecycle: `create`, `destroy`, `wait-ssh`, `run`
18+
- **`bin/ci/setup-ubuntu.sh`** — Ubuntu-specific: prereqs, setup.sh, test suite
19+
- **`.github/workflows/integration.yml`** — matrix-based workflow (Ubuntu now, extensible)
20+
21+
### Design
22+
- **Ephemeral everything**: fresh droplet + SSH key generated per run, destroyed on cleanup
23+
- **Single secret**: `DO_API_TOKEN` (set in GitHub repo)
24+
- **Matrix**: `include` array with `distro`, `image`, `setup_script` — add Arch/other by adding entries
25+
- **Concurrency group**: one run at a time per PR
26+
- **Always cleanup**: `if: always()` destroys droplet + SSH key even on failure
27+
28+
### Local test results (3 full cycles)
29+
- Create droplet: ~15s
30+
- SSH ready: ~10s
31+
- Prereqs + setup + deploy: ~90s
32+
- Tests (5 suites): ~10s
33+
- Destroy: instant
34+
- **Total: ~2 minutes per run**
35+
- All 5 test suites pass on fresh Ubuntu 24.04
36+
37+
### PR
38+
https://github.com/modem-dev/hornet/pull/10

.pi/todos/cb931656.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "cb931656",
3+
"title": "Verify hornet setup on Ubuntu droplet (manual)",
4+
"tags": [
5+
"infra",
6+
"ubuntu",
7+
"ci"
8+
],
9+
"status": "done",
10+
"created_at": "2026-02-17T02:30:39.055Z"
11+
}
12+
13+
## Result
14+
Verified on DigitalOcean Ubuntu 24.04 droplet (4GB RAM, 2 vCPU).
15+
16+
### Bugs found and fixed
17+
1. **`setup.sh` — shared repo permissions**: ran `git config` as `hornet_agent` on admin's repo → agent can't access admin home. Fix: run as `$ADMIN_USER` instead.
18+
2. **`setup.sh` — harden-permissions path**: called source copy (`$REPO_DIR/bin/harden-permissions.sh`) which agent can't access. Fix: use deployed copy (`$HORNET_HOME/runtime/bin/`).
19+
3. **`setup.sh` — CWD inheritance**: `sudo -u hornet_agent` inherits root's CWD, causing `find`/`git` failures. Fix: `cd /tmp` at top of script.
20+
4. **`harden-permissions.sh` — sessions dir**: `find` on non-existent sessions dir fails under `set -e`. Fix: wrap in `if [ -d ... ]` guard.
21+
5. **`hornet-safe-bash``grep -P`**: Perl regex not reliably available on Ubuntu. Fix: all patterns converted to `grep -E`.
22+
23+
### Verification results
24+
-`setup.sh` completes cleanly (user, Node, pi, firewall, hidepid, deploy, harden)
25+
- ✅ All 5 test suites pass (tool-guard, bridge security, extension scanner, safe-bash, log redaction)
26+
- ✅ Varlock validates env schema
27+
-`start.sh` boots pi agent (fails at API auth with dummy keys — expected)
28+
- ✅ Firewall active with all rules
29+
- ✅ Process isolation working
30+
31+
### PR
32+
https://github.com/modem-dev/hornet/pull/10

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ bin/ security & operations scripts
1414
harden-permissions.sh filesystem hardening (runs on boot)
1515
scan-extensions.mjs extension static analysis
1616
redact-logs.sh secret scrubber for session logs
17+
ci/ CI integration scripts
18+
droplet.sh ephemeral DigitalOcean droplet lifecycle (create/destroy/ssh)
19+
setup-ubuntu.sh Ubuntu droplet: prereqs + setup + tests
1720
hooks/
1821
pre-commit blocks agent from modifying security files in git
1922
pi/
@@ -110,6 +113,7 @@ Add new test files to `bin/test.sh` — don't scatter test invocations across CI
110113
- Skills are deployed from `pi/skills/` → agent's `~/.pi/agent/skills/`.
111114
- Agent commits operational learnings to its own skills dir (not back to source).
112115
- **When changing behavior, update all docs.** Check and update: `README.md`, `CONFIGURATION.md`, skill files (`pi/skills/*/SKILL.md`), and `AGENTS.md`. Inline code examples in docs must match the actual implementation.
116+
- **No distro-specific commands.** Scripts must work on both Arch and Ubuntu (and any standard Linux). Use `grep -E` (not `grep -P`), POSIX-compatible tools, and avoid package manager calls (`pacman`, `apt`, etc.). If a package is needed, document it as a prerequisite rather than auto-installing it.
113117

114118
## Security Notes
115119

bin/ci/droplet.sh

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/bin/bash
2+
# Manage ephemeral DigitalOcean droplets for CI.
3+
#
4+
# Usage:
5+
# bin/ci/droplet.sh create <name> <image> <ssh_pub_key_file>
6+
# bin/ci/droplet.sh destroy <droplet_id> [ssh_key_id]
7+
# bin/ci/droplet.sh wait-ssh <ip> <ssh_private_key_file>
8+
# bin/ci/droplet.sh run <ip> <ssh_private_key_file> <script>
9+
#
10+
# Requires: DO_API_TOKEN env var
11+
#
12+
# create: Registers SSH key with DO, creates droplet, polls until active.
13+
# Outputs: DROPLET_ID=xxx DROPLET_IP=xxx SSH_KEY_ID=xxx
14+
# destroy: Deletes droplet and (optionally) SSH key from DO.
15+
# wait-ssh: Polls until SSH is reachable (up to 120s).
16+
# run: Executes a script on the droplet via SSH.
17+
18+
set -euo pipefail
19+
20+
DO_API="https://api.digitalocean.com/v2"
21+
REGION="${DO_REGION:-tor1}"
22+
SIZE="${DO_SIZE:-s-2vcpu-4gb}"
23+
24+
die() { echo "$1" >&2; exit 1; }
25+
26+
require_token() {
27+
if [ -z "${DO_API_TOKEN:-}" ]; then
28+
die "DO_API_TOKEN not set"
29+
fi
30+
}
31+
32+
do_api() {
33+
local method="$1" endpoint="$2"
34+
shift 2
35+
curl -s -X "$method" \
36+
-H "Authorization: Bearer $DO_API_TOKEN" \
37+
-H "Content-Type: application/json" \
38+
"$DO_API/$endpoint" "$@"
39+
}
40+
41+
# ── create <name> <image> <ssh_pub_key_file> ─────────────────────────────────
42+
cmd_create() {
43+
require_token
44+
local name="${1:?Usage: droplet.sh create <name> <image> <ssh_pub_key_file>}"
45+
local image="${2:?}"
46+
local pub_key_file="${3:?}"
47+
48+
[ -f "$pub_key_file" ] || die "SSH public key not found: $pub_key_file"
49+
50+
local pub_key
51+
pub_key=$(cat "$pub_key_file")
52+
53+
# Register ephemeral SSH key
54+
local key_name
55+
key_name="ci-${name}-$(date +%s)"
56+
local key_result
57+
key_result=$(do_api POST "account/keys" -d "{\"name\":\"$key_name\",\"public_key\":\"$pub_key\"}")
58+
59+
local ssh_key_id
60+
ssh_key_id=$(echo "$key_result" | python3 -c "import json,sys; print(json.load(sys.stdin)['ssh_key']['id'])" 2>/dev/null) \
61+
|| die "Failed to register SSH key: $key_result"
62+
63+
echo " SSH key registered: $ssh_key_id ($key_name)" >&2
64+
65+
# Create droplet
66+
local create_result
67+
create_result=$(do_api POST "droplets" -d "{
68+
\"name\": \"$name\",
69+
\"region\": \"$REGION\",
70+
\"size\": \"$SIZE\",
71+
\"image\": \"$image\",
72+
\"ssh_keys\": [$ssh_key_id],
73+
\"backups\": false,
74+
\"monitoring\": false
75+
}")
76+
77+
local droplet_id
78+
droplet_id=$(echo "$create_result" | python3 -c "import json,sys; print(json.load(sys.stdin)['droplet']['id'])" 2>/dev/null) \
79+
|| die "Failed to create droplet: $create_result"
80+
81+
echo " Droplet created: $droplet_id (polling for IP...)" >&2
82+
83+
# Poll until active with public IP
84+
local ip="none"
85+
for i in $(seq 1 60); do
86+
local data
87+
data=$(do_api GET "droplets/$droplet_id")
88+
local status
89+
status=$(echo "$data" | python3 -c "import json,sys; print(json.load(sys.stdin)['droplet']['status'])")
90+
ip=$(echo "$data" | python3 -c "
91+
import json,sys
92+
d=json.load(sys.stdin)['droplet']
93+
v4=[n for n in d['networks']['v4'] if n['type']=='public']
94+
print(v4[0]['ip_address'] if v4 else 'none')
95+
" 2>/dev/null || echo "none")
96+
97+
if [ "$status" = "active" ] && [ "$ip" != "none" ]; then
98+
echo " Droplet active: $ip" >&2
99+
break
100+
fi
101+
sleep 3
102+
done
103+
104+
if [ "$ip" = "none" ]; then
105+
die "Droplet $droplet_id never became active"
106+
fi
107+
108+
# Output for GitHub Actions $GITHUB_OUTPUT or eval
109+
echo "DROPLET_ID=$droplet_id"
110+
echo "DROPLET_IP=$ip"
111+
echo "SSH_KEY_ID=$ssh_key_id"
112+
}
113+
114+
# ── destroy <droplet_id> [ssh_key_id] ────────────────────────────────────────
115+
cmd_destroy() {
116+
require_token
117+
local droplet_id="${1:-}"
118+
local ssh_key_id="${2:-}"
119+
120+
if [ -n "$droplet_id" ]; then
121+
local http_code
122+
http_code=$(do_api DELETE "droplets/$droplet_id" -o /dev/null -w "%{http_code}")
123+
if [ "$http_code" = "204" ]; then
124+
echo " Droplet $droplet_id destroyed" >&2
125+
else
126+
echo " ⚠️ Droplet destroy returned $http_code (may already be gone)" >&2
127+
fi
128+
fi
129+
130+
if [ -n "$ssh_key_id" ]; then
131+
local http_code
132+
http_code=$(do_api DELETE "account/keys/$ssh_key_id" -o /dev/null -w "%{http_code}")
133+
if [ "$http_code" = "204" ]; then
134+
echo " SSH key $ssh_key_id deleted" >&2
135+
else
136+
echo " ⚠️ SSH key delete returned $http_code (may already be gone)" >&2
137+
fi
138+
fi
139+
}
140+
141+
# ── wait-ssh <ip> <ssh_private_key_file> ──────────────────────────────────────
142+
cmd_wait_ssh() {
143+
local ip="${1:?Usage: droplet.sh wait-ssh <ip> <ssh_private_key_file>}"
144+
local key_file="${2:?}"
145+
146+
echo " Waiting for SSH on $ip..." >&2
147+
for i in $(seq 1 40); do
148+
if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=3 -o BatchMode=yes \
149+
-i "$key_file" "root@$ip" true 2>/dev/null; then
150+
echo " SSH ready ($((i * 3))s)" >&2
151+
return 0
152+
fi
153+
sleep 3
154+
done
155+
die "SSH not reachable on $ip after 120s"
156+
}
157+
158+
# ── run <ip> <ssh_private_key_file> <script> ──────────────────────────────────
159+
cmd_run() {
160+
local ip="${1:?Usage: droplet.sh run <ip> <ssh_private_key_file> <script>}"
161+
local key_file="${2:?}"
162+
local script="${3:?}"
163+
164+
ssh -o StrictHostKeyChecking=no -o BatchMode=yes \
165+
-i "$key_file" "root@$ip" bash -s < "$script"
166+
}
167+
168+
# ── Dispatch ──────────────────────────────────────────────────────────────────
169+
case "${1:-}" in
170+
create) shift; cmd_create "$@" ;;
171+
destroy) shift; cmd_destroy "$@" ;;
172+
wait-ssh) shift; cmd_wait_ssh "$@" ;;
173+
run) shift; cmd_run "$@" ;;
174+
*) die "Usage: droplet.sh {create|destroy|wait-ssh|run} ..." ;;
175+
esac

0 commit comments

Comments
 (0)