kampodine 0.2.0 → 0.2.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1 — 2026-10-07
4
+
5
+ Docs, hygiene, and deploy hardening.
6
+
7
+ - Global-audience README: removed origin-specific details (service names,
8
+ drill timings, private-repo references)
9
+ - Removed origin-specific defaults that leaked a real host: the deploy
10
+ target and Host-header defaults are now placeholders — set `ESPELLAR_HOST`
11
+ and `PROXY_HOST` / `APP_HOST_HEADER` explicitly (they were env overrides
12
+ before and still are)
13
+ - `deploy`: `--dockerfile <path>` for alternative image recipes (e.g. a Go
14
+ cutover image, built with `GIT_SHA` instead of `VITE_BUILD_ID`); the
15
+ health probe now runs on the VM host (busybox wget) so scratch/Go images
16
+ without a shell probe cleanly
17
+ - Script/test comments: dropped private runbook and drill-log references,
18
+ kept the technical rationale
19
+ - Fixed standalone-repo test paths (two vm-prepare suites still resolved the
20
+ old monorepo layout); repo gained `.gitignore` + `package-lock.json`
21
+
22
+ ## 0.2.0 — 2026-10-07
23
+
24
+ - `bluegreen` — reserved-IP blue/green pairs on OCI:
25
+ `status|init|provision|flip|rollback`; zero-DNS flips, ACME-first
26
+ cutover, health-gated with automatic rollback; a guest anchor watcher is
27
+ shipped by `vm-prepare`
28
+ - `vm-prepare` — sshd-hardening self-heal for fresh VMs, busybox-wget
29
+ probes (the golden image ships no curl), anchor watcher install
30
+ - `image-import` — every `oci` call honors `OCI_PROFILE`
31
+
32
+ ## 0.1.0 — 2026-10-05
33
+
34
+ - First release: registry-free stream deploy (`podman save | ssh podman
35
+ load`) with sha-verified health gate and instant rollback, kamal-proxy
36
+ TLS/ACME registration, OpenRC golden-image pipeline, `migrate` (sqlite
37
+ migrations over SSH), OCI image-import path
package/README.md CHANGED
@@ -49,22 +49,19 @@ kampodine deploy:
49
49
  existing build; `--rollback [sha]` is an instant image-tag rollback (the
50
50
  previous image stays on the VM for exactly this)
51
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
52
+ public IP management for a two-instance blue/green pair on OCI: zero DNS
53
+ change, health-gated flips with an ACME-first cutover (reserved IP assigned
54
+ → cert issued for its hostname → served sha verified) and automatic
55
+ rollback. A guest **anchor watcher** installed by `vm-prepare` holds the
56
+ flip's IP half — add-only and inert until a flip writes its anchor config.
57
+ - **`kampodine vm-prepare`** — first-run bootstrap of a bare Alpine host:
58
+ sshd hardening (fresh VMs pass unattended), busybox-wget health probes (the
59
+ golden image ships no curl), the blue-green anchor watcher, the podman
60
+ stack, and OpenRC `supervise-daemon` units for app + kamal-proxy
61
+ - **`kampodine image-import`** — golden qcow2 → OCI custom image. Caveat:
62
+ imported custom images boot BIOS and are rejected by Ampere (A1) shapes —
63
+ use `bluegreen provision` for ARM targets
64
+ - **`kampodine migrate`** — sqlite migrations over SSH
68
65
 
69
66
  ## Kamal parity
70
67
 
@@ -133,7 +130,7 @@ tunnel (`ssh -R 5000:127.0.0.1:5000`).
133
130
 
134
131
  Rule of thumb: one VM and no existing registry → stream. Multiple targets,
135
132
  CI-driven deploys, or a registry you already run → mirror. Registry-path
136
- automation is on the roadmap; v0.1.0 ships the stream.
133
+ automation is on the roadmap; today the stream is the automated path.
137
134
 
138
135
  ## Prerequisites
139
136
 
@@ -147,10 +144,11 @@ ssh as root, with kamal-proxy running.
147
144
 
148
145
  ## Status
149
146
 
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).
147
+ Proven on production deployments (v0.2.x). Defaults are still opinionated —
148
+ service/container names, env-file path, deploy-host env — and genericizing
149
+ them is the top roadmap item. Design reference + roadmap:
150
+ [`kampodine.md`](./kampodine.md). Release history:
151
+ [CHANGELOG.md](./CHANGELOG.md).
154
152
 
155
153
  ## License
156
154
 
package/kampodine.md CHANGED
@@ -70,14 +70,15 @@ no registry, no tunnel, no docker, no systemd — SSH + podman only.
70
70
  instance exists
71
71
  - [x] sqlite migration runner (`migrate`)
72
72
  - [x] OCI image import path (`image-import`)
73
+ - [x] npm package — bin `kampodine`, scripts shipped in-package, published on
74
+ npm, with package tests (script contract gates: `bash -n` + shellcheck
75
+ over every script; cli dispatch contract: help/version/exit codes/bin
76
+ integrity)
73
77
 
74
78
  **In flight:**
75
- - [ ] npm package scaffold — bin `kampodine`, scripts shipped in-package
76
79
  - [ ] reference sweep (docs → kampodine invocations)
77
80
 
78
81
  **Todo:**
79
- - [ ] package tests — script contract gates (bash -n + shellcheck over every
80
- script), cli dispatch contract (help/version/exit codes/bin integrity)
81
82
  - [ ] end-to-end verification: one real deploy through the CLI (zero-change
82
83
  restream of the live image, full pipeline verify)
83
84
  - [ ] `kampodine status` — live served sha + pair view in one command
@@ -85,7 +86,6 @@ no registry, no tunnel, no docker, no systemd — SSH + podman only.
85
86
  VM-side pull, same health-gate/cutover tail)
86
87
  - [ ] genericize origin-coupled defaults (env schema path, service/container
87
88
  names, deploy-host env) into config
88
- - [ ] npm publish
89
89
 
90
90
  ## Non-goals
91
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kampodine",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Kamal-style deploys for Alpine + Podman hosts, built on kamal-proxy",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  "files": [
15
15
  "cli.js",
16
16
  "scripts/",
17
- "kampodine.md"
17
+ "kampodine.md",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=20"
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env bash
2
- # bluegreen.sh — reserved-public-IP blue/green pair management for the OCI A1 VMs.
2
+ # bluegreen.sh — reserved-public-IP blue/green pair management for OCI VMs.
3
3
  #
4
- # The go-live topology is ONE instance (esellar-green). This tool is the
5
- # DORMANT future capability: when a second instance is warranted
6
- # (zero-downtime upgrades, risky migrations), it becomes a two-instance pair
7
- # sharing one RESERVED public IP — the internet-facing address never changes,
8
- # the flip is one OCI API call, rollback is the same call reversed.
4
+ # A deployment can start as ONE instance; this tool is the DORMANT future
5
+ # capability: when a second instance is warranted (zero-downtime upgrades,
6
+ # risky migrations), it becomes a two-instance pair sharing one RESERVED
7
+ # public IP — the internet-facing address never changes, the flip is one OCI
8
+ # API call, rollback is the same call reversed.
9
9
  #
10
- # Colors are instance display names: esellar-blue / esellar-green.
10
+ # Colors are instance display names: <app>-blue / <app>-green (defaults:
11
+ # esellar-blue / esellar-green).
11
12
  # The reserved IP is derived from live OCI state — no local state file.
12
13
  #
13
14
  # Usage:
@@ -17,13 +18,13 @@
17
18
  # kampodine bluegreen flip --to <color> # ACME-first health-gated flip (see below)
18
19
  # kampodine bluegreen rollback # unassign to DORMANT + holder guest cleanup
19
20
  #
20
- # FLIP = ACME-FIRST (closes the 2026-10-07 pt2 serving-leg gap):
21
+ # FLIP = ACME-FIRST:
21
22
  # 1. health gate on the target's own IP
22
23
  # 2. anchor.conf written on the TARGET guest over ssh — the esellar-anchor
23
24
  # watcher service (shipped by vm-prepare) configures the anchor private
24
25
  # address within one interval; flip waits for `ip addr` to show it
25
26
  # 3. OCI assigns the reserved IP to the target's anchor (secondary private
26
- # ip — the primary holds the launch-time ephemeral, 409 live-proven)
27
+ # ip — the primary holds the launch-time ephemeral and rejects a second public ip)
27
28
  # 4. ACME on the TARGET's kamal-proxy for the reserved-IP sslip hostname
28
29
  # (`podman exec kamal-proxy kamal-proxy deploy --host=<raddr-dashes>
29
30
  # .sslip.io --tls`): HTTP-01 needs the hostname to already resolve to
@@ -38,7 +39,7 @@
38
39
  # 5. verify https through the reserved ip (curl --resolve, valid cert for
39
40
  # the sslip name + /up 200) and the served sha — only then: FLIPPED.
40
41
  # Any post-assign failure auto-rolls back: the reserved ip goes to the
41
- # other color's EXISTING anchor (lookup-ONLY — drills never mint green-side
42
+ # other color's EXISTING anchor (lookup-ONLY — rollback never creates target-side
42
43
  # artifacts) or, when none exists, back to UNASSIGNED/dormant; the failed
43
44
  # target's anchor.conf is removed and its anchor address deleted.
44
45
  #
@@ -51,16 +52,16 @@
51
52
  # 1. NATIVE — a UEFI_64 esellar-alpine* custom image exists in the
52
53
  # compartment: launch it directly (the golden image boots as-is).
53
54
  # 2. INJECT (provision-via-migrate) — OCI pins imported custom images to
54
- # firmware=BIOS and A1/Ampere is UEFI-only (exhaustively proven,
55
- # RUNBOOK §Blue-green), but green itself runs Alpine on a boot volume
56
- # whose image metadata is the Ubuntu PLATFORM image: green was built by
55
+ # firmware=BIOS and A1/Ampere is UEFI-only, but the template instance
56
+ # itself runs Alpine on a boot volume
57
+ # whose image metadata is the Ubuntu PLATFORM image: it was built by
57
58
  # platform-image launch + disk injection. With no UEFI custom image,
58
59
  # provision queries the template instance's LIVE image-id (never
59
60
  # hardcoded — proven A1-launchable, since the template runs on it),
60
61
  # launches from it with the ops ssh key, then streams the golden qcow2
61
62
  # onto the new instance's boot disk (qemu-img convert -> gzip | ssh
62
63
  # 'gunzip | sudo dd', reboot, verify /etc/alpine-release). The instance
63
- # record keeps the platform image metadata — exactly like green.
64
+ # record keeps the platform image metadata — exactly like the template.
64
65
  #
65
66
  # Env: OCI_PROFILE (default esellar-api), OCI_COMPARTMENT (default esellar).
66
67
  # Injection extras: ALPINE_QCOW2 (golden disk path), OPS_SSH_PUBKEY (ops
@@ -72,7 +73,7 @@ set -euo pipefail
72
73
 
73
74
  PROFILE="${OCI_PROFILE:-esellar-api}"
74
75
  COMPARTMENT_NAME="${OCI_COMPARTMENT:-esellar}"
75
- APP_HOST_HEADER="${APP_HOST_HEADER:-84-13-128-216.sslip.io}"
76
+ APP_HOST_HEADER="${APP_HOST_HEADER:-app.example.com}"
76
77
  REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
77
78
  SSH_OPTS=(-o ConnectTimeout=6 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
78
79
 
@@ -128,8 +129,7 @@ primary_vnic_of() {
128
129
  # reserved_anchor_ip <vnic_ocid> -> private-ip ocid to anchor the reserved
129
130
  # public ip on. OCI allows ONE public ip per private ip: the PRIMARY private
130
131
  # ip holds the instance's launch-time EPHEMERAL public ip, so assigning the
131
- # reserved there is a 409 Conflict ("already has a public IP", live-proven
132
- # 2026-10-07). The reserved anchors on a SECONDARY private ip instead: both
132
+ # reserved there is a 409 Conflict ("already has a public IP"). The reserved anchors on a SECONDARY private ip instead: both
133
133
  # colors keep their ephemeral address + reachability, the flip moves only
134
134
  # the reserved ip between secondaries.
135
135
  # existing_anchor_ip <vnic> -> the SECONDARY private-ip ocid or empty.
@@ -254,8 +254,8 @@ flip_failure_rollback() {
254
254
 
255
255
  # instance_healthy <ephemeral_ip> -> ssh + loopback app check.
256
256
  # busybox wget, NOT curl: the golden image is deliberately minimal (ssh +
257
- # OpenRC + busybox) and ships no curl (2026-10-07 flip attempt 3: the gate
258
- # failed "unhealthy" on a healthy blue because curl is absent).
257
+ # OpenRC + busybox) and ships no curl (a curl-based gate reports
258
+ # "unhealthy" for a healthy instance because curl is absent).
259
259
  instance_healthy() {
260
260
  local ip="$1"
261
261
  [[ -n "$ip" ]] || return 1
@@ -264,8 +264,8 @@ instance_healthy() {
264
264
  }
265
265
 
266
266
  # instance_wait_running <iid> — poll lifecycle-state to RUNNING. The OCI CLI's
267
- # `instance get` has NO --wait-for-state option (3.94.1: "No such option" —
268
- # the 2026-10-07 drill's first provision died on exactly that), so poll.
267
+ # `instance get` has NO --wait-for-state option (CLI 3.94.1: "No such
268
+ # option"), so poll.
269
269
  # Dies on the terminal-bad states; everything else (PROVISIONING, STARTING…)
270
270
  # keeps the loop going.
271
271
  instance_wait_running() {
@@ -298,7 +298,7 @@ inject_alpine() {
298
298
  # IP runs sshd on TWO different host keys (Ubuntu platform first boot ->
299
299
  # injected Alpine first boot). accept-new takes the first, then refuses
300
300
  # the CHANGED key after the reboot — the user's known_hosts would wedge
301
- # the Alpine verify forever (2026-10-07 live drill). The scrub below
301
+ # the Alpine verify forever. The scrub below
302
302
  # clears the phase file between the two boots; the user's file is never
303
303
  # touched.
304
304
  kh="$(mktemp "${TMPDIR:-/tmp}/esellar-inject-kh.XXXXXX")"
@@ -323,12 +323,12 @@ inject_alpine() {
323
323
  rm -f "$raw"
324
324
  # ClientAlive keepalives on the reboot call: reboot -f kills the platform
325
325
  # sshd WITHOUT closing the TCP session, and ConnectTimeout only bounds
326
- # connection ESTABLISHMENT — the pt3 drill hung the whole provisioner on
327
- # that wedged session (the guest was long up when the client gave up).
326
+ # connection ESTABLISHMENT — a wedged session hangs the whole provisioner
327
+ # (the guest is long up when the client gives up).
328
328
  # ClientAliveCountMax x Interval bounds a dead session to ~15s.
329
329
  say "inject: rebooting into the injected disk (reboot -f — the old fs is gone)…"
330
- # Surface the reboot failure: `|| true` here once false-INJECTED (Phase-4
331
- # rehearsal finding 2026-10-07) — the wedged Ubuntu guest kept answering
330
+ # Surface the reboot failure: a `|| true` here once false-INJECTED — the
331
+ # wedged (un-rebooted) guest kept answering
332
332
  # ssh and the probe below accepted its banner as "Alpine boots". The
333
333
  # reboot must SUCCEED for the inject to be real (the disk was replaced).
334
334
  if ! iss -o ClientAliveInterval=5 -o ClientAliveCountMax=3 "${ruser}@${ip}" 'sudo reboot -f' >/dev/null 2>&1; then
@@ -341,8 +341,8 @@ inject_alpine() {
341
341
  rel=""
342
342
  for ((i = 1; i <= probe_tries; i++)); do
343
343
  rel="$(iss "root@${ip}" 'cat /etc/alpine-release' 2>/dev/null || true)"
344
- # Verify the RELEASE STRING, not non-empty output: the Phase-4 rehearsal
345
- # false-INJECTED when the un-rebooted Ubuntu guest's ssh banner satisfied
344
+ # Verify the RELEASE STRING, not non-empty output: a non-empty check
345
+ # false-INJECTS when the un-rebooted platform guest's ssh banner satisfies
346
346
  # a non-empty check. /etc/alpine-release only exists on a real Alpine
347
347
  # boot and reads 3.x for every image we ship.
348
348
  if [[ "$rel" =~ ^3\.[0-9]+\.[0-9]+ ]]; then break; fi
@@ -436,13 +436,13 @@ case "$cmd" in
436
436
  [[ "$image" == ocid1.image* ]] || die "template instance has no resolvable image-id — cannot launch or inject"
437
437
  mode="inject"
438
438
  qcow2="${ALPINE_QCOW2:-${REPO_ROOT}/infra/alpine-host/build/esellar-alpine-3.22.6-aarch64.qcow2}"
439
- [[ -f "$qcow2" ]] || die "golden qcow2 not found: $qcow2 (build via packer, RUNBOOK § 1, or set ALPINE_QCOW2)"
439
+ [[ -f "$qcow2" ]] || die "golden qcow2 not found: $qcow2 (build via packer, or set ALPINE_QCOW2)"
440
440
  command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found in PATH (brew install qemu) — required for the qcow2 -> raw conversion"
441
441
  # ops ssh public key for the platform-image first boot. OPS_SSH_PUBKEY
442
442
  # (explicit file) wins; else derive from the ssh AGENT (ssh-add -L) —
443
443
  # the agent key is what every kampodine ssh + the golden image's baked
444
444
  # ops key expect; ~/.ssh/id_ed25519.pub can be a stale personal key
445
- # (2026-10-07 drill: launch with the stale pub = unreachable instance).
445
+ # (launching with a stale pub = unreachable instance).
446
446
  if [[ -n "${OPS_SSH_PUBKEY:-}" ]]; then
447
447
  [[ -f "$OPS_SSH_PUBKEY" ]] || die "ops ssh public key not found: $OPS_SSH_PUBKEY (set OPS_SSH_PUBKEY or load the key into the agent)"
448
448
  keyfile="$OPS_SSH_PUBKEY"
@@ -465,7 +465,7 @@ case "$cmd" in
465
465
  iid="$(oci compute instance launch "${launch_args[@]}" --profile "$PROFILE" --query 'data.id' --raw-output)"
466
466
  say "LAUNCHED esellar-$color: $iid"
467
467
  if [[ "$mode" == "native" ]]; then
468
- say "next (RUNBOOK § start-fresh): wait RUNNING -> ssh in -> kampodine vm-prepare -> kampodine deploy --host root@<ephemeral-ip>"
468
+ say "next: wait RUNNING -> ssh in -> kampodine vm-prepare -> kampodine deploy --host root@<ephemeral-ip>"
469
469
  say "then 'bluegreen.sh flip --to $color' (health-gated) once its app checks green."
470
470
  exit 0
471
471
  fi
@@ -560,7 +560,7 @@ case "$cmd" in
560
560
  ;;
561
561
 
562
562
  rollback)
563
- # DORMANT rollback (2026-10-07 pt3 contract): holder guest cleanup
563
+ # DORMANT rollback: holder guest cleanup
564
564
  # (anchor.conf removal + anchor address delete — the flip tool's explicit
565
565
  # job; the esellar-anchor watcher is add-only) then the OCI unassign
566
566
  # (documented CLI semantics: an empty --private-ip-id unassigns). For a
package/scripts/deploy.sh CHANGED
@@ -20,13 +20,14 @@
20
20
  # kampodine deploy --version <sha7> # stream an existing local build
21
21
  # kampodine deploy --rollback [<sha7>] # default: previous
22
22
  # kampodine deploy --host root@<ip> ... # target VM override
23
+ # kampodine deploy --dockerfile apps/api-go/Dockerfile.cutover # Go api image
23
24
  # --skip-smoke # skip the public smoke (behind a not-yet-switched proxy)
24
25
  # --refresh-config # ansible container-service refresh BEFORE restart
25
26
  set -euo pipefail
26
27
 
27
28
  REPO_ROOT="$(git rev-parse --show-toplevel)"
28
- ESPELLAR_HOST="${ESPELLAR_HOST:-root@84.13.128.216}"
29
- PROXY_HOST="${PROXY_HOST:-84-13-128-216.sslip.io}"
29
+ ESPELLAR_HOST="${ESPELLAR_HOST:-root@app.example.com}"
30
+ PROXY_HOST="${PROXY_HOST:-app.example.com}"
30
31
  ENV_FILE_REMOTE="/etc/esellar/env"
31
32
  DEPLOYED_SHA_FILE="/etc/esellar/deployed-sha"
32
33
  ENV_CLEAR_KEYS='^(NODE_ENV|PORT|LIBSQL_TENANT_DIR|LIBSQL_API_MOUNT|STATIC_SPA_MOUNT)='
@@ -36,7 +37,11 @@ MODE="deploy"
36
37
  VERSION=""
37
38
  SKIP_SMOKE=0
38
39
  REFRESH_CONFIG=0
39
- SSH_KEY="${ESPELLAR_SSH_KEY:-}"
40
+ SSH_KEY="${ESSELLAR_SSH_KEY:-}"
41
+ # Default image recipe = the TS api Containerfile. The Go cutover image rides
42
+ # --dockerfile apps/api-go/Dockerfile.cutover (build context stays the repo
43
+ # root for BOTH — the cutover Dockerfile path-prefixes its COPYs).
44
+ DOCKERFILE="${KAMPODINE_DOCKERFILE:-apps/api/Containerfile}"
40
45
 
41
46
  say() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
42
47
  die() { printf '\033[1;31m[deploy] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
@@ -53,6 +58,7 @@ while [[ $# -gt 0 ]]; do
53
58
  ;;
54
59
  --skip-smoke) SKIP_SMOKE=1; shift ;;
55
60
  --refresh-config) REFRESH_CONFIG=1; shift ;;
61
+ --dockerfile) DOCKERFILE="$2"; shift 2 ;;
56
62
  -h|--help) usage ;;
57
63
  *) die "unknown argument: $1 (--help)" ;;
58
64
  esac
@@ -85,10 +91,16 @@ podman info >/dev/null 2>&1 || die "podman machine not reachable (podman machine
85
91
 
86
92
  # --- build (deploy mode always builds HEAD — cached layers keep it minutes) ----
87
93
  if [[ "$MODE" == "deploy" ]]; then
88
- say "building linux/arm64 (VITE_BUILD_ID=$VER — build identity, never remove: 2026-09-10 stale-tab pin)…"
94
+ if [[ "$DOCKERFILE" == "apps/api/Containerfile" ]]; then
95
+ say "building linux/arm64 (VITE_BUILD_ID=$VER — build identity, never remove)…"
96
+ BUILD_ARG=("VITE_BUILD_ID=$VER")
97
+ else
98
+ say "building linux/arm64 ($DOCKERFILE, GIT_SHA=$VER)…"
99
+ BUILD_ARG=("GIT_SHA=$VER")
100
+ fi
89
101
  podman build --platform linux/arm64 \
90
- -f apps/api/Containerfile \
91
- --build-arg VITE_BUILD_ID="$VER" \
102
+ -f "$DOCKERFILE" \
103
+ --build-arg "${BUILD_ARG[0]}" \
92
104
  -t "$IMAGE:$VER" \
93
105
  . || die "podman build failed"
94
106
  # Same-origin contract: VITE_API_URL / VITE_LIBSQL_API_URL stay UNSET —
@@ -135,10 +147,13 @@ fi
135
147
  say "rc-service esellar-api restart (supervise-daemon: stop-old/start-new podman run on the retagged :latest)…"
136
148
  vm "rc-service esellar-api restart" || die "esellar-api restart failed (rc-service esellar-api status)"
137
149
 
138
- say "health probe (exec-fetch /api/auth/ok + served-sha — deploy-api.sh pattern)…"
150
+ say "health probe (host-side wget /api/auth/ok + served-sha — works for node AND scratch images)…"
139
151
  HEALTH_OK=0
140
152
  for _ in $(seq 1 30); do
141
- if vm "podman exec esellar-api node -e \"fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/auth/ok').then(r=>r.text()).then(t=>{const m=JSON.parse(t);if(m.git!=='$VER'){console.error('served sha mismatch:',t);process.exit(1)}console.log('HEALTH OK:',t)}).catch(e=>{console.error('HEALTH FAIL:',e.message);process.exit(1)})\"" 2>/dev/null; then
153
+ # Probe from the VM HOST (busybox wget — the Go image is scratch: no node,
154
+ # no shell; the -p 8080:8080 publish makes the port host-reachable for
155
+ # every image flavor). Grep the JSON — same check the public smoke does.
156
+ if vm "wget -qO- -T 3 http://127.0.0.1:8080/api/auth/ok 2>/dev/null | grep -q '\"git\":\"$VER'\\|\"build\":\"$VER'" 2>/dev/null; then
142
157
  HEALTH_OK=1
143
158
  break
144
159
  fi
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # image-import.sh — upload the packer-built qcow2 to Object Storage and import
3
- # it as an OCI custom image (runs on the BUILD Mac; the VM never touches OCI
4
- # APIs — see backup-tenants-par.sh for the no-agent backup path).
3
+ # it as an OCI custom image (runs on the BUILD machine; the VM never touches
4
+ # OCI APIs).
5
5
  #
6
6
  # VALIDATED CONSTRAINTS this script encodes:
7
7
  # - Alpine is NOT in OCI's supported custom-image import OS list -> the import
@@ -93,7 +93,7 @@ oci os object put -bn "$BUCKET" --profile "$PROFILE" --file "$IMAGE" --name "$OB
93
93
  || die "object upload failed (bucket exists? oci os bucket create -bn $BUCKET -c $COMPARTMENT_OCID)"
94
94
 
95
95
  say "importing as custom image '$IMAGE_NAME' (self-supported: PARAVIRTUALIZED)…"
96
- # A1/Ampere firmware gate (2026-10-07 drill, empirically exhausted):
96
+ # A1/Ampere firmware gate (OCI platform behavior, pinned empirically):
97
97
  # OCI pins imported images to launch-options firmware=BIOS. It is NOT
98
98
  # derived from the --operating-system string (a recognized aarch64 string
99
99
  # still yields BIOS) and NOT re-derivable via `compute image update` (OS
@@ -130,15 +130,15 @@ for _ in $(seq 1 120); do
130
130
  done
131
131
  [[ "$STATE" == "AVAILABLE" ]] || die "import did not become AVAILABLE in 30m (state: $STATE)"
132
132
 
133
- # Firmware verdict — A1/Ampere (our target shape) rejects BIOS-pinned images
133
+ # Firmware verdict — A1/Ampere (UEFI-only) rejects BIOS-pinned images
134
134
  # at launch (see the import-call comment above). Loud, non-fatal: other
135
135
  # (x86) shapes launch fine from a BIOS image.
136
136
  IMPORT_FIRMWARE="$(oci compute image get --image-id "$IMPORT_JSON" --profile "$PROFILE" --query 'data."launch-options"."firmware"' --raw-output 2>/dev/null || echo UNKNOWN)"
137
137
  if [[ "$IMPORT_FIRMWARE" != "UEFI_64" ]]; then
138
138
  say "WARN: import landed firmware=$IMPORT_FIRMWARE — VM.Standard.A1.Flex (Ampere, UEFI-only) WILL reject this image at launch."
139
- say " OCI has no import-time firmware control (drill-proven 2026-10-07): the sanctioned A1 route is"
139
+ say " OCI has no import-time firmware control: the sanctioned A1 route is"
140
140
  say " capture-from-instance: boot the qcow2 elsewhere, then 'oci compute image create --instance-id <running-a1>'."
141
- say " x86 shapes CAN launch this image directly. See infra/alpine-host/RUNBOOK.md § blue-green."
141
+ say " x86 shapes CAN launch this image directly."
142
142
  fi
143
143
 
144
144
  if [[ $KEEP_OBJECT -eq 0 ]]; then
@@ -154,5 +154,5 @@ say "next:"
154
154
  if [[ "$IMPORT_FIRMWARE" == "UEFI_64" ]]; then
155
155
  say " A1-ready. kampodine bluegreen provision <blue|green> picks this image (newest esellar-alpine*)."
156
156
  else
157
- say " A1 launch will REJECT this image (firmware $IMPORT_FIRMWARE) — see the WARN above / RUNBOOK § blue-green."
157
+ say " A1 launch will REJECT this image (firmware $IMPORT_FIRMWARE) — see the WARN above."
158
158
  fi
package/scripts/status.sh CHANGED
@@ -2,10 +2,10 @@
2
2
  # status.sh — one command: what is LIVE (through the proxy) + the blue/green
3
3
  # pair view (instances, reserved IP, health).
4
4
  #
5
- # Env: APP_HOST_HEADER (default: the prod host), OCI_PROFILE, OCI_COMPARTMENT.
5
+ # Env: APP_HOST_HEADER (default: app.example.com), OCI_PROFILE, OCI_COMPARTMENT.
6
6
  set -euo pipefail
7
7
  HERE="$(cd "$(dirname "$0")" && pwd)"
8
- HOST="${APP_HOST_HEADER:-84-13-128-216.sslip.io}"
8
+ HOST="${APP_HOST_HEADER:-app.example.com}"
9
9
 
10
10
  say() { printf '%s\n' "$*"; }
11
11
 
@@ -1,16 +1,14 @@
1
1
  #!/usr/bin/env bash
2
- # vm-prepare.sh — first-run host prep for THE production Alpine VM.
2
+ # vm-prepare.sh — first-run bootstrap for a fresh Alpine VM.
3
3
  #
4
- # START-FRESH (owner decision 2026-10-04): the old Ubuntu "blue" VM is
5
- # ABANDONED — dead, not restored. This fresh Alpine VM is production from day
6
- # one: no blue->green flip, no cert carry; the first TLS certificate issues
7
- # during the first deploy.
4
+ # The VM goes to production from its first boot: no blue->green flip, no
5
+ # cert carry; the first TLS certificate issues during the first deploy.
8
6
  #
9
7
  # Alpine ships NO systemd anywhere (verified v3.22 main/community + edge; the
10
8
  # golden image boots OpenRC) — everything here is OpenRC-native:
11
9
  # 1. sanity gates (UEFI boot, OpenRC tooling; sshd hardening is
12
- # ENSURED — drop-in + Include + restart — then gated: the golden image
13
- # predates the baked hardening, RUNBOOK §Blue-green pt2 gap)
10
+ # ENSURED — drop-in + Include + restart — then gated: a fresh golden
11
+ # image may predate the baked hardening drop-in)
14
12
  # 2. apk repositories: ensure the v3.22 community repo (podman lives there)
15
13
  # 3. podman stack: podman podman-docker crun catatonit netavark
16
14
  # aardvark-dns fuse-overlayfs
@@ -92,7 +90,7 @@ REGISTRIES
92
90
 
93
91
  cat > "$TMPDIR_LOCAL/60-esellar.conf" <<'SYSCTL'
94
92
  # Managed by packages/kampodine/scripts/vm-prepare.sh — do not hand-edit.
95
- # OpenRC contract (2026-10-04): let unprivileged/container paths bind port 80
93
+ # OpenRC contract: let unprivileged/container paths bind port 80
96
94
  # (kamal-proxy publishes 80+443; belt-and-braces for a future rootless move).
97
95
  net.ipv4.ip_unprivileged_port_start=80
98
96
  SYSCTL
@@ -211,8 +209,7 @@ cat > "$TMPDIR_LOCAL/esellar-anchor.sh" <<'ANCHOR_WATCHER'
211
209
  # OCI assigns the reserved PUBLIC ip to a SECONDARY private ip ("the anchor")
212
210
  # on the instance VNIC, but this image has NO oracle-cloud-agent: nothing
213
211
  # configures that private ip inside the guest, so packets to the reserved ip
214
- # die until the address exists on the interface (RUNBOOK §Blue-green,
215
- # 2026-10-07 pt2 serving-leg gap). `kampodine bluegreen flip` writes
212
+ # die until the address exists on the interface. `kampodine bluegreen flip` writes
216
213
  # /etc/esellar/anchor.conf over ssh at flip time; this watcher polls it and
217
214
  # runs `ip addr add` within one interval. The unit is enabled+started on every
218
215
  # VM by default and is INERT without the conf — a VM that never flips never
@@ -308,12 +305,12 @@ INITD_ANCHOR
308
305
  cat > "$TMPDIR_LOCAL/sshd-hardening.conf" <<'SSHD_HARDENING'
309
306
  # KEEP IN SYNC with ansible/roles/alpine-base/files/sshd-hardening.conf
310
307
  # (byte-for-byte — ansible owns the file afterwards, rendered-content
311
- # convention). Ensured BEFORE the hardening gate below: the golden image
312
- # predates the baked drop-in (RUNBOOK §Blue-green pt2 gap, closed pt3) and
308
+ # convention). Ensured BEFORE the hardening gate below: a fresh golden image
309
+ # may predate the baked drop-in and
313
310
  # `sshd -T` reports passwordauthentication yes on a fresh VM.
314
311
  # Keys-only management: this VM's ssh surface is the ONLY management path
315
- # (OCI security lists keep 22 closed to the world; access via temporary
316
- # scoped rule or bastion — infra/oci/README.md model still applies).
312
+ # (cloud security lists keep 22 closed to the world; access via temporary
313
+ # scoped rule or bastion).
317
314
  PasswordAuthentication no
318
315
  KbdInteractiveAuthentication no
319
316
  PermitRootLogin prohibit-password
@@ -468,8 +465,8 @@ done
468
465
  # --- 6. optional: pre-pull the app image over the registry tunnel ---------------
469
466
  if [[ $DO_PULL -eq 1 ]]; then
470
467
  say "pre-pulling the app image through the registry tunnel (fail BEFORE the first deploy)…"
471
- # busybox wget, NOT curl: the golden image ships no curl (alpine-base
472
- # installs it later; vm-prepare runs BEFORE any ansible — pt3 live)
468
+ # busybox wget, NOT curl: the golden image ships no curl (ansible
469
+ # installs it later; vm-prepare runs BEFORE any ansible)
473
470
  vm 'busybox wget -q -O /dev/null http://127.0.0.1:5000/v2/' || {
474
471
  pkill -f "ssh.*-R 5000" 2>/dev/null || true; sleep 1
475
472
  nohup ssh -R 5000:127.0.0.1:5000 -N -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes "$HOST" >/tmp/esellar-tunnel.log 2>&1 &