kampodine 0.2.0 → 0.2.2
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 +37 -0
- package/README.md +19 -21
- package/kampodine.md +12 -13
- package/package.json +3 -2
- package/scripts/bluegreen.sh +32 -32
- package/scripts/deploy.sh +37 -9
- package/scripts/image-import.sh +7 -7
- package/scripts/status.sh +2 -2
- package/scripts/vm-prepare.sh +13 -16
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: rewritten so it reads clean outside the
|
|
8
|
+
original deployment (no service names, timings, or internal history)
|
|
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: tightened wording across the shipped scripts,
|
|
18
|
+
keeping 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
|
|
53
|
-
health-gated flips
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- **`kampodine
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- **`kampodine
|
|
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;
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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,22 +70,21 @@ 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
|
-
**In
|
|
75
|
-
- [ ] npm package scaffold — bin `kampodine`, scripts shipped in-package
|
|
78
|
+
**In progress:**
|
|
76
79
|
- [ ] reference sweep (docs → kampodine invocations)
|
|
77
80
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
-
|
|
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
|
|
81
|
+
## Roadmap
|
|
82
|
+
|
|
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
|
+
- genericized defaults (env schema path, service/container names,
|
|
87
|
+
deploy-host env) driven by config
|
|
89
88
|
|
|
90
89
|
## Non-goals
|
|
91
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kampodine",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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"
|
package/scripts/bluegreen.sh
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# bluegreen.sh — reserved-public-IP blue/green pair management for
|
|
2
|
+
# bluegreen.sh — reserved-public-IP blue/green pair management for OCI VMs.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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:
|
|
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
|
|
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
|
|
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 —
|
|
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
|
|
55
|
-
#
|
|
56
|
-
# whose image metadata is the Ubuntu PLATFORM image:
|
|
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
|
|
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:-
|
|
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"
|
|
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 (
|
|
258
|
-
#
|
|
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
|
|
268
|
-
#
|
|
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
|
|
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 —
|
|
327
|
-
#
|
|
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
|
|
331
|
-
#
|
|
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:
|
|
345
|
-
# false-
|
|
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,
|
|
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
|
-
# (
|
|
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
|
|
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
|
|
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:-
|
|
29
|
-
PROXY_HOST="${PROXY_HOST:-
|
|
29
|
+
ESPELLAR_HOST="${ESPELLAR_HOST:-}"
|
|
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,21 @@ MODE="deploy"
|
|
|
36
37
|
VERSION=""
|
|
37
38
|
SKIP_SMOKE=0
|
|
38
39
|
REFRESH_CONFIG=0
|
|
39
|
-
|
|
40
|
+
# SSH key resolution — GENERIC, no hardcoded personal paths:
|
|
41
|
+
# 1. --ssh-key flag (explicit, per-invocation)
|
|
42
|
+
# 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
|
|
43
|
+
# 3. ESSELLAR_SSH_KEY env (legacy alias, kept for existing setups)
|
|
44
|
+
# 4. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
|
|
45
|
+
# (the POSIX way: per-host IdentityFile belongs in ssh config, not here)
|
|
46
|
+
if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
|
|
47
|
+
SSH_KEY="$KAMPODINE_SSH_KEY"
|
|
48
|
+
else
|
|
49
|
+
SSH_KEY=""
|
|
50
|
+
fi
|
|
51
|
+
# Default image recipe = the TS api Containerfile. The Go cutover image rides
|
|
52
|
+
# --dockerfile apps/api-go/Dockerfile.cutover (build context stays the repo
|
|
53
|
+
# root for BOTH — the cutover Dockerfile path-prefixes its COPYs).
|
|
54
|
+
DOCKERFILE="${KAMPODINE_DOCKERFILE:-apps/api/Containerfile}"
|
|
40
55
|
|
|
41
56
|
say() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
|
|
42
57
|
die() { printf '\033[1;31m[deploy] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
@@ -53,11 +68,13 @@ while [[ $# -gt 0 ]]; do
|
|
|
53
68
|
;;
|
|
54
69
|
--skip-smoke) SKIP_SMOKE=1; shift ;;
|
|
55
70
|
--refresh-config) REFRESH_CONFIG=1; shift ;;
|
|
71
|
+
--dockerfile) DOCKERFILE="$2"; shift 2 ;;
|
|
56
72
|
-h|--help) usage ;;
|
|
57
73
|
*) die "unknown argument: $1 (--help)" ;;
|
|
58
74
|
esac
|
|
59
75
|
done
|
|
60
76
|
[[ "$VERSION" != *..* && "$VERSION" =~ ^[0-9a-f]{4,40}$|^$ ]] || die "--version must be a git sha fragment"
|
|
77
|
+
[ -n "$ESPELLAR_HOST" ] || die "set ESPELLAR_HOST=root@<vm-ip> (or a ~/.ssh/config Host alias via --host)"
|
|
61
78
|
|
|
62
79
|
SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
|
|
63
80
|
[[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
|
|
@@ -65,7 +82,7 @@ SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
|
|
|
65
82
|
# shellcheck disable=SC2029
|
|
66
83
|
vm() { ssh "${SSH_ARGS[@]}" "$ESPELLAR_HOST" "$1"; }
|
|
67
84
|
|
|
68
|
-
# --- macOS ssh-agent quirk (deploy
|
|
85
|
+
# --- macOS ssh-agent quirk (first deploy from a fresh machine) ---------------
|
|
69
86
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
70
87
|
SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
|
|
71
88
|
export SSH_AUTH_SOCK
|
|
@@ -85,10 +102,16 @@ podman info >/dev/null 2>&1 || die "podman machine not reachable (podman machine
|
|
|
85
102
|
|
|
86
103
|
# --- build (deploy mode always builds HEAD — cached layers keep it minutes) ----
|
|
87
104
|
if [[ "$MODE" == "deploy" ]]; then
|
|
88
|
-
|
|
105
|
+
if [[ "$DOCKERFILE" == "apps/api/Containerfile" ]]; then
|
|
106
|
+
say "building linux/arm64 (VITE_BUILD_ID=$VER — build identity, never remove)…"
|
|
107
|
+
BUILD_ARG=("VITE_BUILD_ID=$VER")
|
|
108
|
+
else
|
|
109
|
+
say "building linux/arm64 ($DOCKERFILE, GIT_SHA=$VER)…"
|
|
110
|
+
BUILD_ARG=("GIT_SHA=$VER")
|
|
111
|
+
fi
|
|
89
112
|
podman build --platform linux/arm64 \
|
|
90
|
-
-f
|
|
91
|
-
--build-arg
|
|
113
|
+
-f "$DOCKERFILE" \
|
|
114
|
+
--build-arg "${BUILD_ARG[0]}" \
|
|
92
115
|
-t "$IMAGE:$VER" \
|
|
93
116
|
. || die "podman build failed"
|
|
94
117
|
# Same-origin contract: VITE_API_URL / VITE_LIBSQL_API_URL stay UNSET —
|
|
@@ -135,10 +158,15 @@ fi
|
|
|
135
158
|
say "rc-service esellar-api restart (supervise-daemon: stop-old/start-new podman run on the retagged :latest)…"
|
|
136
159
|
vm "rc-service esellar-api restart" || die "esellar-api restart failed (rc-service esellar-api status)"
|
|
137
160
|
|
|
138
|
-
say "health probe (
|
|
161
|
+
say "health probe (host-side wget /api/auth/ok + served-sha — works for node AND scratch images)…"
|
|
139
162
|
HEALTH_OK=0
|
|
140
163
|
for _ in $(seq 1 30); do
|
|
141
|
-
|
|
164
|
+
# wget runs on the VM (busybox, -p 8080:8080 publish); the served-sha
|
|
165
|
+
# grep runs LOCALLY — remote grep quoting is fragile (busybox BRE
|
|
166
|
+
# (busybox BRE alternation + nested quotes → permanent false-negative).
|
|
167
|
+
# grep -E keeps the alternation portable across BSD/GNU/busybox.
|
|
168
|
+
if vm "wget -qO- -T 3 http://127.0.0.1:8080/api/auth/ok 2>/dev/null" \
|
|
169
|
+
| grep -qE "\"(git|build)\":\"$VER"; then
|
|
142
170
|
HEALTH_OK=1
|
|
143
171
|
break
|
|
144
172
|
fi
|
package/scripts/image-import.sh
CHANGED
|
@@ -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
|
|
4
|
-
# APIs
|
|
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 (
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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:-
|
|
8
|
+
HOST="${APP_HOST_HEADER:-app.example.com}"
|
|
9
9
|
|
|
10
10
|
say() { printf '%s\n' "$*"; }
|
|
11
11
|
|
package/scripts/vm-prepare.sh
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# vm-prepare.sh — first-run
|
|
2
|
+
# vm-prepare.sh — first-run bootstrap for a fresh Alpine VM.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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:
|
|
13
|
-
#
|
|
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
|
|
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
|
|
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:
|
|
312
|
-
#
|
|
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
|
-
# (
|
|
316
|
-
# scoped rule or bastion
|
|
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 (
|
|
472
|
-
# installs it later; vm-prepare runs BEFORE any ansible
|
|
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 &
|