diff --git a/.claude b/.claude index b6b873127..1361f7d5a 160000 --- a/.claude +++ b/.claude @@ -1 +1 @@ -Subproject commit b6b8731270d1afcf420f434a5f25f93113764cdb +Subproject commit 1361f7d5ada0ea03a3afde67e7034fa335f8ae33 diff --git a/.cursor/rules/no-commit-coauthor.mdc b/.cursor/rules/no-commit-coauthor.mdc new file mode 100644 index 000000000..4eed77104 --- /dev/null +++ b/.cursor/rules/no-commit-coauthor.mdc @@ -0,0 +1,31 @@ +--- +description: Commits and pull requests have one human author. No Cursor co-author trailer. +alwaysApply: true +--- + +# One author on commits + +A commit or pull request has one author: the person who asked for it. The message must not contain `Co-authored-by` or `Generated with`. + +Passing a clean message is not enough. The commit path can append `Co-authored-by: Cursor ` after the text you supplied. Reviewers read the stored object. + +After every `git commit` you create, including a commit inside a submodule, read the stored message: + +```sh +git log -1 --format='%B' | grep -E -i 'co-authored-by|generated with' +``` + +No output means the object is clean. Any output means a trailer was injected. When that commit is one you just created and it is not on the remote, amend the message back to the exact text you intended, trailer removed, same tree, no `--no-verify`. Leave a commit that is already pushed alone and say so. + +Author and committer stay the person who asked for the commit. Do not set either to Cursor. + +```sh +git commit -m "$(cat <<'EOF' +fix(scope): what changed + +Why it had to change. +EOF +)" +``` + +The same ban applies to a pull request title and body. diff --git a/.github/workflows/baas-ci.yml b/.github/workflows/baas-ci.yml index cd8bff7ec..cbe740860 100644 --- a/.github/workflows/baas-ci.yml +++ b/.github/workflows/baas-ci.yml @@ -1,22 +1,20 @@ # BaaS CI — fast pre-merge checks for the grobase backend. # -# grobase is now a SEPARATE repo (Univers42/grobase), tracked here as the -# `apps/grobase` submodule. Its own CI (that repo's .github/workflows/ci.yml) runs -# the exhaustive image-build + integration + enterprise battery. THIS workflow is -# the lean superproject gate: it checks out the pinned grobase commit and proves -# the Go control-plane and the two Rust planes compile + unit-test, plus validates -# static config. Toolchains run in containers (Docker-first) so no host setup drifts. +# Each job is one documented Makefile target (subject: single-command / make entrypoints). +# grobase is a SEPARATE repo (Univers42/grobase), tracked here as apps/grobase. name: BaaS CI on: pull_request: paths: - "apps/grobase" + - "utils/mk/baas.mk" - ".github/workflows/baas-ci.yml" push: - branches: ["main", "feat/**", "fix/**", "chore/**"] + branches: ["main", "develop", "feat/**", "fix/**", "chore/**"] paths: - "apps/grobase" + - "utils/mk/baas.mk" - ".github/workflows/baas-ci.yml" workflow_dispatch: @@ -26,7 +24,7 @@ concurrency: jobs: go: - name: Go control-plane (vet + test) + name: Go control-plane (make baas-ci-go) runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -39,13 +37,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: vet + test (containerised toolchain) - run: | - docker run --rm -v "$PWD/apps/grobase/src/control-plane:/src" -w /src \ - golang:1.25-bookworm sh -c "go vet ./... && go test ./..." + - name: make baas-ci-go + run: make baas-ci-go rust-data-plane: - name: Rust data-plane (test) + name: Rust data-plane (make baas-ci-rust-data-plane) runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -58,14 +54,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: cargo test --workspace - run: | - docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ - -v "$PWD/apps/grobase/src/data-plane-router:/src" -w /src \ - rust:1-bookworm cargo test --workspace + - name: make baas-ci-rust-data-plane + run: make baas-ci-rust-data-plane rust-realtime: - name: Rust realtime (test) + name: Rust realtime (make baas-ci-rust-realtime) runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -78,14 +71,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: cargo test --workspace - run: | - docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ - -v "$PWD/apps/grobase/infra/docker/services/realtime/realtime-agnostic:/src" -w /src \ - rust:1-bookworm cargo test --workspace + - name: make baas-ci-rust-realtime + run: make baas-ci-rust-realtime static: - name: Static config (manifest parity + Prometheus rules) + name: Static config (make baas-ci-static) runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -98,18 +88,5 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: package manifest parity (config == Go-embedded copy) - working-directory: apps/grobase - run: | - diff <(jq -S . infra/config/packages/packages.json) \ - <(jq -S . src/control-plane/internal/packages/packages.json) \ - && echo "package manifest is the single source of truth" - - name: Prometheus rules + config (m52) - working-directory: apps/grobase - run: | - docker run --rm --entrypoint promtool \ - -v "$PWD/infra/config/prometheus":/etc/prometheus \ - prom/prometheus:v2.52.0 check config /etc/prometheus/prometheus.yml - - name: shellcheck verify gates (advisory) - working-directory: apps/grobase - run: shellcheck -S error scripts/verify/m*.sh || true + - name: make baas-ci-static + run: make baas-ci-static diff --git a/infrastructure/docker/osionos/ai.Dockerfile b/infrastructure/docker/osionos/ai.Dockerfile index a6670187b..c6abe82a4 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile +++ b/infrastructure/docker/osionos/ai.Dockerfile @@ -17,19 +17,21 @@ # IMPORT CLOSURE of services/osionos-ai/src into scripts/ — bridge-session-token # imports bridge-session-cutoff (which imports bridge-baas-body), and since the # account platform's P0-4 also bridge-session-revocations and bridge-roles; both -# revocations and cutoff import bridge-session-tracker: +# revocations and cutoff import bridge-session-tracker, which imports +# bridge-access-generation. Share rules (retrieval/shareRules.ts) call +# bridge-page-access, which calls bridge-share-rules: # services/osionos-ai/src/auth.ts -> scripts/bridge-session-token.mjs # -> scripts/bridge-session-cutoff.mjs # -> scripts/bridge-baas-body.mjs # -> scripts/bridge-session-revocations.mjs -# -> scripts/bridge-session-tracker.mjs # -> scripts/bridge-roles.mjs +# -> scripts/bridge-session-tracker.mjs +# -> scripts/bridge-access-generation.mjs # services/osionos-ai/src/runs/prompt.ts -> scripts/bridge-page-read.mjs # services/osionos-ai/src/retrieval/acl.ts -> scripts/bridge-live-workspaces.mjs -# services/osionos-ai/src/retrieval/related.ts -> scripts/bridge-graph-edges.mjs -> scripts/bridge-page-read.mjs # services/osionos-ai/src/retrieval/shareRules.ts -> scripts/bridge-page-access.mjs # -> scripts/bridge-share-rules.mjs -# -> scripts/bridge-access-generation.mjs +# services/osionos-ai/src/retrieval/related.ts -> scripts/bridge-graph-edges.mjs -> scripts/bridge-page-read.mjs # The thirteenth is the L0 gate of the image prompt (devil C72): the module reads # its lists next to itself, so they travel with it: # scripts/bridge-moderation.mjs -> scripts/moderation-lists/{en,fr,es}.txt @@ -63,10 +65,10 @@ COPY --from=deps /app/services/osionos-ai/node_modules ./services/osionos-ai/nod # The service itself: its manifest, its sources. No tests, no dev deps. COPY services/osionos-ai/package.json services/osionos-ai/tsconfig.json ./services/osionos-ai/ COPY services/osionos-ai/src ./services/osionos-ai/src -# The thirteen bridge modules (see the header): the twelve of the import closure and -# the gate of the image prompt. scripts/tests/bridge-image-closure.bats checks +# The thirteen bridge modules (see the header): the twelve of the import closure +# and the gate of the image prompt. scripts/tests/bridge-image-closure.bats checks # this list against the closure, so a new import fails CI, not the container. -COPY scripts/bridge-session-token.mjs scripts/bridge-session-cutoff.mjs scripts/bridge-baas-body.mjs scripts/bridge-session-revocations.mjs scripts/bridge-session-tracker.mjs scripts/bridge-roles.mjs scripts/bridge-page-read.mjs scripts/bridge-live-workspaces.mjs scripts/bridge-graph-edges.mjs scripts/bridge-page-access.mjs scripts/bridge-share-rules.mjs scripts/bridge-access-generation.mjs scripts/bridge-moderation.mjs ./scripts/ +COPY scripts/bridge-session-token.mjs scripts/bridge-session-cutoff.mjs scripts/bridge-baas-body.mjs scripts/bridge-session-revocations.mjs scripts/bridge-session-tracker.mjs scripts/bridge-access-generation.mjs scripts/bridge-roles.mjs scripts/bridge-page-read.mjs scripts/bridge-page-access.mjs scripts/bridge-share-rules.mjs scripts/bridge-live-workspaces.mjs scripts/bridge-graph-edges.mjs scripts/bridge-moderation.mjs ./scripts/ # The lists of the L0 gate: the module reads them next to itself, at import. COPY scripts/moderation-lists ./scripts/moderation-lists diff --git a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore index 79d703add..fd2f234f5 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore +++ b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore @@ -16,8 +16,8 @@ vendor src tests # The scripts/ directory holds a hundred-odd modules; the service's closure is -# twelve of them, and the gate of the image prompt a thirteenth. They are re-included -# one by one, so the closure is visible in both files +# twelve of them, and the gate of the image prompt a thirteenth. They are +# re-included one by one, so the closure is visible in both files # (scripts/tests/bridge-image-closure.bats checks it). scripts/* !scripts/bridge-session-token.mjs @@ -25,13 +25,13 @@ scripts/* !scripts/bridge-baas-body.mjs !scripts/bridge-session-revocations.mjs !scripts/bridge-session-tracker.mjs +!scripts/bridge-access-generation.mjs !scripts/bridge-roles.mjs !scripts/bridge-page-read.mjs -!scripts/bridge-live-workspaces.mjs -!scripts/bridge-graph-edges.mjs !scripts/bridge-page-access.mjs !scripts/bridge-share-rules.mjs -!scripts/bridge-access-generation.mjs +!scripts/bridge-live-workspaces.mjs +!scripts/bridge-graph-edges.mjs !scripts/bridge-moderation.mjs # The lists of the L0 gate: the module reads them next to itself, at import. !scripts/moderation-lists/ diff --git a/scripts/gen-local-env.sh b/scripts/gen-local-env.sh index d2b7109dd..ca28996a6 100755 --- a/scripts/gen-local-env.sh +++ b/scripts/gen-local-env.sh @@ -62,9 +62,9 @@ note() { printf '[gen-local-env] %s\n' "$1" >&2; } # The host port the osionos app is published on. The environment wins (make exports it); # .env.ports is the record scripts/host-ports.sh wrote. 3001 is the fallback. -if [ -z "${OSIONOS_APP_HOST_PORT:-}" ]; then +if [[ -z "${OSIONOS_APP_HOST_PORT:-}" ]]; then PORTS_FILE="${HOST_PORTS_FILE:-$REPO/.env.ports}" - if [ -f "$PORTS_FILE" ]; then + if [[ -f "$PORTS_FILE" ]]; then OSIONOS_APP_HOST_PORT="$(get_env "$PORTS_FILE" OSIONOS_APP_HOST_PORT || true)" fi fi @@ -103,8 +103,8 @@ gsrc() { while :; do _one="${_spec%%|*}" _v="$(gval "$_one")" - [ -n "$_v" ] && { printf '%s' "$_v"; return 0; } - [ "$_spec" = "$_one" ] && return 0 + [[ -n "$_v" ]] && { printf '%s' "$_v"; return 0; } + [[ "$_spec" = "$_one" ]] && return 0 _spec="${_spec#*|}" done } @@ -118,7 +118,7 @@ gen_password() { gen | head -c 24; } # minted_missing: names of MINTED keys absent or empty in $OUT, one per line. minted_missing() { for key in $MINTED; do - [ -n "$(val "$OUT" "$key")" ] || printf '%s\n' "$key" + [[ -n "$(val "$OUT" "$key")" ]] || printf '%s\n' "$key" done } @@ -150,12 +150,12 @@ mint_missing() { media_fill() { for key in $MEDIA; do want="$(get_env "$MEDIA_ENV" "$key" || true)" - [ -n "$want" ] || continue + [[ -n "$want" ]] || continue have="$(get_env "$OUT" "$key" || true)" - if [ -z "$have" ]; then + if [[ -z "$have" ]]; then put_env "$OUT" "$key" "$want" note "copied $key from $MSRC" - elif [ "$have" != "$want" ]; then + elif [[ "$have" != "$want" ]]; then note "kept $key (differs from $MSRC)" fi done @@ -166,7 +166,7 @@ media_differing() { for key in $MEDIA; do want="$(get_env "$MEDIA_ENV" "$key" || true)" have="$(get_env "$OUT" "$key" || true)" - if [ -n "$want" ] && [ -n "$have" ] && [ "$want" != "$have" ]; then printf '%s\n' "$key"; fi + if [[ -n "$want" ]] && [[ -n "$have" ]] && [[ "$want" != "$have" ]]; then printf '%s\n' "$key"; fi done } @@ -179,16 +179,16 @@ drift_report() { dst="${pair%%=*}" src="${pair#*=}" want="$(gsrc "$src")" - if [ -z "$want" ]; then printf 'unknown %s\n' "$dst"; bad=1 + if [[ -z "$want" ]]; then printf 'unknown %s\n' "$dst"; bad=1 elif ! grep -q "^$dst=" "$OUT"; then printf 'missing %s\n' "$dst"; bad=1 - elif [ "$(val "$OUT" "$dst")" = "$want" ]; then printf 'sync %s\n' "$dst" + elif [[ "$(val "$OUT" "$dst")" = "$want" ]]; then printf 'sync %s\n' "$dst" else printf 'drift %s\n' "$dst"; bad=1 fi done want="$(port_url)" for dst in $PORT_KEYS; do if ! grep -q "^$dst=" "$OUT"; then printf 'missing %s\n' "$dst"; bad=1 - elif [ "$(val "$OUT" "$dst")" = "$want" ]; then printf 'sync %s\n' "$dst" + elif [[ "$(val "$OUT" "$dst")" = "$want" ]]; then printf 'sync %s\n' "$dst" else printf 'drift %s\n' "$dst"; bad=1 fi done @@ -196,19 +196,19 @@ drift_report() { } require_inputs() { - [ -f "$GRO_ENV" ] || { note "$SRC not found — bring the backend up first (make backend-up)."; exit 1; } + [[ -f "$GRO_ENV" ]] || { note "$SRC not found — bring the backend up first (make backend-up)."; exit 1; } } check_mode() { require_inputs - [ -f "$OUT" ] || { note "$(basename "$OUT") absent — run without --check to generate it."; exit 1; } + [[ -f "$OUT" ]] || { note "$(basename "$OUT") absent — run without --check to generate it."; exit 1; } rc=0 report="$(drift_report)" || rc=$? for key in $(minted_missing); do report="$report missing $key"; rc=1; done printf '%s\n' "$report" | sed 's/^/[gen-local-env] /' >&2 for key in $(media_differing); do note " media $key differs from $MSRC (informational, exit status unchanged)"; done - if [ "$rc" -eq 0 ]; then note "in sync with $SRC (8 derived keys + 4 port-derived keys)."; else + if [[ "$rc" -eq 0 ]]; then note "in sync with $SRC (8 derived keys + 4 port-derived keys)."; else note "DRIFT — $(basename "$OUT") disagrees with $SRC. Fix: bash scripts/gen-local-env.sh --sync" fi exit "$rc" @@ -218,12 +218,12 @@ missing $key"; rc=1; done # atomic, mode kept, every other line untouched). An EMPTY source is never written. sync_mode() { require_inputs - [ -f "$OUT" ] || { note "$(basename "$OUT") absent — generating it."; generate; return; } + [[ -f "$OUT" ]] || { note "$(basename "$OUT") absent — generating it."; generate; return; } mint_missing media_fill rc=0 report="$(drift_report)" || rc=$? - [ "$rc" -ne 0 ] || { note "in sync with $SRC — nothing to write."; exit 0; } + [[ "$rc" -ne 0 ]] || { note "in sync with $SRC — nothing to write."; exit 0; } changed="" unknown="" for pair in $DERIVED; do @@ -240,12 +240,12 @@ sync_mode() { changed="$changed $dst" fi done - [ -z "$changed" ] || note "synced from $SRC:$changed" - [ -z "$unknown" ] || { note "left as is (EMPTY source in $SRC — is the backend healthy?):$unknown"; exit 1; } + [[ -z "$changed" ]] || note "synced from $SRC:$changed" + [[ -z "$unknown" ]] || { note "left as is (EMPTY source in $SRC — is the backend healthy?):$unknown"; exit 1; } } generate() { -if [ -f "$OUT" ] && [ "$FORCE" != 1 ]; then +if [[ -f "$OUT" ]] && [[ "$FORCE" != 1 ]]; then note "$(basename "$OUT") already present — leaving it (FORCE=1 to overwrite; --sync to refresh the derived keys)." exit 0 fi @@ -261,7 +261,7 @@ PUBLIC="$(gval ANON_KEY)" SERVICE="$(gval SERVICE_ROLE_KEY)" JWT="$(gval JWT_SECRET)" ADAPTER="$(gval ADAPTER_REGISTRY_SERVICE_TOKEN)" -if [ -z "$PUBLIC" ] || [ -z "$SERVICE" ] || [ -z "$JWT" ]; then +if [[ -z "$PUBLIC" ]] || [[ -z "$SERVICE" ]] || [[ -z "$JWT" ]]; then note "grobase .env is missing ANON_KEY/SERVICE_ROLE_KEY/JWT_SECRET — is the backend healthy?" exit 1 fi @@ -363,5 +363,5 @@ case "$MODE" in --check) check_mode ;; --sync) sync_mode ;; generate) generate ;; -*) printf 'usage: gen-local-env.sh [--check|--sync] (no flag: generate ./.env.local when absent)\n' >&2; exit 2 ;; +*) printf 'usage: gen-local-env.sh [[--check|--sync]] (no flag: generate ./.env.local when absent)\n' >&2; exit 2 ;; esac diff --git a/scripts/host-ports.sh b/scripts/host-ports.sh index 3834bf7f3..7227a8b5a 100755 --- a/scripts/host-ports.sh +++ b/scripts/host-ports.sh @@ -47,7 +47,7 @@ declare -A reserved=() # port -> 1, taken by a NAME of this run compose_files() { local f for f in "$root"/docker-compose*.yml; do - [ -f "$f" ] || continue + [[ -f "$f" ]] || continue printf '%s\n' "${f#"$root"/}" done } @@ -58,10 +58,10 @@ scrape() { local file line name def for file in $(compose_files); do while IFS= read -r line; do - [ -n "$line" ] || continue + [[ -n "$line" ]] || continue name="${line%%=*}" def="${line#*=}" - if [ -n "${defaults[$name]:-}" ] && [ "${defaults[$name]}" != "$def" ]; then + if [[ -n "${defaults[$name]:-}" ]] && [[ "${defaults[$name]}" != "$def" ]]; then printf 'host-ports: %s has two defaults — %s in %s, %s in %s. Fix the compose files.\n' \ "$name" "${defaults[$name]}" "${default_files[$name]}" "$def" "$file" >&2 exit 2 @@ -119,7 +119,7 @@ is_wildcard() { addr_conflicts() { is_wildcard "$1" && return 0 is_wildcard "$bind_addr" && return 0 - [ "$1" = "$bind_addr" ] + [[ "$1" = "$bind_addr" ]] } # holder_of PORT — the name of the process holding PORT at the bind address, or @@ -127,9 +127,9 @@ addr_conflicts() { holder_of() { local port="$1" addr listen_port proc while IFS=$'\t' read -r addr listen_port proc; do - [ "$listen_port" = "$port" ] || continue + [[ "$listen_port" = "$port" ]] || continue addr_conflicts "$addr" || continue - [ -n "$proc" ] || proc="another process" + [[ -n "$proc" ]] || proc="another process" printf '%s\n' "$proc" return 0 done < <(printf '%s\n' "$LISTENER_TABLE") @@ -140,8 +140,8 @@ holder_of() { is_own_port() { local port="$1" p while read -r p; do - [ -n "$p" ] || continue - [ "$p" = "$port" ] && return 0 + [[ -n "$p" ]] || continue + [[ "$p" = "$port" ]] && return 0 done < <(printf '%s\n' "$OWN_PORTS") return 1 } @@ -159,7 +159,7 @@ cmd_list() { scrape local name while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue printf '%s=%s\n' "$name" "${defaults[$name]}" done < <(sorted_names) } @@ -167,7 +167,7 @@ cmd_list() { # read_previous — the values already recorded in .env.ports. read_previous() { local key value - [ -f "$ports_file" ] || return 0 + [[ -f "$ports_file" ]] || return 0 while IFS='=' read -r key value; do case "$key" in '' | '#'*) continue ;; @@ -209,13 +209,13 @@ render_ports_file() { # The host ports this clone publishes; re-run the resolver after any change. " while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue content+="$name=${chosen[$name]}"$'\n' done < <(sorted_names) [ -z "${UDP_RANGE:-}" ] || content+="LIVEKIT_UDP_PORTS=$UDP_RANGE"$'\n' tmp="$(mktemp "$ports_file.XXXXXX")" printf '%s' "$content" >"$tmp" - if [ -f "$ports_file" ] && cmp -s "$tmp" "$ports_file"; then + if [[ -f "$ports_file" ]] && cmp -s "$tmp" "$ports_file"; then rm -f "$tmp" return 0 fi @@ -228,7 +228,7 @@ render_ports_file() { # it (one code path). It never writes any tracked file. render_origin_override() { local port="${chosen[OSIONOS_APP_HOST_PORT]:-}" - [ -n "$port" ] || return 0 + [[ -n "$port" ]] || return 0 local out="$root/infrastructure/tls/host-ports.d/osionos-origin.conf" local tmp content content="# Generated by scripts/host-ports.sh resolve — do not edit by hand. @@ -236,7 +236,7 @@ set \$osionos_origin https://localhost:$port; " tmp="$(mktemp "$out.XXXXXX")" printf '%s' "$content" >"$tmp" - if [ -f "$out" ] && cmp -s "$tmp" "$out"; then + if [[ -f "$out" ]] && cmp -s "$tmp" "$out"; then rm -f "$tmp" return 0 fi @@ -256,13 +256,13 @@ cmd_resolve() { own_ports_status=$? set -e OWN_PORTS="$own_ports_out" - if [ $own_ports_status -ne 0 ]; then + if [[ $own_ports_status -ne 0 ]]; then printf 'host-ports: cannot list this project'\''s containers (%s ps failed); a running groot'\''s own ports look busy\n' "$docker_cmd" >&2 fi local names=() name default env_value file_value port holder holder_port while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue names+=("$name") done < <(sorted_names) @@ -270,7 +270,7 @@ cmd_resolve() { for name in "${names[@]}"; do default="${defaults[$name]}" env_value="${!name-}" - if [ -n "$env_value" ]; then + if [[ -n "$env_value" ]]; then if ! [[ "$env_value" =~ ^[0-9]{1,5}$ ]] || (( 10#$env_value < 1 || 10#$env_value > 65535 )); then printf 'host-ports: %s must be an integer port from 1 through 65535\n' "$name" >&2 exit 2 @@ -278,13 +278,13 @@ cmd_resolve() { env_value=$((10#$env_value)) fi file_value="${previous[$name]:-}" - if [ -n "$env_value" ] && [ "$env_value" != "$file_value" ]; then + if [[ -n "$env_value" ]] && [[ "$env_value" != "$file_value" ]]; then chosen[$name]="$env_value" pinned[$name]=1 reserved[$env_value]=1 continue fi - if [ -z "${reserved[$default]:-}" ] && port_free "$default"; then + if [[ -z "${reserved[$default]:-}" ]] && port_free "$default"; then chosen[$name]="$default" reserved[$default]=1 fi @@ -292,10 +292,10 @@ cmd_resolve() { # 2. the value already recorded, while it is free (a moved port stays put). for name in "${names[@]}"; do - [ -n "${chosen[$name]:-}" ] && continue + [[ -n "${chosen[$name]:-}" ]] && continue file_value="${previous[$name]:-}" - [ -n "$file_value" ] || continue - if [ -z "${reserved[$file_value]:-}" ] && port_free "$file_value"; then + [[ -n "$file_value" ]] || continue + if [[ -z "${reserved[$file_value]:-}" ]] && port_free "$file_value"; then chosen[$name]="$file_value" reserved[$file_value]=1 fi @@ -303,18 +303,18 @@ cmd_resolve() { # 3. the window above the default. for name in "${names[@]}"; do - [ -n "${chosen[$name]:-}" ] && continue + [[ -n "${chosen[$name]:-}" ]] && continue default="${defaults[$name]}" port=$((default + 1)) - while [ "$port" -le $((default + window)) ]; do - if [ -z "${reserved[$port]:-}" ] && port_free "$port"; then + while [[ "$port" -le $((default + window)) ]]; do + if [[ -z "${reserved[$port]:-}" ]] && port_free "$port"; then chosen[$name]="$port" reserved[$port]=1 break fi port=$((port + 1)) done - if [ -z "${chosen[$name]:-}" ]; then + if [[ -z "${chosen[$name]:-}" ]]; then holder="$(holder_of "$default" || true)" printf 'host-ports: %s default %s is held by %s, and no free port in %s..%s. Free one, or pin %s=.\n' \ "$name" "$default" "${holder:-another process}" "$((default + 1))" "$((default + window))" "$name" >&2 @@ -328,20 +328,20 @@ cmd_resolve() { port="${chosen[$name]}" holder="$(holder_of "$default" || true)" holder_port="$(holder_of "$port" || true)" - if [ "$port" = "$default" ] && [ -z "${pinned[$name]:-}" ]; then + if [[ "$port" = "$default" ]] && [[ -z "${pinned[$name]:-}" ]]; then continue fi - if [ -n "$holder" ]; then + if [[ -n "$holder" ]]; then printf 'host-ports: %s default %s is held by %s; using %s' "$name" "$default" "$holder" "$port" - if [ -n "$holder_port" ]; then + if [[ -n "$holder_port" ]]; then printf ' (also held by %s)' "$holder_port" fi - elif [ -n "$holder_port" ]; then + elif [[ -n "$holder_port" ]]; then printf 'host-ports: %s default %s is free; pinned to %s (held by %s)' "$name" "$default" "$port" "$holder_port" else printf 'host-ports: %s default %s is free; pinned to %s' "$name" "$default" "$port" fi - if [ "$name" = "OSIONOS_APP_HOST_PORT" ]; then + if [[ "$name" = "OSIONOS_APP_HOST_PORT" ]]; then printf '; add https://localhost:%s to grobase'\''s allowed origins (born2root: make grobase_cors)' "$port" fi printf '\n' >&2 @@ -349,25 +349,25 @@ cmd_resolve() { UDP_RANGE="$(free_udp_range)" local before="" name - [ -f "$ports_file" ] && before="$(cat "$ports_file")" + [[ -f "$ports_file" ]] && before="$(cat "$ports_file")" render_ports_file render_origin_override # A moved port that a build arg bakes in needs the image rebuilt. local changed=() service while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue file_value="${previous[$name]:-}" - [ "$file_value" = "${chosen[$name]}" ] && continue + [[ "$file_value" = "${chosen[$name]}" ]] && continue changed+=("$name") done < <(sorted_names) - if [ "${#changed[@]}" -gt 0 ] && [ "$before" != "$(cat "$ports_file")" ]; then + if [[ "${#changed[@]}" -gt 0 ]] && [[ "$before" != "$(cat "$ports_file")" ]]; then # For each changed NAME, find the services that bake it; print one line per service # listing the specific NAMEs that service uses. declare -A service_changed_names=() for name in "${changed[@]}"; do while read -r service; do - [ -n "$service" ] || continue + [[ -n "$service" ]] || continue service_changed_names["$service"]+=" $name" done < <(build_arg_users "$name") done diff --git a/scripts/lib/grobase-link-target.sh b/scripts/lib/grobase-link-target.sh index a6437ebaa..d9c86511f 100644 --- a/scripts/lib/grobase-link-target.sh +++ b/scripts/lib/grobase-link-target.sh @@ -14,7 +14,7 @@ # costs 5 s, and the first match wins, not the nearest. Ponytail: a bare host is tried as # ssh, then http://host:8000, then https://host, so a Kong on another port needs the URL. -# One-off ssh with the user's config but none of its forwards; "$@" = [-p port] dest cmd. +# One-off ssh with the user's config but none of its forwards; "$@" = [[-p port]] dest cmd. ssh_once() { ssh -o ClearAllForwardings=yes -o BatchMode=yes -o ConnectTimeout=5 -o LogLevel=ERROR "$@"; } local_has_grobase() { docker ps --format '{{.Names}}' 2>/dev/null | grep -qx mini-baas-kong; } # ssh://[user@]host[:port] -> "dest" or "dest -p port" (also accepts alias, user@host, host). @@ -47,7 +47,7 @@ resolve_ipv4() { # HOST -> its first IPv4 address, or HOST itself when getent kn # EXPLICIT when set, else TARGET's own URL, else the loopback WAF; exit 1 when it has none. api_url() { local url=${2:-} - if [ -z "$url" ]; then + if [[ -z "$url" ]]; then case "$1" in http://* | https://*) url=$1 ;; local) url=https://127.0.0.1:8443 ;; @@ -101,9 +101,9 @@ ssh_candidates() { awk 'tolower($1) == "host" { for (i = 2; i <= NF; i++) if ($i !~ /[*?!]/) print $i }' ~/.ssh/config 2>/dev/null local marker port for marker in "$REPO"/vendor/born2root/disk_images/.vm_path.* ${B2B_DIR:+"$B2B_DIR"/disk_images/.vm_path.*}; do - [ -f "$marker" ] || continue + [[ -f "$marker" ]] || continue port=$(awk -F= '$1 == "ssh" { print $2 }' "$(cat "$marker")/${marker##*.vm_path.}/ports.env" 2>/dev/null) - [ -n "$port" ] && printf 'ssh://%s@127.0.0.1:%s\n' "$USER" "$port" + [[ -n "$port" ]] && printf 'ssh://%s@127.0.0.1:%s\n' "$USER" "$port" done } @@ -125,7 +125,7 @@ resolve_auto() { return fi while read -r c; do - [ -n "$c" ] || continue + [[ -n "$c" ]] || continue note "probing $c" if remote_has_grobase "$c"; then printf 'ssh %s\n' "$c" @@ -148,7 +148,7 @@ resolve_host() { # a bare host or alias: ssh first, then Kong's plain port, then # relay_cafile -> the CA file the relay verifies Kong with, as the relay sees it: the copy up # installs in LINK_DIR (/link in the relay) when GROBASE_CA_FILE is set, else the system store. relay_cafile() { - if [ -n "$CA_FILE" ]; then + if [[ -n "$CA_FILE" ]]; then printf '/link/ca.pem\n' else printf '/etc/ssl/certs/ca-certificates.crt\n' @@ -165,7 +165,7 @@ relay_cafile() { capability_status() { local host port body="" line read -r host port < <(url_hostport "$1") - [ "$2" != POST ] || body='{}' + [[ "$2" != POST ]] || body='{}' line=$(printf '%s %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' \ "$2" "$3" "$host" "${#body}" "$body" | docker run --rm -i -v "$LINK_DIR:/link:ro" "$SOCAT_IMG" -T10 -t10 - "$(kong_addr "$1" "$(relay_cafile)"),shut-none,connect-timeout=5" 2>/dev/null | @@ -189,21 +189,21 @@ capability_status() { # write OSIONOS_TENANT_MOUNTS_URL; they only clear one a direct link left. publish_env() { local url="" key="" query="" registry="" mounts="" keepalive="" - if [ "$1" = direct ]; then + if [[ "$1" = direct ]]; then url=http://mini-baas-kong:8000/realtime keepalive=1 key=$(get_env "$2" SERVICE_ROLE_KEY) || key="" - if [ "$(capability_status "$3" POST /realtime/v1/publish)" = 403 ]; then + if [[ "$(capability_status "$3" POST /realtime/v1/publish)" = 403 ]]; then url=ws://mini-baas-kong:8000/realtime/v1/ws key="" note "direct: $3 refuses /realtime/v1/publish without credentials; the bridge publishes over /realtime/v1/ws" fi - if [ "$(capability_status "$3" GET /v1/tenants/me/mounts)" = 401 ]; then + if [[ "$(capability_status "$3" GET /v1/tenants/me/mounts)" = 401 ]]; then mounts=http://mini-baas-kong:8000/v1/tenants/me/mounts note "direct: $3 serves /v1/tenants/me/mounts; the bridge lists mounts there with its tenant key" fi fi - if [ "$1" != local ]; then + if [[ "$1" != local ]]; then query=http://mini-baas-kong:8000/query/v1 registry=http://mini-baas-kong:8000/admin/v1 fi @@ -211,12 +211,12 @@ publish_env() { put_env "$2" REALTIME_PUBLISH_APIKEY "$key" put_env "$2" OSIONOS_QUERY_ROUTER_URL "$query" put_env "$2" OSIONOS_ADAPTER_REGISTRY_URL "$registry" - if [ "$1" = direct ] || get_env "$2" OSIONOS_TENANT_MOUNTS_URL >/dev/null; then + if [[ "$1" = direct ]] || get_env "$2" OSIONOS_TENANT_MOUNTS_URL >/dev/null; then put_env "$2" OSIONOS_TENANT_MOUNTS_URL "$mounts" fi # Kept-alive pool: a workspace open after 40 s idle 488 -> 313 ms median (2026-10-18, 8 # workspaces, interleaved); measured on the direct link only, so ssh/local never turn it on. - if [ "$1" = direct ] || get_env "$2" OSIONOS_BAAS_KEEPALIVE >/dev/null; then + if [[ "$1" = direct ]] || get_env "$2" OSIONOS_BAAS_KEEPALIVE >/dev/null; then put_env "$2" OSIONOS_BAAS_KEEPALIVE "$keepalive" fi } @@ -278,7 +278,7 @@ rt_token_ensure() { local secret_src="$2" local secret cur out bad="" kind exp token secret=$(get_env "$secret_src" JWT_SECRET) || return 0 - [ -n "$secret" ] || return 0 + [[ -n "$secret" ]] || return 0 cur=$(get_env "$env_local" VITE_BAAS_REALTIME_TOKEN || true) out=$(RT_JWT_SECRET="$secret" RT_TOKEN="$cur" \ docker run --rm --network none -e RT_JWT_SECRET -e RT_TOKEN node:22-alpine node -e "$RT_TOKEN_JS") || { @@ -289,19 +289,19 @@ rt_token_ensure() { set -- $out kind=${1:-} case $kind in - kept) [ "$#" -eq 2 ] || bad=1 ;; - minted) [ "$#" -eq 3 ] || bad=1 ;; + kept) [[ "$#" -eq 2 ]] || bad=1 ;; + minted) [[ "$#" -eq 3 ]] || bad=1 ;; *) bad=1 ;; esac exp=${2:-} case $exp in '' | *[!0-9]*) bad=1 ;; esac - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then note "warning: the realtime token could not be refreshed; the live views fall back to 15 s polling" return 0 fi - if [ "$kind" = kept ]; then + if [[ "$kind" = kept ]]; then note "realtime token valid until $(date -d "@$exp" +%F)" else token=${3:-} diff --git a/scripts/lib/public-verify-appcontainers.sh b/scripts/lib/public-verify-appcontainers.sh index d6e299e5c..00450a8c9 100644 --- a/scripts/lib/public-verify-appcontainers.sh +++ b/scripts/lib/public-verify-appcontainers.sh @@ -27,18 +27,18 @@ check_appenv() { local svc c names bad="" unread="" n=0 for svc in mail-web calendar-web; do c=$(svc_container "$svc") - [ -n "$c" ] || { + [[ -n "$c" ]] || { apps_absent appenv "$svc is not running" || true return } - if ! names=$(container_env_names "$c") || [ -z "$names" ]; then + if ! names=$(container_env_names "$c") || [[ -z "$names" ]]; then unread+=" $svc" continue fi n=$((n + $(grep -c . <<<"$names" || true))) bad+=$(grep -E "$APPS_FORBIDDEN_ENV" <<<"$names" | sed "s/^/ $svc:/" | paste -sd' ' - || true) done - if [ -n "$unread" ]; then + if [[ -n "$unread" ]]; then fail appenv "environment unreadable or empty for:$unread (docker inspect)" return fi @@ -58,10 +58,10 @@ appshape_problems() { return } read -r ro mem nets mounts <<<"$shape" - [ "$ro" = ro=true ] || bad+=" rootfs-writable" + [[ "$ro" = ro=true ]] || bad+=" rootfs-writable" mem=${mem#mem=} - { [[ "$mem" =~ ^[0-9]+$ ]] && [ "$mem" -ge 1 ] && [ "$mem" -le "$APPS_STATIC_MEMORY_MAX" ]; } || bad+=" memory-not-in-1..$APPS_STATIC_MEMORY_MAX" - [ "$nets" = "nets=$net," ] || bad+=" network-not-only-$net" + { [[ "$mem" =~ ^[0-9]+$ ]] && [[ "$mem" -ge 1 ]] && [[ "$mem" -le "$APPS_STATIC_MEMORY_MAX" ]]; } || bad+=" memory-not-in-1..$APPS_STATIC_MEMORY_MAX" + [[ "$nets" = "nets=$net," ]] || bad+=" network-not-only-$net" [[ "$mounts" =~ ^mounts=(tmpfs,)*$ ]] || bad+=" mounts-not-tmpfs-only" printf '%s' "${bad# }" } @@ -72,12 +72,12 @@ check_appshape() { local svc c problems bad="" for svc in mail-web calendar-web; do c=$(svc_container "$svc") - [ -n "$c" ] || { + [[ -n "$c" ]] || { apps_absent appshape "$svc is not running" || true return } problems=$(appshape_problems "$c") - [ -z "$problems" ] || bad+=" $svc:$problems;" + [[ -z "$problems" ]] || bad+=" $svc:$problems;" done expect appshape "mail-web and calendar-web: read-only rootfs, memory <= 64 MiB, only ${PROJECT}_apps-static-net, tmpfs mounts only" "not the locked-down shape:$bad" [ -z "$bad" ] } diff --git a/scripts/lib/public-verify-apps.sh b/scripts/lib/public-verify-apps.sh index 4243a59f3..18dbff267 100644 --- a/scripts/lib/public-verify-apps.sh +++ b/scripts/lib/public-verify-apps.sh @@ -27,12 +27,12 @@ app_origin() { local name=${1^^} penv "PUBLIC_${name}_ORIGIN" || true } -apps_configured() { [ -n "$(app_origin mail)$(app_origin calendar)" ]; } +apps_configured() { [[ -n "$(app_origin mail)$(app_origin calendar)" ]]; } # Loopback listener of an app: the local port paired with its public port in APPS_FUNNEL_MAP. app_port() { local want=443 pair - [ "$1" = calendar ] && want=8443 - for pair in $APPS_FUNNEL_MAP; do [ "${pair%%:*}" = "$want" ] && printf '%s' "${pair##*:}"; done + [[ "$1" = calendar ]] && want=8443 + for pair in $APPS_FUNNEL_MAP; do [[ "${pair%%:*}" = "$want" ]] && printf '%s' "${pair##*:}"; done } # The Host header the Funnel sends for the app: its origin without the scheme. app_host_header() { @@ -44,14 +44,14 @@ app_host_header() { apps_host() { local o h o=$(app_origin mail) - [ -n "$o" ] || o=$(app_origin calendar) + [[ -n "$o" ]] || o=$(app_origin calendar) h=${o#https://} - if [ -z "$h" ]; then h=$(node_dns tsa || true); fi + if [[ -z "$h" ]]; then h=$(node_dns tsa || true); fi h=${h%.} printf '%s' "${h%%[:/]*}" } # The apps node must answer when the apps are configured or it has ever been started here. -apps_node_expected() { apps_configured || [ -e "$(apps_node_dir)" ]; } +apps_node_expected() { apps_configured || [[ -e "$(apps_node_dir)" ]]; } # SKIP when the apps are not deployed, FAIL when they are and $2 is missing; returns 1 either way. apps_absent() { @@ -67,8 +67,8 @@ check_exposure2() { return fi have=$(funnel_ports tsa || true) - if [ -z "$have" ]; then - if [ "$1" = apps ] || apps_configured; then + if [[ -z "$have" ]]; then + if [[ "$1" = apps ]] || apps_configured; then fail exposure2 "apps node $dns has its Funnel closed" else skip exposure2 "apps node $dns runs with its Funnel closed and no origin is set"; fi return @@ -76,9 +76,9 @@ check_exposure2() { targets=$(funnel_targets tsa | paste -sd, -) want_targets="443 / 127.0.0.1:$(app_port mail),8443 / 127.0.0.1:$(app_port calendar)" host=$(apps_host) - if [ "$have" != 443,8443 ] || [ "$targets" != "$want_targets" ]; then + if [[ "$have" != 443,8443 ]] || [[ "$targets" != "$want_targets" ]]; then fail exposure2 "apps node funnel ports '$have' -> '$targets', expected exactly 443,8443 -> $want_targets" - elif [ -n "$(app_origin mail)" ] && [ "$dns" != "$host." ]; then + elif [[ -n "$(app_origin mail)" ]] && [[ "$dns" != "$host." ]]; then fail exposure2 "apps node DNS name is '$dns', the origins want '$host.'" else pass exposure2 "apps node ${dns}: funnel $have -> $targets"; fi } @@ -88,7 +88,7 @@ bridge_targets_problems() { local node label targets for node in ts tsa; do label=main - if [ "$node" = tsa ]; then + if [[ "$node" = tsa ]]; then label=apps apps_node_expected || continue fi @@ -100,7 +100,7 @@ bridge_targets_problems() { check_nobridge() { local running problems unreadable mapped scope="no node maps 4100 or 4200" running=$(docker ps --filter "label=com.docker.compose.project=$PROJECT" --format '{{.Label "com.docker.compose.service"}}' 2>/dev/null || echo unreadable) - if [ "$running" = unreadable ]; then + if [[ "$running" = unreadable ]]; then fail nobridge "cannot list the containers of project $PROJECT" return fi @@ -109,11 +109,11 @@ check_nobridge() { unreadable=$(grep '^unreadable:' <<<"$problems" | cut -d: -f2 | paste -sd, - || true) mapped=$(grep -v '^unreadable:' <<<"$problems" | paste -sd, - || true) apps_node_expected || scope="main node maps neither 4100 nor 4200 (apps node not deployed: not checked)" - if [ -n "$running" ]; then + if [[ -n "$running" ]]; then fail nobridge "bridge container(s) running in $PROJECT: $running" - elif [ -n "$unreadable" ]; then + elif [[ -n "$unreadable" ]]; then fail nobridge "cannot read the Funnel targets of: $unreadable (daemon not answering)" - elif [ -n "$mapped" ]; then + elif [[ -n "$mapped" ]]; then fail nobridge "a Funnel node proxies to a bridge port: $mapped" else pass nobridge "no mail-bridge or calendar-bridge container; $scope"; fi } @@ -136,7 +136,7 @@ check_nodev() { for path in $APPS_DEV_PATHS; do code=$(app_get "$app" "$path" -o /dev/null -w '%{http_code}' || true) n=$((n + 1)) - [ "$code" = 404 ] || bad+=" $app$path=${code:-none}" + [[ "$code" = 404 ]] || bad+=" $app$path=${code:-none}" done done expect nodev "$n probes (dev client, entry, package.json, token files) -> 404" "not 404:$bad" [ -z "$bad" ] @@ -145,7 +145,7 @@ check_nodev() { # What is wrong with one response's headers ($1); empty when nothing is. MC4. apphdrs_problems() { local headers=$1 csp ancestors bad="" d - [ -n "$headers" ] || { + [[ -n "$headers" ]] || { printf 'no answer' return } @@ -173,7 +173,7 @@ check_apphdrs() { for path in / /no-such-file-apphdrs; do headers=$(app_get "$app" "$path" -D - -o /dev/null | tr -d '\r' || true) problems=$(apphdrs_problems "$headers") - [ -z "$problems" ] || bad+=" $app$path:$problems;" + [[ -z "$problems" ]] || bad+=" $app$path:$problems;" done done expect apphdrs "mail and calendar, on / and on a 404: CSP (frame-ancestors, default, script, connect), nosniff, no-referrer, HSTS, no CORS, no X-Frame-Options" "headers wrong:$bad" [ -z "$bad" ] @@ -185,20 +185,20 @@ bundle2_offenders() { local image name mode pattern hits key file value for image in "$APPS_MAIL_IMAGE" "$APPS_CALENDAR_IMAGE"; do hits=$(docker run --rm --entrypoint sh "$image" -c 'find /usr/share/nginx/html -type f | wc -l' 2>/dev/null | tr -d '[:space:]' || true) - if ! [[ "$hits" =~ ^[0-9]+$ ]] || [ "$hits" -eq 0 ]; then + if ! [[ "$hits" =~ ^[0-9]+$ ]] || [[ "$hits" -eq 0 ]]; then printf 'unscannable:%s ' "$image" continue fi while IFS='|' read -r name mode pattern; do hits=$(V="$pattern" bundle_hits "$image" "$mode" || true) - [ "$hits" = 0 ] || printf '%s:%s(%s) ' "${image##*/}" "$name" "${hits:-?}" + [[ "$hits" = 0 ]] || printf '%s:%s(%s) ' "${image##*/}" "$name" "${hits:-?}" done <<<"$APPS_BUNDLE_PATTERNS" for key in $BUNDLE_SECRET_KEYS; do for file in "$LOCAL_ENV" "$REMOTE_ENV" "$ENV_FILE"; do value=$(get_env "$file" "$key" || true) - [ -n "$value" ] || continue + [[ -n "$value" ]] || continue hits=$(V="$value" bundle_hits "$image" || true) - [ "$hits" = 0 ] || printf '%s:%s(%s) ' "${image##*/}" "$key" "${hits:-?}" + [[ "$hits" = 0 ]] || printf '%s:%s(%s) ' "${image##*/}" "$key" "${hits:-?}" done done done @@ -228,7 +228,7 @@ appurls_config_problems() { cal=$(app_origin calendar) host=$(apps_host) main=$(origin_host) - if [ -z "$mail" ] || [ -z "$cal" ]; then + if [[ -z "$mail" ]] || [[ -z "$cal" ]]; then printf 'only one of PUBLIC_MAIL_ORIGIN, PUBLIC_CALENDAR_ORIGIN is set' return fi @@ -236,11 +236,11 @@ appurls_config_problems() { printf 'PUBLIC_MAIL_ORIGIN is not https://host' return } - [ "$cal" = "$mail:8443" ] || { + [[ "$cal" = "$mail:8443" ]] || { printf 'PUBLIC_CALENDAR_ORIGIN is not PUBLIC_MAIL_ORIGIN:8443' return } - [ "$host" != "$main" ] || printf 'the apps origins share PUBLIC_ORIGIN host %s (MC3: a separate hostname)' "$main" + [[ "$host" != "$main" ]] || printf 'the apps origins share PUBLIC_ORIGIN host %s (MC3: a separate hostname)' "$main" } # MC8: the osionos app image links to the configured origins and to no localhost. @@ -251,19 +251,19 @@ check_appurls() { return } problems=$(appurls_config_problems) - if [ -n "$problems" ]; then + if [[ -n "$problems" ]]; then fail appurls "$problems" return fi for app in mail calendar; do origin=$(app_origin "$app") hits=$(origin_hits "$APP_IMAGE" "$origin" || true) - [[ "$hits" =~ ^[0-9]+$ ]] && [ "$hits" -gt 0 ] || bad+=" $origin(${hits:-unscannable})" + [[ "$hits" =~ ^[0-9]+$ ]] && [[ "$hits" -gt 0 ]] || bad+=" $origin(${hits:-unscannable})" done links=$(bundle_links "$APP_IMAGE" | head -n1 | tr -d '[:space:]' || true) - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then fail appurls "$APP_IMAGE does not contain the origin:$bad" - elif [ "$links" != 0 ]; then + elif [[ "$links" != 0 ]]; then fail appurls "$APP_IMAGE has ${links:-an unreadable count of} file(s) linking to localhost" else pass appurls "$APP_IMAGE contains $(app_origin mail) and $(app_origin calendar), no localhost"; fi } @@ -273,16 +273,16 @@ check_appurls() { check_tls2() { local ports ports=$(funnel_ports tsa || true) - if [ -n "$ports" ]; then + if [[ -n "$ports" ]]; then tls_probe "$(apps_host)" "$ports" tls2 - elif [ "$1" = apps ] || apps_configured; then + elif [[ "$1" = apps ]] || apps_configured; then fail tls2 "apps node has no open port: no certificate to check" else skip tls2 "apps node has no open port and no origin is set"; fi } # $1: pre | full $2: the verify scope check_apps() { - if [ "$1" != pre ]; then + if [[ "$1" != pre ]]; then check_exposure2 "$2" check_tls2 "$2" fi diff --git a/scripts/lib/public-verify-surfaces.sh b/scripts/lib/public-verify-surfaces.sh index a7cdfac11..6c61f10f2 100644 --- a/scripts/lib/public-verify-surfaces.sh +++ b/scripts/lib/public-verify-surfaces.sh @@ -26,13 +26,13 @@ check_ide_mode() { check_ide_access() { local bridge ide bridge=$(svc_container osionos-bridge) - [ -n "$bridge" ] || { + [[ -n "$bridge" ]] || { fail ide-access "osionos-bridge is not running" return } check_ide_mode osionos-bridge "$bridge" ide=$(svc_container osionos-ide-server || true) - if [ -n "$ide" ]; then + if [[ -n "$ide" ]]; then check_ide_mode osionos-ide-server "$ide" else pass ide-access "no osionos-ide-server runs in this project"; fi } @@ -51,7 +51,7 @@ check_bundle_links() { count=$(head -n1 <<<"$out" | tr -d '[:space:]') if ! [[ "$count" =~ ^[0-9]+$ ]]; then fail bundle-links "could not be scanned in $APP_IMAGE" - elif [ "$count" = 0 ]; then + elif [[ "$count" = 0 ]]; then pass bundle-links "0 files in $APP_IMAGE link to localhost" else ports=$(tail -n +2 <<<"$out" | paste -sd' ' -) diff --git a/scripts/lib/public-verify.sh b/scripts/lib/public-verify.sh index 7ba070d98..69d151815 100644 --- a/scripts/lib/public-verify.sh +++ b/scripts/lib/public-verify.sh @@ -51,17 +51,17 @@ origin_host() { printf '%s' "${o%%[:/]*}" } -is_int_between() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge "$2" ] && [ "$1" -le "$3" ]; } +is_int_between() { [[ "$1" =~ ^[0-9]+$ ]] && [[ "$1" -ge "$2" ]] && [[ "$1" -le "$3" ]]; } check_node_name() { local host have host=$(origin_host) - [ -n "$host" ] || { + [[ -n "$host" ]] || { fail node-name "PUBLIC_ORIGIN is not an https://host origin in .env.public" return } have=$(node_dns ts || true) - if [ "$have" = "$host." ]; then + if [[ "$have" = "$host." ]]; then pass node-name "this node is $have" else fail node-name "node DNS name is '${have:-unreadable}', PUBLIC_ORIGIN wants '$host.'"; fi } @@ -79,11 +79,11 @@ check_exposure() { all) want=443,8443,10000 ;; *) want="" ;; esac - if [ -n "$want" ]; then + if [[ -n "$want" ]]; then expect exposure "funnel ports: $have" "funnel ports '${have:-none}', expected exactly $want" [ "$have" = "$want" ] - elif [ -z "$have" ]; then + elif [[ -z "$have" ]]; then skip exposure "funnel closed: nothing is exposed" - elif [ "$have" = 443 ] || [ "$have" = 443,8443,10000 ]; then + elif [[ "$have" = 443 ]] || [[ "$have" = 443,8443,10000 ]]; then pass exposure "funnel ports: $have" else fail exposure "funnel ports '$have', expected 443 alone or 443,8443,10000"; fi } @@ -99,13 +99,13 @@ scan_secret() { local key=$1 file=$2 value=$3 image hits bad=0 for image in "$APP_IMAGE" "$WEB_IMAGE"; do hits=$(V="$value" bundle_hits "$image" || true) - if [ "$hits" = 0 ]; then continue; fi + if [[ "$hits" = 0 ]]; then continue; fi bad=1 - if [ -n "$hits" ]; then + if [[ -n "$hits" ]]; then fail bundle-secret "$key (from ${file##*/}) found in $hits file(s) of $image" else fail bundle-secret "$key (from ${file##*/}) could not be scanned in $image"; fi done - [ "$bad" = 1 ] || pass bundle-secret "$key (from ${file##*/}): 0 files in $APP_IMAGE and $WEB_IMAGE" + [[ "$bad" = 1 ]] || pass bundle-secret "$key (from ${file##*/}): 0 files in $APP_IMAGE and $WEB_IMAGE" } check_bundle_secrets() { @@ -114,13 +114,13 @@ check_bundle_secrets() { found=0 for file in "$LOCAL_ENV" "$REMOTE_ENV" "$ENV_FILE"; do value=$(get_env "$file" "$key" || true) - [ -n "$value" ] || continue + [[ -n "$value" ]] || continue case "$seen" in *$'\n'"$key=$value"$'\n'*) continue ;; esac seen+="$key=$value"$'\n' found=1 scan_secret "$key" "$file" "$value" done - [ "$found" = 1 ] || skip bundle-secret "$key is unset in every env file: nothing to look for" + [[ "$found" = 1 ]] || skip bundle-secret "$key is unset in every env file: nothing to look for" done } @@ -129,7 +129,7 @@ container_env() { docker exec "$1" printenv "$2" 2>/dev/null || true; } check_bridge_env() { local c key have want c=$(svc_container osionos-bridge) - [ -n "$c" ] || { + [[ -n "$c" ]] || { fail bridge-env "osionos-bridge is not running" return } @@ -138,7 +138,7 @@ check_bridge_env() { done want=$(penv OSIONOS_ADMIN_EMAILS || true) have=$(container_env "$c" OSIONOS_ADMIN_EMAILS) - if [ -n "$want" ] && [ "$have" = "$want" ]; then + if [[ -n "$want" ]] && [[ "$have" = "$want" ]]; then pass bridge-env "OSIONOS_ADMIN_EMAILS equals .env.public" else fail bridge-env "OSIONOS_ADMIN_EMAILS differs from .env.public (or is empty there)"; fi want="$(penv PUBLIC_ORIGIN || true):8443" @@ -153,7 +153,7 @@ list_overlap() { local a b a=$(list_entries "$1") b=$(list_entries "$2") - if [ -n "$a" ] && [ -n "$b" ]; then grep -Fxf <(printf '%s\n' "$b") <<<"$a" || true; fi + if [[ -n "$a" ]] && [[ -n "$b" ]]; then grep -Fxf <(printf '%s\n' "$b") <<<"$a" || true; fi } # An address list, not a secret: printed as a value. An exempt account is never mailed, so an @@ -168,7 +168,7 @@ check_verification_exempt() { check_gateway_env() { local c hops logs c=$(svc_container auth-gateway) - [ -n "$c" ] || { + [[ -n "$c" ]] || { fail gateway-env "auth-gateway is not running" return } @@ -178,7 +178,7 @@ check_gateway_env() { check_verification_exempt "$c" hops=$(penv AUTH_TRUSTED_PROXY_HOPS || true) logs=$(docker logs "$c" 2>&1 || true) - if [ -z "$hops" ]; then + if [[ -z "$hops" ]]; then fail gateway-env "AUTH_TRUSTED_PROXY_HOPS is unset in .env.public" elif grep -qF "trusted proxy hops: $hops)" <<<"$logs"; then pass gateway-env "boot log: trusted proxy hops: $hops" @@ -224,7 +224,7 @@ tls_probe() { check_env_file() { local mode - if [ ! -f "$ENV_FILE" ]; then + if [[ ! -f "$ENV_FILE" ]]; then fail env-file ".env.public is missing (make public-env)" return fi @@ -244,10 +244,10 @@ extra_ancestors_ok() { check_env_values() { local bad="" v - [ -n "$(origin_host)" ] && [[ "$(penv PUBLIC_ORIGIN)" =~ ^https://[A-Za-z0-9.-]+$ ]] || bad+=" PUBLIC_ORIGIN" + [[ -n "$(origin_host)" ]] && [[ "$(penv PUBLIC_ORIGIN)" =~ ^https://[A-Za-z0-9.-]+$ ]] || bad+=" PUBLIC_ORIGIN" v=$(penv AUTH_LINK_SIGNING_KEY || true) - [ "${#v}" -ge 32 ] || bad+=" AUTH_LINK_SIGNING_KEY(<32 chars)" - [ -n "$(penv OSIONOS_ADMIN_EMAILS || true)" ] || bad+=" OSIONOS_ADMIN_EMAILS(empty: write emails or none)" + [[ "${#v}" -ge 32 ]] || bad+=" AUTH_LINK_SIGNING_KEY(<32 chars)" + [[ -n "$(penv OSIONOS_ADMIN_EMAILS || true)" ]] || bad+=" OSIONOS_ADMIN_EMAILS(empty: write emails or none)" is_int_between "$(penv AUTH_MAIL_DAILY_CAP || echo 50)" 1 50 || bad+=" AUTH_MAIL_DAILY_CAP(1-50)" is_int_between "$(penv AUTH_REGISTER_RATE_LIMIT_PER_HOUR || echo 10)" 1 10 || bad+=" AUTH_REGISTER_RATE_LIMIT_PER_HOUR(1-10)" v=$(penv AUTH_TRUSTED_PROXY_HOPS || true) @@ -262,7 +262,7 @@ run_verify() { check_env_file check_env_values check_node_name - [ "$1" = pre ] || { + [[ "$1" = pre ]] || { check_exposure "${2:-}" check_tls } @@ -275,5 +275,5 @@ run_verify() { check_edge check_apps "$1" "${2:-}" printf 'verify (%s): %d pass, %d fail, %d skip\n' "$1" "$V_PASS" "$V_FAIL" "$V_SKIP" - [ "$V_FAIL" -eq 0 ] + [[ "$V_FAIL" -eq 0 ]] } diff --git a/scripts/osionos-publish-socket.sh b/scripts/osionos-publish-socket.sh index a20253b2d..9cac37b07 100644 --- a/scripts/osionos-publish-socket.sh +++ b/scripts/osionos-publish-socket.sh @@ -28,15 +28,15 @@ ssh_user=${PUBLISH_SSH_USER:-$(id -un)} read_host_key() { # validate HOST_KEY_FILE (exit 2: unset, unreadable, not exactly one valid key) and print " " local keytype='' keydata='' _ - [ -n "${HOST_KEY_FILE:-}" ] || { + [[ -n "${HOST_KEY_FILE:-}" ]] || { echo "[publish-socket] HOST_KEY_FILE is unset: the VM's ssh host public key line has to be pinned by the operator, over a trusted step" >&2 exit 2 } - [ -r "$HOST_KEY_FILE" ] || { + [[ -r "$HOST_KEY_FILE" ]] || { echo "[publish-socket] HOST_KEY_FILE=$HOST_KEY_FILE is not a readable file" >&2 exit 2 } - if [ "$(grep -c '[^[:space:]]' "$HOST_KEY_FILE")" -ne 1 ]; then + if [[ "$(grep -c '[^[:space:]]' "$HOST_KEY_FILE")" -ne 1 ]]; then echo "[publish-socket] HOST_KEY_FILE must hold exactly one host key line" >&2 exit 2 fi @@ -48,7 +48,7 @@ read_host_key() { # validate HOST_KEY_FILE (exit 2: unset, unreadable, not exact exit 2 ;; esac - [ -n "$keydata" ] || { + [[ -n "$keydata" ]] || { echo "[publish-socket] HOST_KEY_FILE has a key type but no key" >&2 exit 2 } @@ -90,7 +90,7 @@ install_unit() { pinned=$(read_host_key) require_plain # bytes, not characters: a multibyte XDG_STATE_HOME is longer than ${#sock} says - if [ "$(printf %s "$sock" | wc -c)" -gt 100 ]; then + if [[ "$(printf %s "$sock" | wc -c)" -gt 100 ]]; then echo "[publish-socket] $sock is longer than 100 bytes: a unix socket path has to fit 108" >&2 exit 1 fi @@ -98,7 +98,7 @@ install_unit() { chmod 700 "$state" "$sockdir" printf 'b2b-publish %s\n' "$pinned" >"$known_hosts" chmod 600 "$known_hosts" - [ -f "$identity" ] || { + [[ -f "$identity" ]] || { echo "[publish-socket] no identity file at $identity (PUBLISH_SSH_IDENTITY=)" >&2 exit 1 } @@ -110,7 +110,7 @@ install_unit() { remove_unit() { # only an absent unit is skipped: any other disable/stop failure must not report "off" - if [ -e "$unitdir/$unit" ]; then + if [[ -e "$unitdir/$unit" ]]; then systemctl --user disable --now "$unit" >/dev/null fi rm -f "$unitdir/$unit" @@ -123,7 +123,7 @@ print_status() { local active active=$(systemctl --user is-active "$unit" 2>/dev/null) || true echo "[publish-socket] unit: ${active:-unknown}" - if [ -e "$sock" ]; then + if [[ -e "$sock" ]]; then echo "[publish-socket] socket: $sock exists" else echo "[publish-socket] socket: $sock missing" diff --git a/scripts/public-b2b/build-images.sh b/scripts/public-b2b/build-images.sh index 4a0000665..68256d14b 100755 --- a/scripts/public-b2b/build-images.sh +++ b/scripts/public-b2b/build-images.sh @@ -27,13 +27,13 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) # shellcheck source=../lib/envfile.sh . "$root/scripts/lib/envfile.sh" -[ $# -eq 1 ] || fail "usage: build-images.sh " +[[ $# -eq 1 ]] || fail "usage: build-images.sh " oo=$(cd "$1" && pwd) build_env=${PUBLIC_BUILD_ENV_FILE:-$root/.env.public-b2b} git -C "$oo" rev-parse --verify -q HEAD >/dev/null || fail "$oo is not a git worktree" -[ -f "$oo/docker/services/web/Dockerfile" ] || fail "$oo is not an opposite-osiris tree" -[ -z "$(git -C "$oo" status --porcelain)" ] || fail "$oo has uncommitted changes: commit them, the tag names a commit" +[[ -f "$oo/docker/services/web/Dockerfile" ]] || fail "$oo is not an opposite-osiris tree" +[[ -z "$(git -C "$oo" status --porcelain)" ]] || fail "$oo has uncommitted changes: commit them, the tag names a commit" sha=$(git -C "$oo" rev-parse --short=12 HEAD) anon_key=$(get_env "$build_env" PUBLIC_BAAS_ANON_KEY) || fail "PUBLIC_BAAS_ANON_KEY missing in $build_env" @@ -87,9 +87,9 @@ js_anon=$(count_in "$anon_key" '*.js') echo " html files with $PUBLIC_HOST:$FUNNEL_PORT: $html_funnel (want >= 1)" echo " js files with https://localhost:4322: $js_localhost (want 0); index.html with it: $index_localhost (want 0)" echo " js files with the anon key: $js_anon (want >= 1)" -[ "$html_funnel" -ge 1 ] || fail "no page contains $PUBLIC_HOST:$FUNNEL_PORT" -if [ "$js_localhost" -ne 0 ] || [ "$index_localhost" -ne 0 ]; then fail "the bundle still contains https://localhost:4322"; fi -[ "$js_anon" -ge 1 ] || fail "the anon key is not in the bundle" +[[ "$html_funnel" -ge 1 ]] || fail "no page contains $PUBLIC_HOST:$FUNNEL_PORT" +if [[ "$js_localhost" -ne 0 ]] || [[ "$index_localhost" -ne 0 ]]; then fail "the bundle still contains https://localhost:4322"; fi +[[ "$js_anon" -ge 1 ]] || fail "the anon key is not in the bundle" echo " info: js files with the literal http://localhost:4322 (portal.ts redirect allowlist, hard-coded): $(count_in "http://localhost:4322" '*.js')" echo " info: files that still name localhost:4322 (robots.txt Sitemap line, docs pages): $(count_in "localhost:4322" '*')" diff --git a/scripts/public-b2b/deploy.sh b/scripts/public-b2b/deploy.sh index 76632e796..54b8f0128 100755 --- a/scripts/public-b2b/deploy.sh +++ b/scripts/public-b2b/deploy.sh @@ -24,7 +24,7 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) # shellcheck source=lib.sh . "$root/scripts/public-b2b/lib.sh" -[ $# -eq 1 ] || fail "usage: deploy.sh (the tag printed by build-images.sh)" +[[ $# -eq 1 ]] || fail "usage: deploy.sh (the tag printed by build-images.sh)" sha=$1 site_image=prismatica-public/site:$sha gateway_image=prismatica-public/auth-gateway:$sha @@ -39,7 +39,7 @@ wrote_on_vm=0 on_exit() { local status=$? - if [ "$status" -ne 0 ] && [ "$wrote_on_vm" -eq 1 ]; then + if [[ "$status" -ne 0 ]] && [[ "$wrote_on_vm" -eq 1 ]]; then echo "== FAILED (exit $status). Roll back with:" >&2 echo " $rollback" >&2 fi @@ -49,7 +49,7 @@ trap on_exit EXIT # curl from this host, or from the VM when PUBLIC_CHECK_FROM=vm. Prints the status code only. public_status() { local method=$1 path=$2 - if [ "${PUBLIC_CHECK_FROM:-host}" = vm ]; then + if [[ "${PUBLIC_CHECK_FROM:-host}" = vm ]]; then "${B2B[@]}" "curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X $method -d '{}' -H 'content-type: application/json' '$PUBLIC_URL$path'" else curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X "$method" -d '{}' -H 'content-type: application/json' "$PUBLIC_URL$path" || true @@ -60,7 +60,7 @@ expect_status() { local label=$1 got=$2 want shift 2 for want in "$@"; do - if [ "$got" = "$want" ]; then + if [[ "$got" = "$want" ]]; then echo " ok $label -> $got" return 0 fi @@ -79,7 +79,7 @@ echo " $gateway_image layers=$gateway_layers" echo "== 1. preflight on the VM (reads only)" self=$("${B2B[@]}" "tailscale status --json | jq -r .Self.DNSName") -[ "$self" = "$PUBLIC_HOST." ] || fail "this node is $self, the URL and the build assume $PUBLIC_HOST." +[[ "$self" = "$PUBLIC_HOST." ]] || fail "this node is $self, the URL and the build assume $PUBLIC_HOST." "${B2B[@]}" "tailscale serve status --json | jq -e --arg h '$PUBLIC_HOST:$FUNNEL_PORT' '.Web[\$h] == null' >/dev/null" || fail ":$FUNNEL_PORT is already configured in the serve config: inspect it, then rollback.sh or stop" "${B2B[@]}" "test -d $REMOTE_DIR" || fail "$REMOTE_DIR is missing: run env-init.sh first" @@ -97,8 +97,8 @@ echo "$code $at_signs" PROBE ) read -r probe_code probe_at <<<"$probe" -echo " status=$probe_code at_sign_in_body=$([ "$probe_at" = 0 ] && echo no || echo YES)" -[ "$probe_at" = 0 ] || fail "PUB-3: an email came back for the anon key: fix the users grant, do not ship" +echo " status=$probe_code at_sign_in_body=$([[ "$probe_at" = 0 ]] && echo no || echo YES)" +[[ "$probe_at" = 0 ]] || fail "PUB-3: an email came back for the anon key: fix the users grant, do not ship" case $probe_code in 401 | 403) ;; *) fail "PUB-3: anon got $probe_code on users?select=email, want a permission error (401/403)" ;; @@ -115,7 +115,7 @@ echo " waf: ${waf_before:0:12} ... mini-baas containers: $count_before digest echo " port $port is free (or ours)" busy=$("${B2B[@]}" "ss -ltn 'sport = :$port' | grep -c LISTEN || true") ours=$("${B2B[@]}" "docker ps -q --filter label=com.docker.compose.project=$PROJECT | wc -l") -{ [ "$busy" = 0 ] || [ "$ours" -gt 0 ]; } || fail "port $port is taken by something that is not $PROJECT" +{ [[ "$busy" = 0 ]] || [[ "$ours" -gt 0 ]]; } || fail "port $port is taken by something that is not $PROJECT" echo " $REMOTE_DIR/.env: mode 600, keys present (names only)" "${B2B[@]}" bash -s </dev/null got=$(remote_layers_hash "$image") - [ "$got" = "$want" ] || fail "layers of $image differ on b2b ($got vs $want)" + [[ "$got" = "$want" ]] || fail "layers of $image differ on b2b ($got vs $want)" echo " $image layers=$got (match)" done if ! "${B2B[@]}" "docker image inspect $redis_image >/dev/null 2>&1"; then @@ -152,7 +152,7 @@ printf 'PUBLIC_SITE_IMAGE=%s\nPUBLIC_GATEWAY_IMAGE=%s\nPUBLIC_REDIS_IMAGE=%s\nPR "$site_image" "$gateway_image" "$redis_image" "$port" | "${B2B[@]}" "cat > $REMOTE_DIR/images.env" local_sum=$(sha256sum <"$root/infrastructure/compose/public-b2b.yml" | cut -c1-16) remote_sum=$("${B2B[@]}" "sha256sum < $REMOTE_DIR/public-b2b.yml | cut -c1-16") -[ "$local_sum" = "$remote_sum" ] || fail "compose file differs on the VM ($remote_sum vs $local_sum)" +[[ "$local_sum" = "$remote_sum" ]] || fail "compose file differs on the VM ($remote_sum vs $local_sum)" echo " public-b2b.yml $remote_sum (match)" echo "== 4. up -d in project $PROJECT (no build, no pull)" @@ -161,10 +161,10 @@ for service in public-redis public-auth-gateway public-site; do state=starting for _ in $(seq 1 40); do state=$("${B2B[@]}" "docker inspect -f '{{.State.Health.Status}}' \$($compose ps -q $service)" 2>/dev/null || echo starting) - [ "$state" = healthy ] && break + [[ "$state" = healthy ]] && break sleep 3 done - [ "$state" = healthy ] || fail "$service is $state after 2 minutes: ssh b2b '$compose logs $service'" + [[ "$state" = healthy ]] || fail "$service is $state after 2 minutes: ssh b2b '$compose logs $service'" echo " $service healthy" done boot=$("${B2B[@]}" "$compose logs --no-log-prefix public-auth-gateway 2>&1 | grep -E 'FATAL|listening' | tail -3" || true) @@ -190,23 +190,23 @@ serve_adds_only_funnel_keys "$before_json" "$after_json" || fail "PUB-9: the serve config changed beyond the :$FUNNEL_PORT keys (compare $before_json and $after_json)" echo " ok serve diff adds only TCP/Web/AllowFunnel :$FUNNEL_PORT; both :443 hosts equal" waf_after=$("${B2B[@]}" "docker inspect mini-baas-waf --format '{{.Id}} {{.State.StartedAt}}'") -[ "$waf_after" = "$waf_before" ] || fail "PUB-10: mini-baas-waf changed ($waf_before -> $waf_after)" +[[ "$waf_after" = "$waf_before" ]] || fail "PUB-10: mini-baas-waf changed ($waf_before -> $waf_after)" ps_after=$("${B2B[@]}" "docker compose -p mini-baas ps -a -q | sort | sha256sum | cut -c1-16") -[ "$ps_after" = "$ps_before" ] || fail "PUB-5: the mini-baas container set changed ($ps_before -> $ps_after)" +[[ "$ps_after" = "$ps_before" ]] || fail "PUB-5: the mini-baas container set changed ($ps_before -> $ps_after)" echo " ok WAF Id+StartedAt unchanged; mini-baas digest $ps_after unchanged" echo "== 7. through $PUBLIC_URL (certificate issuance can take a minute)" status=000 for _ in $(seq 1 12); do status=$(public_status GET /) - [ "$status" = 200 ] && break + [[ "$status" = 200 ]] && break sleep 5 done expect_status "GET /" "$status" 200 expect_status "GET /api/auth/health" "$(public_status GET /api/auth/health)" 200 expect_status "POST /api/email/v1/send" "$(public_status POST /api/email/v1/send)" 401 403 expect_status "POST /api/v1/auth/otp/request" "$(public_status POST /api/v1/auth/otp/request)" 401 403 -if [ "${PUBLIC_CHECK_FROM:-host}" = vm ]; then +if [[ "${PUBLIC_CHECK_FROM:-host}" = vm ]]; then page=$("${B2B[@]}" "curl -s --max-time 15 '$PUBLIC_URL/'") else page=$(curl -s --max-time 15 "$PUBLIC_URL/") diff --git a/scripts/public-b2b/env-init.sh b/scripts/public-b2b/env-init.sh index 31c6f21d3..3443bf3da 100755 --- a/scripts/public-b2b/env-init.sh +++ b/scripts/public-b2b/env-init.sh @@ -30,7 +30,7 @@ fail() { echo "FAIL: $*" >&2; exit 1; } put_env() { local tmp tmp=$(mktemp "$DIR/.env.XXXXXX") - [ ! -f "$FILE" ] || cat "$FILE" >"$tmp" + [[ ! -f "$FILE" ]] || cat "$FILE" >"$tmp" ENVFILE_KEY=$1 ENVFILE_VAL=$2 awk ' BEGIN { k = ENVIRON["ENVFILE_KEY"]; v = ENVIRON["ENVFILE_VAL"] } { @@ -45,7 +45,7 @@ END { if (!seen) print k "=" v }' "$tmp" >"$tmp.new" } get_env() { - [ -f "$FILE" ] || return 0 + [[ -f "$FILE" ]] || return 0 sed -n "s/^$1=//p" "$FILE" | tail -1 } @@ -62,42 +62,42 @@ refuse_unsafe() { prompt() { local name=$1 hidden=$2 current answer current=$(get_env "$name") - if [ -n "$current" ]; then + if [[ -n "$current" ]]; then printf '%s is set; Enter keeps it, or type a new value: ' "$name" >&2 else printf '%s: ' "$name" >&2 fi - if [ "$hidden" = secret ]; then + if [[ "$hidden" = secret ]]; then read -r -s answer <&3 echo >&2 else read -r answer <&3 fi answer=${answer:-$current} - [ -n "$answer" ] || fail "$name is required" + [[ -n "$answer" ]] || fail "$name is required" refuse_unsafe "$name" "$answer" put_env "$name" "$answer" } -[ -r "$SRC" ] || fail "cannot read $SRC" -[ -d "$DIR" ] || sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 700 "$DIR" -[ -f "$FILE" ] || install -m 600 /dev/null "$FILE" +[[ -r "$SRC" ]] || fail "cannot read $SRC" +[[ -d "$DIR" ]] || sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 700 "$DIR" +[[ -f "$FILE" ]] || install -m 600 /dev/null "$FILE" ! grep -q '^AUTH_DEMO_INSECURE=' "$FILE" || fail "AUTH_DEMO_INSECURE is in $FILE: remove it (PUB-8)" service_key=$(source_value SERVICE_ROLE_KEY) anon_key=$(source_value ANON_KEY) -[ -n "$service_key" ] || fail "SERVICE_ROLE_KEY is empty in $SRC" -[ -n "$anon_key" ] || fail "ANON_KEY is empty in $SRC" +[[ -n "$service_key" ]] || fail "SERVICE_ROLE_KEY is empty in $SRC" +[[ -n "$anon_key" ]] || fail "ANON_KEY is empty in $SRC" put_env SERVICE_ROLE_KEY "$service_key" put_env PUBLIC_BAAS_ANON_KEY "$anon_key" unset service_key anon_key -[ -n "$(get_env AUTH_LINK_SIGNING_KEY)" ] || put_env AUTH_LINK_SIGNING_KEY "$(head -c 48 /dev/urandom | base64 -w0)" +[[ -n "$(get_env AUTH_LINK_SIGNING_KEY)" ]] || put_env AUTH_LINK_SIGNING_KEY "$(head -c 48 /dev/urandom | base64 -w0)" prompt TURNSTILE_SECRET_KEY secret prompt AUTH_SIGNUP_ALLOWLIST visible echo "$FILE mode $(stat -c %a "$FILE") (want 600)" -[ "$(stat -c %a "$FILE")" = 600 ] || fail "mode is not 600" +[[ "$(stat -c %a "$FILE")" = 600 ]] || fail "mode is not 600" for name in SERVICE_ROLE_KEY PUBLIC_BAAS_ANON_KEY AUTH_LINK_SIGNING_KEY TURNSTILE_SECRET_KEY AUTH_SIGNUP_ALLOWLIST; do - if [ -n "$(get_env "$name")" ]; then echo " $name set"; else echo " $name UNSET"; fi + if [[ -n "$(get_env "$name")" ]]; then echo " $name set"; else echo " $name UNSET"; fi done diff --git a/scripts/public-b2b/rollback.sh b/scripts/public-b2b/rollback.sh index 1118e8bf1..a396b2037 100755 --- a/scripts/public-b2b/rollback.sh +++ b/scripts/public-b2b/rollback.sh @@ -22,10 +22,10 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) . "$root/scripts/public-b2b/lib.sh" before_json=${1:-} -if [ -z "$before_json" ]; then +if [[ -z "$before_json" ]]; then before_json=$("${B2B[@]}" "ls -t $REMOTE_DIR/serve-before-*.json 2>/dev/null | head -1" || true) fi -[ -n "$before_json" ] || fail "no saved serve config under $REMOTE_DIR: pass the path explicitly" +[[ -n "$before_json" ]] || fail "no saved serve config under $REMOTE_DIR: pass the path explicitly" "${B2B[@]}" "test -s '$before_json'" || fail "$before_json is missing or empty on the VM" stamp=$(date -u +%Y%m%dT%H%M%SZ) now_json=$REMOTE_DIR/serve-rollback-$stamp.json @@ -45,7 +45,7 @@ if ! "${B2B[@]}" "set -o pipefail; ${down[*]} 2>&1 | tail -6"; then fail "compose down failed: remove the containers labelled com.docker.compose.project=$PROJECT by hand" fi left=$("${B2B[@]}" "docker ps -aq --filter label=com.docker.compose.project=$PROJECT | wc -l") -[ "$left" = 0 ] || fail "$left container(s) of $PROJECT remain" +[[ "$left" = 0 ]] || fail "$left container(s) of $PROJECT remain" echo " ok no $PROJECT container left (volume ${PROJECT}_public-redis-data kept)" echo " mini-baas for the human to compare with the deploy output: $("${B2B[@]}" "docker compose -p mini-baas ps -a -q | wc -l") containers, waf $("${B2B[@]}" "docker inspect mini-baas-waf --format '{{.State.StartedAt}}'")" diff --git a/scripts/public-funnel.sh b/scripts/public-funnel.sh index 4bcc54365..0348c8435 100755 --- a/scripts/public-funnel.sh +++ b/scripts/public-funnel.sh @@ -60,7 +60,7 @@ svc_container() { scope_pairs() { local pair for pair in $FUNNEL_MAP; do - if [ "$1" = all ] || [ "${pair%%:*}" = 443 ]; then printf '%s\n' "$pair"; fi + if [[ "$1" = all ]] || [[ "${pair%%:*}" = 443 ]]; then printf '%s\n' "$pair"; fi done } @@ -72,7 +72,7 @@ reset_both() { if ts funnel reset; then CLOSED="main"; else rc=1; fi if tsa status --json >/dev/null 2>&1; then if tsa funnel reset; then CLOSED+="${CLOSED:+, }apps"; else rc=1; fi - elif [ -e "$(apps_node_dir)" ]; then + elif [[ -e "$(apps_node_dir)" ]]; then note "apps node has state in $(apps_node_dir) but nothing answers on $(apps_node_socket): its Funnel could not be reset" rc=1 fi @@ -98,14 +98,14 @@ open_apps() { cmd_up() { local scope=${1:-443} pair case "$scope" in 443 | all | apps) ;; *) die "usage: up [443|all|apps]" ;; esac - if [ "$scope" = all ] && [ -z "$(penv AUTH_TRUSTED_PROXY_HOPS || true)" ]; then + if [[ "$scope" = all ]] && [[ -z "$(penv AUTH_TRUSTED_PROXY_HOPS || true)" ]]; then die "refusing 'up all': AUTH_TRUSTED_PROXY_HOPS is unset in $ENV_FILE (measure it on 443 first)" fi - if [ "$scope" = apps ] && ! apps_configured; then + if [[ "$scope" = apps ]] && ! apps_configured; then die "refusing 'up apps': set PUBLIC_MAIL_ORIGIN and PUBLIC_CALENDAR_ORIGIN in $ENV_FILE (the apps checks need the origins)" fi run_verify pre || die "verify --pre failed: the Funnel stays closed" - if [ "$scope" = apps ]; then + if [[ "$scope" = apps ]]; then open_apps return fi @@ -147,22 +147,22 @@ cmd_verify() { # the file got an empty key (CI's bats image, run 39148082034); od is in busybox and coreutils. cmd_env() { local key - [ -f "$ENV_FILE" ] || (umask 077 && cp "$REPO/.env.public.example" "$ENV_FILE") + [[ -f "$ENV_FILE" ]] || (umask 077 && cp "$REPO/.env.public.example" "$ENV_FILE") chmod 600 "$ENV_FILE" - if [ -z "$(penv AUTH_LINK_SIGNING_KEY || true)" ]; then + if [[ -z "$(penv AUTH_LINK_SIGNING_KEY || true)" ]]; then key="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')" - [ "${#key}" -eq 64 ] || die "could not mint AUTH_LINK_SIGNING_KEY from /dev/urandom; $ENV_FILE left without it" + [[ "${#key}" -eq 64 ]] || die "could not mint AUTH_LINK_SIGNING_KEY from /dev/urandom; $ENV_FILE left without it" put_env "$ENV_FILE" AUTH_LINK_SIGNING_KEY "$key" note "minted AUTH_LINK_SIGNING_KEY in $ENV_FILE" fi for k in OSIONOS_ADMIN_EMAILS AUTH_TRUSTED_PROXY_HOPS; do - [ -n "$(penv "$k" || true)" ] || note "decide $k in $ENV_FILE" + [[ -n "$(penv "$k" || true)" ]] || note "decide $k in $ENV_FILE" done } main() { local cmd=${1:-} - [ $# -eq 0 ] || shift + [[ $# -eq 0 ]] || shift command -v jq >/dev/null || die "jq is required" case "$cmd" in up) cmd_up "$@" ;; diff --git a/scripts/public-node.sh b/scripts/public-node.sh index 329721393..211dc1f70 100755 --- a/scripts/public-node.sh +++ b/scripts/public-node.sh @@ -55,10 +55,10 @@ backend_state() { tsa status --json 2>/dev/null | jq -r '.BackendState // empty' read_key() { local key - if [ -t 0 ]; then + if [[ -t 0 ]]; then read -rsp "auth key (single-use, tagged, pre-approved, not ephemeral): " key >&2 && echo >&2 else IFS= read -r key || true; fi - [ -n "$key" ] || return 1 + [[ -n "$key" ]] || return 1 printf '%s' "$key" } @@ -78,7 +78,7 @@ join_tailnet() { # Where the daemon's output goes: nowhere unless PUBLIC_APPS_LOG=1. daemon_log() { - if [ "${PUBLIC_APPS_LOG:-}" = 1 ]; then printf '%s/tailscaled.log' "$1"; else printf /dev/null; fi + if [[ "${PUBLIC_APPS_LOG:-}" = 1 ]]; then printf '%s/tailscaled.log' "$1"; else printf /dev/null; fi } # Waits (50 x 0.2 s) for a BackendState other than NoState/Starting/unreadable and prints it; a @@ -143,7 +143,7 @@ cmd_start() { # True when $1 is a tailscaled started with this node's --socket (not, say, the main node's). is_our_daemon() { [[ "$1" =~ ^[0-9]+$ ]] || return 1 - [ "$(cat "/proc/$1/comm" 2>/dev/null || true)" = tailscaled ] || return 1 + [[ "$(cat "/proc/$1/comm" 2>/dev/null || true)" = tailscaled ]] || return 1 tr '\0' '\n' <"/proc/$1/cmdline" 2>/dev/null | grep -qxF -- "--socket=$(apps_node_socket)" } @@ -160,10 +160,10 @@ reset_funnel_before_stop() { stop_daemon() { local pidfile pid rc=0 pidfile="$(apps_node_dir)/tailscaled.pid" - [ -f "$pidfile" ] || return 1 + [[ -f "$pidfile" ]] || return 1 pid=$(cat "$pidfile") if is_our_daemon "$pid"; then - if [ "${1:-}" = reset ]; then reset_funnel_before_stop || rc=1; fi + if [[ "${1:-}" = reset ]]; then reset_funnel_before_stop || rc=1; fi kill "$pid" note "stopped tailscaled $pid" else note "pid $pid is not a running tailscaled of ours (socket $(apps_node_socket))"; fi @@ -172,7 +172,7 @@ stop_daemon() { } cmd_stop() { - [ -f "$(apps_node_dir)/tailscaled.pid" ] || die "no pidfile $(apps_node_dir)/tailscaled.pid: nothing of ours to stop" + [[ -f "$(apps_node_dir)/tailscaled.pid" ]] || die "no pidfile $(apps_node_dir)/tailscaled.pid: nothing of ours to stop" stop_daemon reset } @@ -184,8 +184,8 @@ unit_dir() { printf '%s/systemd/user' "${XDG_CONFIG_HOME:-$HOME/.config}"; } cmd_autostart() { local groot groot=$(cd "$SELF/.." && pwd) - [ -x "$TAILSCALED_BIN" ] || die "no tailscaled at $TAILSCALED_BIN: set TAILSCALED_BIN" - [ -x "${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}" ] || die "no tailscale at ${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}: set TAILSCALE_BIN" + [[ -x "$TAILSCALED_BIN" ]] || die "no tailscaled at $TAILSCALED_BIN: set TAILSCALED_BIN" + [[ -x "${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}" ]] || die "no tailscale at ${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}: set TAILSCALE_BIN" install -d "$(unit_dir)" sed -e "s|@GROOT@|$groot|g" -e "s|@NODE@|$(apps_node_name)|g" -e "s|@STATE_DIR@|$(apps_node_dir)|g" \ -e "s|@TAILSCALED_BIN@|$TAILSCALED_BIN|g" -e "s|@TAILSCALE_BIN@|${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}|g" \ @@ -201,7 +201,7 @@ cmd_autostart() { fi systemctl --user start "$UNIT" || die "$UNIT did not start: journalctl --user -u $UNIT" fi - [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" = yes ] || + [[ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" = yes ]] || note "linger is off: the node starts at login, not at boot (loginctl enable-linger $(id -un))" note "$UNIT installed and running" } diff --git a/scripts/reencrypt-mounts.sh b/scripts/reencrypt-mounts.sh index f229d6868..050550e09 100644 --- a/scripts/reencrypt-mounts.sh +++ b/scripts/reencrypt-mounts.sh @@ -35,7 +35,7 @@ # containers' own env, exactly as the seeders first built them, and are never # printed. # -# Usage: bash scripts/reencrypt-mounts.sh [--check | --force NAME...] +# Usage: bash scripts/reencrypt-mounts.sh [[--check | --force NAME...]] # --check report which mounts are broken; change nothing. # --force also re-seal the named mounts when /connect answers 200: a DSN that # still decrypts but carries an engine password changed since. On baas @@ -74,7 +74,7 @@ urlenc() { python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv load_service_auth() { local lib="${REPO_ROOT}/apps/grobase/scripts/lib/service-auth.sh" - [ -f "${lib}" ] || die "missing ${lib}" + [[ -f "${lib}" ]] || die "missing ${lib}" # shellcheck source=/dev/null . "${lib}" # grobase's compose maps the host-side ADAPTER_REGISTRY_SERVICE_TOKEN into the container @@ -82,9 +82,9 @@ load_service_auth() { # only reaches the container via the monolith shape (env_file: [.env]) or an optional # .env.adapter-registry-go (absent on grobase develop 7abe647d, measured 2026-10-10). SERVICE_TOKEN="$(cenv "${REG_CTN}" ADAPTER_REGISTRY_SERVICE_TOKEN)" - [ -n "${SERVICE_TOKEN}" ] || SERVICE_TOKEN="$(cenv "${REG_CTN}" INTERNAL_SERVICE_TOKEN)" + [[ -n "${SERVICE_TOKEN}" ]] || SERVICE_TOKEN="$(cenv "${REG_CTN}" INTERNAL_SERVICE_TOKEN)" export SERVICE_TOKEN - [ -n "${SERVICE_TOKEN}" ] || die "no adapter-registry service token — is ${REG_CTN} running?" + [[ -n "${SERVICE_TOKEN}" ]] || die "no adapter-registry service token — is ${REG_CTN} running?" } # HTTP status of GET /databases/{id}/connect. 200 = ciphertext opens. @@ -103,24 +103,24 @@ dsn_for() { pg-commerce|agency-db) running "${PG_CTN}" || return 1 u="$(cenv "${PG_CTN}" POSTGRES_USER)"; p="$(cenv "${PG_CTN}" POSTGRES_PASSWORD)" - [ -n "${u}" ] && [ -n "${p}" ] || return 1 - local db=commerce; [ "${name}" = "agency-db" ] && db=agency + [[ -n "${u}" ]] && [[ -n "${p}" ]] || return 1 + local db=commerce; [[ "${name}" = "agency-db" ]] && db=agency printf 'postgres://%s:%s@postgres:5432/%s' "$(urlenc "${u}")" "$(urlenc "${p}")" "${db}" ;; mysql-ops) running mini-baas-mysql || return 1 u="$(cenv mini-baas-mysql MYSQL_USER)"; p="$(cenv mini-baas-mysql MYSQL_PASSWORD)" - if [ -z "${u}" ] || [ -z "${p}" ]; then + if [[ -z "${u}" ]] || [[ -z "${p}" ]]; then u=root; p="$(cenv mini-baas-mysql MYSQL_ROOT_PASSWORD)" fi - [ -n "${p}" ] || return 1 + [[ -n "${p}" ]] || return 1 printf 'mysql://%s:%s@mysql:3306/ops' "$(urlenc "${u}")" "$(urlenc "${p}")" ;; mongo-activity) running mini-baas-mongo || return 1 u="$(cenv mini-baas-mongo MONGO_INITDB_ROOT_USERNAME)" p="$(cenv mini-baas-mongo MONGO_INITDB_ROOT_PASSWORD)" - [ -n "${u}" ] && [ -n "${p}" ] || return 1 + [[ -n "${u}" ]] && [[ -n "${p}" ]] || return 1 printf 'mongodb://%s:%s@mongo:27017/activity?authSource=admin' "$(urlenc "${u}")" "$(urlenc "${p}")" ;; osionos-restaurant) @@ -130,7 +130,7 @@ dsn_for() { osionos-finance) running mini-baas-mssql || return 1 p="$(cenv mini-baas-mssql MSSQL_SA_PASSWORD)" - [ -n "${p}" ] || return 1 + [[ -n "${p}" ]] || return 1 printf 'mssql://sa:%s@mssql:1433/finance' "${p}" ;; osionos-iot) @@ -186,16 +186,16 @@ main() { local broken=0 repaired=0 id name engine code dsn tmp tmpname forced while IFS='|' read -r id name engine; do - [ -n "${id}" ] || continue + [[ -n "${id}" ]] || continue code="$(connect_code "${id}")" forced="" case "${FORCE}" in *" ${name} "*) forced=" (forced)" ;; esac - if [ "${code}" = "200" ] && [ -z "${forced}" ]; then + if [[ "${code}" = "200" ]] && [[ -z "${forced}" ]]; then continue fi broken=$((broken + 1)) note "${name} (${engine}): /connect → ${code}${forced}" - [ "${CHECK_ONLY}" -eq 1 ] && continue + [[ "${CHECK_ONLY}" -eq 1 ]] && continue if ! dsn="$(dsn_for "${name}")"; then note " ↳ engine unavailable — cannot rebuild its DSN; re-run once it is up" @@ -203,13 +203,13 @@ main() { fi tmpname="zz-reenc-$(printf '%s' "${name}" | tr -c 'a-z0-9-' '-')" tmp="$(register_temp "${engine}" "${dsn}" "${tmpname}")" - if [ -z "${tmp}" ]; then + if [[ -z "${tmp}" ]]; then note " ↳ temp registration failed — left untouched" continue fi adopt_ciphertext "${id}" "${tmp}" code="$(connect_code "${id}")" - if [ "${code}" = "200" ]; then + if [[ "${code}" = "200" ]]; then note " ↳ repaired (/connect → 200)" repaired=$((repaired + 1)) else @@ -219,11 +219,11 @@ main() { "SELECT id::text||'|'||name||'|'||engine FROM public.tenant_databases WHERE tenant_id = '${TENANT}' ORDER BY name;" 2>/dev/null) - if [ "${broken}" -eq 0 ]; then + if [[ "${broken}" -eq 0 ]]; then note "all '${TENANT}' mounts open under the current key" exit 0 fi - if [ "${CHECK_ONLY}" -eq 1 ]; then + if [[ "${CHECK_ONLY}" -eq 1 ]]; then note "${broken} mount(s) need repair — run: make mounts-reencrypt" exit 1 fi diff --git a/scripts/verify/account-roundtrip.sh b/scripts/verify/account-roundtrip.sh index d9d625f1c..802c8efba 100755 --- a/scripts/verify/account-roundtrip.sh +++ b/scripts/verify/account-roundtrip.sh @@ -40,7 +40,7 @@ RT_SQL=$(cat "$REPO/scripts/verify/account-roundtrip.sql") rt_line() { printf '%-4s %s %s\n' "$1" "$2" "$3"; } report() { rt_line "$@" - [ "$1" != FAIL ] || FAILED=1 + [[ "$1" != FAIL ]] || FAILED=1 } # A failed HTTP step stops the run: every later step needs what this one produced. fail() { @@ -89,7 +89,7 @@ edge_port() { # hp=$(docker compose -f "$REPO/docker-compose.yml" port "$EDGE_SERVICE" "$1" 2>/dev/null | head -n1) || true port=${hp##*:} addr=${hp%:*} - [ -n "$port" ] || port=${!2:-$1} + [[ -n "$port" ]] || port=${!2:-$1} case $addr in '' | 0.0.0.0 | :: | '[::]') addr=127.0.0.1 ;; esac RESOLVE+=(--resolve "localhost:$port:$addr") printf -v "$3" '%s' "$port" @@ -102,8 +102,8 @@ edge_port() { # request() { # [body file] [header file] local args=(-sS -m 20 --cacert "$LINK_CA" "${RESOLVE[@]}" -b "$TMP/jar" -c "$TMP/jar" -o "$TMP/resp" -w '%{http_code}' -X "$1") - [ -z "${3:-}" ] || args+=(-H 'content-type: application/json' --data-binary "@$3") - [ -z "${4:-}" ] || args+=(-H "@$4") + [[ -z "${3:-}" ]] || args+=(-H 'content-type: application/json' --data-binary "@$3") + [[ -z "${4:-}" ]] || args+=(-H "@$4") : >"$TMP/resp" curl "${args[@]}" "$2" 2>"$TMP/curl.err" || true } @@ -124,14 +124,14 @@ step_edge() { BRIDGE="https://localhost:$BRIDGE_PORT" url="$SITE/api/auth/health" code=$(request GET "$url") - if [ "$code" = 404 ]; then + if [[ "$code" = 404 ]]; then url="$SITE/api/auth/availability" code=$(request GET "$url") fi - [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" + [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" url="$BRIDGE/api/auth/bridge/health" code=$(request GET "$url") - [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" + [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" report ok edge "$SITE (site + auth gateway) and $BRIDGE (osionos bridge) over TLS, CA ${LINK_CA#"$REPO"/}" } @@ -140,11 +140,11 @@ step_edge() { step_availability() { local code code=$(request GET "$SITE/api/auth/availability?email=${EMAIL/@/%40}&username=$HANDLE") - if [ "$code" = 404 ]; then + if [[ "$code" = 404 ]]; then report SKIP availability "this gateway has no /api/auth/availability (prismatica main checks it inside register)" return 0 fi - if ! { [ "$code" = 200 ] && jq -e '.email.available and .username.available' "$TMP/resp" >/dev/null; }; then + if ! { [[ "$code" = 200 ]] && jq -e '.email.available and .username.available' "$TMP/resp" >/dev/null; }; then fail availability "HTTP $code $(said)" fi report ok availability "HTTP 200, email and username $HANDLE are free" @@ -156,7 +156,7 @@ step_register() { RT_PW=$PASSWORD jq -n --arg email "$EMAIL" --arg user "$HANDLE" '{email: $email, password: $ENV.RT_PW, turnstileToken: "localhost-turnstile-token", profile: {username: $user, confirmPassword: $ENV.RT_PW}}' >"$TMP/body" code=$(request POST "$SITE/api/auth/register" "$TMP/body") - [ "$code" = 200 ] || fail register "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail register "HTTP $code $(said)" report ok register "HTTP 200 $(said)" } @@ -167,7 +167,7 @@ step_login() { RT_PW=$PASSWORD jq -n --arg email "$EMAIL" \ '{email: $email, password: $ENV.RT_PW, turnstileToken: "localhost-turnstile-token"}' >"$TMP/body" code=$(request POST "$SITE/api/auth/login" "$TMP/body") - [ "$code" = 200 ] || fail login "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail login "HTTP $code $(said)" jq -er '(.access_token // .session.access_token) | strings | "Authorization: Bearer " + .' "$TMP/resp" >"$TMP/site-auth" 2>/dev/null || fail login "HTTP 200 without an access_token" USER_ID=$(jq -r '.user.id // .session.user.id // empty' "$TMP/resp") @@ -181,7 +181,7 @@ step_login() { step_session() { local code code=$(request POST "$SITE/api/auth/osionos-session" "$TMP/empty" "$TMP/site-auth") - [ "$code" = 200 ] || fail osionos-session "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail osionos-session "HTTP $code $(said)" jq -e '{token: (.redirectUrl | capture("#bridge_token=(?[A-Za-z0-9_-]+)").t)}' "$TMP/resp" >"$TMP/handoff" 2>/dev/null || fail osionos-session "HTTP 200 without a #bridge_token redirect" report ok osionos-session "HTTP 200, redirect to $(jq -r '.redirectUrl | sub("#.*"; "")' "$TMP/resp")#bridge_token=(not shown)" @@ -192,12 +192,12 @@ step_session() { step_consume() { local code code=$(request POST "$BRIDGE/api/auth/bridge/consume" "$TMP/handoff") - [ "$code" = 200 ] || fail bridge-consume "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail bridge-consume "HTTP $code $(said)" jq -er '.session.accessToken | select(startswith("osionos_v1.")) | "Authorization: Bearer " + .' \ "$TMP/resp" >"$TMP/app-auth" 2>/dev/null || fail bridge-consume "HTTP 200 without an osionos_v1. app token" WORKSPACE_ID=$(jq -r '.session.privateWorkspaces[0]._id // empty' "$TMP/resp") - [ -n "$WORKSPACE_ID" ] || fail bridge-consume "HTTP 200 without a private workspace" - [ "$(jq -r '.session.userId' "$TMP/resp")" = "$USER_ID" ] || fail bridge-consume "the app session is for another user" + [[ -n "$WORKSPACE_ID" ]] || fail bridge-consume "HTTP 200 without a private workspace" + [[ "$(jq -r '.session.userId' "$TMP/resp")" = "$USER_ID" ]] || fail bridge-consume "the app session is for another user" report ok bridge-consume "HTTP 200, osionos_v1. app token (not shown), user $USER_ID, workspace $WORKSPACE_ID" } @@ -207,7 +207,7 @@ step_consume() { step_workspaces() { local code code=$(request GET "$BRIDGE/api/workspaces" "" "$TMP/app-auth") - [ "$code" = 200 ] || fail workspaces "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail workspaces "HTTP $code $(said)" jq -er --arg id "$WORKSPACE_ID" 'first(.[] | select(._id == $id and .createdAt != null)) | "\(.name) role=\(.role)"' "$TMP/resp" >"$TMP/ws" 2>/dev/null || fail workspaces "HTTP 200, but $WORKSPACE_ID has no grobase row: see 'BaaS persistence skipped' in the bridge log" @@ -219,9 +219,9 @@ step_page_write() { jq -n --arg ws "$WORKSPACE_ID" --arg title "$TITLE" --arg text "$MARKER" '{workspaceId: $ws, title: $title, icon: "R", content: [{id: ("block-" + $title | gsub(" "; "-")), type: "paragraph", content: $text}]}' >"$TMP/body" code=$(request POST "$BRIDGE/api/pages" "$TMP/body" "$TMP/app-auth") - [ "$code" = 201 ] || fail page-write "HTTP $code $(said)" + [[ "$code" = 201 ]] || fail page-write "HTTP $code $(said)" PAGE_ID=$(jq -r '._id // empty' "$TMP/resp") - [ -n "$PAGE_ID" ] || fail page-write "HTTP 201 without a page id" + [[ -n "$PAGE_ID" ]] || fail page-write "HTTP 201 without a page id" report ok page-write "HTTP 201, page $PAGE_ID \"$TITLE\" in workspace $WORKSPACE_ID" } @@ -229,7 +229,7 @@ step_page_write() { step_page_read() { local code code=$(request GET "$BRIDGE/api/pages/$PAGE_ID" "" "$TMP/app-auth") - if [ "$code" = 200 ] && jq -e --arg t "$TITLE" --arg m "$MARKER" \ + if [[ "$code" = 200 ]] && jq -e --arg t "$TITLE" --arg m "$MARKER" \ '.title == $t and any(.content[]?; .content == $m)' "$TMP/resp" >/dev/null; then report ok page-read "HTTP 200, title and paragraph read back through the bridge" else @@ -249,13 +249,13 @@ step_db() { ssh) via="over ssh to $(state_get target 2>/dev/null || printf 'no target (make link)')" ;; *) fail db "link mode '$DB_MODE' is none of local, ssh, direct" ;; esac - [ "${#PSQL[@]}" -gt 0 ] || fail db "$DB_MODE link, $via" + [[ "${#PSQL[@]}" -gt 0 ]] || fail db "$DB_MODE link, $via" report ok db-link "$DB_MODE link: docker exec $RT_PG psql $via" timeout "$RT_DB_TIMEOUT" "${PSQL[@]}" <<<"$RT_SQL" >"$TMP/db" 2>"$TMP/db.err" || fail db "psql through the $DB_MODE link failed: $(head -n1 "$TMP/db.err")" - [ "$(grep -c . "$TMP/db")" = 6 ] || fail db "expected 6 rows, got: $(head -c 160 "$TMP/db")" + [[ "$(grep -c . "$TMP/db")" = 6 ]] || fail db "expected 6 rows, got: $(head -c 160 "$TMP/db")" while IFS='|' read -r step verdict detail; do - [ "$verdict" = ok ] || verdict=FAIL + [[ "$verdict" = ok ]] || verdict=FAIL report "$verdict" "$step" "$detail" done <"$TMP/db" } @@ -275,7 +275,7 @@ main() { for tool in curl jq timeout; do need "$tool"; done DB_MODE=$(bash "$RT_LINK_SH" mode 2>/dev/null) || DB_MODE=local case $DB_MODE in local) need docker ;; ssh) need ssh ;; esac - [ -f "$LINK_CA" ] || fail preflight "no CA at $LINK_CA (make certs)" + [[ -f "$LINK_CA" ]] || fail preflight "no CA at $LINK_CA (make certs)" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT : >"$TMP/jar" @@ -292,5 +292,5 @@ main() { exit "$FAILED" } -[ "${BASH_SOURCE[0]}" = "$0" ] || return 0 # sourced by scripts/tests/account-roundtrip.bats +[[ "${BASH_SOURCE[0]}" = "$0" ]] || return 0 # sourced by scripts/tests/account-roundtrip.bats main "$@" diff --git a/scripts/verify/ai-guard-wire.sh b/scripts/verify/ai-guard-wire.sh index ae2c72bdd..1c48c3d3e 100755 --- a/scripts/verify/ai-guard-wire.sh +++ b/scripts/verify/ai-guard-wire.sh @@ -74,7 +74,7 @@ else fail "the image copies scripts/moderation-lists" fi -if [ "$status" -ne 0 ]; then +if [[ "$status" -ne 0 ]]; then echo "FAIL: the gates of the image generation are not wired" exit 1 fi diff --git a/scripts/verify/ai-image-closure.sh b/scripts/verify/ai-image-closure.sh index bed38e6c7..911bb1a31 100755 --- a/scripts/verify/ai-image-closure.sh +++ b/scripts/verify/ai-image-closure.sh @@ -15,13 +15,13 @@ set -euo pipefail REPO="$(cd "$(dirname "$0")/../.." && pwd)" usage() { echo "Usage: $(basename "$0") [dockerfile]" >&2; exit 2; } -[ "$#" -ge 1 ] || usage +[[ "$#" -ge 1 ]] || usage APPDIR="$(realpath -m "$1")" SRCDIR="$APPDIR/services/osionos-ai/src" SCRIPTSDIR="$(realpath -m "$APPDIR/scripts")" DOCKERFILE="${2:-$REPO/infrastructure/docker/osionos/ai.Dockerfile}" -[ -d "$SRCDIR" ] || usage -[ -f "$DOCKERFILE" ] || usage +[[ -d "$SRCDIR" ]] || usage +[[ -f "$DOCKERFILE" ]] || usage # The specifiers of one file — from '…', from "…", import '…', import "…", # import('…'), import("…") — the text between the quotes, relative ones only. @@ -39,17 +39,17 @@ push_specs() { resolved="$(realpath -m "$(dirname "$source")/$spec")" case "$resolved" in "$SCRIPTSDIR"/*) - if [ -f "$resolved" ]; then WORKLIST+=("$resolved"); fi + if [[ -f "$resolved" ]]; then WORKLIST+=("$resolved"); fi ;; esac done < <(specs_of "$source") } while IFS= read -r source; do push_specs "$source"; done < <(find "$SRCDIR" -type f -name '*.ts' | sort) -while [ "${#WORKLIST[@]}" -gt 0 ]; do +while [[ "${#WORKLIST[@]}" -gt 0 ]]; do file="${WORKLIST[0]}" WORKLIST=("${WORKLIST[@]:1}") - if [ -n "${VISITED[$file]:-}" ]; then continue; fi + if [[ -n "${VISITED[$file]:-}" ]]; then continue; fi VISITED["$file"]=1 push_specs "$file" done @@ -63,6 +63,6 @@ for name in "${NAMES[@]}"; do else echo "MISSING $name"; missing=$((missing + 1)); fi done -if [ "$missing" -eq 0 ]; then echo "PASS"; exit 0; fi +if [[ "$missing" -eq 0 ]]; then echo "PASS"; exit 0; fi echo "FAIL: $missing script(s) imported by the AI service are not copied into its image" exit 1 diff --git a/scripts/verify/ai-image-closure.test.sh b/scripts/verify/ai-image-closure.test.sh index 4310da20a..3be0dc30c 100755 --- a/scripts/verify/ai-image-closure.test.sh +++ b/scripts/verify/ai-image-closure.test.sh @@ -59,7 +59,7 @@ EOF out="$("$SCRIPT" "$SVC" "$SVC/Dockerfile" 2>&1)" code=$? -if [ "$code" -eq 1 ]; then pass "fixture: exit 1"; else fail "fixture: exit $code, expected 1"; fi +if [[ "$code" -eq 1 ]]; then pass "fixture: exit 1"; else fail "fixture: exit $code, expected 1"; fi if grep -qF "MISSING z.mjs" <<<"$out"; then pass "fixture: MISSING z.mjs (transitive: a.ts -> x -> y -> z)" else @@ -86,7 +86,7 @@ COPY scripts/x.mjs scripts/y.mjs scripts/z.mjs scripts/d.mjs ./scripts/ EOF out="$("$SCRIPT" "$SVC" "$SVC/Dockerfile.all" 2>&1)" code=$? -if [ "$code" -eq 0 ] && grep -qF "PASS" <<<"$out"; then +if [[ "$code" -eq 0 ]] && grep -qF "PASS" <<<"$out"; then pass "fixture: z.mjs copied -> exit 0 and PASS" else fail "fixture: z.mjs copied -> exit $code, no PASS" @@ -95,22 +95,22 @@ fi # The osionos app checkout the image is built from. app_dir="" for candidate in "${OSIONOS:-}" "$REPO/apps/osionos/app"; do - if [ -n "$candidate" ] && [ -d "$candidate/services/osionos-ai/src" ]; then + if [[ -n "$candidate" ]] && [[ -d "$candidate/services/osionos-ai/src" ]]; then app_dir="$(cd "$candidate" && pwd)" break fi done -if [ -z "$app_dir" ]; then +if [[ -z "$app_dir" ]]; then skipped="$skipped real-tree"; echo "SKIPPED: the real tree (the osionos app checkout was not found — set OSIONOS=/path/to/osionos)" else echo "-- real tree: $app_dir" out="$("$SCRIPT" "$app_dir" 2>&1)" code=$? - if [ "$code" -eq 0 ]; then pass "real tree: exit 0"; else fail "real tree: exit $code, expected 0"; fi + if [[ "$code" -eq 0 ]]; then pass "real tree: exit 0"; else fail "real tree: exit $code, expected 0"; fi if grep -qF "PASS" <<<"$out"; then pass "real tree: PASS"; else fail "real tree: PASS is missing"; fi count="$(grep -c '^ok ' <<<"$out")" - if [ "$count" -eq 6 ]; then pass "real tree: 6 ok lines"; else fail "real tree: $count ok lines, expected 6"; fi + if [[ "$count" -eq 6 ]]; then pass "real tree: 6 ok lines"; else fail "real tree: $count ok lines, expected 6"; fi for name in bridge-baas-body.mjs bridge-graph-edges.mjs bridge-live-workspaces.mjs \ bridge-page-read.mjs bridge-session-cutoff.mjs bridge-session-token.mjs; do if grep -qF "ok $name" <<<"$out"; then @@ -130,7 +130,7 @@ else sed 's| scripts/bridge-graph-edges.mjs||' "$DOCKERFILE" > "$TMP/no-graph.Dockerfile" out="$("$SCRIPT" "$app_dir" "$TMP/no-graph.Dockerfile" 2>&1)" code=$? - if [ "$code" -eq 1 ] && grep -qF "MISSING bridge-graph-edges.mjs" <<<"$out" && + if [[ "$code" -eq 1 ]] && grep -qF "MISSING bridge-graph-edges.mjs" <<<"$out" && grep -qF "FAIL: 1 script(s)" <<<"$out"; then pass "bite: bridge-graph-edges.mjs out of the COPY line -> MISSING, FAIL: 1, exit 1" else @@ -140,7 +140,7 @@ else sed 's| scripts/bridge-baas-body.mjs||' "$DOCKERFILE" > "$TMP/no-body.Dockerfile" out="$("$SCRIPT" "$app_dir" "$TMP/no-body.Dockerfile" 2>&1)" code=$? - if [ "$code" -eq 1 ] && grep -qF "MISSING bridge-baas-body.mjs" <<<"$out"; then + if [[ "$code" -eq 1 ]] && grep -qF "MISSING bridge-baas-body.mjs" <<<"$out"; then pass "bite: bridge-baas-body.mjs out of the COPY line -> MISSING (transitive), exit 1" else fail "bite: bridge-baas-body.mjs out of the COPY line -> exit $code, no MISSING" @@ -157,7 +157,7 @@ else skipped="$skipped shellcheck"; echo "SKIPPED: shellcheck is not installed" fi -if [ "$status" -ne 0 ]; then echo "FAIL: a check failed" -elif [ -n "$skipped" ]; then echo "PASS: every check that ran passed; SKIPPED:$skipped" +if [[ "$status" -ne 0 ]]; then echo "FAIL: a check failed" +elif [[ -n "$skipped" ]]; then echo "PASS: every check that ran passed; SKIPPED:$skipped" else echo "PASS: all the checks passed"; fi exit "$status" diff --git a/utils/mk/baas.mk b/utils/mk/baas.mk index b1b0d58eb..607562e6c 100644 --- a/utils/mk/baas.mk +++ b/utils/mk/baas.mk @@ -79,7 +79,7 @@ models-grants-verify: ## Prove on a throwaway DB that only service_role reaches ## SKIP. @sh scripts/verify/models-grants.sh -account-load: ## Run 10K-user seed + query budgets on throwaway DB (baseline for P0-5/P1-1) +account-load: ## Run 10K-user seed + latency budgets on throwaway DB (baseline for P0-5/P1-1) ## Spins up a throwaway grobase postgres (--network none), applies grobase init + 012 + shim + ## apply-models-migrations.sh, seeds 10K users + workspaces + pages + connections + feed shares, ## runs ANALYZE, then executes each query from scripts/verify/account-load/queries.sql RUNS times @@ -91,3 +91,38 @@ account-load: ## Run 10K-user seed + query budgets on throwaway DB (baseline for account-load-baseline: ## Run account-load in baseline mode (reports only, exit 0) @sh scripts/verify/account-load.sh --baseline + +##@ CI (BaaS gate — each job is one make target) +baas-ci-go: ## Go control-plane: vet + unit tests (containerised) +## Usage: make baas-ci-go +## Requires apps/grobase checked out. Same command the BaaS CI workflow runs. + @test -d apps/grobase/src/control-plane || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -v "$(CURDIR)/apps/grobase/src/control-plane:/src" -w /src \ + golang:1.25-bookworm sh -c "go vet ./... && go test ./..." + +baas-ci-rust-data-plane: ## Rust data-plane-router: cargo test --workspace +## Usage: make baas-ci-rust-data-plane + @test -d apps/grobase/src/data-plane-router || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ + -v "$(CURDIR)/apps/grobase/src/data-plane-router:/src" -w /src \ + rust:1-bookworm cargo test --workspace + +baas-ci-rust-realtime: ## Rust realtime plane: cargo test --workspace +## Usage: make baas-ci-rust-realtime + @test -d apps/grobase/infra/docker/services/realtime/realtime-agnostic || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ + -v "$(CURDIR)/apps/grobase/infra/docker/services/realtime/realtime-agnostic:/src" -w /src \ + rust:1-bookworm cargo test --workspace + +baas-ci-static: ## Package manifest parity + Prometheus config check +## Usage: make baas-ci-static + @test -d apps/grobase || { echo "apps/grobase not checked out"; exit 1; } + @cd apps/grobase && \ + diff <(jq -S . infra/config/packages/packages.json) \ + <(jq -S . src/control-plane/internal/packages/packages.json) \ + && echo "package manifest is the single source of truth" + @cd apps/grobase && \ + docker run --rm --entrypoint promtool \ + -v "$$PWD/infra/config/prometheus":/etc/prometheus \ + prom/prometheus:v2.52.0 check config /etc/prometheus/prometheus.yml + @cd apps/grobase && shellcheck -S error scripts/verify/m*.sh || true diff --git a/vendor/monkey-bot b/vendor/monkey-bot index 4100e874b..5d7125938 160000 --- a/vendor/monkey-bot +++ b/vendor/monkey-bot @@ -1 +1 @@ -Subproject commit 4100e874bd15fb58810d0070be7361f0349b2504 +Subproject commit 5d7125938b3978976d656852d83264b1771549af diff --git a/vendor/scripts b/vendor/scripts index c619e5d10..94b9def9e 160000 --- a/vendor/scripts +++ b/vendor/scripts @@ -1 +1 @@ -Subproject commit c619e5d1059621a6f38bdde741369d099baa4907 +Subproject commit 94b9def9ea33d41675c421531d3f3b276ae89f9a