diff --git a/.github/workflows/service-e2e.yml b/.github/workflows/service-e2e.yml new file mode 100644 index 0000000..8accecd --- /dev/null +++ b/.github/workflows/service-e2e.yml @@ -0,0 +1,83 @@ +name: service-e2e + +# End-to-end acceptance of the relsynth service on a real stack (roadmap T09): +# compose: docker compose with Keycloak SSO, a PostgreSQL source, the API and +# two workers (deploy/e2e/compose-e2e.sh), including a worker killed +# mid-run; +# kind: the Helm chart on a real Kubernetes cluster (kind), with the same +# flow plus `helm test` and a worker pod deleted mid-run +# (deploy/e2e/k8s-e2e.sh). +# It runs when the service, the chart, the images or this file change, weekly, +# and by hand. docs/development/ci.md describes it. + +on: + pull_request: + paths: + - "src/relsynth/service/**" + - "deploy/**" + - "pyproject.toml" + - ".github/workflows/service-e2e.yml" + push: + branches: [main] + paths: + - "src/relsynth/service/**" + - "deploy/**" + - "pyproject.toml" + - ".github/workflows/service-e2e.yml" + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: service-e2e-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + compose: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: { persist-credentials: false } + - name: Compose end to end (SSO, run, approval, download, audit, tenant isolation, worker loss) + run: deploy/e2e/compose-e2e.sh + + kind: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + KIND_VERSION: v0.27.0 + KUBECONFIG: /home/runner/.kube/config + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: { persist-credentials: false } + - name: kind, kubectl and helm + run: | + if ! command -v kind >/dev/null; then + go install "sigs.k8s.io/kind@${KIND_VERSION}" && echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + fi + command -v helm >/dev/null || { echo "::error::helm missing on the runner"; exit 1; } + - name: Create the cluster (NodePorts 30080/30081 on localhost) + run: | + kind create cluster --name relsynth-e2e --config deploy/e2e/k8s/kind.yaml --wait 300s + kubectl version + kubectl get nodes -o wide + - name: Build and load the images + run: | + v=$(python3 -c "exec(open('src/relsynth/_version.py').read());print(__version__)") + docker build -f deploy/docker/Dockerfile.service -t "relsynth-service:$v" . + docker pull postgres:16-alpine + docker pull keycloak/keycloak:26.0.7 + kind load docker-image --name relsynth-e2e "relsynth-service:$v" postgres:16-alpine keycloak/keycloak:26.0.7 + - name: Helm lint and a server-side dry run under Pod Security "restricted" + run: | + helm lint deploy/helm/relsynth -f deploy/e2e/k8s/values-e2e.yaml + kubectl create namespace pss-check + kubectl label namespace pss-check pod-security.kubernetes.io/enforce=restricted + helm template pss deploy/helm/relsynth -n pss-check -f deploy/e2e/k8s/values-e2e.yaml \ + | kubectl apply -n pss-check --dry-run=server -f - + - name: Kubernetes end to end (chart, helm test, flow, worker pod loss) + run: deploy/e2e/k8s-e2e.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d38bc32..b0498e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable user-visible changes are recorded here, following [Keep a Changelog ## Unreleased +### Fixed + +- **Helm chart: a fresh `helm install` no longer times out.** The pre-install migrate job ran as the release's ServiceAccount, which Helm only creates after the pre-install hooks; the job now runs as its own hook ServiceAccount (`-migrate`, same annotations), created just before it. Found by the new kind-cluster end-to-end job. +- **Helm chart: workers can store run outputs.** The data PVC was mounted at `/var/lib/relsynth`, so the container runtime created `/var/lib/relsynth/data` (the parent of the work `emptyDir`) as root and the non-root workers failed with `Permission denied: '/var/lib/relsynth/data/tenants'`, losing every synthesis run. The PVC is now mounted at `/tenants`, the only directory the service writes. An install of 2.0.0 that got this far keeps its artefacts under `data/tenants/` on the volume: move that folder's contents to the volume root before upgrading. Found by the kind-cluster end-to-end job. + ## 2.0.0 (2026-10-31) Release 2.0 is the first release of the enterprise roadmap: IR 2.0, generator plug-ins, MySQL/MariaDB, cloud warehouses, Iceberg and Delta Lake, privacy attacks, differential privacy, governance, time series, partitioned generation, engine settings with autotune, and the service platform. **Draws for the same seed change** on databases with link tables (see Changed). The proofs PDF is rebuilt with all 2.0 propositions; the partitioned-runtime propositions are renumbered 950–954 (they clashed with DP's 900–904). diff --git a/deploy/e2e/k8s-e2e.sh b/deploy/e2e/k8s-e2e.sh index 647f703..4b2f042 100755 --- a/deploy/e2e/k8s-e2e.sh +++ b/deploy/e2e/k8s-e2e.sh @@ -5,13 +5,34 @@ # isolation) against NodePorts 30080 (API) and 30081 (Keycloak). # KUBECONFIG=... deploy/e2e/k8s-e2e.sh # images relsynth-service:, postgres:16-alpine, # # keycloak/keycloak:26.0.7 must be loaded on the nodes +# A kind cluster that maps the NodePorts to localhost: deploy/e2e/k8s/kind.yaml +# (CI: .github/workflows/service-e2e.yml). On failure the pods, events and logs +# of the namespace are printed. set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)"; root="$here/../.." ns=relsynth-e2e kc() { kubectl -n "$ns" "$@"; } -if command -v helm >/dev/null; then helm() { command helm "$@"; } -else helm() { docker run --rm --network host -v "$KUBECONFIG:/kube:ro" -v "$root:/src:ro" -e KUBECONFIG=/kube alpine/helm:3.16.3 "$@"; }; fi -chart=/src/deploy/helm/relsynth; command -v helm >/dev/null 2>&1 && [ "$(type -t helm)" = file ] && chart="$root/deploy/helm/relsynth" +diagnose() { + rc=$?; [ "$rc" = 0 ] && return + echo "::group::pods, events and logs of $ns (exit $rc)" + kc get pods -o wide || true; kc get events --sort-by=.lastTimestamp | tail -60 || true + for p in $(kc get pods -o name 2>/dev/null); do echo "--- $p"; kc logs "$p" --all-containers --tail=80 || true; done + # restarted containers: why the last one ended, and its own log + kc get pods -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.containerStatuses[*].lastState.terminated}{"\n"}{end}' || true + for p in $(kc get pods -o name 2>/dev/null); do + kc logs "$p" --all-containers --previous --tail=120 >/tmp/prev.log 2>/dev/null && { echo "--- previous $p"; cat /tmp/prev.log; } + done + kc top pods 2>/dev/null || true + echo "::endgroup::" +} +trap diagnose EXIT +# helm from the PATH, or else the alpine/helm image with the repository mounted at /src +if command -v helm >/dev/null; then + chart="$root/deploy/helm/relsynth"; values="$here/k8s/values-e2e.yaml" +else + helm() { docker run --rm --network host -v "$KUBECONFIG:/kube:ro" -v "$root:/src:ro" -e KUBECONFIG=/kube alpine/helm:3.16.3 "$@"; } + chart=/src/deploy/helm/relsynth; values=/src/deploy/e2e/k8s/values-e2e.yaml +fi rand() { python3 -c "import secrets;print(secrets.token_urlsafe(24))"; } v=$(python3 -c "exec(open('$root/src/relsynth/_version.py').read());print(__version__)") kubectl create namespace "$ns" --dry-run=client -o yaml | kubectl apply -f - @@ -24,8 +45,12 @@ kc create configmap shop-sql --from-file=shop.sql="$here/shop.sql" --dry-run=cli kc create configmap keycloak-realm --from-file=realm-relsynth.json="$root/deploy/compose/keycloak/realm-relsynth.json" --dry-run=client -o yaml | kc apply -f - kc apply -f "$here/k8s/deps.yaml" kc rollout status deploy/metadata deploy/sources deploy/keycloak --timeout=600s -helm upgrade --install e2e "$chart" -n "$ns" -f "${chart}/../../e2e/k8s/values-e2e.yaml" --set image.tag="$v" --wait --timeout 10m +helm upgrade --install e2e "$chart" -n "$ns" -f "$values" --set image.tag="$v" --wait --timeout 10m helm test e2e -n "$ns" python3 "$here/e2e_flow.py" --api http://localhost:30080 \ --token-url http://localhost:30081/realms/relsynth/protocol/openid-connect/token \ --source-url postgresql://reader@sources:5432/shop --secret-ref file:/run/secrets/sources/shop-db/password +# restart survival: delete the worker pod that runs a job; the other worker must finish it +python3 "$here/restart_check.py" --k8s-namespace "$ns" --api http://localhost:30080 \ + --token-url http://localhost:30081/realms/relsynth/protocol/openid-connect/token +kc wait --for=condition=available deploy -l app.kubernetes.io/component=worker --timeout=300s diff --git a/deploy/e2e/k8s/kind.yaml b/deploy/e2e/k8s/kind.yaml new file mode 100644 index 0000000..b8eb875 --- /dev/null +++ b/deploy/e2e/k8s/kind.yaml @@ -0,0 +1,10 @@ +# kind cluster for deploy/e2e/k8s-e2e.sh: the API (NodePort 30080) and Keycloak +# (30081) are reachable on localhost, as the e2e flow and the OIDC issuer expect. +# kind create cluster --name relsynth-e2e --config deploy/e2e/k8s/kind.yaml +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + extraPortMappings: + - {containerPort: 30080, hostPort: 30080, protocol: TCP} + - {containerPort: 30081, hostPort: 30081, protocol: TCP} diff --git a/deploy/e2e/restart_check.py b/deploy/e2e/restart_check.py index b12a3c6..55b4ea2 100644 --- a/deploy/e2e/restart_check.py +++ b/deploy/e2e/restart_check.py @@ -1,7 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 -"""Kill the worker container that runs a job; another worker must finish it. +"""Kill the worker that runs a job; another worker must finish it. -Run after e2e_flow.py (it reuses its tenant, project and spec).""" +Run after e2e_flow.py (it reuses its tenant, project and spec). With +``--compose`` the worker container is killed with ``docker kill``; with +``--k8s-namespace`` the worker pod is deleted at once (``--grace-period=0 +--force``), as a node loss would, and its Deployment starts a new one.""" from __future__ import annotations import argparse @@ -17,7 +20,9 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--api", default="http://localhost:8080") ap.add_argument("--token-url", default="http://localhost:8081/realms/relsynth/protocol/openid-connect/token") - ap.add_argument("--compose", required=True) + where = ap.add_mutually_exclusive_group(required=True) + where.add_argument("--compose", help="docker compose file of the stack") + where.add_argument("--k8s-namespace", help="Kubernetes namespace of the Helm release") a = ap.parse_args() erin = Client(a.api, login(a.token_url, "relsynth-e2e", "erin", "erin-Demo-Passw0rd")) T = "/api/v1/t/acme" @@ -33,12 +38,23 @@ def main() -> int: time.sleep(0.5) check("big run is leased by a worker", owner is not None) time.sleep(3) - host = owner.split("-")[0] - ids = subprocess.run(["docker", "compose", "-f", a.compose, "ps", "-q", "worker"], # noqa: S603, S607 # nosec B603 B607 - capture_output=True, text=True, check=True).stdout.split() - victim = next(i for i in ids if i.startswith(host)) - subprocess.run(["docker", "kill", victim], check=True, capture_output=True) # noqa: S603, S607 # nosec B603 B607 - check("worker container killed mid-run", True) + # a worker's lease owner is "--"; the hostname is the + # container id (compose) or the pod name (Kubernetes) + if a.compose: + ids = subprocess.run(["docker", "compose", "-f", a.compose, "ps", "-q", "worker"], # noqa: S603, S607 # nosec B603 B607 + capture_output=True, text=True, check=True).stdout.split() + victim = next((i for i in ids if i.startswith(owner.split("-")[0])), None) + check("the leasing worker container is found", victim is not None, f"{owner} not in {ids}") + subprocess.run(["docker", "kill", victim], check=True, capture_output=True) # noqa: S603, S607 # nosec B603 B607 + else: + pods = subprocess.run(["kubectl", "-n", a.k8s_namespace, "get", "pods", # noqa: S603, S607 # nosec B603 B607 + "-l", "app.kubernetes.io/component=worker", "-o", "name"], + capture_output=True, text=True, check=True).stdout.split() + victim = next((p for p in pods if owner.startswith(p.split("/", 1)[1] + "-")), None) + check("the leasing worker pod is found", victim is not None, f"{owner} not in {pods}") + subprocess.run(["kubectl", "-n", a.k8s_namespace, "delete", victim, # noqa: S603, S607 # nosec B603 B607 + "--grace-period=0", "--force", "--wait=false"], check=True, capture_output=True) + check("worker killed mid-run", True, victim) t0 = time.time() while time.time() - t0 < 900: _, r = erin.call("GET", f"{T}/runs/{run['id']}") @@ -47,8 +63,9 @@ def main() -> int: time.sleep(2) check("the other worker finished the run", r["state"] == "succeeded" and r["attempts"] == 2, f"{r['state']} attempts={r['attempts']} {r.get('error')}") - subprocess.run(["docker", "compose", "-f", a.compose, "up", "-d", "worker"], check=True, # noqa: S603, S607 # nosec B603 B607 - capture_output=True) + if a.compose: + subprocess.run(["docker", "compose", "-f", a.compose, "up", "-d", "worker"], check=True, # noqa: S603, S607 # nosec B603 B607 + capture_output=True) return 0 diff --git a/deploy/helm/relsynth/templates/_helpers.tpl b/deploy/helm/relsynth/templates/_helpers.tpl index 81dfa35..6ff02a5 100644 --- a/deploy/helm/relsynth/templates/_helpers.tpl +++ b/deploy/helm/relsynth/templates/_helpers.tpl @@ -13,6 +13,11 @@ app.kubernetes.io/name: relsynth app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} {{- define "relsynth.sa" -}}{{ include "relsynth.fullname" . }}{{- end -}} +{{/* the migrate job runs as a pre-install hook, before the release's own + ServiceAccount exists, so it gets a hook ServiceAccount of its own */}} +{{- define "relsynth.migrateSa" -}} +{{- if .Values.serviceAccount.create -}}{{ include "relsynth.fullname" . }}-migrate{{- else -}}{{ include "relsynth.sa" . }}{{- end -}} +{{- end -}} {{/* environment shared by every pod */}} {{- define "relsynth.env" -}} @@ -54,6 +59,10 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} {{- define "relsynth.mounts" -}} - {name: master-key, mountPath: /run/secrets/relsynth-master, readOnly: true} -- {name: data, mountPath: /var/lib/relsynth} +{{- /* the PVC root is /tenants: the only directory the service writes + there. Mounting it higher up made the container runtime create the + parents of the work and tenant mounts as root, so the non-root service + could not create /tenants (Permission denied) */}} +- {name: data, mountPath: /var/lib/relsynth/data/tenants} - {name: tmp, mountPath: /tmp} {{- end -}} diff --git a/deploy/helm/relsynth/templates/migrate-job.yaml b/deploy/helm/relsynth/templates/migrate-job.yaml index b630d92..5709b89 100644 --- a/deploy/helm/relsynth/templates/migrate-job.yaml +++ b/deploy/helm/relsynth/templates/migrate-job.yaml @@ -16,7 +16,7 @@ spec: app.kubernetes.io/component: migrate spec: restartPolicy: OnFailure - serviceAccountName: {{ include "relsynth.sa" . }} + serviceAccountName: {{ include "relsynth.migrateSa" . }} securityContext: {{- include "relsynth.podSecurity" . | nindent 8 }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} diff --git a/deploy/helm/relsynth/templates/serviceaccount.yaml b/deploy/helm/relsynth/templates/serviceaccount.yaml index ae01c97..759848a 100644 --- a/deploy/helm/relsynth/templates/serviceaccount.yaml +++ b/deploy/helm/relsynth/templates/serviceaccount.yaml @@ -9,3 +9,21 @@ metadata: {{- end }} automountServiceAccountToken: false {{- end }} +{{- if .Values.serviceAccount.create }} +--- +# ServiceAccount of the migrate job: a hook created before it (weight -5), so +# it exists at pre-install; same annotations (workload identity) as the main one +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "relsynth.migrateSa" . }} + labels: {{- include "relsynth.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation + {{- with .Values.serviceAccount.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} diff --git a/docs/development/ci.md b/docs/development/ci.md index f6ad042..08aed65 100644 --- a/docs/development/ci.md +++ b/docs/development/ci.md @@ -17,7 +17,8 @@ Every pull request and every push to `main` runs [`.github/workflows/ci.yml`](.. The suite also runs weekly, so that new dependency releases and new advisories in pip-audit are caught when nothing changed in the repository. Actions are pinned to commit SHAs, and Dependabot ([`.github/dependabot.yml`](../../.github/dependabot.yml)) proposes updates weekly. Repository CodeQL (default setup) adds code scanning for Python and Actions. -Two more workflows run outside the pull-request gate: +More workflows run outside the required gate: +- [`service-e2e.yml`](../../.github/workflows/service-e2e.yml), when the service, the Helm chart or the images change, weekly and by hand: the service end to end on docker compose and on a real Kubernetes cluster (kind), with SSO, a run, approval, download, audit, tenant isolation, `helm test`, Pod Security `restricted`, and a worker lost mid-run ([deployment](../service/deployment.md)). - [`mutation.yml`](../../.github/workflows/mutation.yml), weekly: the sampled mutation score of the core engine ([testing](../testing.md#mutation-testing)). - [`pypi_upload.yml`](../../.github/workflows/pypi_upload.yml), on a published release: the whole gate, then build, SBOM, signatures, provenance and the upload ([supply chain](supply-chain.md)). diff --git a/docs/service/deployment.md b/docs/service/deployment.md index 2d5777c..a2a6564 100644 --- a/docs/service/deployment.md +++ b/docs/service/deployment.md @@ -63,7 +63,17 @@ What the chart creates: the API Deployment (2 replicas, anti-affinity, PodDisrup **Secrets from Vault or a cloud store.** Add `VAULT_ADDR` and `VAULT_K8S_ROLE` to `extraEnv` and use `vault:` references in connections; or use the External Secrets Operator or the Secrets Store CSI driver to project secrets as files and keep `file:` references. Set `RELSYNTH_SVC_SECRET_PREFIX_VAULT=vault:kv/data/relsynth/{tenant}/` so each tenant can only name its own secrets. -`deploy/e2e/k8s-e2e.sh` installs the chart with test dependencies (PostgreSQL, Keycloak) in a kind, k3d or k3s cluster and runs the acceptance flow. +`deploy/e2e/k8s-e2e.sh` installs the chart with test dependencies (PostgreSQL, Keycloak) in a kind, k3d or k3s cluster, runs `helm test` and the acceptance flow, then deletes the worker pod that runs a job and checks that the other worker finishes it. For kind, `deploy/e2e/k8s/kind.yaml` maps the NodePorts to localhost: + +```bash +kind create cluster --name relsynth-e2e --config deploy/e2e/k8s/kind.yaml +v=$(python3 -c "exec(open('src/relsynth/_version.py').read());print(__version__)") +docker build -f deploy/docker/Dockerfile.service -t relsynth-service:$v . +kind load docker-image --name relsynth-e2e relsynth-service:$v postgres:16-alpine keycloak/keycloak:26.0.7 +deploy/e2e/k8s-e2e.sh +``` + +The workflow `.github/workflows/service-e2e.yml` runs this on a kind cluster, and the docker compose test, whenever the service, the chart or the images change, and weekly. kind's default network plugin does not enforce NetworkPolicy, so that test does not prove the policy; it does check the chart against Pod Security `restricted` with a server-side dry run. ## Air-gapped install diff --git a/src/relsynth/connectors/ddl.py b/src/relsynth/connectors/ddl.py index 07bfbba..21f4494 100644 --- a/src/relsynth/connectors/ddl.py +++ b/src/relsynth/connectors/ddl.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +from typing import Any import numpy as np import pandas as pd @@ -119,7 +120,7 @@ def clone_metadata(source, names: list[str], dialect: str, schema: str | None) - cols = [] for c in st.columns: ty = c.type if same else _generic(c.type, dialect) - kw = {"nullable": c.nullable, "primary_key": c.primary_key, "autoincrement": False} + kw: dict[str, Any] = {"nullable": c.nullable, "primary_key": c.primary_key, "autoincrement": False} args = [] if c.identity is not None: ident = c.identity diff --git a/tests/e2e/test_cli.py b/tests/e2e/test_cli.py index 159cc91..b5f7c83 100644 --- a/tests/e2e/test_cli.py +++ b/tests/e2e/test_cli.py @@ -286,7 +286,9 @@ def test_governed_dp_run_charges_the_domain_and_releases_externally(capsys, tmp_ pol = yaml.safe_load(open(os.path.join(os.path.dirname(__file__), "..", "..", "docs", "governance", "examples", "org-policy.yaml"))) pol["domains"]["customers"]["unit"]["entity"] = "customer" - pol["quality"]["min_pass_rate"] = 0.5 # a fixture of a few hundred rows at epsilon 1 + # a fixture of a few hundred rows at epsilon 1; DP noise is never seeded (by design), so the + # pass rate varies between runs (0.47 to 0.6 seen in CI): the floor tests the rule, not the fit + pol["quality"]["min_pass_rate"] = 0.3 pf = tmp_path / "policy.yaml" pf.write_text(yaml.safe_dump(pol)) assert cli(capsys, "gov", "policy", "sign", pf, "--key", k / "dpo.key", "--registry", reg)[0] == 0