Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/service-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<release>-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 `<data_dir>/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).

Expand Down
33 changes: 29 additions & 4 deletions deploy/e2e/k8s-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,34 @@
# isolation) against NodePorts 30080 (API) and 30081 (Keycloak).
# KUBECONFIG=... deploy/e2e/k8s-e2e.sh # images relsynth-service:<v>, 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 -
Expand All @@ -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
10 changes: 10 additions & 0 deletions deploy/e2e/k8s/kind.yaml
Original file line number Diff line number Diff line change
@@ -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}
39 changes: 28 additions & 11 deletions deploy/e2e/restart_check.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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 "<hostname>-<pid>-<hex>"; 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']}")
Expand All @@ -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


Expand Down
11 changes: 10 additions & 1 deletion deploy/helm/relsynth/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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" -}}
Expand Down Expand Up @@ -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 <data_dir>/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 <data_dir>/tenants (Permission denied) */}}
- {name: data, mountPath: /var/lib/relsynth/data/tenants}
- {name: tmp, mountPath: /tmp}
{{- end -}}
2 changes: 1 addition & 1 deletion deploy/helm/relsynth/templates/migrate-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
18 changes: 18 additions & 0 deletions deploy/helm/relsynth/templates/serviceaccount.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
3 changes: 2 additions & 1 deletion docs/development/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
12 changes: 11 additions & 1 deletion docs/service/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/relsynth/connectors/ddl.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import logging
from typing import Any

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading