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/LICENSE +661 -0
- package/README.md +160 -2
- package/cli.js +58 -0
- package/kampodine.md +99 -0
- package/package.json +32 -4
- package/scripts/bluegreen.sh +610 -0
- package/scripts/deploy.sh +180 -0
- package/scripts/image-import.sh +158 -0
- package/scripts/migrate.sh +119 -0
- package/scripts/status.sh +21 -0
- package/scripts/vm-prepare.sh +531 -0
package/README.md
CHANGED
|
@@ -1,3 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kampodine
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|