diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f33ab3c..40ff413 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -37,12 +37,15 @@ jobs: install -d -m 700 ~/.ssh printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key + printf '%s\n' "${{ secrets.SERVICE_ENV }}" > ~/service.env + chmod 600 ~/service.env - name: deploy env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_SSH_OPTS: "-i /root/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20" run: | export DEPLOY_SSH_OPTS="${DEPLOY_SSH_OPTS//\/root\/.ssh/$HOME/.ssh}" + export SERVICE_ENV_FILE="$HOME/service.env" infra/deploy/deploy.sh HEAD - name: smoke run: | diff --git a/Makefile b/Makefile index 6e871cc..c2d01a2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install up down logs seed demo-reset lint fmt test +.PHONY: install up down logs seed demo-reset lint fmt test env-sync deploy install: uv sync @@ -30,3 +30,9 @@ fmt: test: uv run pytest -q + +env-sync: + infra/deploy/env-sync.sh .env + +deploy: + infra/deploy/deploy.sh origin/dev diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh index 8dbae52..72265c0 100755 --- a/infra/deploy/deploy.sh +++ b/infra/deploy/deploy.sh @@ -20,11 +20,18 @@ retry() { local n=0; until "$@"; do n=$((n + 1)); [ $n -ge 5 ] && return 1; echo echo "deploy $REF ($SHA) -> $HOST:$DIR" # shellcheck disable=SC2086 retry scp -q $OPTS "$TGZ" "$HOST:$DIR/release.tgz" +# Service variables managed via the SERVICE_ENV secret (infra/deploy/env-sync.sh) — merged before the restart. +if [ -n "${SERVICE_ENV_FILE:-}" ] && [ -s "$SERVICE_ENV_FILE" ]; then + # shellcheck disable=SC2086 + retry scp -q $OPTS "$SERVICE_ENV_FILE" "$HOST:$DIR/service.env" +fi # shellcheck disable=SC2086 retry ssh $OPTS "$HOST" "bash -s" <&1 | tail -2 + for i in \$(seq 1 30); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 2; done + curl -sf 127.0.0.1:8010/health >/dev/null && echo "api ok" +elif [ "\$rc" != 3 ]; then + exit "\$rc" +fi +SH diff --git a/infra/deploy/env_merge.py b/infra/deploy/env_merge.py new file mode 100755 index 0000000..92b16d4 --- /dev/null +++ b/infra/deploy/env_merge.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Merge service variables into the server env file (runs on the server, stdlib only). + + env_merge.py TARGET SOURCE + +Keys from SOURCE overwrite / add to TARGET; keys only in TARGET stay (server-generated secrets: DB passwords, +PSEUDO_KEY, PUBLIC_URL). Protected keys are never taken from SOURCE. Prints changed key NAMES only (no values). +Exit code: 0 = changed, 3 = nothing changed. +""" + +import os +import sys +import tempfile + +PROTECTED = { + "POSTGRES_PASSWORD", + "IDENTITY_DB_PASSWORD", + "CLINICAL_DB_PASSWORD", + "PSEUDO_KEY", + "PUBLIC_URL", + "IDENTITY_DB_URL", + "CLINICAL_DB_URL", + "API_BASE_URL", +} + + +def parse(path: str) -> list[tuple[str | None, str]]: + """Lines as (key, raw_line); comments/blank lines have key None. Keeps order and comments.""" + out: list[tuple[str | None, str]] = [] + with open(path, encoding="utf-8") as f: + for raw in f.read().splitlines(): + line = raw.strip() + key = line.split("=", 1)[0].strip() if line and not line.startswith("#") and "=" in line else None + out.append((key, raw)) + return out + + +def merge(target: str, source: str) -> list[str]: + lines = parse(target) + incoming = { + k: raw + for k, raw in parse(source) + if k and k not in PROTECTED and not k.startswith("GITEA_") and raw.split("=", 1)[1].strip() + } + changed: list[str] = [] + seen: set[str] = set() + for i, (k, raw) in enumerate(lines): + if k in incoming: + seen.add(k) + if raw != incoming[k]: + lines[i] = (k, incoming[k]) + changed.append(k) + for k, raw in incoming.items(): + if k not in seen: + lines.append((k, raw)) + changed.append(k) + if changed: + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target))) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write("\n".join(raw for _, raw in lines) + "\n") + st = os.stat(target) + os.chmod(tmp, 0o600) + if os.geteuid() == 0: + os.chown(tmp, st.st_uid, st.st_gid) + os.replace(tmp, target) + return changed + + +def main() -> int: + changed = merge(sys.argv[1], sys.argv[2]) + if not changed: + print("env: no changes") + return 3 + print("env: updated " + ", ".join(sorted(changed))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_env_merge.py b/tests/test_env_merge.py new file mode 100644 index 0000000..969c822 --- /dev/null +++ b/tests/test_env_merge.py @@ -0,0 +1,21 @@ +import importlib.util +from pathlib import Path + +spec = importlib.util.spec_from_file_location("env_merge", Path("infra/deploy/env_merge.py")) +env_merge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(env_merge) + + +def test_merge_updates_adds_and_protects(tmp_path): + target = tmp_path / "server.env" + target.write_text("# server\nPSEUDO_KEY=server-secret\nCHAT_AGENT=off\nTZ=Europe/Moscow\n") + source = tmp_path / "service.env" + source.write_text("CHAT_AGENT=gigachat\nINTAKE_DEMO=true\nPSEUDO_KEY=evil\nGITEA_TOKEN=x\nEMPTY=\n") + changed = env_merge.merge(str(target), str(source)) + assert sorted(changed) == ["CHAT_AGENT", "INTAKE_DEMO"] + text = target.read_text() + assert "PSEUDO_KEY=server-secret" in text and "evil" not in text + assert "CHAT_AGENT=gigachat" in text and "INTAKE_DEMO=true" in text and "TZ=Europe/Moscow" in text + assert "GITEA_TOKEN" not in text and "EMPTY" not in text + assert text.startswith("# server\n") + assert env_merge.merge(str(target), str(source)) == [] # idempotent