kampodine 0.0.0-stage → 0.2.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.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # kampodine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **kam**al + **pod**man + alpi**ne** — Kamal-style deploys for Alpine + Podman hosts, built on [kamal-proxy](https://github.com/basecamp/kamal-proxy).
4
+
5
+ ## Why this exists
6
+
7
+ [kamal](https://kamal-deploy.org) is a great deploy tool with one hard
8
+ requirement pair: **Docker engine and systemd on the target host**. If you run
9
+ Alpine Linux — or a strict-podman fleet with no Docker anywhere — kamal cannot
10
+ deploy for you:
11
+
12
+ - **Alpine ships no systemd at all.** Not in main, community, or edge
13
+ (verified on v3.22). OpenRC is the init system, period.
14
+ - **Not every fleet runs Docker.** Rootful Podman under OpenRC
15
+ `supervise-daemon` is a perfectly good way to host containers — kamal just
16
+ can't talk to it.
17
+ - **kamal-proxy was never the problem.** It's a container image — completely
18
+ init-agnostic. Host routing, ACME TLS, and gapless target swaps via
19
+ `kamal-proxy deploy` work fine on an Alpine + Podman host.
20
+
21
+ kampodine keeps kamal-proxy as the TLS edge and replaces everything around it
22
+ with Alpine-native pieces:
23
+
24
+ | kamal assumes | kampodine uses |
25
+ |---|---|
26
+ | Docker engine | Podman (rootful, no Docker anywhere) |
27
+ | systemd units | OpenRC `supervise-daemon` services |
28
+ | Container registry | `podman save \| ssh podman load` — a plain SSH stream |
29
+ | kamal proxy orchestration | kamal-proxy itself, unchanged |
30
+
31
+ ## What it does
32
+
33
+ Same mental model as kamal: **build → ship → health-gate → cutover → instant
34
+ rollback.**
35
+
36
+ ```
37
+ kampodine deploy:
38
+ podman build --build-arg BUILD_ID=<git sha> # committed tree only
39
+ podman save <img>:<sha> | ssh <host> podman load
40
+ podman tag <img>:<sha> <img>:latest # on the VM
41
+ env file generation -> /etc/<app>/env (0600) # from your secret store
42
+ rc-service <app> restart
43
+ health endpoint must report the DEPLOYED sha # health gate
44
+ kamal-proxy deploy <host> -> <target> # gapless cutover
45
+ public smoke: health + build-id endpoints
46
+ ```
47
+
48
+ - **`kampodine deploy`** — stream deploy; `--version <sha>` restreams an
49
+ existing build; `--rollback [sha]` is an instant image-tag rollback (the
50
+ previous image stays on the VM for exactly this)
51
+ - **`kampodine bluegreen status|init|provision|flip|rollback`** — reserved
52
+ public IP management for a two-instance blue/green pair (zero DNS change,
53
+ health-gated flips, auto-rollback). **0.2.0: live-proven end-to-end** —
54
+ provision via platform-launch + golden-qcow2 disk injection (~9 min to a
55
+ booting Alpine A1; OCI custom-image import is BIOS-pinned and A1-rejects),
56
+ a guest **anchor watcher** shipped by `vm-prepare` (add-only, inert until
57
+ a flip writes `/etc/esellar/anchor.conf`), the **ACME-first flip**
58
+ (~27 s: anchor → OCI assign → cert for the reserved-IP hostname →
59
+ served-sha verify) with a ~6 s rollback, and a strict inject gate
60
+ (release must match Alpine `3.x`; reboot failures die loudly)
61
+ - **`kampodine vm-prepare`** — first-run bootstrap of a bare Alpine host
62
+ (0.2.0: sshd-hardening self-heal — fresh VMs pass unattended; busybox-wget
63
+ probes — the golden image ships no curl; ships `esellar-anchor`)
64
+ - **`kampodine image-import`** — golden qcow2 → OCI custom image (0.2.0:
65
+ every `oci` call honors `OCI_PROFILE`; note A1 rejects imported images —
66
+ use `bluegreen provision` for Ampere targets)
67
+ - **`kampodine migrate`** — sqlite migrations overSSH
68
+
69
+ ## Kamal parity
70
+
71
+ | kamal | kampodine |
72
+ |---|---|
73
+ | `kamal deploy` | `kampodine deploy` |
74
+ | `kamal rollback` | `kampodine deploy --rollback [sha]` |
75
+ | `kamal details` | `kampodine bluegreen status` |
76
+ | `kamal proxy …` | kamal-proxy container, unchanged |
77
+ | `kamal app exec/logs` | `ssh root@<host>` |
78
+ | `kamal reboot / app boot` | `rc-service <app> restart` over SSH |
79
+
80
+ ## Building the expected image
81
+
82
+ kampodine's target host is a **converged Alpine image** — Alpine (aarch64 or
83
+ x86_64) with the podman runtime, OpenRC services, and kernel settings the
84
+ deploy flow relies on. The reference build pipeline:
85
+
86
+ 1. **packer + qemu (UEFI)** boots the Alpine `virt` ISO. Boot is driven over
87
+ the **serial console**, not VNC — the ISO's VGA tty has no getty; a serial
88
+ bootstrap helper handles the login prompt and ash's `ESC[6n` cursor query,
89
+ then types one line that brings up DHCP and fetches the bootstrap script.
90
+ 2. **install** runs `setup-disk` under UEFI (ESP + grub-efi, `--removable`
91
+ fallback for hosts with fresh NVRAM), sanitizes apk repos, installs
92
+ `python3` (ansible runtime) + `openssh`, enables boot-critical OpenRC
93
+ services, bakes your public key, reboots.
94
+ 3. **ansible converge** installs the podman stack — podman, netavark,
95
+ aardvark-dns, **iptables** (netavark needs it), the **cgroups** service
96
+ (cloud VMs boot without one), `ip_unprivileged_port_start=80` sysctl — and
97
+ lays down `supervise-daemon` templates for app + kamal-proxy. **The same
98
+ playbook converges the golden image and repairs live drift**: one
99
+ convergence path, the image is by construction what the playbook produces.
100
+ 4. **ship the qcow2** to your cloud. On OCI: import **from a launched
101
+ instance** (or boot-volume seeding) — raw QCOW2 object imports land
102
+ x86-flagged and won't boot on Ampere. `kampodine image-import` +
103
+ `kampodine vm-prepare` handle that path.
104
+ 5. First deploy issues TLS (ACME) via kamal-proxy — no cert carry.
105
+
106
+ Verify a build by booting the qcow2 locally (qemu UEFI boot, serial console)
107
+ before importing — the image should reach a working sshd with the podman
108
+ stack installed.
109
+
110
+ ## Shipping the image: mirror vs no-mirror
111
+
112
+ Two ways to get the built container image onto the target — kampodine
113
+ automates the second; the first is a documented recipe:
114
+
115
+ **No-mirror (default): the SSH stream.** `podman save | ssh podman load` over
116
+ the deploy's own SSH connection. Nothing exists between you and the VM: no
117
+ registry, no tunnel, no extra ports, no credentials beyond SSH.
118
+
119
+ - ✅ zero infrastructure; works air-gapped; nothing to keep alive
120
+ - ✅ sha-tagged images land atomically; `podman load` dedupes layers by digest
121
+ - ⚠️ each deploy streams the full image tar; no cross-deploy layer cache
122
+
123
+ **Mirror (optional): any OCI registry.** `podman push` the sha-tagged image to
124
+ a registry the VM can reach; the VM `podman pull`s and retags `:latest` — the
125
+ health-gate/cutover tail is identical. Works with hosted registries (GHCR,
126
+ Docker Hub, cloud registries) or a local one mirrored over an SSH reverse
127
+ tunnel (`ssh -R 5000:127.0.0.1:5000`).
128
+
129
+ - ✅ layer caching (fast repeated deploys), multi-target fan-out, CI-native
130
+ - ⚠️ requires registry reachability + credentials on the VM
131
+ - ⚠️ macOS note: AirPlay listens on :5000 by default — pick another port for
132
+ a local registry
133
+
134
+ Rule of thumb: one VM and no existing registry → stream. Multiple targets,
135
+ CI-driven deploys, or a registry you already run → mirror. Registry-path
136
+ automation is on the roadmap; v0.1.0 ships the stream.
137
+
138
+ ## Prerequisites
139
+
140
+ Deploy machine: node ≥ 20, podman, ssh key access to the target, `oci` CLI
141
+ (for bluegreen / image-import), and whatever secret-resolution your env-file
142
+ step uses (kampodine is agnostic; the reference setup uses
143
+ [varlock](https://varlock.dev) + pass).
144
+
145
+ Target: a converged Alpine + Podman + OpenRC host (see above), reachable over
146
+ ssh as root, with kamal-proxy running.
147
+
148
+ ## Status
149
+
150
+ v0.1.0 — kampodine deploys a production service today. Some defaults are
151
+ still coupled to the deployment it grew out of (env schema path, service and
152
+ container names, deploy-host env) — genericizing them is the top roadmap
153
+ item. Founding doc + checklist: [`kampodine.md`](./kampodine.md).
154
+
155
+ ## License
156
+
157
+ Copyright (c) 2026 talha7k — **AGPL-3.0** (see [LICENSE](./LICENSE)).
158
+
159
+ Copyleft with teeth: anyone who uses, modifies, or hosts kampodine —
160
+ including as a network service — must publish their source under AGPL-3.0,
161
+ and original attribution must be preserved.
package/cli.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const pkgDir = dirname(fileURLToPath(import.meta.url));
8
+ const { version } = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
9
+
10
+ const commands = {
11
+ deploy: "deploy.sh",
12
+ status: "status.sh",
13
+ bluegreen: "bluegreen.sh",
14
+ "vm-prepare": "vm-prepare.sh",
15
+ "image-import": "image-import.sh",
16
+ migrate: "migrate.sh",
17
+ };
18
+
19
+ const usage = `kampodine — kamal-alternative CLI for Alpine + Podman deploys, built on kamal-proxy
20
+
21
+ Usage: kampodine <command> [args...]
22
+
23
+ Commands:
24
+ deploy stream deploy (podman save | ssh podman load) with sha-verified health gate; --rollback [sha] = instant image-tag rollback
25
+ bluegreen reserved-IP blue/green pair: status (pair + reserved IP + health) | init | provision | flip | rollback
26
+ vm-prepare first-run bootstrap of a bare Alpine host (OpenRC + podman stack)
27
+ image-import golden qcow2 -> OCI custom image
28
+ migrate tenant db migrations over SSH
29
+
30
+ All further args pass through to the underlying script.
31
+ `;
32
+
33
+ const [cmd, ...args] = process.argv.slice(2);
34
+
35
+ if (!cmd || cmd === "--help" || cmd === "-h") {
36
+ process.stdout.write(usage);
37
+ process.exit(0);
38
+ }
39
+ if (cmd === "--version") {
40
+ process.stdout.write(`${version}\n`);
41
+ process.exit(0);
42
+ }
43
+
44
+ const script = commands[cmd];
45
+ if (!script) {
46
+ process.stderr.write(`kampodine: unknown command: ${cmd}\n\n${usage}`);
47
+ process.exit(2);
48
+ }
49
+
50
+ const result = spawnSync("bash", [join(pkgDir, "scripts", script), ...args], {
51
+ stdio: "inherit",
52
+ env: process.env,
53
+ });
54
+ if (result.error) {
55
+ process.stderr.write(`kampodine: ${result.error.message}\n`);
56
+ process.exit(1);
57
+ }
58
+ process.exit(result.status ?? 1);
package/kampodine.md ADDED
@@ -0,0 +1,99 @@
1
+ # kampodine — founding doc
2
+
3
+ Positioning, architecture, and build-out checklist. Public README covers the
4
+ pitch; this doc is the deeper reference.
5
+
6
+ ## Why this exists
7
+
8
+ - **kamal (the tool) requires Docker engine + systemd on the target host.**
9
+ - **Alpine ships no systemd at all** (verified: absent from v3.22
10
+ main/community + edge — OpenRC is the init, full stop).
11
+ - **Podman-only fleets exist** — no Docker engine on any machine.
12
+ - **kamal-proxy doesn't care about any of that** — it's a container image,
13
+ init-agnostic. It was never the problem; kamal's host assumptions were.
14
+
15
+ So: keep kamal-proxy as the TLS edge, replace the deploy machinery around it
16
+ with Alpine-native pieces — OpenRC `supervise-daemon`, rootful podman, and a
17
+ plain SSH stream instead of a registry.
18
+
19
+ ## Positioning
20
+
21
+ - A **kamal alternative** for podman + Alpine targets.
22
+ - **Built ON kamal-proxy** — host routing, ACME TLS, gapless target swaps via
23
+ `kamal-proxy deploy` are all still kamal-proxy's job.
24
+ - Same mental model as kamal: **build → ship → health-gate → cutover →
25
+ instant rollback**. No registry in the middle.
26
+
27
+ ## Commands (kamal parity)
28
+
29
+ | kamal | kampodine |
30
+ |---|---|
31
+ | `kamal deploy` | `kampodine deploy` — stream deploy (`podman save \| ssh podman load`) |
32
+ | `kamal rollback` | `kampodine deploy --rollback [sha]` — instant image-tag rollback |
33
+ | `kamal details` | `kampodine bluegreen status` — pair + reserved IP + health |
34
+ | `kamal proxy …` | unchanged — kamal-proxy container (OpenRC-supervised) |
35
+ | `kamal app exec/logs` | `ssh root@<host>` |
36
+ | — | `kampodine bluegreen init/provision/flip` — reserved-IP blue-green pair |
37
+ | — | `kampodine image-import` — golden qcow2 → OCI custom image |
38
+ | — | `kampodine vm-prepare` — first-run bootstrap of a bare Alpine host |
39
+ | — | `kampodine migrate` — sqlite migrations over SSH |
40
+
41
+ ## Deploy pipeline (what `kampodine deploy` does)
42
+
43
+ ```
44
+ build (BUILD_ID=<git sha>, committed tree only)
45
+ -> podman save <img>:<sha> | ssh vm podman load
46
+ -> podman tag <img>:<sha> <img>:latest
47
+ -> env file generation -> /etc/<app>/env (0600, secret-store resolved)
48
+ -> rc-service <app> restart
49
+ -> health endpoint must report the DEPLOYED sha # health gate
50
+ -> kamal-proxy deploy <host> -> <target> # gapless cutover
51
+ -> public smoke: health + build-id endpoints
52
+ ```
53
+
54
+ Guarantees: committed-tree-only deploys (sha = build identity, verified at
55
+ health time); the previous image stays on the VM (rollback = retag + restart);
56
+ no registry, no tunnel, no docker, no systemd — SSH + podman only.
57
+
58
+ ## Checklist
59
+
60
+ **Done (battle-tested in production):**
61
+ - [x] registry-free stream deploy with sha-verified health gate + public smoke
62
+ - [x] instant rollback (`--rollback [sha]`)
63
+ - [x] secret-store-resolved env file generation (zero plaintext)
64
+ - [x] kamal-proxy TLS registration + ACME (per-host)
65
+ - [x] OpenRC golden image — packer build + single-convergence ansible roles
66
+ - [x] `vm-prepare` start-fresh bootstrap
67
+ - [x] blue-green reserved-IP tooling — `status`/`init`/`provision`/`flip`/
68
+ `rollback`; discovery, dormant reserved-IP detection, health checks,
69
+ and flip-guards verified against live OCI; flip E2E runs when a second
70
+ instance exists
71
+ - [x] sqlite migration runner (`migrate`)
72
+ - [x] OCI image import path (`image-import`)
73
+
74
+ **In flight:**
75
+ - [ ] npm package scaffold — bin `kampodine`, scripts shipped in-package
76
+ - [ ] reference sweep (docs → kampodine invocations)
77
+
78
+ **Todo:**
79
+ - [ ] package tests — script contract gates (bash -n + shellcheck over every
80
+ script), cli dispatch contract (help/version/exit codes/bin integrity)
81
+ - [ ] end-to-end verification: one real deploy through the CLI (zero-change
82
+ restream of the live image, full pipeline verify)
83
+ - [ ] `kampodine status` — live served sha + pair view in one command
84
+ - [ ] registry-path automation (mirror mode: push to any OCI registry,
85
+ VM-side pull, same health-gate/cutover tail)
86
+ - [ ] genericize origin-coupled defaults (env schema path, service/container
87
+ names, deploy-host env) into config
88
+ - [ ] npm publish
89
+
90
+ ## Non-goals
91
+
92
+ - multi-host orchestration, roles, accessory containers — kamal's scale
93
+ problems small fleets don't have
94
+ - replacing kamal-proxy (that part is already right)
95
+
96
+ ## Prerequisites (deploy machine)
97
+
98
+ node ≥ 20, podman, oci CLI (bluegreen/image-import), ssh key to the target,
99
+ your secret-resolution tool of choice for the env step.
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "kampodine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Kamal-style deploys for Alpine + Podman hosts, built on kamal-proxy",
5
+ "license": "AGPL-3.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "kampodine": "./cli.js"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run",
12
+ "test:exact": "vitest run"
13
+ },
14
+ "files": [
15
+ "cli.js",
16
+ "scripts/",
17
+ "kampodine.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "devDependencies": {
23
+ "vite": "^8.0.16",
24
+ "vitest": "^4.1.10"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/talha7k/kampodine.git"
29
+ },
30
+ "homepage": "https://github.com/talha7k/kampodine#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/talha7k/kampodine/issues"
33
+ }
34
+ }