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/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/esellar-api:latest -> rc-service esellar-api
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
- ESPELLAR_HOST="${ESPELLAR_HOST:-}"
29
+ KAMPODINE_HOST="${KAMPODINE_HOST:-}"
30
30
  PROXY_HOST="${PROXY_HOST:-app.example.com}"
31
- ENV_FILE_REMOTE="/etc/esellar/env"
32
- DEPLOYED_SHA_FILE="/etc/esellar/deployed-sha"
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/esellar-api"
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. ESSELLAR_SSH_KEY env (legacy alias, kept for existing setups)
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() { grep '^# kampodine deploy' "$0" | sed 's/^# //'; exit 0; }
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) ESPELLAR_HOST="$2"; shift 2 ;;
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 "$ESPELLAR_HOST" ] || die "set ESPELLAR_HOST=root@<vm-ip> (or a ~/.ssh/config Host alias via --host)"
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[@]}" "$ESPELLAR_HOST" "$1"; }
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/esellar-env.XXXXXX)"
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" "$ESPELLAR_HOST:/etc/esellar/env.tmp"
147
- vm "mv /etc/esellar/env.tmp $ENV_FILE_REMOTE && chmod 600 $ENV_FILE_REMOTE"
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 "esellar-vm ansible_host=${ESPELLAR_HOST#*@},ansible_user=${ESPELLAR_HOST%%@*}," playbook.yml --tags container-service --private-key "${SSH_KEY:-~/.ssh/id_ed25519}") \
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 esellar-api restart (supervise-daemon: stop-old/start-new podman run on the retagged :latest)…"
159
- vm "rc-service esellar-api restart" || die "esellar-api restart failed (rc-service esellar-api status)"
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 esellar-api 2>&1" || true
177
- vm "rc-service esellar-api status 2>&1" || true
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 esellar-api 2>&1 | grep -c ENV_IMPORT_SUSPECT || true")"
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 esellar-api --host=$PROXY_HOST --target=esellar-api:8080 --tls --health-check-path=/api/auth/ok" \
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