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/scripts/deploy.sh
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Mac: podman build (VITE_BUILD_ID=<sha>) -> podman save (docker-archive)
|
|
5
5
|
# streamed over SSH -> podman load on the VM
|
|
6
|
-
# VM: retag to 127.0.0.1:5000/
|
|
6
|
+
# VM: retag to 127.0.0.1:5000/kampodine-api:latest -> rc-service kampodine-api
|
|
7
7
|
# restart (the OpenRC supervise-daemon service re-runs the whole
|
|
8
8
|
# `podman run` against :latest — no pull in the run, so restarts are
|
|
9
9
|
# offline-safe) -> exec-fetch health probe with served-sha verify ->
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
set -euo pipefail
|
|
27
27
|
|
|
28
28
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
29
|
-
|
|
29
|
+
KAMPODINE_HOST="${KAMPODINE_HOST:-}"
|
|
30
30
|
PROXY_HOST="${PROXY_HOST:-app.example.com}"
|
|
31
|
-
ENV_FILE_REMOTE="/etc/
|
|
32
|
-
DEPLOYED_SHA_FILE="/etc/
|
|
31
|
+
ENV_FILE_REMOTE="/etc/kampodine/env"
|
|
32
|
+
DEPLOYED_SHA_FILE="/etc/kampodine/deployed-sha"
|
|
33
33
|
ENV_CLEAR_KEYS='^(NODE_ENV|PORT|LIBSQL_TENANT_DIR|LIBSQL_API_MOUNT|STATIC_SPA_MOUNT)='
|
|
34
|
-
IMAGE="127.0.0.1:5000/
|
|
34
|
+
IMAGE="127.0.0.1:5000/kampodine-api"
|
|
35
35
|
|
|
36
36
|
MODE="deploy"
|
|
37
37
|
VERSION=""
|
|
@@ -40,8 +40,7 @@ REFRESH_CONFIG=0
|
|
|
40
40
|
# SSH key resolution — GENERIC, no hardcoded personal paths:
|
|
41
41
|
# 1. --ssh-key flag (explicit, per-invocation)
|
|
42
42
|
# 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
|
|
43
|
-
# 3.
|
|
44
|
-
# 4. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
|
|
43
|
+
# 3. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
|
|
45
44
|
# (the POSIX way: per-host IdentityFile belongs in ssh config, not here)
|
|
46
45
|
if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
|
|
47
46
|
SSH_KEY="$KAMPODINE_SSH_KEY"
|
|
@@ -55,11 +54,26 @@ DOCKERFILE="${KAMPODINE_DOCKERFILE:-apps/api/Containerfile}"
|
|
|
55
54
|
|
|
56
55
|
say() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
|
|
57
56
|
die() { printf '\033[1;31m[deploy] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
58
|
-
usage() {
|
|
57
|
+
usage() {
|
|
58
|
+
cat <<'EOF'
|
|
59
|
+
Usage:
|
|
60
|
+
kampodine deploy [--host root@<ip>] [--version <sha7>] [--rollback [<sha7>]]
|
|
61
|
+
[--dockerfile <path>] [--ssh-key <path>] [--skip-smoke] [--refresh-config]
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
EOF
|
|
65
|
+
grep '^# kampodine deploy' "$0" | sed 's/^# //'
|
|
66
|
+
cat <<'EOF'
|
|
67
|
+
|
|
68
|
+
Host/key resolution: --host | KAMPODINE_HOST; --ssh-key | KAMPODINE_SSH_KEY |
|
|
69
|
+
KAMPODINE_SSH_KEY | ssh-agent / ~/.ssh/config.
|
|
70
|
+
EOF
|
|
71
|
+
exit 0
|
|
72
|
+
}
|
|
59
73
|
|
|
60
74
|
while [[ $# -gt 0 ]]; do
|
|
61
75
|
case "$1" in
|
|
62
|
-
--host)
|
|
76
|
+
--host) KAMPODINE_HOST="$2"; shift 2 ;;
|
|
63
77
|
--ssh-key) SSH_KEY="$2"; shift 2 ;;
|
|
64
78
|
--version) VERSION="$2"; MODE="version"; shift 2 ;;
|
|
65
79
|
--rollback)
|
|
@@ -74,13 +88,13 @@ while [[ $# -gt 0 ]]; do
|
|
|
74
88
|
esac
|
|
75
89
|
done
|
|
76
90
|
[[ "$VERSION" != *..* && "$VERSION" =~ ^[0-9a-f]{4,40}$|^$ ]] || die "--version must be a git sha fragment"
|
|
77
|
-
[ -n "$
|
|
91
|
+
[ -n "$KAMPODINE_HOST" ] || die "set KAMPODINE_HOST=root@<vm-ip> (or a ~/.ssh/config Host alias via --host)"
|
|
78
92
|
|
|
79
93
|
SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
|
|
80
94
|
[[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
|
|
81
95
|
# $1 is a composed remote command — client-side expansion is the design.
|
|
82
96
|
# shellcheck disable=SC2029
|
|
83
|
-
vm() { ssh "${SSH_ARGS[@]}" "$
|
|
97
|
+
vm() { ssh "${SSH_ARGS[@]}" "$KAMPODINE_HOST" "$1"; }
|
|
84
98
|
|
|
85
99
|
# --- macOS ssh-agent quirk (first deploy from a fresh machine) ---------------
|
|
86
100
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
@@ -126,7 +140,7 @@ vm "podman tag $IMAGE:$VER $IMAGE:latest" || die "stream retag failed"
|
|
|
126
140
|
|
|
127
141
|
# --- env file from varlock (schema-only from HEAD; secrets from pass) ----------
|
|
128
142
|
say "generating $ENV_FILE_REMOTE from varlock (schema-only from HEAD; secrets resolve from pass — no plaintext secret files)…"
|
|
129
|
-
ENV_TMP="$(mktemp /tmp/
|
|
143
|
+
ENV_TMP="$(mktemp /tmp/kampodine-env.XXXXXX)"
|
|
130
144
|
trap 'rm -f "$ENV_TMP"' EXIT
|
|
131
145
|
# Schema-only resolution: extract the COMMITTED schema into a scratch dir
|
|
132
146
|
# inside the repo (plugin resolution needs node_modules; local
|
|
@@ -143,20 +157,20 @@ git -C "$REPO_ROOT" archive HEAD apps/api/.env.schema | tar -x -C "$ENV_SCHEMA_D
|
|
|
143
157
|
| sed -E 's/^([A-Za-z_][A-Za-z0-9_]*)="(.*)"$/\1=\2/' > "$ENV_TMP" \
|
|
144
158
|
|| die "varlock env generation failed (pass store unlocked? schema committed?)"
|
|
145
159
|
printf 'API_GIT_SHA=%s\n' "$VER" >> "$ENV_TMP"
|
|
146
|
-
scp -q "${SSH_ARGS[@]}" "$ENV_TMP" "$
|
|
147
|
-
vm "mv /etc/
|
|
160
|
+
scp -q "${SSH_ARGS[@]}" "$ENV_TMP" "$KAMPODINE_HOST:/etc/kampodine/env.tmp"
|
|
161
|
+
vm "mv /etc/kampodine/env.tmp $ENV_FILE_REMOTE && chmod 600 $ENV_FILE_REMOTE"
|
|
148
162
|
rm -f "$ENV_TMP"
|
|
149
163
|
rm -rf "$ENV_SCHEMA_DIR"
|
|
150
164
|
|
|
151
165
|
if [[ $REFRESH_CONFIG -eq 1 ]]; then
|
|
152
166
|
say "ansible container-service refresh (--tags container-service — rc script/config drift repair)…"
|
|
153
167
|
(cd "$REPO_ROOT/infra/alpine-host/ansible" \
|
|
154
|
-
&& ansible-playbook -i "
|
|
168
|
+
&& ansible-playbook -i "kampodine-vm ansible_host=${KAMPODINE_HOST#*@},ansible_user=${KAMPODINE_HOST%%@*}," playbook.yml --tags container-service --private-key "${SSH_KEY:-~/.ssh/id_ed25519}") \
|
|
155
169
|
|| die "ansible playbook failed"
|
|
156
170
|
fi
|
|
157
171
|
|
|
158
|
-
say "rc-service
|
|
159
|
-
vm "rc-service
|
|
172
|
+
say "rc-service kampodine-api restart (supervise-daemon: stop-old/start-new podman run on the retagged :latest)…"
|
|
173
|
+
vm "rc-service kampodine-api restart" || die "kampodine-api restart failed (rc-service kampodine-api status)"
|
|
160
174
|
|
|
161
175
|
say "health probe (host-side wget /api/auth/ok + served-sha — works for node AND scratch images)…"
|
|
162
176
|
HEALTH_OK=0
|
|
@@ -173,17 +187,17 @@ for _ in $(seq 1 30); do
|
|
|
173
187
|
sleep 3
|
|
174
188
|
done
|
|
175
189
|
if [[ $HEALTH_OK -ne 1 ]]; then
|
|
176
|
-
vm "podman logs --tail 30
|
|
177
|
-
vm "rc-service
|
|
190
|
+
vm "podman logs --tail 30 kampodine-api 2>&1" || true
|
|
191
|
+
vm "rc-service kampodine-api status 2>&1" || true
|
|
178
192
|
PREV_HINT="$(vm "test -f $DEPLOYED_SHA_FILE && cat $DEPLOYED_SHA_FILE" 2>/dev/null || true)"
|
|
179
193
|
die "container never became healthy — rollback: kampodine deploy --rollback${PREV_HINT:+ $PREV_HINT}"
|
|
180
194
|
fi
|
|
181
|
-
CANARY="$(vm "podman logs
|
|
195
|
+
CANARY="$(vm "podman logs kampodine-api 2>&1 | grep -c ENV_IMPORT_SUSPECT || true")"
|
|
182
196
|
say "container healthy, env-canary hits: $CANARY"
|
|
183
197
|
|
|
184
198
|
say "kamal-proxy re-point (podman exec — same invocation kamal used)…"
|
|
185
199
|
if vm "podman ps --format '{{.Names}}' | grep -qx kamal-proxy"; then
|
|
186
|
-
vm "podman exec kamal-proxy kamal-proxy deploy
|
|
200
|
+
vm "podman exec kamal-proxy kamal-proxy deploy kampodine-api --host=$PROXY_HOST --target=kampodine-api:8080 --tls --health-check-path=/api/auth/ok" \
|
|
187
201
|
|| die "proxy re-point failed (podman logs kamal-proxy)"
|
|
188
202
|
else
|
|
189
203
|
say " kamal-proxy not running — skipping re-point"
|
package/scripts/dns.sh
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dns.sh — OCI DNS record management: the single surface for the coming DNS
|
|
3
|
+
# era (today the stack is raw IP + sslip.io; when real zones land, records
|
|
4
|
+
# are managed here, never by hand in the console).
|
|
5
|
+
#
|
|
6
|
+
# AUTH RULE (hard): the `oci` CLI's own auth ONLY — its config file
|
|
7
|
+
# (~/.oci/config, selected via --profile) or instance principal. kampodine
|
|
8
|
+
# NEVER accepts, stores, or logs credential material: no key flags, no
|
|
9
|
+
# credential env vars, nothing token-shaped in args or output. The OCI config
|
|
10
|
+
# file stays the single credential store.
|
|
11
|
+
#
|
|
12
|
+
# Types are pinned to A | AAAA | CNAME. `add` merges into the existing RRSet
|
|
13
|
+
# (round-robin records survive); `rm` filters it; both are idempotence-aware.
|
|
14
|
+
#
|
|
15
|
+
# Zone/compartment: OCI_PROFILE (default: "default"), OCI_COMPARTMENT (required)
|
|
16
|
+
# (required). Zone defaults to the compartment's ONLY zone; pass
|
|
17
|
+
# --zone <id-or-name> when several exist.
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# kampodine dns records [--zone <id-or-name>] # list records: domain / type / ttl / value
|
|
21
|
+
# kampodine dns add --name <label> --type A|AAAA|CNAME --value <target> [--ttl 300]
|
|
22
|
+
# kampodine dns rm --name <label> --type <A|AAAA|CNAME> --value <target>
|
|
23
|
+
# (all: optional --zone <id-or-name>; --instance-principal for instance auth)
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
PROFILE="${OCI_PROFILE:-default}"
|
|
27
|
+
COMPARTMENT_NAME="${OCI_COMPARTMENT:-kampodine}"
|
|
28
|
+
AUTH_ARGS=()
|
|
29
|
+
|
|
30
|
+
say() { printf '\033[1;34m[dns]\033[0m %s\n' "$*"; }
|
|
31
|
+
die() { printf '\033[1;31m[dns] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
32
|
+
usage() {
|
|
33
|
+
local code="${1:-0}"
|
|
34
|
+
printf 'Usage:\n'
|
|
35
|
+
grep '^# kampodine dns' "$0" | sed 's/^# //'
|
|
36
|
+
cat <<'EOF'
|
|
37
|
+
|
|
38
|
+
Auth is the oci CLI's own — its config file (--profile) or instance
|
|
39
|
+
principal. kampodine never accepts, stores, or logs credential material.
|
|
40
|
+
Zone/compartment: OCI_PROFILE (default: "default"), OCI_COMPARTMENT (default
|
|
41
|
+
kampodine). Types are pinned to A | AAAA | CNAME; ttl range 60..172800.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
kampodine dns records
|
|
45
|
+
kampodine dns records --zone kampodine.example.com
|
|
46
|
+
kampodine dns add --name app --type A --value 203.0.113.10 --zone kampodine.example.com
|
|
47
|
+
kampodine dns add --name www --type CNAME --value app.example.com --ttl 3600
|
|
48
|
+
kampodine dns rm --name app --type A --value 203.0.113.10
|
|
49
|
+
EOF
|
|
50
|
+
exit "$code"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# --- pure validators (no side effects, run before any provider call) ----------
|
|
54
|
+
is_ipv4() {
|
|
55
|
+
local ip="$1" i octet
|
|
56
|
+
[[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
|
|
57
|
+
for i in 1 2 3 4; do
|
|
58
|
+
octet="${BASH_REMATCH[i]}"
|
|
59
|
+
[[ $((10#$octet)) -le 255 ]] || return 1
|
|
60
|
+
done
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
is_ipv6() {
|
|
65
|
+
local ip="$1" colons
|
|
66
|
+
[[ "$ip" =~ ^[0-9A-Fa-f:]+$ ]] || return 1
|
|
67
|
+
[[ -n "${ip//:/}" ]] || return 1 # at least one hex digit
|
|
68
|
+
[[ "$ip" != *":::"* ]] || return 1 # no triple colons
|
|
69
|
+
colons="${ip//[0-9A-Fa-f]/}"
|
|
70
|
+
[[ ${#colons} -le 7 ]] || return 1 # at most 8 groups
|
|
71
|
+
[[ "${ip/::/}" != *"::"* ]] || return 1 # at most one "::"
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
is_hostname() {
|
|
76
|
+
[[ "$1" =~ ^([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.)+[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.?$ ]]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# --- dispatch -----------------------------------------------------------------
|
|
80
|
+
CMD="${1:-}"
|
|
81
|
+
case "$CMD" in
|
|
82
|
+
records|add|rm) shift ;;
|
|
83
|
+
-h|--help|help) usage 0 ;;
|
|
84
|
+
"") usage 2 ;;
|
|
85
|
+
*)
|
|
86
|
+
printf 'kampodine dns: unknown subcommand: %s\n\n' "$CMD" >&2
|
|
87
|
+
usage 2
|
|
88
|
+
;;
|
|
89
|
+
esac
|
|
90
|
+
|
|
91
|
+
ZONE_ARG=""
|
|
92
|
+
NAME=""
|
|
93
|
+
RTYPE=""
|
|
94
|
+
VALUE=""
|
|
95
|
+
TTL="300"
|
|
96
|
+
while [[ $# -gt 0 ]]; do
|
|
97
|
+
case "$1" in
|
|
98
|
+
--zone) ZONE_ARG="$2"; shift 2 ;;
|
|
99
|
+
--name) NAME="$2"; shift 2 ;;
|
|
100
|
+
--type) RTYPE="$2"; shift 2 ;;
|
|
101
|
+
--value) VALUE="$2"; shift 2 ;;
|
|
102
|
+
--ttl) TTL="$2"; shift 2 ;;
|
|
103
|
+
--instance-principal) AUTH_ARGS=(--auth instance_principal); shift ;;
|
|
104
|
+
-h|--help) usage 0 ;;
|
|
105
|
+
*) die "unknown argument: $1 (--help)" ;;
|
|
106
|
+
esac
|
|
107
|
+
done
|
|
108
|
+
|
|
109
|
+
need_provider() {
|
|
110
|
+
command -v oci >/dev/null 2>&1 \
|
|
111
|
+
|| die "oci CLI not found — brew install oci-cli (https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/cliinstall.htm), then: oci setup config"
|
|
112
|
+
command -v jq >/dev/null 2>&1 \
|
|
113
|
+
|| die "jq not found — brew install jq (dns.sh uses it for RRSet surgery)"
|
|
114
|
+
[[ -n "${OCI_COMPARTMENT:-}" ]] \
|
|
115
|
+
|| die "set OCI_COMPARTMENT=<compartment name or ocid> — no default: compartments are account-specific"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
compartment_ocid() {
|
|
119
|
+
local ocid
|
|
120
|
+
ocid="$(oci iam compartment list --all --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} \
|
|
121
|
+
--query "data[?name=='$COMPARTMENT_NAME'].id | [0]" --raw-output 2>/dev/null || true)"
|
|
122
|
+
[[ "$ocid" == ocid1.compartment* ]] || die "compartment '$COMPARTMENT_NAME' not found (profile $PROFILE)"
|
|
123
|
+
printf '%s' "$ocid"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# resolve_zone -> ZONE_NAME + ZONE_ID (explicit --zone wins; otherwise the
|
|
127
|
+
# compartment must hold exactly one zone — never guess between several).
|
|
128
|
+
resolve_zone() {
|
|
129
|
+
local zj zl count
|
|
130
|
+
COMPARTMENT_OCID="$(compartment_ocid)"
|
|
131
|
+
if [[ -n "$ZONE_ARG" ]]; then
|
|
132
|
+
if [[ "$ZONE_ARG" == ocid1.dns-zone* ]]; then
|
|
133
|
+
zj="$(oci dns zone get --zone-name-or-id "$ZONE_ARG" -c "$COMPARTMENT_OCID" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null)" \
|
|
134
|
+
|| die "zone not found: $ZONE_ARG"
|
|
135
|
+
ZONE_NAME="$(jq -r '.data.name' <<<"$zj")"
|
|
136
|
+
ZONE_ID="$ZONE_ARG"
|
|
137
|
+
else
|
|
138
|
+
ZONE_NAME="${ZONE_ARG%.}"
|
|
139
|
+
ZONE_ID="$ZONE_ARG"
|
|
140
|
+
fi
|
|
141
|
+
return 0
|
|
142
|
+
fi
|
|
143
|
+
zl="$(oci dns zone list --all -c "$COMPARTMENT_OCID" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null)" \
|
|
144
|
+
|| die "zone list failed (profile $PROFILE, compartment $COMPARTMENT_NAME)"
|
|
145
|
+
count="$(jq '.data.items | length' <<<"$zl")"
|
|
146
|
+
if [[ "$count" -eq 0 ]]; then
|
|
147
|
+
die "no DNS zones in compartment '$COMPARTMENT_NAME' — create one in the OCI console first (or pass --zone <id-or-name>)"
|
|
148
|
+
fi
|
|
149
|
+
if [[ "$count" -gt 1 ]]; then
|
|
150
|
+
printf '\033[1;31m[dns] FAIL:\033[0m multiple zones in %s — pass --zone with one of:\n' "$COMPARTMENT_NAME" >&2
|
|
151
|
+
jq -r '.data.items[].name' <<<"$zl" | sed 's/^/ /' >&2
|
|
152
|
+
exit 1
|
|
153
|
+
fi
|
|
154
|
+
ZONE_NAME="$(jq -r '.data.items[0].name' <<<"$zl")"
|
|
155
|
+
ZONE_ID="$(jq -r '.data.items[0].id' <<<"$zl")"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# turn --name into the record domain: plain label -> label.zone; fqdn must
|
|
159
|
+
# live inside the zone (never write into someone else's zone by accident).
|
|
160
|
+
validate_name_shape() {
|
|
161
|
+
[[ -n "$NAME" ]] || die "--name is required (--help)"
|
|
162
|
+
[[ "$NAME" =~ ^[A-Za-z0-9_-]{1,63}$ ]] && return 0
|
|
163
|
+
is_hostname "$NAME" || die "invalid --name '$NAME' — use a DNS label (app) or an fqdn inside the zone (app.example.com)"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
resolve_domain() {
|
|
167
|
+
if [[ "$NAME" =~ ^[A-Za-z0-9_-]{1,63}$ ]]; then
|
|
168
|
+
DOMAIN="$NAME.$ZONE_NAME"
|
|
169
|
+
return 0
|
|
170
|
+
fi
|
|
171
|
+
DOMAIN="${NAME%.}"
|
|
172
|
+
[[ "$DOMAIN" == *".$ZONE_NAME" || "$DOMAIN" == "$ZONE_NAME" ]] \
|
|
173
|
+
|| die "--name '$NAME' is outside zone '$ZONE_NAME' — pass --zone <that-zone> explicitly if intended"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
validate_type() {
|
|
177
|
+
case "$RTYPE" in
|
|
178
|
+
A|AAAA|CNAME) return 0 ;;
|
|
179
|
+
"") die "--type is required: A, AAAA, or CNAME (--help)" ;;
|
|
180
|
+
*) die "--type must be one of: A, AAAA, CNAME (got: $RTYPE)" ;;
|
|
181
|
+
esac
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
validate_value() {
|
|
185
|
+
[[ -n "$VALUE" ]] || die "--value is required (--help)"
|
|
186
|
+
case "$VALUE" in *[\"\\]*) die "invalid --value — quotes/backslashes are never valid in a record value" ;; esac
|
|
187
|
+
case "$RTYPE" in
|
|
188
|
+
A) is_ipv4 "$VALUE" || die "invalid --value '$VALUE' for A — need dotted-quad IPv4 (203.0.113.10)" ;;
|
|
189
|
+
AAAA) is_ipv6 "$VALUE" || die "invalid --value '$VALUE' for AAAA — need an IPv6 literal (fd00::1)" ;;
|
|
190
|
+
CNAME) is_hostname "$VALUE" || die "invalid --value '$VALUE' for CNAME — need a hostname (app.example.com)" ;;
|
|
191
|
+
esac
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
validate_ttl() {
|
|
195
|
+
[[ "$TTL" =~ ^[0-9]+$ ]] || die "invalid --ttl '$TTL' — seconds (60..172800)"
|
|
196
|
+
[[ "$TTL" -ge 60 && "$TTL" -le 172800 ]] || die "invalid --ttl $TTL — OCI allows 60..172800 seconds"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# fetch_rrset -> ITEMS (canonical JSON array of {domain,rtype,ttl,rdata};
|
|
200
|
+
# a missing RRSet reads as empty — first add must not 404).
|
|
201
|
+
fetch_rrset() {
|
|
202
|
+
local rj
|
|
203
|
+
rj="$(oci dns record rrset get -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" --domain "$DOMAIN" --rtype "$RTYPE" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} 2>/dev/null || true)"
|
|
204
|
+
if [[ -n "$rj" ]]; then
|
|
205
|
+
ITEMS="$(jq -c '.data.items | map({domain, rtype, ttl, rdata})' <<<"$rj")" || ITEMS="[]"
|
|
206
|
+
else
|
|
207
|
+
ITEMS="[]"
|
|
208
|
+
fi
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case "$CMD" in
|
|
212
|
+
records)
|
|
213
|
+
need_provider
|
|
214
|
+
resolve_zone
|
|
215
|
+
say "DNS records — zone $ZONE_NAME ($ZONE_ID), profile $PROFILE:"
|
|
216
|
+
oci dns record zone get -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" --profile "$PROFILE" ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} --all \
|
|
217
|
+
| jq -r '.data.items[] | [.domain, .rtype, (.ttl|tostring), .rdata] | @tsv'
|
|
218
|
+
;;
|
|
219
|
+
|
|
220
|
+
add)
|
|
221
|
+
[[ -n "$NAME" ]] || die "--name is required (--help)"
|
|
222
|
+
validate_type
|
|
223
|
+
validate_value
|
|
224
|
+
validate_ttl
|
|
225
|
+
validate_name_shape
|
|
226
|
+
need_provider
|
|
227
|
+
resolve_zone
|
|
228
|
+
resolve_domain
|
|
229
|
+
fetch_rrset
|
|
230
|
+
norm_value="${VALUE%.}"
|
|
231
|
+
present="$(jq -r --arg v "$norm_value" 'any(.[]; (.rdata | sub("\\.$"; "")) == $v)' <<<"$ITEMS")"
|
|
232
|
+
if [[ "$present" == "true" ]]; then
|
|
233
|
+
say "already present: $DOMAIN $RTYPE $VALUE — nothing to do"
|
|
234
|
+
exit 0
|
|
235
|
+
fi
|
|
236
|
+
new_items="$(jq -c --arg d "$DOMAIN" --arg t "$RTYPE" --arg v "$VALUE" --argjson ttl "$TTL" \
|
|
237
|
+
'. + [{domain: $d, rtype: $t, ttl: $ttl, rdata: $v}]' <<<"$ITEMS")"
|
|
238
|
+
# NOTE: --profile stays ON the first line (script-gates static scan reads it there)
|
|
239
|
+
oci dns record rrset update --profile "$PROFILE" -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" \
|
|
240
|
+
--domain "$DOMAIN" --rtype "$RTYPE" --items "$new_items" --force ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} >/dev/null \
|
|
241
|
+
|| die "RRSet update failed for $DOMAIN $RTYPE (zone $ZONE_NAME)"
|
|
242
|
+
say "added: $DOMAIN $RTYPE $VALUE (ttl $TTL)"
|
|
243
|
+
;;
|
|
244
|
+
|
|
245
|
+
rm)
|
|
246
|
+
[[ -n "$NAME" ]] || die "--name is required (--help)"
|
|
247
|
+
validate_type
|
|
248
|
+
validate_value
|
|
249
|
+
validate_name_shape
|
|
250
|
+
need_provider
|
|
251
|
+
resolve_zone
|
|
252
|
+
resolve_domain
|
|
253
|
+
fetch_rrset
|
|
254
|
+
[[ "$ITEMS" == "[]" ]] && die "no matching record: $DOMAIN $RTYPE $VALUE (see: kampodine dns records)"
|
|
255
|
+
norm_value="${VALUE%.}"
|
|
256
|
+
remaining="$(jq -c --arg v "$norm_value" '[.[] | select((.rdata | sub("\\.$"; "")) != $v)]' <<<"$ITEMS")"
|
|
257
|
+
[[ "$(jq 'length' <<<"$remaining")" -eq "$(jq 'length' <<<"$ITEMS")" ]] \
|
|
258
|
+
&& die "no matching record: $DOMAIN $RTYPE $VALUE (see: kampodine dns records)"
|
|
259
|
+
# an emptied RRSet is legal: --items '[]' removes it entirely
|
|
260
|
+
# NOTE: --profile stays ON the first line (script-gates static scan reads it there)
|
|
261
|
+
oci dns record rrset update --profile "$PROFILE" -c "$COMPARTMENT_OCID" --zone-name-or-id "$ZONE_ID" \
|
|
262
|
+
--domain "$DOMAIN" --rtype "$RTYPE" --items "$remaining" --force ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} >/dev/null \
|
|
263
|
+
|| die "RRSet update failed for $DOMAIN $RTYPE (zone $ZONE_NAME)"
|
|
264
|
+
say "removed: $DOMAIN $RTYPE $VALUE"
|
|
265
|
+
;;
|
|
266
|
+
esac
|
package/scripts/env.sh
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# env.sh — manage the remote app env file (/etc/kampodine/env, 0600 root).
|
|
3
|
+
#
|
|
4
|
+
# SECRETS NEVER PRINT. `env list`, `env push`, and the `env pull` summaries
|
|
5
|
+
# surface FINGERPRINTS ONLY: KEY + value length + first 2 characters. Raw
|
|
6
|
+
# values move exactly twice — push uploads the local file over ssh stdin,
|
|
7
|
+
# pull streams the remote file to stdout (payload) or --out (0600). Nothing
|
|
8
|
+
# secret ever lands in terminal output, logs, or process args.
|
|
9
|
+
#
|
|
10
|
+
# Host/key resolution is IDENTICAL to deploy.sh:
|
|
11
|
+
# --host root@<ip> | KAMPODINE_HOST | ~/.ssh/config Host alias
|
|
12
|
+
# --ssh-key <path> | KAMPODINE_SSH_KEY |
|
|
13
|
+
# ssh-agent (the POSIX default; per-host IdentityFile belongs in ssh config)
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# kampodine env list [--host <user@ip>] # KEY + fingerprint table (NEVER values)
|
|
17
|
+
# kampodine env push --file <local-env-file> [...] # upload: 0600 temp + atomic mv + restart hint
|
|
18
|
+
# kampodine env pull [--out <file>] [...] # raw payload to stdout/--out (0600); masked summary follows it
|
|
19
|
+
# kampodine env fingerprint [--file <f>] # preview the masking for a LOCAL file / stdin (never values)
|
|
20
|
+
#
|
|
21
|
+
# push sends the file VERBATIM (what you push is what lands). deploy's env
|
|
22
|
+
# step additionally drops rc-script-owned CLEAR keys — keep those out of the
|
|
23
|
+
# file you push unless you mean to own them here.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
ENV_FILE_REMOTE="${KAMPODINE_ENV_REMOTE:-/etc/kampodine/env}"
|
|
27
|
+
ENV_TMP_BASE="$(dirname "$ENV_FILE_REMOTE")"
|
|
28
|
+
|
|
29
|
+
KAMPODINE_HOST="${KAMPODINE_HOST:-}"
|
|
30
|
+
# SSH key resolution — same ladder as deploy.sh:
|
|
31
|
+
# 1. --ssh-key flag (explicit, per-invocation)
|
|
32
|
+
# 2. KAMPODINE_SSH_KEY env (project-level: direnv / .envrc / export)
|
|
33
|
+
# 3. KAMPODINE_SSH_KEY env (legacy alias, kept for existing setups)
|
|
34
|
+
# 4. empty → ssh-agent and/or the operator's ~/.ssh/config Host block
|
|
35
|
+
if [ -n "${KAMPODINE_SSH_KEY:-}" ]; then
|
|
36
|
+
SSH_KEY="$KAMPODINE_SSH_KEY"
|
|
37
|
+
else
|
|
38
|
+
SSH_KEY=""
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
say() { printf '\033[1;36m[env]\033[0m %s\n' "$*"; }
|
|
42
|
+
die() { printf '\033[1;31m[env] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
43
|
+
usage() {
|
|
44
|
+
printf 'Usage:\n'
|
|
45
|
+
grep '^# kampodine env' "$0" | sed 's/^# //'
|
|
46
|
+
cat <<'EOF'
|
|
47
|
+
|
|
48
|
+
Fingerprints NEVER leak values: every line is KEY + value length + first 2 chars.
|
|
49
|
+
Raw values move only in push's upload stream and pull's stdout/--out payload.
|
|
50
|
+
Host/key resolution matches deploy.sh: --host | KAMPODINE_HOST; --ssh-key |
|
|
51
|
+
KAMPODINE_SSH_KEY | ssh-agent / ~/.ssh/config.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
kampodine env list --host root@203.0.113.10
|
|
55
|
+
kampodine env push --file ./ops/env.production --host root@203.0.113.10
|
|
56
|
+
kampodine env pull --out ./env.snapshot --host root@203.0.113.10 # written 0600
|
|
57
|
+
kampodine env pull --host root@203.0.113.10 | wc -l # raw payload on stdout, summary on stderr
|
|
58
|
+
kampodine env fingerprint --file ./.env.local # preview masking, values never leave stdin
|
|
59
|
+
EOF
|
|
60
|
+
exit 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
SUB="${1:-}"
|
|
64
|
+
[[ -n "$SUB" ]] || usage
|
|
65
|
+
case "$SUB" in
|
|
66
|
+
list|push|pull|fingerprint) shift ;;
|
|
67
|
+
-h|--help|help) usage ;;
|
|
68
|
+
*) die "unknown env subcommand: $SUB (--help)" ;;
|
|
69
|
+
esac
|
|
70
|
+
|
|
71
|
+
FILE=""
|
|
72
|
+
OUT=""
|
|
73
|
+
while [[ $# -gt 0 ]]; do
|
|
74
|
+
case "$1" in
|
|
75
|
+
--host) KAMPODINE_HOST="$2"; shift 2 ;;
|
|
76
|
+
--ssh-key) SSH_KEY="$2"; shift 2 ;;
|
|
77
|
+
--file) FILE="$2"; shift 2 ;;
|
|
78
|
+
--out) OUT="$2"; shift 2 ;;
|
|
79
|
+
-h|--help) usage ;;
|
|
80
|
+
*) die "unknown argument: $1 (--help)" ;;
|
|
81
|
+
esac
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
SSH_ARGS=(-o ConnectTimeout=10 -o BatchMode=yes)
|
|
85
|
+
[[ -n "$SSH_KEY" ]] && SSH_ARGS+=(-i "$SSH_KEY")
|
|
86
|
+
# $1 is a composed remote command — client-side expansion is the design.
|
|
87
|
+
# shellcheck disable=SC2029
|
|
88
|
+
vm() { ssh "${SSH_ARGS[@]}" "$KAMPODINE_HOST" "$1"; }
|
|
89
|
+
|
|
90
|
+
# --- macOS ssh-agent quirk (first run from a fresh machine) -------------------
|
|
91
|
+
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
92
|
+
SSH_AUTH_SOCK="$(launchctl getenv SSH_AUTH_SOCK 2>/dev/null || true)"
|
|
93
|
+
export SSH_AUTH_SOCK
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
require_host() {
|
|
97
|
+
[[ -n "$KAMPODINE_HOST" ]] || die "target required: --host root@<ip> or KAMPODINE_HOST=root@<ip> (resolution matches deploy.sh)"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# env_fp_lines — env content on stdin -> fingerprint table on stdout.
|
|
101
|
+
# THE masking contract lives here: KEY + value length + first 2 chars.
|
|
102
|
+
# $value is never printed; only ${value:0:2} and its length are.
|
|
103
|
+
env_fp_lines() {
|
|
104
|
+
local line key value len
|
|
105
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
106
|
+
case "$line" in ''|'#'*) continue ;; esac
|
|
107
|
+
[[ "$line" == *=* ]] || continue
|
|
108
|
+
key="${line%%=*}"
|
|
109
|
+
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
|
110
|
+
value="${line#*=}"
|
|
111
|
+
# one pair of matching surrounding quotes is formatting, not content
|
|
112
|
+
# (podman --env-file does not unquote — the deploy pipeline strips them)
|
|
113
|
+
if [[ ${#value} -ge 2 ]]; then
|
|
114
|
+
case "$value" in
|
|
115
|
+
\"*\") value="${value#\"}"; value="${value%\"}" ;;
|
|
116
|
+
\'*\') value="${value#\'}"; value="${value%\'}" ;;
|
|
117
|
+
esac
|
|
118
|
+
fi
|
|
119
|
+
len="${#value}"
|
|
120
|
+
if [[ "$len" -eq 0 ]]; then
|
|
121
|
+
printf '%-32s len=0 (empty)\n' "$key"
|
|
122
|
+
else
|
|
123
|
+
printf '%-32s len=%-7s %s…\n' "$key" "$len" "${value:0:2}"
|
|
124
|
+
fi
|
|
125
|
+
done
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fetch_remote_env() {
|
|
129
|
+
vm "cat $ENV_FILE_REMOTE" || die "cannot read $ENV_FILE_REMOTE on $KAMPODINE_HOST (no env file yet? run: kampodine env push --file <env>, or kampodine deploy)"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "$SUB" in
|
|
133
|
+
list)
|
|
134
|
+
require_host
|
|
135
|
+
raw="$(fetch_remote_env)"
|
|
136
|
+
env_fp_lines <<<"$raw"
|
|
137
|
+
;;
|
|
138
|
+
|
|
139
|
+
push)
|
|
140
|
+
[[ -n "$FILE" ]] || die "push requires --file <local-env-file> (--help)"
|
|
141
|
+
[[ -f "$FILE" ]] || die "no such file: $FILE"
|
|
142
|
+
require_host
|
|
143
|
+
count="$(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$FILE" || true)"
|
|
144
|
+
[[ "${count:-0}" -gt 0 ]] || die "no KEY=VALUE lines in $FILE — nothing to push"
|
|
145
|
+
say "pushing $FILE -> $KAMPODINE_HOST:$ENV_FILE_REMOTE (fingerprint summary below; values are NEVER printed)"
|
|
146
|
+
env_fp_lines < "$FILE"
|
|
147
|
+
tmp_remote="$ENV_TMP_BASE/env.tmp.$$"
|
|
148
|
+
# 0600 FROM CREATION: umask 077 + ssh stdin pipe — the temp is never
|
|
149
|
+
# world-readable, not even for an instant; `mv` within the same
|
|
150
|
+
# directory is atomic (busybox-safe: umask/cat/mv/chmod only).
|
|
151
|
+
vm "umask 077; cat > $tmp_remote" < "$FILE" || die "upload failed"
|
|
152
|
+
vm "chmod 600 $tmp_remote && mv -f $tmp_remote $ENV_FILE_REMOTE" \
|
|
153
|
+
|| die "atomic install failed (remote temp left at: $tmp_remote)"
|
|
154
|
+
say "installed $ENV_FILE_REMOTE (0600) on $KAMPODINE_HOST"
|
|
155
|
+
say "restart to apply: ssh $KAMPODINE_HOST 'rc-service kampodine-api restart' # or: kampodine deploy"
|
|
156
|
+
;;
|
|
157
|
+
|
|
158
|
+
pull)
|
|
159
|
+
require_host
|
|
160
|
+
raw="$(fetch_remote_env)"
|
|
161
|
+
if [[ -n "$OUT" ]]; then
|
|
162
|
+
( umask 077; printf '%s\n' "$raw" > "$OUT" ) || die "cannot write $OUT"
|
|
163
|
+
chmod 600 "$OUT"
|
|
164
|
+
say "wrote $OUT (0600) — fingerprint summary below (payload went to the file, stdout is free):"
|
|
165
|
+
env_fp_lines <<<"$raw"
|
|
166
|
+
else
|
|
167
|
+
# stdout IS the payload (pipe into whatever needs the values);
|
|
168
|
+
# the human-readable summary goes to stderr, masked.
|
|
169
|
+
printf '%s\n' "$raw"
|
|
170
|
+
say "fingerprint summary for $ENV_FILE_REMOTE on $KAMPODINE_HOST (payload above on stdout):" >&2
|
|
171
|
+
env_fp_lines <<<"$raw" >&2
|
|
172
|
+
fi
|
|
173
|
+
;;
|
|
174
|
+
|
|
175
|
+
fingerprint)
|
|
176
|
+
if [[ -n "$FILE" ]]; then
|
|
177
|
+
[[ -f "$FILE" ]] || die "no such file: $FILE"
|
|
178
|
+
env_fp_lines < "$FILE"
|
|
179
|
+
else
|
|
180
|
+
env_fp_lines
|
|
181
|
+
fi
|
|
182
|
+
;;
|
|
183
|
+
esac
|