kampodine 0.2.2 → 0.4.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/CHANGELOG.md +47 -0
- package/README.md +24 -3
- package/cli.js +18 -4
- package/package.json +1 -1
- package/scripts/bluegreen.sh +157 -55
- package/scripts/deploy.sh +35 -21
- package/scripts/dns.sh +266 -0
- package/scripts/env.sh +183 -0
- package/scripts/image-import.sh +31 -20
- package/scripts/migrate.sh +21 -5
- package/scripts/status.sh +23 -0
- package/scripts/vm-prepare.sh +98 -84
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
**Breaking: project-generic naming.** Every origin-coupled default name is
|
|
6
|
+
gone; the package now speaks only for itself.
|
|
7
|
+
|
|
8
|
+
- `KAMPODINE_HOST` replaces `ESPELLAR_HOST` everywhere (deploy, env); the
|
|
9
|
+
key-resolution ladder is `--ssh-key` | `KAMPODINE_SSH_KEY` | ssh-agent /
|
|
10
|
+
ssh-config — the old `ESPELLAR_SSH_KEY` / `ESSELLAR_SSH_KEY` aliases are
|
|
11
|
+
no longer mentioned anywhere
|
|
12
|
+
- Guest paths renamed: `/etc/esellar/*` → `/etc/kampodine/*` (env file,
|
|
13
|
+
deployed-sha, anchor.conf); the anchor watcher is `kampodine-anchor`
|
|
14
|
+
- Service/container/image names: `esellar-api` → `kampodine-api`,
|
|
15
|
+
`esellar-blue`/`esellar-green` → `kampodine-blue`/`kampodine-green`,
|
|
16
|
+
golden image `esellar-alpine*` → `kampodine-alpine*`
|
|
17
|
+
- `OCI_COMPARTMENT` is now REQUIRED (no default — compartments are
|
|
18
|
+
account-specific); `OCI_PROFILE` defaults to `default` (the OCI CLI's
|
|
19
|
+
own default profile) instead of a named profile
|
|
20
|
+
- Existing guests keep booting, but flips against guests provisioned by
|
|
21
|
+
older releases need their watcher/paths updated (or re-run
|
|
22
|
+
`vm-prepare` on a fresh host) since the anchor protocol path changed
|
|
23
|
+
|
|
24
|
+
## 0.3.0 — 2026-10-07
|
|
25
|
+
|
|
26
|
+
Full-lifecycle orchestration with first-class help — the "mini vercel CLI"
|
|
27
|
+
release.
|
|
28
|
+
|
|
29
|
+
- `env` — manage the remote app env file (`/etc/esellar/env`, 0600 root):
|
|
30
|
+
`list` (KEY + value fingerprints only — length + first 2 chars, values
|
|
31
|
+
NEVER printed), `push --file` (0600 temp from creation via umask + atomic
|
|
32
|
+
`mv` + restart hint), `pull` (raw payload to stdout or `--out` 0600, masked
|
|
33
|
+
summary), `fingerprint` (local masking preview). Host/key resolution
|
|
34
|
+
identical to deploy (`--host` | `ESPELLAR_HOST`, `--ssh-key` |
|
|
35
|
+
`KAMPODINE_SSH_KEY` | ssh-agent)
|
|
36
|
+
- `dns` — OCI DNS records: `records`, `add`, `rm`. Shells out to the `oci`
|
|
37
|
+
CLI; auth is the OCI config file (`--profile`) or `--instance-principal`
|
|
38
|
+
ONLY — no credential material is ever accepted, stored, or logged. Types
|
|
39
|
+
pinned to A|AAAA|CNAME; name/type/value/ttl validated locally before any
|
|
40
|
+
provider call; `add` merges into the existing RRSet, `rm` filters it
|
|
41
|
+
- Help everywhere: `kampodine --help` prints a grouped command index
|
|
42
|
+
(DEPLOY / INFRA / DNS / ENV, vercel-style); every command and every
|
|
43
|
+
bluegreen/env/dns sub-step answers `--help`/`-h` with `Usage:` +
|
|
44
|
+
`Examples:` — pinned by a test that enumerates all of them
|
|
45
|
+
- Tests grew from 38 to 114: help coverage for every subcommand, env
|
|
46
|
+
fingerprint masking against fixture ssh shims (a value in output fails the
|
|
47
|
+
suite), dns arg validation, top-level index groups, oci `--profile` gate
|
|
48
|
+
extended to `dns` calls
|
|
49
|
+
|
|
3
50
|
## 0.2.1 — 2026-10-07
|
|
4
51
|
|
|
5
52
|
Docs, hygiene, and deploy hardening.
|
package/README.md
CHANGED
|
@@ -48,12 +48,28 @@ kampodine deploy:
|
|
|
48
48
|
- **`kampodine deploy`** — stream deploy; `--version <sha>` restreams an
|
|
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
|
+
- **`kampodine env`** — manage the remote app env file (`/etc/kampodine/env`,
|
|
52
|
+
0600 root) without ever printing a secret: `list` shows KEY + fingerprints
|
|
53
|
+
only (value length + first 2 chars), `push --file <env>` uploads over ssh
|
|
54
|
+
stdin into a 0600 temp + atomic `mv`, `pull` streams the raw payload to
|
|
55
|
+
stdout or `--out` (written 0600). Host/key resolution is identical to
|
|
56
|
+
deploy: `--host root@<ip>` | `KAMPODINE_HOST`, `--ssh-key <path>` |
|
|
57
|
+
`KAMPODINE_SSH_KEY` | ssh-agent. `env fingerprint` previews the masking
|
|
58
|
+
for any local file — values never leave stdin
|
|
59
|
+
- **`kampodine dns`** — OCI DNS records for the post-sslip.io era:
|
|
60
|
+
`records`, `add --name <label> --type A|AAAA|CNAME --value <target>
|
|
61
|
+
[--ttl 300]`, `rm`. Shell out to the `oci` CLI; auth is the OCI config
|
|
62
|
+
file (`--profile`) or `--instance-principal` ONLY — kampodine never
|
|
63
|
+
accepts, stores, or logs credential material. `add` merges into the
|
|
64
|
+
existing RRSet (round-robin survives), `rm` filters it; types/ttl/value
|
|
65
|
+
are validated locally before any provider call
|
|
51
66
|
- **`kampodine bluegreen status|init|provision|flip|rollback`** — reserved
|
|
52
67
|
public IP management for a two-instance blue/green pair on OCI: zero DNS
|
|
53
68
|
change, health-gated flips with an ACME-first cutover (reserved IP assigned
|
|
54
69
|
→ cert issued for its hostname → served sha verified) and automatic
|
|
55
70
|
rollback. A guest **anchor watcher** installed by `vm-prepare` holds the
|
|
56
71
|
flip's IP half — add-only and inert until a flip writes its anchor config.
|
|
72
|
+
Every sub-step has its own `--help` with usage + examples
|
|
57
73
|
- **`kampodine vm-prepare`** — first-run bootstrap of a bare Alpine host:
|
|
58
74
|
sshd hardening (fresh VMs pass unattended), busybox-wget health probes (the
|
|
59
75
|
golden image ships no curl), the blue-green anchor watcher, the podman
|
|
@@ -63,6 +79,11 @@ kampodine deploy:
|
|
|
63
79
|
use `bluegreen provision` for ARM targets
|
|
64
80
|
- **`kampodine migrate`** — sqlite migrations over SSH
|
|
65
81
|
|
|
82
|
+
**Help everywhere:** `kampodine --help` (or bare `kampodine`) prints a
|
|
83
|
+
grouped command index (DEPLOY / INFRA / DNS / ENV, vercel-style); every
|
|
84
|
+
command — and every bluegreen/env/dns sub-step — answers `--help` with
|
|
85
|
+
usage + examples. No subcommand silently does nothing on `--help`.
|
|
86
|
+
|
|
66
87
|
## Kamal parity
|
|
67
88
|
|
|
68
89
|
| kamal | kampodine |
|
|
@@ -135,9 +156,9 @@ automation is on the roadmap; today the stream is the automated path.
|
|
|
135
156
|
## Prerequisites
|
|
136
157
|
|
|
137
158
|
Deploy machine: node ≥ 20, podman, ssh key access to the target, `oci` CLI
|
|
138
|
-
(for bluegreen / image-import),
|
|
139
|
-
step uses (kampodine is agnostic;
|
|
140
|
-
[varlock](https://varlock.dev) + pass).
|
|
159
|
+
(for bluegreen / image-import / dns), `jq` (for dns record surgery), and
|
|
160
|
+
whatever secret-resolution your env-file step uses (kampodine is agnostic;
|
|
161
|
+
the reference setup uses [varlock](https://varlock.dev) + pass).
|
|
141
162
|
|
|
142
163
|
Target: a converged Alpine + Podman + OpenRC host (see above), reachable over
|
|
143
164
|
ssh as root, with kamal-proxy running.
|
package/cli.js
CHANGED
|
@@ -14,20 +14,34 @@ const commands = {
|
|
|
14
14
|
"vm-prepare": "vm-prepare.sh",
|
|
15
15
|
"image-import": "image-import.sh",
|
|
16
16
|
migrate: "migrate.sh",
|
|
17
|
+
env: "env.sh",
|
|
18
|
+
dns: "dns.sh",
|
|
17
19
|
};
|
|
18
20
|
|
|
21
|
+
// Command index, grouped vercel-style. Every command (and every sub-step of
|
|
22
|
+
// bluegreen/env/dns) answers --help with Usage + Examples — pinned by
|
|
23
|
+
// test/help-coverage.test.ts.
|
|
19
24
|
const usage = `kampodine — kamal-alternative CLI for Alpine + Podman deploys, built on kamal-proxy
|
|
20
25
|
|
|
21
26
|
Usage: kampodine <command> [args...]
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
DEPLOY
|
|
24
29
|
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
|
|
30
|
+
bluegreen reserved-IP blue/green pair: status | init | provision | flip | rollback (each sub-step has --help)
|
|
31
|
+
migrate tenant db migrations over SSH
|
|
32
|
+
|
|
33
|
+
INFRA
|
|
26
34
|
vm-prepare first-run bootstrap of a bare Alpine host (OpenRC + podman stack)
|
|
27
35
|
image-import golden qcow2 -> OCI custom image
|
|
28
|
-
|
|
36
|
+
status live health through the proxy + the blue/green pair view
|
|
37
|
+
|
|
38
|
+
DNS
|
|
39
|
+
dns OCI DNS records (oci config-file / instance-principal auth ONLY): records | add | rm
|
|
40
|
+
|
|
41
|
+
ENV
|
|
42
|
+
env remote app env file (/etc/kampodine/env, 0600): list | push | pull — values NEVER printed, fingerprints only
|
|
29
43
|
|
|
30
|
-
All further args pass through to the underlying script.
|
|
44
|
+
Every command supports --help with usage + examples. All further args pass through to the underlying script.
|
|
31
45
|
`;
|
|
32
46
|
|
|
33
47
|
const [cmd, ...args] = process.argv.slice(2);
|
package/package.json
CHANGED
package/scripts/bluegreen.sh
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# API call, rollback is the same call reversed.
|
|
9
9
|
#
|
|
10
10
|
# Colors are instance display names: <app>-blue / <app>-green (defaults:
|
|
11
|
-
#
|
|
11
|
+
# kampodine-blue / kampodine-green).
|
|
12
12
|
# The reserved IP is derived from live OCI state — no local state file.
|
|
13
13
|
#
|
|
14
14
|
# Usage:
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
#
|
|
21
21
|
# FLIP = ACME-FIRST:
|
|
22
22
|
# 1. health gate on the target's own IP
|
|
23
|
-
# 2. anchor.conf written on the TARGET guest over ssh — the
|
|
23
|
+
# 2. anchor.conf written on the TARGET guest over ssh — the kampodine-anchor
|
|
24
24
|
# watcher service (shipped by vm-prepare) configures the anchor private
|
|
25
25
|
# address within one interval; flip waits for `ip addr` to show it
|
|
26
26
|
# 3. OCI assigns the reserved IP to the target's anchor (secondary private
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
# instead — it runs the same ACME-first sequence there.
|
|
50
50
|
#
|
|
51
51
|
# provision has TWO routes:
|
|
52
|
-
# 1. NATIVE — a UEFI_64
|
|
52
|
+
# 1. NATIVE — a UEFI_64 kampodine-alpine* custom image exists in the
|
|
53
53
|
# compartment: launch it directly (the golden image boots as-is).
|
|
54
54
|
# 2. INJECT (provision-via-migrate) — OCI pins imported custom images to
|
|
55
55
|
# firmware=BIOS and A1/Ampere is UEFI-only, but the template instance
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
# 'gunzip | sudo dd', reboot, verify /etc/alpine-release). The instance
|
|
64
64
|
# record keeps the platform image metadata — exactly like the template.
|
|
65
65
|
#
|
|
66
|
-
# Env: OCI_PROFILE (default
|
|
66
|
+
# Env: OCI_PROFILE (default: "default"), OCI_COMPARTMENT (required).
|
|
67
67
|
# Injection extras: ALPINE_QCOW2 (golden disk path), OPS_SSH_PUBKEY (ops
|
|
68
68
|
# public key for the platform-image launch), PLATFORM_SSH_USER (default
|
|
69
69
|
# ubuntu), INJECT_PROBE_SLEEP / INJECT_PROBE_TRIES (ssh wait tuning).
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
# loopback; the reserved IP is checked over :80 with the prod Host header).
|
|
72
72
|
set -euo pipefail
|
|
73
73
|
|
|
74
|
-
PROFILE="${OCI_PROFILE:-
|
|
75
|
-
COMPARTMENT_NAME="${OCI_COMPARTMENT:-
|
|
74
|
+
PROFILE="${OCI_PROFILE:-default}"
|
|
75
|
+
COMPARTMENT_NAME="${OCI_COMPARTMENT:-kampodine}"
|
|
76
76
|
APP_HOST_HEADER="${APP_HOST_HEADER:-app.example.com}"
|
|
77
77
|
REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
|
78
78
|
SSH_OPTS=(-o ConnectTimeout=6 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
|
@@ -80,6 +80,95 @@ SSH_OPTS=(-o ConnectTimeout=6 -o BatchMode=yes -o StrictHostKeyChecking=accept-n
|
|
|
80
80
|
say() { printf '%s\n' "$*"; }
|
|
81
81
|
die() { printf '✋ %s\n' "$*" >&2; exit 1; }
|
|
82
82
|
|
|
83
|
+
usage() {
|
|
84
|
+
printf 'Usage:\n'
|
|
85
|
+
grep '^# kampodine bluegreen' "$0" | sed 's/^# //'
|
|
86
|
+
cat <<'EOF'
|
|
87
|
+
|
|
88
|
+
Each sub-step has its own --help: status | init | provision | flip | rollback.
|
|
89
|
+
Env: OCI_PROFILE (default: "default"), OCI_COMPARTMENT (required).
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
kampodine bluegreen status
|
|
93
|
+
kampodine bluegreen init
|
|
94
|
+
kampodine bluegreen provision green
|
|
95
|
+
kampodine bluegreen flip --to green
|
|
96
|
+
kampodine bluegreen rollback
|
|
97
|
+
EOF
|
|
98
|
+
exit 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
step_usage() {
|
|
102
|
+
case "$1" in
|
|
103
|
+
status)
|
|
104
|
+
cat <<'EOF'
|
|
105
|
+
Usage:
|
|
106
|
+
kampodine bluegreen status
|
|
107
|
+
|
|
108
|
+
Pair view: reserved IP + holder, both instances (ocid, ip, AD), per-color app
|
|
109
|
+
health. Read-only.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
kampodine bluegreen status
|
|
113
|
+
EOF
|
|
114
|
+
;;
|
|
115
|
+
init)
|
|
116
|
+
cat <<'EOF'
|
|
117
|
+
Usage:
|
|
118
|
+
kampodine bluegreen init
|
|
119
|
+
|
|
120
|
+
Create the DORMANT reserved public IP (unassigned — no instance attached).
|
|
121
|
+
Idempotent: exits 0 when the reserved IP already exists.
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
kampodine bluegreen init
|
|
125
|
+
EOF
|
|
126
|
+
;;
|
|
127
|
+
provision)
|
|
128
|
+
cat <<'EOF'
|
|
129
|
+
Usage:
|
|
130
|
+
kampodine bluegreen provision <blue|green>
|
|
131
|
+
|
|
132
|
+
Launch the second instance from the golden image (native UEFI custom image,
|
|
133
|
+
or the INJECT route when none exists), then vm-prepare it.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
kampodine bluegreen provision green
|
|
137
|
+
kampodine vm-prepare --host root@<new-ip> # after provision hands you the IP
|
|
138
|
+
EOF
|
|
139
|
+
;;
|
|
140
|
+
flip)
|
|
141
|
+
cat <<'EOF'
|
|
142
|
+
Usage:
|
|
143
|
+
kampodine bluegreen flip --to <blue|green> [--force]
|
|
144
|
+
|
|
145
|
+
ACME-first health-gated cutover: health gate on the target, the guest anchor
|
|
146
|
+
watcher claims the reserved-IP half, OCI assigns, the cert issues on the
|
|
147
|
+
target, verify through the reserved IP — any post-assign failure auto-rolls
|
|
148
|
+
back.
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
kampodine bluegreen flip --to green
|
|
152
|
+
kampodine bluegreen flip --to green --force
|
|
153
|
+
EOF
|
|
154
|
+
;;
|
|
155
|
+
rollback)
|
|
156
|
+
cat <<'EOF'
|
|
157
|
+
Usage:
|
|
158
|
+
kampodine bluegreen rollback
|
|
159
|
+
|
|
160
|
+
Unassign the reserved IP back to DORMANT: holder guest cleanup (anchor.conf
|
|
161
|
+
removal + address delete) then the OCI unassign. To move traffic to the other
|
|
162
|
+
color of a real pair instead, use flip --to <other>.
|
|
163
|
+
|
|
164
|
+
Examples:
|
|
165
|
+
kampodine bluegreen rollback
|
|
166
|
+
EOF
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
169
|
+
exit 0
|
|
170
|
+
}
|
|
171
|
+
|
|
83
172
|
compartment_ocid() {
|
|
84
173
|
local ocid
|
|
85
174
|
ocid="$(oci iam compartment list --all --profile "$PROFILE" \
|
|
@@ -92,7 +181,7 @@ compartment_ocid() {
|
|
|
92
181
|
instance_by_color() {
|
|
93
182
|
local comp="$1" color="$2" row
|
|
94
183
|
row="$(oci compute instance list -c "$comp" --profile "$PROFILE" \
|
|
95
|
-
--display-name "
|
|
184
|
+
--display-name "kampodine-$color" --lifecycle-state RUNNING \
|
|
96
185
|
--query 'sort_by(data, &"time-created")[-1] | ["id", "availability-domain"] | join('\'' '\'', @)' \
|
|
97
186
|
--raw-output 2>/dev/null || true)"
|
|
98
187
|
[[ -n "$row" ]] || return 0
|
|
@@ -151,7 +240,7 @@ reserved_anchor_ip() {
|
|
|
151
240
|
return 0
|
|
152
241
|
fi
|
|
153
242
|
oci network private-ip create --profile "$PROFILE" --vnic-id "$vnic" \
|
|
154
|
-
--display-name "
|
|
243
|
+
--display-name "kampodine-reserved-anchor" \
|
|
155
244
|
--query 'data.id' --raw-output 2>/dev/null
|
|
156
245
|
}
|
|
157
246
|
|
|
@@ -160,9 +249,9 @@ FLIP_POLL_SLEEP="${FLIP_POLL_SLEEP:-5}"
|
|
|
160
249
|
FLIP_ADDR_TRIES="${FLIP_ADDR_TRIES:-12}" # guest watcher pickup: 12 x 5s = 60s
|
|
161
250
|
FLIP_ACME_TRIES="${FLIP_ACME_TRIES:-24}" # LE HTTP-01: 24 x 5s = 120s
|
|
162
251
|
|
|
163
|
-
# Guest anchor protocol (the
|
|
164
|
-
# infra/alpine-host/ansible/roles/container-service/files/
|
|
165
|
-
# both halves of the conf path must stay in sync: /etc/
|
|
252
|
+
# Guest anchor protocol (the kampodine-anchor watcher half lives in
|
|
253
|
+
# infra/alpine-host/ansible/roles/container-service/files/kampodine-anchor.sh —
|
|
254
|
+
# both halves of the conf path must stay in sync: /etc/kampodine/anchor.conf).
|
|
166
255
|
# Remote commands are composed client-side BY DESIGN (vm-prepare convention);
|
|
167
256
|
# the interpolated values are OCI-API derived and regex-gated at the call
|
|
168
257
|
# sites, never user input.
|
|
@@ -171,7 +260,7 @@ FLIP_ACME_TRIES="${FLIP_ACME_TRIES:-24}" # LE HTTP-01: 24 x 5s = 120s
|
|
|
171
260
|
write_anchor_conf() {
|
|
172
261
|
local ip="$1" addr="$2"
|
|
173
262
|
ssh "${SSH_OPTS[@]}" "root@$ip" \
|
|
174
|
-
"umask 077; mkdir -p /etc/
|
|
263
|
+
"umask 077; mkdir -p /etc/kampodine; printf 'ANCHOR_ADDR=%s\nANCHOR_IFACE=\n' '$addr' > /etc/kampodine/anchor.conf && echo ANCHOR_CONF_WRITTEN"
|
|
175
264
|
}
|
|
176
265
|
|
|
177
266
|
# shellcheck disable=SC2029
|
|
@@ -185,7 +274,7 @@ anchor_addr_ready() {
|
|
|
185
274
|
read_anchor_conf_addr() {
|
|
186
275
|
local ip="$1" line addr
|
|
187
276
|
line="$(ssh "${SSH_OPTS[@]}" "root@$ip" \
|
|
188
|
-
'grep -h "^ANCHOR_ADDR=" /etc/
|
|
277
|
+
'grep -h "^ANCHOR_ADDR=" /etc/kampodine/anchor.conf 2>/dev/null | head -n 1' 2>/dev/null || true)"
|
|
189
278
|
addr="${line#ANCHOR_ADDR=}"
|
|
190
279
|
addr="${addr//\"/}"
|
|
191
280
|
addr="${addr//\'/}"
|
|
@@ -208,7 +297,7 @@ delete_guest_addr() {
|
|
|
208
297
|
# re-added the address once; retry the delete across one watcher interval.
|
|
209
298
|
cleanup_target_anchor() {
|
|
210
299
|
local ip="$1" addr="$2" i
|
|
211
|
-
ssh "${SSH_OPTS[@]}" "root@$ip" "rm -f /etc/
|
|
300
|
+
ssh "${SSH_OPTS[@]}" "root@$ip" "rm -f /etc/kampodine/anchor.conf" >/dev/null 2>&1 || true
|
|
212
301
|
for ((i = 1; i <= 3; i++)); do
|
|
213
302
|
delete_guest_addr "$ip" "$addr"
|
|
214
303
|
anchor_addr_ready "$ip" "$addr" >/dev/null 2>&1 || return 0
|
|
@@ -234,7 +323,7 @@ flip_failure_rollback() {
|
|
|
234
323
|
if [[ "$bpip" == ocid1.privateip* ]]; then
|
|
235
324
|
if oci network public-ip update --public-ip-id "$rocid" --profile "$PROFILE" \
|
|
236
325
|
--private-ip-id "$bpip" --force >/dev/null 2>&1; then
|
|
237
|
-
say "rolled back to
|
|
326
|
+
say "rolled back to kampodine-$ob (existing anchor $bpip)" >&2
|
|
238
327
|
recovered=1
|
|
239
328
|
fi
|
|
240
329
|
fi
|
|
@@ -242,14 +331,14 @@ flip_failure_rollback() {
|
|
|
242
331
|
if (( ! recovered )); then
|
|
243
332
|
if oci network public-ip update --public-ip-id "$rocid" --profile "$PROFILE" \
|
|
244
333
|
--private-ip-id "" --force --wait-for-state AVAILABLE >/dev/null 2>&1; then
|
|
245
|
-
say "reserved ip UNASSIGNED (dormant) — no existing anchor on
|
|
334
|
+
say "reserved ip UNASSIGNED (dormant) — no existing anchor on kampodine-$ob" >&2
|
|
246
335
|
else
|
|
247
336
|
cleanup_target_anchor "$tpub" "$taddr"
|
|
248
337
|
die "AUTO-ROLLBACK FAILED — reserved ip state unknown; flip manually via console: $rocid"
|
|
249
338
|
fi
|
|
250
339
|
fi
|
|
251
340
|
cleanup_target_anchor "$tpub" "$taddr"
|
|
252
|
-
say "
|
|
341
|
+
say "kampodine-$to cleaned (anchor.conf removed, anchor address deleted) — investigate before retrying" >&2
|
|
253
342
|
}
|
|
254
343
|
|
|
255
344
|
# instance_healthy <ephemeral_ip> -> ssh + loopback app check.
|
|
@@ -260,7 +349,7 @@ instance_healthy() {
|
|
|
260
349
|
local ip="$1"
|
|
261
350
|
[[ -n "$ip" ]] || return 1
|
|
262
351
|
ssh "${SSH_OPTS[@]}" "root@$ip" \
|
|
263
|
-
"rc-service
|
|
352
|
+
"rc-service kampodine-api status >/dev/null 2>&1 && busybox wget -q -O /dev/null http://127.0.0.1:8080/up" 2>/dev/null
|
|
264
353
|
}
|
|
265
354
|
|
|
266
355
|
# instance_wait_running <iid> — poll lifecycle-state to RUNNING. The OCI CLI's
|
|
@@ -276,7 +365,7 @@ instance_wait_running() {
|
|
|
276
365
|
--query 'data."lifecycle-state"' --raw-output 2>/dev/null || true)"
|
|
277
366
|
case "$state" in
|
|
278
367
|
RUNNING) return 0 ;;
|
|
279
|
-
FAILED | TERMINATED | TERMINATING) die "
|
|
368
|
+
FAILED | TERMINATED | TERMINATING) die "kampodine instance reached $state — nothing to inject, check the console" ;;
|
|
280
369
|
esac
|
|
281
370
|
sleep "$sleep_s"
|
|
282
371
|
done
|
|
@@ -301,7 +390,7 @@ inject_alpine() {
|
|
|
301
390
|
# the Alpine verify forever. The scrub below
|
|
302
391
|
# clears the phase file between the two boots; the user's file is never
|
|
303
392
|
# touched.
|
|
304
|
-
kh="$(mktemp "${TMPDIR:-/tmp}/
|
|
393
|
+
kh="$(mktemp "${TMPDIR:-/tmp}/kampodine-inject-kh.XXXXXX")"
|
|
305
394
|
iss() { ssh -o UserKnownHostsFile="$kh" "${SSH_OPTS[@]}" "$@"; }
|
|
306
395
|
say "inject: waiting for ssh (${ruser}@${ip}, platform-image first boot)…"
|
|
307
396
|
ssh_wait_probe() { iss "${ruser}@${ip}" true; }
|
|
@@ -311,7 +400,7 @@ inject_alpine() {
|
|
|
311
400
|
[[ "$i" == "$probe_tries" ]] && die "ssh never came up on ${ip} (${ruser}) — check the instance console connection"
|
|
312
401
|
sleep "$probe_sleep"
|
|
313
402
|
done
|
|
314
|
-
raw="$(mktemp "${TMPDIR:-/tmp}/
|
|
403
|
+
raw="$(mktemp "${TMPDIR:-/tmp}/kampodine-inject-raw.XXXXXX")"
|
|
315
404
|
say "inject: converting ${qcow2} -> raw…"
|
|
316
405
|
qemu-img convert -O raw "$qcow2" "$raw"
|
|
317
406
|
say "inject: streaming golden disk -> ${ip} boot volume (gunzip | dd, conv=fsync)…"
|
|
@@ -351,17 +440,30 @@ inject_alpine() {
|
|
|
351
440
|
done
|
|
352
441
|
rm -f "$kh"
|
|
353
442
|
[[ -n "$rel" ]] || die "injection streamed but no ALPINE 3.x ssh on ${ip} after reboot (got: '${rel:-nothing}') — check the serial console; terminate, do NOT flip to ${color}"
|
|
354
|
-
say "INJECTED
|
|
443
|
+
say "INJECTED kampodine-$color: Alpine ${rel} boots on ${ip} (instance image metadata stays the platform image — like green)"
|
|
355
444
|
say "next: kampodine vm-prepare --host root@${ip} -> kampodine deploy --host root@${ip}"
|
|
356
445
|
say "then 'bluegreen.sh flip --to ${color}' (health-gated) once its app checks green."
|
|
357
446
|
}
|
|
358
447
|
|
|
359
448
|
cmd="${1:-}"
|
|
449
|
+
# Any -h/--help in the args answers with usage for that sub-step (exit 0) —
|
|
450
|
+
# before any validation or OCI call, so help is always hermetic.
|
|
451
|
+
for help_arg in "$@"; do
|
|
452
|
+
case "$help_arg" in
|
|
453
|
+
-h|--help)
|
|
454
|
+
case "$cmd" in
|
|
455
|
+
status|init|provision|flip|rollback) step_usage "$cmd" ;;
|
|
456
|
+
*) usage ;;
|
|
457
|
+
esac
|
|
458
|
+
;;
|
|
459
|
+
esac
|
|
460
|
+
done
|
|
461
|
+
[[ -n "${OCI_COMPARTMENT:-}" ]] || die "set OCI_COMPARTMENT=<compartment name or ocid> — no default: compartments are account-specific"
|
|
360
462
|
case "$cmd" in
|
|
361
463
|
status)
|
|
362
464
|
comp="$(compartment_ocid)"
|
|
363
465
|
rp="$(reserved_ip "$comp")"
|
|
364
|
-
say "==
|
|
466
|
+
say "== kampodine blue/green pair (compartment $COMPARTMENT_NAME) =="
|
|
365
467
|
if [[ -n "$rp" ]]; then
|
|
366
468
|
read -r rocid raddr rholder <<<"$rp"
|
|
367
469
|
say "reserved IP : $raddr ($rocid)"
|
|
@@ -374,9 +476,9 @@ case "$cmd" in
|
|
|
374
476
|
if [[ -n "$row" ]]; then
|
|
375
477
|
read -r iid pub ad <<<"$row"
|
|
376
478
|
if instance_healthy "$pub"; then verdict=HEALTHY; else verdict=UNHEALTHY/unreachable; fi
|
|
377
|
-
say "
|
|
479
|
+
say "kampodine-$color : $iid ip=${pub:-none} ad=${ad:-?} app=$verdict"
|
|
378
480
|
else
|
|
379
|
-
say "
|
|
481
|
+
say "kampodine-$color : not provisioned"
|
|
380
482
|
fi
|
|
381
483
|
done
|
|
382
484
|
;;
|
|
@@ -390,7 +492,7 @@ case "$cmd" in
|
|
|
390
492
|
exit 0
|
|
391
493
|
fi
|
|
392
494
|
addr="$(oci network public-ip create -c "$comp" --profile "$PROFILE" \
|
|
393
|
-
--lifetime RESERVED --display-name
|
|
495
|
+
--lifetime RESERVED --display-name kampodine-active \
|
|
394
496
|
--query 'data."ip-address"' --raw-output)"
|
|
395
497
|
say "created DORMANT reserved IP: $addr (unassigned — no instance attached)"
|
|
396
498
|
say "Point DNS/SSLIP at this address when the pair goes active."
|
|
@@ -401,25 +503,25 @@ case "$cmd" in
|
|
|
401
503
|
color="${1:-}"
|
|
402
504
|
[[ "$color" == blue || "$color" == green ]] || die "usage: kampodine bluegreen provision <blue|green>"
|
|
403
505
|
comp="$(compartment_ocid)"
|
|
404
|
-
instance_by_color "$comp" "$color" | grep -q . && die "
|
|
506
|
+
instance_by_color "$comp" "$color" | grep -q . && die "kampodine-$color already RUNNING"
|
|
405
507
|
# base: the OTHER color's AD + subnet (same fault domain layout), golden image
|
|
406
508
|
other=green; [[ "$color" == green ]] && other=blue
|
|
407
509
|
orow="$(instance_by_color "$comp" "$other")"
|
|
408
|
-
[[ -n "$orow" ]] || die "
|
|
510
|
+
[[ -n "$orow" ]] || die "kampodine-$other not RUNNING — need its AD/subnet as the pair template"
|
|
409
511
|
# instance_by_color rows are "<ocid> <ephemeral_public_ip> <ad>" — field 2
|
|
410
512
|
# is the IP, the AD is field 3 (launching with the IP as AD fails).
|
|
411
513
|
read -r oiid _ oad <<<"$orow"
|
|
412
514
|
subnet="$(oci compute vnic-attachment list -c "$comp" --profile "$PROFILE" \
|
|
413
515
|
--instance-id "$oiid" --query 'data[0]."subnet-id"' --raw-output)"
|
|
414
516
|
|
|
415
|
-
# Route selection. NATIVE only with a UEFI_64
|
|
517
|
+
# Route selection. NATIVE only with a UEFI_64 kampodine-alpine* custom image:
|
|
416
518
|
# OCI pins IMPORTED images to firmware=BIOS and A1 is UEFI-only, so a BIOS
|
|
417
519
|
# verdict means the import would die at launch (Shape ... is not valid for
|
|
418
520
|
# image) — skip it. Otherwise take the template's LIVE image-id from its
|
|
419
521
|
# instance record and inject the golden disk (provision-via-migrate).
|
|
420
522
|
mode="" image=""
|
|
421
523
|
custom="$(oci compute image list -c "$comp" --profile "$PROFILE" --all --sort-by TIMECREATED \
|
|
422
|
-
--query "data[?\"display-name\" != null && starts_with(\"display-name\", '
|
|
524
|
+
--query "data[?\"display-name\" != null && starts_with(\"display-name\", 'kampodine-alpine')] | [0].id" \
|
|
423
525
|
--raw-output 2>/dev/null || true)"
|
|
424
526
|
if [[ "$custom" == ocid1.image* ]]; then
|
|
425
527
|
fw="$(oci compute image get --image-id "$custom" --profile "$PROFILE" \
|
|
@@ -427,7 +529,7 @@ case "$cmd" in
|
|
|
427
529
|
if [[ "$fw" == "UEFI_64" ]]; then
|
|
428
530
|
image="$custom" mode="native"
|
|
429
531
|
else
|
|
430
|
-
say "note: newest
|
|
532
|
+
say "note: newest kampodine-alpine* custom image is firmware=${fw:-unknown} — A1 rejects BIOS-pinned imports, skipping to the platform-image + injection route"
|
|
431
533
|
fi
|
|
432
534
|
fi
|
|
433
535
|
if [[ -z "$image" ]]; then
|
|
@@ -435,7 +537,7 @@ case "$cmd" in
|
|
|
435
537
|
--query 'data."image-id"' --raw-output 2>/dev/null || true)"
|
|
436
538
|
[[ "$image" == ocid1.image* ]] || die "template instance has no resolvable image-id — cannot launch or inject"
|
|
437
539
|
mode="inject"
|
|
438
|
-
qcow2="${ALPINE_QCOW2:-${REPO_ROOT}/infra/alpine-host/build/
|
|
540
|
+
qcow2="${ALPINE_QCOW2:-${REPO_ROOT}/infra/alpine-host/build/kampodine-alpine-3.22.6-aarch64.qcow2}"
|
|
439
541
|
[[ -f "$qcow2" ]] || die "golden qcow2 not found: $qcow2 (build via packer, or set ALPINE_QCOW2)"
|
|
440
542
|
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
543
|
# ops ssh public key for the platform-image first boot. OPS_SSH_PUBKEY
|
|
@@ -448,22 +550,22 @@ case "$cmd" in
|
|
|
448
550
|
keyfile="$OPS_SSH_PUBKEY"
|
|
449
551
|
else
|
|
450
552
|
agent_keys="$(ssh-add -L 2>/dev/null | grep -v '\.pub$' || true)"
|
|
451
|
-
[[ -n "$agent_keys" ]] || die "no ssh key available: OPS_SSH_PUBKEY unset and ssh-add lists no keys (ssh-add ~/.ssh/id_ed25519-
|
|
452
|
-
keyfile="$(mktemp "${TMPDIR:-/tmp}/
|
|
553
|
+
[[ -n "$agent_keys" ]] || die "no ssh key available: OPS_SSH_PUBKEY unset and ssh-add lists no keys (ssh-add ~/.ssh/id_ed25519-kampodine, or set OPS_SSH_PUBKEY)"
|
|
554
|
+
keyfile="$(mktemp "${TMPDIR:-/tmp}/kampodine-ops-pubkey.XXXXXX")"
|
|
453
555
|
printf '%s\n' "$agent_keys" > "$keyfile"
|
|
454
556
|
chmod 600 "$keyfile"
|
|
455
557
|
fi
|
|
456
558
|
fi
|
|
457
559
|
|
|
458
|
-
say "launching
|
|
560
|
+
say "launching kampodine-$color: image=${image:0:60}… ad=$oad subnet=${subnet:0:60}… mode=$mode"
|
|
459
561
|
# NOTE: --profile stays ON the launch line (script-gates static scan reads
|
|
460
562
|
# the invocation line, not array contents).
|
|
461
563
|
launch_args=(-c "$comp" --availability-domain "$oad" --subnet-id "$subnet"
|
|
462
564
|
--image-id "$image" --shape VM.Standard.A1.Flex --shape-config '{"ocpus":2,"memoryInGBs":12}'
|
|
463
|
-
--assign-public-ip true --display-name "
|
|
565
|
+
--assign-public-ip true --display-name "kampodine-$color")
|
|
464
566
|
[[ "$mode" == "inject" ]] && launch_args+=(--ssh-authorized-keys-file "$keyfile")
|
|
465
567
|
iid="$(oci compute instance launch "${launch_args[@]}" --profile "$PROFILE" --query 'data.id' --raw-output)"
|
|
466
|
-
say "LAUNCHED
|
|
568
|
+
say "LAUNCHED kampodine-$color: $iid"
|
|
467
569
|
if [[ "$mode" == "native" ]]; then
|
|
468
570
|
say "next: wait RUNNING -> ssh in -> kampodine vm-prepare -> kampodine deploy --host root@<ephemeral-ip>"
|
|
469
571
|
say "then 'bluegreen.sh flip --to $color' (health-gated) once its app checks green."
|
|
@@ -493,16 +595,16 @@ case "$cmd" in
|
|
|
493
595
|
read -r rocid raddr _ <<<"$rp"
|
|
494
596
|
rhost="${raddr//./-}.sslip.io"
|
|
495
597
|
trow="$(instance_by_color "$comp" "$to")"
|
|
496
|
-
[[ -n "$trow" ]] || die "
|
|
598
|
+
[[ -n "$trow" ]] || die "kampodine-$to is not RUNNING — nothing to flip to"
|
|
497
599
|
read -r tiid tpub _ <<<"$trow"
|
|
498
600
|
tvnic="$(primary_vnic_of "$tiid")"
|
|
499
|
-
[[ -n "$tvnic" ]] || die "no primary VNIC on
|
|
601
|
+
[[ -n "$tvnic" ]] || die "no primary VNIC on kampodine-$to"
|
|
500
602
|
tpip="$(reserved_anchor_ip "$tvnic")"
|
|
501
|
-
[[ "$tpip" == ocid1.privateip* ]] || die "could not resolve/create the reserved-anchor secondary private ip on
|
|
603
|
+
[[ "$tpip" == ocid1.privateip* ]] || die "could not resolve/create the reserved-anchor secondary private ip on kampodine-$to"
|
|
502
604
|
if instance_healthy "$tpub"; then
|
|
503
|
-
say "target health:
|
|
605
|
+
say "target health: kampodine-$to app HEALTHY on its own IP"
|
|
504
606
|
else
|
|
505
|
-
(( force )) || die "
|
|
607
|
+
(( force )) || die "kampodine-$to app UNHEALTHY — refusing flip (override: --force)"
|
|
506
608
|
say "target health: UNHEALTHY — flipping anyway (--force)"
|
|
507
609
|
fi
|
|
508
610
|
|
|
@@ -510,19 +612,19 @@ case "$cmd" in
|
|
|
510
612
|
# once the hostname resolves to the reserved ip AND routes to the target,
|
|
511
613
|
# so the guest anchor address goes FIRST, then the assign, then the cert.
|
|
512
614
|
ob=green; [[ "$to" == green ]] && ob=blue
|
|
513
|
-
say "flip[1/4]: anchor conf ->
|
|
615
|
+
say "flip[1/4]: anchor conf -> kampodine-$to guest ($tpub), watcher configures the address"
|
|
514
616
|
tsubnet="$(oci compute vnic-attachment list -c "$comp" --profile "$PROFILE" \
|
|
515
617
|
--instance-id "$tiid" --query 'data[0]."subnet-id"' --raw-output 2>/dev/null || true)"
|
|
516
|
-
[[ "$tsubnet" == ocid1.subnet* ]] || die "could not resolve
|
|
618
|
+
[[ "$tsubnet" == ocid1.subnet* ]] || die "could not resolve kampodine-$to's subnet id"
|
|
517
619
|
tcidr="$(oci network subnet get --subnet-id "$tsubnet" --profile "$PROFILE" \
|
|
518
620
|
--query 'data."cidr-block"' --raw-output 2>/dev/null || true)"
|
|
519
|
-
[[ "$tcidr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || die "could not resolve
|
|
621
|
+
[[ "$tcidr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || die "could not resolve kampodine-$to's subnet cidr (got: ${tcidr:-none})"
|
|
520
622
|
tanchor_addr="$(oci network private-ip get --private-ip-id "$tpip" --profile "$PROFILE" \
|
|
521
623
|
--query 'data."ip-address"' --raw-output 2>/dev/null || true)"
|
|
522
|
-
[[ "$tanchor_addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "could not resolve the anchor private address on
|
|
624
|
+
[[ "$tanchor_addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "could not resolve the anchor private address on kampodine-$to"
|
|
523
625
|
taddr_cidr="${tanchor_addr}/${tcidr##*/}"
|
|
524
626
|
if ! write_anchor_conf "$tpub" "$taddr_cidr" >/dev/null 2>&1; then
|
|
525
|
-
die "anchor.conf write failed on
|
|
627
|
+
die "anchor.conf write failed on kampodine-$to ($tpub) — nothing mutated, flip aborted"
|
|
526
628
|
fi
|
|
527
629
|
addr_ok=0
|
|
528
630
|
for ((i = 1; i <= FLIP_ADDR_TRIES; i++)); do
|
|
@@ -531,15 +633,15 @@ case "$cmd" in
|
|
|
531
633
|
done
|
|
532
634
|
if (( ! addr_ok )); then
|
|
533
635
|
cleanup_target_anchor "$tpub" "$taddr_cidr"
|
|
534
|
-
die "guest watcher never configured $taddr_cidr on
|
|
636
|
+
die "guest watcher never configured $taddr_cidr on kampodine-$to — is rc-service kampodine-anchor running? (conf removed, NOTHING mutated)"
|
|
535
637
|
fi
|
|
536
|
-
say "flip[2/4]: guest answers on $taddr_cidr — reserved $raddr ->
|
|
638
|
+
say "flip[2/4]: guest answers on $taddr_cidr — reserved $raddr -> kampodine-$to (anchor $tpip)"
|
|
537
639
|
oci network public-ip update --public-ip-id "$rocid" --profile "$PROFILE" \
|
|
538
640
|
--private-ip-id "$tpip" --force --wait-for-state ASSIGNED >/dev/null \
|
|
539
641
|
|| { cleanup_target_anchor "$tpub" "$taddr_cidr"; die "OCI flip call failed — guest conf removed; run 'kampodine bluegreen status'"; }
|
|
540
|
-
say "flip[3/4]: reserved IP ASSIGNED — registering $rhost on
|
|
642
|
+
say "flip[3/4]: reserved IP ASSIGNED — registering $rhost on kampodine-$to's kamal-proxy (ACME HTTP-01 through the reserved ip)…"
|
|
541
643
|
if ! ssh "${SSH_OPTS[@]}" "root@$tpub" \
|
|
542
|
-
"podman exec kamal-proxy kamal-proxy deploy
|
|
644
|
+
"podman exec kamal-proxy kamal-proxy deploy kampodine-api --host=$rhost --target=kampodine-api:8080 --tls --health-check-path=/api/auth/ok"; then
|
|
543
645
|
flip_failure_rollback "$to" "$ob" "$comp" "$rocid" "$tpub" "$taddr_cidr"
|
|
544
646
|
exit 1
|
|
545
647
|
fi
|
|
@@ -555,14 +657,14 @@ case "$cmd" in
|
|
|
555
657
|
fi
|
|
556
658
|
served="$(curl -s -m 8 --resolve "$rhost:443:$raddr" "https://$rhost/api/auth/ok" || true)"
|
|
557
659
|
say "flip[4/4]: cert for $rhost VALID + serving through $raddr"
|
|
558
|
-
say "FLIPPED: https://$raddr/ (https://$rhost/) now serves from
|
|
660
|
+
say "FLIPPED: https://$raddr/ (https://$rhost/) now serves from kampodine-$to"
|
|
559
661
|
say "served /api/auth/ok: ${served:-<no body>}"
|
|
560
662
|
;;
|
|
561
663
|
|
|
562
664
|
rollback)
|
|
563
665
|
# DORMANT rollback: holder guest cleanup
|
|
564
666
|
# (anchor.conf removal + anchor address delete — the flip tool's explicit
|
|
565
|
-
# job; the
|
|
667
|
+
# job; the kampodine-anchor watcher is add-only) then the OCI unassign
|
|
566
668
|
# (documented CLI semantics: an empty --private-ip-id unassigns). For a
|
|
567
669
|
# real pair where traffic must land on the other color, use
|
|
568
670
|
# 'flip --to <other>' — it runs the full ACME-first sequence there.
|
|
@@ -591,10 +693,10 @@ case "$cmd" in
|
|
|
591
693
|
if [[ -z "$holder_color" ]]; then
|
|
592
694
|
say "warning: reserved $raddr held by an anchor of neither RUNNING color (terminated instance?) — unassigning without guest cleanup"
|
|
593
695
|
else
|
|
594
|
-
say "current holder:
|
|
696
|
+
say "current holder: kampodine-$holder_color ($holder_ip) — cleaning the guest anchor, then unassigning"
|
|
595
697
|
conf_addr="$(read_anchor_conf_addr "$holder_ip")"
|
|
596
698
|
cleanup_target_anchor "$holder_ip" "$conf_addr"
|
|
597
|
-
say "
|
|
699
|
+
say "kampodine-$holder_color guest cleaned (anchor.conf removed${conf_addr:+, address $conf_addr deleted})"
|
|
598
700
|
fi
|
|
599
701
|
say "unassigning reserved $raddr (-> dormant)…"
|
|
600
702
|
oci network public-ip update --public-ip-id "$rocid" --profile "$PROFILE" \
|