diff --git a/.github/workflows/helm-ci.yaml b/.github/workflows/helm-ci.yaml index c9f61c3..0ae78fc 100644 --- a/.github/workflows/helm-ci.yaml +++ b/.github/workflows/helm-ci.yaml @@ -5,11 +5,13 @@ on: branches: [main, openshift] paths: - 'client/**' + - 'ingestor/**' - '.github/workflows/helm-ci.yaml' pull_request: branches: [main, openshift] paths: - 'client/**' + - 'ingestor/**' - '.github/workflows/helm-ci.yaml' jobs: @@ -24,13 +26,24 @@ jobs: with: version: v3.15.4 - - name: Helm lint (strict) — all platforms + - name: Helm lint (strict) — parent client chart, all platforms run: | for f in client/ci/*-values.yaml; do - echo "=== Linting with $f ===" + echo "=== Linting client with $f ===" helm lint --strict ./client -f "$f" done + - name: Helm lint (strict) — ingestor subchart + # The ingestor subchart has no per-platform CI values files because + # its templates don't branch on platform — they only render a + # ConfigMap + Job + SA. The chart's one required value + # (`ingestConfig`) is supplied by customers at install time via + # `--set-file`; missing it during lint emits an INFO, not a FAIL, + # so plain `helm lint --strict ./ingestor` exercises the templates + # cleanly. See #135 for why we lint it here at all (publish workflow + # now packages both charts). + run: helm lint --strict ./ingestor + template: name: Template render runs-on: ubuntu-latest diff --git a/.github/workflows/release-helm-chart.yaml b/.github/workflows/release-helm-chart.yaml index 5593c24..61b2f39 100644 --- a/.github/workflows/release-helm-chart.yaml +++ b/.github/workflows/release-helm-chart.yaml @@ -6,10 +6,6 @@ name: Release Helm Chart on: - push: - tags: - - 'client-v*' - - 'v*' release: types: [published] @@ -26,8 +22,14 @@ jobs: id-token: write steps: - name: Checkout + # Pin ref to the release tag rather than letting checkout default to + # github.ref, which intermittently arrives empty on release-triggered + # runs (actions/runner#2788, still open). An empty default would cause + # checkout to fall back to the repo default branch and package the + # chart from the wrong commit. uses: actions/checkout@v4 with: + ref: ${{ github.event.release.tag_name }} fetch-depth: 0 - name: Set up Helm @@ -35,28 +37,44 @@ jobs: with: version: v3.15.4 - - name: Lint chart - # Use a CI values file so `--strict` sees non-empty clientId / - # clientPassword (the defaults in values.yaml are deliberately empty - # to force operators to supply real credentials, and the schema - # enforces minLength:1). Exhaustive multi-platform linting happens in - # helm-ci.yaml on every PR — here we just need the chart to lint - # cleanly for packaging. - run: helm lint --strict ./client -f client/ci/eks-values.yaml + - name: Lint charts + # The parent `client` chart needs a CI values file so `--strict` sees + # non-empty clientId / clientPassword (the defaults in values.yaml are + # deliberately empty to force operators to supply real credentials, and + # the schema enforces minLength:1). The `ingestor` subchart needs no + # values file — its only required value (ingestConfig) emits an INFO + # level, not FAIL, when missing, and customers always supply it via + # --set-file at install time. + # Exhaustive multi-platform linting happens in helm-ci.yaml on every + # PR — here we just need both charts to lint cleanly for packaging. + run: | + helm lint --strict ./client -f client/ci/eks-values.yaml + helm lint --strict ./ingestor - - name: Package tracebloc chart + - name: Package tracebloc charts id: package + # Package BOTH the parent client chart AND the ingestor subchart. + # Customers run two flavors of install: + # 1. `helm install tracebloc/client ...` — bootstraps the cluster. + # 2. `helm install tracebloc/ingestor ...` — per-dataset ingestion. + # Both must resolve from the same helm repo, so both .tgz files need + # to live alongside each other in gh-pages and be referenced in a + # single shared index.yaml. Earlier versions packaged only `./client` + # and the second install path failed with "chart not found" — see #135. run: | helm package ./client - TGZ=$(ls -t client-*.tgz | head -1) - echo "chart_tgz=$TGZ" >> $GITHUB_OUTPUT - echo "Packaged $TGZ" + helm package ./ingestor + echo "Packaged:" + ls -la *.tgz - - name: Upload chart artifact + - name: Upload chart artifacts uses: actions/upload-artifact@v4 with: - name: helm-chart - path: ${{ steps.package.outputs.chart_tgz }} + name: helm-charts + # Glob picks up both client-*.tgz and ingestor-*.tgz. + path: | + client-*.tgz + ingestor-*.tgz - name: Configure Git run: | @@ -68,7 +86,8 @@ jobs: run: | # Remove any local chart packages before switching branches to avoid # "untracked working tree files would be overwritten" checkout errors. - rm -f client-*.tgz || true + # Cover both chart name prefixes — see Package step above. + rm -f client-*.tgz ingestor-*.tgz || true if git fetch origin gh-pages 2>/dev/null; then git checkout gh-pages echo "index_exists=true" >> $GITHUB_OUTPUT @@ -78,34 +97,45 @@ jobs: echo "index_exists=false" >> $GITHUB_OUTPUT fi - - name: Download chart artifact + - name: Download chart artifacts uses: actions/download-artifact@v4 with: - name: helm-chart + name: helm-charts - name: Update index and push to gh-pages env: REPO_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} run: | + # `helm repo index .` indexes every .tgz in the working dir, so both + # client-*.tgz and ingestor-*.tgz get listed in the shared + # index.yaml. Customers then resolve `tracebloc/client` and + # `tracebloc/ingestor` from the same repo URL. if [ "${{ steps.fetch.outputs.index_exists }}" = "true" ] && [ -f index.yaml ]; then helm repo index . --url "$REPO_URL" --merge index.yaml else helm repo index . --url "$REPO_URL" fi - git add index.yaml client-*.tgz + git add index.yaml client-*.tgz ingestor-*.tgz git status if git diff --staged --quiet; then echo "No index/tgz changes to commit" else - git commit -m "Release helm chart(s): $(ls client-*.tgz 2>/dev/null | tr '\n' ' ')" + git commit -m "Release helm chart(s): $(ls client-*.tgz ingestor-*.tgz 2>/dev/null | tr '\n' ' ')" git push origin gh-pages fi - - name: Upload chart to GitHub Release (on tag) - if: startsWith(github.ref, 'refs/tags/') + - name: Upload charts to GitHub Release + # Pin tag_name from the release event payload rather than relying on + # github.ref / github.ref_name, which intermittently arrive empty on + # release-triggered runs (actions/runner#2788, still open). + # Attach BOTH chart tgzs so downloaders pinning a specific release + # tag can pull either chart's exact bytes from this release page. uses: softprops/action-gh-release@v2 with: - files: client-*.tgz + tag_name: ${{ github.event.release.tag_name }} + files: | + client-*.tgz + ingestor-*.tgz generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/wip-limit-check.yml b/.github/workflows/wip-limit-check.yml new file mode 100644 index 0000000..8c027df --- /dev/null +++ b/.github/workflows/wip-limit-check.yml @@ -0,0 +1,10 @@ +name: WIP limit check + +on: + pull_request: + types: [opened, ready_for_review] + +jobs: + check: + uses: tracebloc/.github/.github/workflows/wip-limit-check.yml@main + secrets: inherit diff --git a/.gitignore b/.gitignore index 14e8cdb..245a241 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,11 @@ venv.bak/ /values*.yaml /*/values*.yaml !client/values*.yaml +# The ingestor subchart's values.yaml is part of the chart shipped to +# customers — must be tracked despite matching the `/*/values*.yaml` +# anti-leak pattern above (which exists to keep operator-local values +# files out of the repo). +!ingestor/values.yaml secret-values.yaml test-template.yaml eks.diff diff --git a/client/Chart.yaml b/client/Chart.yaml index 23fecef..b2916af 100644 --- a/client/Chart.yaml +++ b/client/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: client description: A unified Helm chart for tracebloc on AKS, EKS, bare-metal, and OpenShift type: application -version: 1.3.2 -appVersion: "1.3.2" +version: 1.3.5 +appVersion: "1.3.5" keywords: - tracebloc - kubernetes diff --git a/client/MIGRATION.md b/client/MIGRATION.md index c507eb6..4d43c72 100644 --- a/client/MIGRATION.md +++ b/client/MIGRATION.md @@ -2,6 +2,76 @@ This guide explains how to migrate from the legacy per-platform charts (`aks/`, `bm/`, `eks/`, `oc/`) to the unified `client/` chart. +## Upgrading to 1.3.4 — parent chart owns the shared ingestor ServiceAccount + +[#129](https://github.com/tracebloc/client/issues/129): the ingestor +ServiceAccount has moved from the `tracebloc/ingestor` subchart into this +parent chart. Background: the SA is shared by every ingestor subchart +release in a namespace, but per-release Helm ownership meant two concurrent +`helm install tracebloc/ingestor` calls collided with "cannot import into +current release", and uninstalling the first release ripped the SA out +from under all the others. With the SA in the parent chart, every +ingestor release in the namespace shares it cleanly and `helm uninstall` +of any individual ingestor release leaves it alone. + +> **The matching ingestor subchart change** ships as +> `tracebloc/ingestor` **0.2.0** — `serviceAccount.create` default +> flipped from `true` to `false`. Upgrade the subchart releases in +> lockstep with the parent so they stop trying to own the SA. + +### When you need to adopt an existing SA + +If you already have a `tracebloc/ingestor` 0.1.0 release installed in the +same namespace as this `tracebloc/client` release, `kubectl get sa +ingestor -n -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}'` +returns that subchart release's name. Plain `helm upgrade tracebloc/client` +to 1.3.4 will fail with `Unable to continue with update: ServiceAccount +"ingestor" ... exists and cannot be imported into the current release`. + +Transfer Helm ownership before upgrading: + +```bash +# 1. Identify the values you need. +NAMESPACE= +CLIENT_RELEASE= # e.g. "tracebloc" +SA_NAME=ingestor # or ingestionAuthz.serviceAccountName if overridden + +# 2. Re-annotate the SA so Helm sees the parent client release as its owner. +kubectl annotate sa "$SA_NAME" -n "$NAMESPACE" \ + meta.helm.sh/release-name="$CLIENT_RELEASE" \ + meta.helm.sh/release-namespace="$NAMESPACE" \ + --overwrite + +kubectl label sa "$SA_NAME" -n "$NAMESPACE" \ + app.kubernetes.io/managed-by=Helm \ + --overwrite + +# 3. Now run the upgrade — Helm adopts the SA on next reconcile. +helm upgrade "$CLIENT_RELEASE" tracebloc/client \ + -n "$NAMESPACE" --version 1.3.4 --reset-then-reuse-values + +# 4. Upgrade each ingestor subchart release to 0.2.0 so it stops trying +# to create the SA itself. The flipped default does this for you, but +# use --reset-then-reuse-values so pre-0.2.0 stored values don't +# re-apply serviceAccount.create=true. +helm upgrade tracebloc/ingestor \ + -n "$NAMESPACE" --version 0.2.0 --reset-then-reuse-values +``` + +If no ingestor 0.1.0 release exists in the namespace yet, you don't have +to do anything — the parent chart creates the SA on first install of +1.3.4 and subsequent ingestor 0.2.0 releases consume it. + +### `--reuse-values` upgrade path + +Operators using plain `--reuse-values` (or the auto-upgrade cronjob +prior to 1.3.0, which used that flag) won't get the new +`ingestionAuthz.serviceAccountName` default. The chart's template +defaults the value to `"ingestor"` when absent, so the SA is created +with the expected name and existing `allowed` entries keep matching. +No template-level breakage; this is the same nil-guard pattern as +[#124](https://github.com/tracebloc/client/pull/124). + ## Upgrading to 1.3.0 — self-upgrade CronJob lands on by default Releases of 1.3.0+ install a `-auto-upgrade` CronJob that polls diff --git a/client/templates/_helpers.tpl b/client/templates/_helpers.tpl index 6515869..bb22b8b 100644 --- a/client/templates/_helpers.tpl +++ b/client/templates/_helpers.tpl @@ -25,6 +25,20 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{ .Release.Name }}-jobs-manager {{- end }} +{{/* + Name of the shared ServiceAccount the parent chart creates for ingestor + subchart releases. Single source of truth — used by: + - templates/ingestor-serviceaccount.yaml (creates the SA) + - templates/ingestion-authz-configmap.yaml (default authz entry) + The ingestor subchart's post-install hook runs as this SA; jobs-manager + validates its token via TokenReview against `ingestionAuthz.allowed`. + Nil-guarded: pre-#129 stored values from `--reuse-values` upgrades won't + have `ingestionAuthz.serviceAccountName`, so default to "ingestor". +*/}} +{{- define "tracebloc.ingestorServiceAccountName" -}} +{{- (default dict .Values.ingestionAuthz).serviceAccountName | default "ingestor" -}} +{{- end }} + {{/* Release-scoped name for the resource-monitor DaemonSet, ServiceAccount, ClusterRoleBinding subject, and selector/pod labels. Multiple releases diff --git a/client/templates/ingestion-authz-configmap.yaml b/client/templates/ingestion-authz-configmap.yaml new file mode 100644 index 0000000..24b02a3 --- /dev/null +++ b/client/templates/ingestion-authz-configmap.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + # client-runtime#21: defines which ServiceAccount(s) may call + # POST /internal/submit-ingestion-run on jobs-manager, scoped to which + # tables. Mounted into jobs-manager at /etc/tracebloc/ingestion-authz.yaml + # and read at startup (see submit_ingestion_run.load_authz_policy). + # + # Each entry in `ingestionAuthz.allowed` maps a (namespace, service_account) + # to a list of table-name prefixes. Omit `namespace` to default to the + # release's namespace — the common case where the ingestor subchart is + # installed alongside the tracebloc client. + name: {{ .Release.Name }}-ingestion-authz + namespace: {{ .Release.Namespace }} + labels: + {{- include "tracebloc.labels" . | nindent 4 }} +data: + ingestion-authz.yaml: | + allowed: + {{- /* + Nil-guarded chain: an upgrade with `--reuse-values` from a + pre-#123 release won't have `.Values.ingestionAuthz` in its + stored values, and an unguarded `.Values.ingestionAuthz.allowed` + crashes with "nil pointer evaluating interface {}.allowed". + `default dict` + `default list` collapse the missing parent / + missing child to an empty list, which renders as `allowed: []` + — fail-safe (the authz policy then denies every caller, which + is correct: there's no policy until the operator sets one). + + Per-entry `service_account` is nil-guarded to fall back to + `ingestionAuthz.serviceAccountName` (#129) so the default + values.yaml entry doesn't need to repeat the SA name — change + the name in one place and both the SA template + this policy + pick it up. + */ -}} + {{- range default list (default dict .Values.ingestionAuthz).allowed }} + - service_account: {{ .service_account | default (include "tracebloc.ingestorServiceAccountName" $) | quote }} + namespace: {{ .namespace | default $.Release.Namespace | quote }} + table_prefixes: + {{- range .table_prefixes }} + - {{ . | quote }} + {{- end }} + {{- end }} diff --git a/client/templates/ingestor-serviceaccount.yaml b/client/templates/ingestor-serviceaccount.yaml new file mode 100644 index 0000000..284b474 --- /dev/null +++ b/client/templates/ingestor-serviceaccount.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + # Shared ServiceAccount that every ingestor subchart release in this + # namespace runs its post-install hook as. Owned by the parent chart + # (not the subchart) because: + # - multiple concurrent ingestor releases need to share it — per-release + # ownership made the second `helm install tracebloc/ingestor` fail + # with Helm's "cannot import into current release" error (#129); + # - the matching `ingestionAuthz` ConfigMap is already owned here, so + # the SA and the policy referencing it have the same lifecycle. + # + # The subchart's `serviceAccount.create` defaults to `false` as of + # ingestor 0.2.0 — it consumes this SA rather than creating its own. + name: {{ include "tracebloc.ingestorServiceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "tracebloc.labels" . | nindent 4 }} diff --git a/client/templates/jobs-manager-deployment.yaml b/client/templates/jobs-manager-deployment.yaml index cf02e20..95dea16 100644 --- a/client/templates/jobs-manager-deployment.yaml +++ b/client/templates/jobs-manager-deployment.yaml @@ -46,11 +46,25 @@ spec: limits: cpu: {{ .Values.resources.jobsManager.limits.cpu | default "1000m" | quote }} memory: {{ .Values.resources.jobsManager.limits.memory | default "1Gi" | quote }} + ports: + # client-runtime#21: POST /internal/submit-ingestion-run. The + # ingestor subchart's post-install hook hits this through the + # jobs-manager Service. Cluster-internal only. + - name: http + containerPort: 8080 + protocol: TCP volumeMounts: - name: shared-volume mountPath: "/data/shared" - name: logs-volume mountPath: "/data/logs" + # ingestion-authz policy read by jobs-manager at startup. The + # subPath mount means an authz update via `helm upgrade` is + # picked up on the next jobs-manager restart, not hot-reloaded. + - name: ingestion-authz + mountPath: /etc/tracebloc/ingestion-authz.yaml + subPath: ingestion-authz.yaml + readOnly: true env: - name: CLIENT_ID valueFrom: @@ -68,6 +82,17 @@ spec: value: {{ include "tracebloc.clientLogsPvc" . | quote }} - name: MYSQL_HOST value: "mysql-client" + # client-runtime#40: default ingestor image digest for the + # POST /internal/submit-ingestion-run endpoint. Auto-upgrade + # keeps this current; the ingestor subchart no longer requires + # the customer to pin a digest. Nil-guarded so `--reuse-values` + # from a pre-this-PR release doesn't crash templating on the + # missing `images.ingestor` key — empty value means jobs-manager + # accepts only request-body overrides and returns 503 if absent. + - name: INGESTOR_IMAGE_DIGEST + value: {{ (default dict .Values.images.ingestor).digest | default "" | quote }} + - name: REQUESTS_PROXY_URL + value: "http://requests-proxy-service:8888" - name: JOB_IMAGE_HOST value: "docker.io/" - name: CLIENT_ENV @@ -149,4 +174,7 @@ spec: - name: logs-volume persistentVolumeClaim: claimName: {{ include "tracebloc.clientLogsPvc" . }} + - name: ingestion-authz + configMap: + name: {{ .Release.Name }}-ingestion-authz restartPolicy: Always diff --git a/client/templates/jobs-manager-service.yaml b/client/templates/jobs-manager-service.yaml new file mode 100644 index 0000000..d048d10 --- /dev/null +++ b/client/templates/jobs-manager-service.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + # client-runtime#21: exposes jobs-manager's HTTP ingestion endpoint + # (POST /internal/submit-ingestion-run) at a stable in-cluster name so + # the ingestor subchart's post-install hook (tracebloc/client#86) can + # POST without discovering Pod IPs. ClusterIP-only — there is no + # legitimate caller outside the cluster. + name: jobs-manager + namespace: {{ .Release.Namespace }} + labels: + {{- include "tracebloc.labels" . | nindent 4 }} + app: manager +spec: + selector: + app: manager + ports: + - name: http + port: 8080 + targetPort: 8080 + protocol: TCP + type: ClusterIP diff --git a/client/templates/rbac.yaml b/client/templates/rbac.yaml index 3f25a4f..3982a5c 100644 --- a/client/templates/rbac.yaml +++ b/client/templates/rbac.yaml @@ -30,6 +30,21 @@ rules: - apiGroups: ["metrics.k8s.io"] resources: ["pods"] verbs: ["get", "list"] + # client-runtime#21: jobs-manager's POST /internal/submit-ingestion-run + # endpoint authenticates callers (the ingestor subchart's post-install + # hook) via TokenReview, then creates a per-run ConfigMap (ingest.yaml) + # and Secret (BACKEND_TOKEN) before spawning the ingestor Job. + # + # TokenReview is a cluster-scoped Kubernetes API; the create verb cannot + # be granted by a namespace Role. This means the ingestion endpoint + # requires ``clusterScope: true``. The else branch below intentionally + # omits it. + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] + - apiGroups: [""] + resources: ["configmaps", "secrets"] + verbs: ["create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -73,6 +88,14 @@ rules: - apiGroups: ["metrics.k8s.io"] resources: ["pods"] verbs: ["get", "list"] + # client-runtime#21: ingestion endpoint creates a per-run ConfigMap + # (ingest.yaml) and Secret (BACKEND_TOKEN) before spawning the Job. + # TokenReview is omitted here (cluster-scoped API; not grantable by a + # namespace Role). Customers using clusterScope: false won't be able + # to authenticate ingestion calls — see the ClusterRole branch above. + - apiGroups: [""] + resources: ["configmaps", "secrets"] + verbs: ["create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/client/templates/requests-proxy-deployment.yaml b/client/templates/requests-proxy-deployment.yaml new file mode 100644 index 0000000..cde610c --- /dev/null +++ b/client/templates/requests-proxy-deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-requests-proxy + namespace: {{ .Release.Namespace }} + labels: + {{- include "tracebloc.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: requests-proxy + template: + metadata: + labels: + app: requests-proxy + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: proxy + image: {{ include "tracebloc.image" (dict "repository" "tracebloc/jobs-manager" "tag" .Values.env.CLIENT_ENV "digest" (dig "requestsProxy" "digest" "" .Values.images) "registry" "docker.io") | quote }} + imagePullPolicy: Always + workingDir: /app + command: ["python", "-m", "gunicorn"] + args: + - "--bind=0.0.0.0:8888" + # The proxy stores registered pod tokens in process-local memory, + # so it must run as a single worker unless the registry moves to + # shared storage. + - "--workers=1" + - "--worker-tmp-dir=/dev/shm" + - "--threads=4" + - "--graceful-timeout=30" + - "--timeout=60" + - "--access-logfile=-" + - "--error-logfile=-" + - "requests_proxy_server:create_app()" + ports: + - containerPort: 8888 + securityContext: + runAsNonRoot: true + runAsUser: 1001 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + {{- /* Nil-guard the whole resources.requestsProxy chain. A + helm upgrade --reuse-values from a release predating this + field (any 1.3.x ≤ 1.3.5) won't have it in stored values, + and the direct path .Values.resources.requestsProxy... on + a nil parent dict crashes with "nil pointer evaluating + interface{}.requests". Default-through-dict idiom lets + missing values fall through to the inline literals. + The trailing dash on `{{-` strips the newline AFTER + readOnlyRootFilesystem; we deliberately do NOT use `-}}` + on the last `:=` so the newline before `resources:` is + preserved (an earlier version of this guard ate the + newline and rendered `readOnlyRootFilesystem: trueresources:`). */ -}} + {{- $rp := default dict (default dict .Values.resources).requestsProxy }} + {{- $rpReq := default dict $rp.requests }} + {{- $rpLim := default dict $rp.limits }} + resources: + requests: + cpu: {{ $rpReq.cpu | default "100m" | quote }} + memory: {{ $rpReq.memory | default "256Mi" | quote }} + limits: + cpu: {{ $rpLim.cpu | default "1000m" | quote }} + memory: {{ $rpLim.memory | default "512Mi" | quote }} + env: + - name: MYSQL_HOST + value: "mysql-client" + - name: EXPERIMENTS_QUEUE_NAME + value: "experiments" + - name: FLOPS_QUEUE_NAME + value: "flops" + {{- if include "tracebloc.useImagePullSecrets" . }} + imagePullSecrets: + - name: {{ include "tracebloc.registrySecretName" . }} + {{- end }} + restartPolicy: Always diff --git a/client/templates/requests-proxy-service.yaml b/client/templates/requests-proxy-service.yaml new file mode 100644 index 0000000..22cfe8f --- /dev/null +++ b/client/templates/requests-proxy-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: requests-proxy-service + namespace: {{ .Release.Namespace }} + labels: + {{- include "tracebloc.labels" . | nindent 4 }} + app: requests-proxy +spec: + selector: + app: requests-proxy + ports: + - name: http + port: 8888 + targetPort: 8888 + protocol: TCP + type: ClusterIP diff --git a/client/tests/ingestor_serviceaccount_test.yaml b/client/tests/ingestor_serviceaccount_test.yaml new file mode 100644 index 0000000..79319fe --- /dev/null +++ b/client/tests/ingestor_serviceaccount_test.yaml @@ -0,0 +1,45 @@ +suite: Ingestor ServiceAccount +templates: + - templates/ingestor-serviceaccount.yaml +set: + clientId: "test-id" + clientPassword: "test" +tests: + - it: renders the shared ingestor SA with the default name + asserts: + - isKind: + of: ServiceAccount + - equal: + path: metadata.name + value: ingestor + - equal: + path: metadata.namespace + value: NAMESPACE + + - it: respects an overridden ingestionAuthz.serviceAccountName + set: + ingestionAuthz: + serviceAccountName: my-ingestor + asserts: + - equal: + path: metadata.name + value: my-ingestor + + - it: nil-guards when ingestionAuthz is absent from --reuse-values + # Simulates a pre-#129 stored values dict — the helper falls back to + # the literal default "ingestor" rather than panicking on a nil access. + set: + ingestionAuthz: null + asserts: + - equal: + path: metadata.name + value: ingestor + + - it: stamps the standard tracebloc labels + asserts: + - equal: + path: metadata.labels["app.kubernetes.io/name"] + value: client + - equal: + path: metadata.labels["app.kubernetes.io/managed-by"] + value: Helm diff --git a/client/tests/node_agents_namespace_test.yaml b/client/tests/node_agents_namespace_test.yaml index 1fe1f7a..67d4214 100644 --- a/client/tests/node_agents_namespace_test.yaml +++ b/client/tests/node_agents_namespace_test.yaml @@ -222,6 +222,7 @@ tests: - equal: path: metadata.namespace value: tracebloc-templates + documentIndex: 0 - it: should not mirror the tracebloc Secret when node-agents namespace equals release namespace template: templates/secrets.yaml @@ -236,6 +237,7 @@ tests: - equal: path: metadata.namespace value: tracebloc-templates + documentIndex: 0 - it: should mirror the docker registry Secret into the node-agents namespace when in use template: templates/docker-registry-secret.yaml diff --git a/client/tests/secrets_test.yaml b/client/tests/secrets_test.yaml index 3feab58..0113e46 100644 --- a/client/tests/secrets_test.yaml +++ b/client/tests/secrets_test.yaml @@ -11,19 +11,25 @@ tests: asserts: - isKind: of: Secret + documentIndex: 0 - equal: path: metadata.name value: RELEASE-NAME-secrets + documentIndex: 0 - equal: path: type value: Opaque + documentIndex: 0 - isNotEmpty: path: data.CLIENT_ID + documentIndex: 0 - isNotEmpty: path: data.CLIENT_PASSWORD + documentIndex: 0 - matchRegex: path: metadata.labels["app.kubernetes.io/managed-by"] pattern: Helm + documentIndex: 0 - it: should create docker registry secret when create is true template: templates/docker-registry-secret.yaml diff --git a/client/values.schema.json b/client/values.schema.json index 67d39b2..d2efd22 100644 --- a/client/values.schema.json +++ b/client/values.schema.json @@ -284,6 +284,25 @@ } } }, + "requestsProxy": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "pattern": "^(sha256:[a-f0-9]{64})?$" + } + } + }, + "ingestor": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "pattern": "^(sha256:[a-f0-9]{64})?$", + "description": "Optional canonical ghcr.io/tracebloc/ingestor digest (sha256:<64 hex>). Surfaced into jobs-manager as the INGESTOR_IMAGE_DIGEST env so the customer-facing tracebloc/ingestor subchart can pick it up without per-install overrides. Empty disables the default and forces customers to --set image.digest on each ingestor subchart install." + } + } + }, "mysqlClient": { "type": "object", "properties": { @@ -374,6 +393,25 @@ } } } + }, + "requestsProxy": { + "type": "object", + "properties": { + "requests": { + "type": "object", + "properties": { + "cpu": { "type": "string", "pattern": "^[0-9]+m?$" }, + "memory": { "type": "string", "pattern": "^[0-9]+(Ki|Mi|Gi|Ti)$" } + } + }, + "limits": { + "type": "object", + "properties": { + "cpu": { "type": "string", "pattern": "^[0-9]+m?$" }, + "memory": { "type": "string", "pattern": "^[0-9]+(Ki|Mi|Gi|Ti)$" } + } + } + } } } }, diff --git a/client/values.yaml b/client/values.yaml index ad8326c..28de382 100644 --- a/client/values.yaml +++ b/client/values.yaml @@ -179,10 +179,29 @@ networkPolicy: images: jobsManager: digest: "" + # client-runtime#40 / client#125: the ingestor image is spawned by + # jobs-manager at ingestion-submission time, not as a long-lived pod. + # Setting `digest` here surfaces it into jobs-manager as the + # `INGESTOR_IMAGE_DIGEST` env; the auto-upgrade flow then keeps it + # current. Customers can override per-install via the ingestor + # subchart's `image.digest` (for pinning / debugging), but the + # dominant path uses this value. + # + # Bumped to v0.3.0 (2026-05-20) — first production-ready release of + # the declarative-YAML ingestor with schema-packaging, image-validator, + # and file-transfer fixes from tracebloc/data-ingestors#106. + # Bump this on each ingestor release; chart `version` in Chart.yaml must + # also bump so the auto-upgrade cronjob detects the change. Future + # automation in tracebloc/data-ingestors (release-image.yml) can + # raise a PR to this file when a new image is published. + ingestor: + digest: "sha256:463e236748708a5e3564569eec9173ea8cb3bcf515992d4939c5b610f3807a4a" podsMonitor: digest: "" resourceMonitor: digest: "" + requestsProxy: + digest: "" mysqlClient: # mysql-client is only published under the "prod" tag — it has no dev/ # staging variants, so we decouple it from env.CLIENT_ENV. Override only @@ -230,6 +249,17 @@ resources: limits: cpu: "500m" memory: "512Mi" + # requests-proxy serves the Service Bus / backend communication path and + # is mostly idle (a few req/min). 100m/256Mi requests with headroom for + # the occasional burst; revisit if heap profiling shows growth. Schema + # at values.schema.json#resources.requestsProxy validates overrides. + requestsProxy: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" # -- PriorityClass for the data-plane (mysql). # Cluster-scoped resource. Created with helm.sh/resource-policy: keep so a @@ -339,3 +369,37 @@ autoUpgrade: limits: cpu: "500m" memory: "256Mi" + +# ============================================================ +# Ingestion endpoint authorization (client-runtime#21) +# ============================================================ +# jobs-manager's POST /internal/submit-ingestion-run authenticates callers +# via Kubernetes TokenReview, then authorizes against this policy. Mapping +# is (namespace, service_account) → list of allowed table-name prefixes; +# "*" means any table. +# +# Default: allow the ingestor subchart's SA (created below by this chart, +# named `ingestionAuthz.serviceAccountName`) to ingest into any table. +# Customers tighten via overrides — e.g.: +# +# ingestionAuthz: +# allowed: +# - service_account: my-dataset-ingestor +# namespace: tracebloc +# table_prefixes: ["chest_xrays_", "tumors_"] +ingestionAuthz: + # -- Name of the shared ServiceAccount this chart creates for ingestor + # subchart releases (client#129). Single source of truth: the SA + # template and the default `allowed` entry below both read this value. + # Override only if the name "ingestor" collides with something in your + # namespace; the matching ingestor subchart's `serviceAccount.name` + # must be updated to the same value. + serviceAccountName: ingestor + allowed: + # Default: the SA named `ingestionAuthz.serviceAccountName` above, in + # the same namespace as this release, can ingest into any table. When + # `service_account` is omitted the ConfigMap template substitutes + # `ingestionAuthz.serviceAccountName`; when `namespace` is omitted it + # substitutes `.Release.Namespace`. Customers tighten by adding more + # specific entries with explicit fields. + - table_prefixes: ["*"] diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d556ca8..81690c1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -207,7 +207,7 @@ The chart repository used for installation is **[tracebloc/client](https://githu To make the chart available via `helm repo add tracebloc https://tracebloc.github.io/client`: -1. **In the repo that hosts the chart (e.g. tracebloc/client or tracebloc-helm-charts):** +1. **In the tracebloc/client repo:** Enable **GitHub Pages** → **Settings** → **Pages** → **Source**: branch `gh-pages` (root). 2. **Create a release or push a tag** @@ -224,8 +224,6 @@ To make the chart available via `helm repo add tracebloc https://tracebloc.githu 4. **First time only:** ensure the `gh-pages` branch exists. The workflow creates it if missing. -5. **If you develop in a different repo** (e.g. tracebloc-helm-charts): run the release workflow there to build the chart, then copy the generated `tracebloc-.tgz` and updated `index.yaml` into the **tracebloc/client** repo’s `gh-pages` branch so the chart is served at `https://tracebloc.github.io/client`. - After that, users can run: ```bash @@ -233,8 +231,6 @@ helm repo add tracebloc https://tracebloc.github.io/client helm install my-tracebloc tracebloc/tracebloc -n tracebloc -f my-values.yaml ``` -**Note:** If the chart is developed in a different repo (e.g. `tracebloc-helm-charts`), run the release workflow there to produce the `.tgz` and `index.yaml`, then copy the packaged chart and updated index into the `tracebloc/client` repo’s `gh-pages` branch (or run the same release workflow from the client repo) so the chart is served at `https://tracebloc.github.io/client`. - --- ## Pre-install checklist (production) diff --git a/ingestor/.helmignore b/ingestor/.helmignore new file mode 100644 index 0000000..48556d6 --- /dev/null +++ b/ingestor/.helmignore @@ -0,0 +1,18 @@ +# Patterns to ignore when building chart packages. +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.project +.idea/ +*.tmproj +.vscode/ diff --git a/ingestor/Chart.yaml b/ingestor/Chart.yaml new file mode 100644 index 0000000..9ca3a34 --- /dev/null +++ b/ingestor/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: ingestor +description: | + Helm chart for submitting a tracebloc data-ingestion run. Renders a + customer's ingest.yaml into a ConfigMap and runs a post-install hook + that POSTs it to jobs-manager's /internal/submit-ingestion-run. + jobs-manager validates the YAML, mints a backend token, and creates + the ingestor Job directly — this chart owns the config + hook + artifacts, not the resulting Job or its data. +type: application +version: 0.2.0 +appVersion: "0.3.0" +keywords: + - tracebloc + - kubernetes + - data-ingestion +home: https://github.com/tracebloc/client/tree/develop/ingestor +sources: + - https://github.com/tracebloc/client + - https://github.com/tracebloc/data-ingestors + - https://github.com/tracebloc/client-runtime +maintainers: + - name: tracebloc + email: support@tracebloc.io diff --git a/ingestor/README.md b/ingestor/README.md new file mode 100644 index 0000000..10e375d --- /dev/null +++ b/ingestor/README.md @@ -0,0 +1,128 @@ +# tracebloc/ingestor Helm chart + +A thin chart that submits one data-ingestion run to your tracebloc client cluster. Wraps the `POST /internal/submit-ingestion-run` endpoint on jobs-manager (client-runtime#21) so the customer-facing UX is: + +```bash +helm install my-dataset tracebloc/ingestor \ + --namespace tracebloc \ + --set-file ingestConfig=./my-ingest.yaml +``` + +**The ingestor image is managed centrally** by the tracebloc client chart's auto-upgrade flow — you don't need to pin a digest for each install. New ingestor releases roll out automatically when the cluster's daily auto-upgrade cronjob (`autoUpgrade.enabled: true` in the client chart) bumps the chart version. See [Pinning a specific image version](#pinning-a-specific-image-version) below for the override path. + +## Prerequisites + +> **Install the `tracebloc/client` parent chart (1.3.4 or newer) into the +> target namespace before installing this chart.** The parent chart +> creates the `ingestor` ServiceAccount this chart's post-install hook +> runs as, and renders the `ingestionAuthz` ConfigMap that authorizes +> it. Without those preconditions the hook either has no SA to mount +> or fails authentication at jobs-manager. + +The SA is shared by every `tracebloc/ingestor` release in the namespace +— that's the point. Before 0.2.0 this chart created the SA itself, +which broke as soon as a second ingestor release tried to install +([tracebloc/client#129](https://github.com/tracebloc/client/issues/129)). + +## What this chart owns + +| Resource | Owner | Lifecycle | +|---|---|---| +| `ConfigMap/-config` (holds `ingest.yaml`) | this chart | created by `helm install`, deleted by `helm uninstall` | +| `Job/-submit` (post-install hook that POSTs) | this chart | created post-install, removed before each `helm upgrade` | +| `ServiceAccount/` | **parent `tracebloc/client` chart** (as of 0.2.0; this chart can still create it via `serviceAccount.create: true` when targeting a pre-1.3.4 parent) | tied to the parent client release | +| `ConfigMap/ingest-config-` (per-run, mounted into the ingestor Pod) | **jobs-manager** | created by jobs-manager on accept; not managed by Helm | +| `Secret/ingest-token-` (per-run, holds `BACKEND_TOKEN`) | **jobs-manager** | same | +| `Job/ingest-job-` (the actual ingestor) | **jobs-manager** | same | +| **Ingested data** (rows in cluster-internal MySQL + metadata POSTed to the backend) | **the cluster** | persists past `helm uninstall` | + +**`helm uninstall my-dataset` will not delete the running ingestor Job or any ingested data.** It removes only the config + hook artifacts above. Document this with operators so they don't expect uninstall to act as a "cancel ingestion" button. + +## How the install works end-to-end + +1. `helm install` renders `ConfigMap/-config` containing the customer's `ingest.yaml` body. +2. Helm fires the `post-install` hook: a Job that runs as the chart's ServiceAccount. +3. The hook reads its SA token from the projected volume and the `ingest.yaml` from the ConfigMap mount. +4. The hook POSTs `{ ingest_config, idempotency_key }` (and `image_digest` if you explicitly pinned one) to `jobs-manager:8080/internal/submit-ingestion-run`. +5. **jobs-manager** validates the SA token via Kubernetes TokenReview, then checks the (SA, table) pair against the cluster's `ingestionAuthz` policy (a ConfigMap rendered by the parent `tracebloc/client` chart). +6. If authorized, jobs-manager validates the YAML against the `ingest.v1` JSON schema, resolves the image digest (the body's value if you pinned one, otherwise the cluster's configured default from `INGESTOR_IMAGE_DIGEST`), mints a backend token, creates the per-run ConfigMap + Secret + Job, records the run for idempotency, and returns `201` (or `200` if this idempotency_key has been seen before). +7. The hook treats `2xx` as success and exits 0; `helm install` reports success. Non-`2xx` exits 1 and `helm install` fails with the response body in the output. + +The customer never builds an image. The customer never writes a Dockerfile. The customer writes ~8 lines of YAML. + +## How updates work + +The ingestor has two independent update lifecycles, and customers usually only need to think about one. + +**Image: always current, automatically.** New `ghcr.io/tracebloc/ingestor` releases roll out to your cluster via the parent `tracebloc/client` chart's auto-upgrade cronjob (`autoUpgrade.enabled: true`, default). The cronjob runs `helm repo update` + `helm upgrade tracebloc/client` daily, which writes the new digest into the `INGESTOR_IMAGE_DIGEST` env on the running `tracebloc-jobs-manager` deployment. Your next `helm install tracebloc/ingestor ...` uses the new image automatically — no digest to pin, no version to track, no redeploy of anything you've already installed. + +**Chart: refresh your local cache before each install.** Helm's repo cache on _your workstation_ is independent of the cluster. The cluster's cronjob can refresh its own cache, but it cannot reach your laptop. Run `helm repo update` before each install to pick up new chart features (new values, new templates, new defaults). A stale cache still works — it just locks you out of chart-level options added since you last refreshed. **The image you run does not depend on the chart version**: jobs-manager picks the current `INGESTOR_IMAGE_DIGEST` regardless of which subchart version submitted the request. + +This stratification is intentional. The image picks up bugfixes and security patches without anyone restating their dataset configs; the chart only changes when there's a real protocol or UX shift. + +### What about previously-installed ingestor releases? + +Nothing to upgrade. The chart is fire-and-forget: each `helm install` POSTs once to jobs-manager, the ingestor Job runs to completion, and the chart artifacts (ConfigMap + completed hook Job) become inert. There's no controller to update, no deployment to roll, no scheduled work to bump. `helm upgrade ` would replay the same submission as a 200 no-op (the idempotency key was stamped at install time and is preserved under `--reuse-values`). + +## Required values + +| Value | Description | +|---|---| +| `ingestConfig` | The full `ingest.yaml` body. **Set via `--set-file`** — the body almost always contains YAML special characters that don't survive `--set`. | + +## Pinning a specific image version + +The dominant install path leaves `image.digest` empty and lets jobs-manager pick the cluster's current ingestor version (set by the parent client chart's `images.ingestor.digest`, kept current by the auto-upgrade cronjob). Override only when you have a specific reason: + +| Scenario | What to do | +|---|---| +| Reproducing an older ingestion run for audit / debugging | `--set image.digest=sha256:` | +| Testing a new ingestor release before cluster-wide rollout | `--set image.digest=sha256:` ahead of the auto-upgrade tick | +| Air-gapped mirror with frozen versions | Use both `--set image.repository=...` and `--set image.digest=sha256:...` | + +When set, the digest must be the full canonical form (`sha256:` + 64 lowercase hex chars). Tags like `v0.3.0` are rejected by jobs-manager. See the [data-ingestors releases page](https://github.com/tracebloc/data-ingestors/releases) for current digests. + +## Frequently-overridden values + +| Value | Default | When to override | +|---|---|---| +| `jobsManager.endpoint` | `http://jobs-manager..svc.cluster.local:8080` (auto-resolved) | The ingestor release and the parent `tracebloc/client` release live in different namespaces, or you're testing against a port-forward. | +| `serviceAccount.name` | `ingestor` | The cluster's `ingestionAuthz` policy expects a different SA name. (Default matches the parent chart's default.) | +| `image.repository` | `ghcr.io/tracebloc/ingestor` | Air-gapped mirror. | +| `idempotencyKey` | `-` (regenerated every install) | You want strict at-most-once semantics across reinstalls of the same release name — pass a stable UUID so jobs-manager replays the original run instead of starting a new one. | +| `hookTimeoutSeconds` | `30` | Slow networks or large schemas. | + +See `values.yaml` for the full set. + +## Verifying after install + +```bash +# Helm-side artifacts (this chart's footprint): +kubectl -n tracebloc get configmap,job,serviceaccount -l app.kubernetes.io/instance=my-dataset + +# jobs-manager-side artifacts (the actual run): +kubectl -n tracebloc get jobs -l tracebloc.io/ingestion-run + +# Watch the ingestion run progress: +kubectl -n tracebloc logs -l tracebloc.io/ingestion-run --tail=-1 +``` + +## Uninstalling + +```bash +helm uninstall my-dataset --namespace tracebloc +``` + +Removes the chart's ConfigMap + hook Job. The shared `ingestor` ServiceAccount is owned by the parent `tracebloc/client` release (as of 0.2.0) and stays put for other ingestor releases in the namespace. Does **not** remove the running ingestor Job, its outputs, or the metadata posted to the backend — those are owned by jobs-manager and the cluster respectively. + +To cancel an in-flight run, work with jobs-manager directly: + +```bash +kubectl -n tracebloc delete job -l tracebloc.io/ingestion-run= +``` + +## Related + +- [tracebloc/data-ingestors](https://github.com/tracebloc/data-ingestors) — the ingestor image and YAML schema. +- [tracebloc/client-runtime#21](https://github.com/tracebloc/client-runtime/pull/35) — the `submit-ingestion-run` endpoint this chart calls. +- [tracebloc/client](https://github.com/tracebloc/client) — the parent chart that runs jobs-manager and renders the `ingestionAuthz` policy. diff --git a/ingestor/templates/_helpers.tpl b/ingestor/templates/_helpers.tpl new file mode 100644 index 0000000..5a8edf7 --- /dev/null +++ b/ingestor/templates/_helpers.tpl @@ -0,0 +1,85 @@ +{{- /* +Shared template helpers for the tracebloc/ingestor chart. + +Naming: + {release}-config — ConfigMap holding the ingest.yaml the post-install + hook reads + POSTs. + {release}-submit — Job that runs the helm post-install hook (the POST). + Resulting ingestor Job created by jobs-manager has + its own name (idempotency-key-derived) and is NOT + managed by this chart. +*/ -}} + +{{- define "ingestor.fullname" -}} +{{ .Release.Name }} +{{- end -}} + +{{- define "ingestor.configMapName" -}} +{{ .Release.Name }}-config +{{- end -}} + +{{- define "ingestor.hookJobName" -}} +{{ .Release.Name }}-submit +{{- end -}} + +{{- define "ingestor.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{ .Values.serviceAccount.name }} +{{- else -}} +{{ .Values.serviceAccount.name | required "serviceAccount.name is required when serviceAccount.create=false" }} +{{- end -}} +{{- end -}} + +{{- /* +Resolved idempotency key. + +Default behavior: + - First helm install: stamp a fresh "-" key. + - helm upgrade of the same release: REUSE the existing key by looking + up the post-install hook ConfigMap from the previous render. This + preserves replay semantics — jobs-manager sees the same key on + upgrade and returns 200 (replay) rather than spawning a new run. + - helm install after uninstall: lookup misses (ConfigMap was deleted + on uninstall), so we fall through to a fresh now-based key. No + collision with the previous run because the epoch differs. + +Earlier versions defaulted to `now | unixEpoch` on every render. That +worked for installs but accidentally created a NEW key on +`helm upgrade --reuse-values` (Helm preserves the stored value `""`, +not the previously-rendered key, so the template re-evaluates `now`). +The result: customers running `helm upgrade` thinking it was a no-op +got duplicate ingestion runs. Bugbot caught it on PR #137. See #139. + +Helm template (no cluster connection) returns empty for lookup, so +local previews always re-stamp with a fresh key — matches the +in-cluster install path the first time around. + +Explicit override is honored verbatim; set `idempotencyKey` to a +stable UUID when you want strict at-most-once semantics across +uninstall/reinstall cycles. +*/ -}} +{{- define "ingestor.idempotencyKey" -}} +{{- if .Values.idempotencyKey -}} +{{ .Values.idempotencyKey }} +{{- else -}} +{{- $existing := lookup "v1" "ConfigMap" .Release.Namespace (include "ingestor.configMapName" .) -}} +{{- /* The ConfigMap key is literally "body.json" (a single key with a dot in + its name, not a nested path), so use `index` rather than dot-access. + The fromJson call then parses the JSON body and we read its + idempotency_key field. Guards against missing data map (e.g. an + in-flight create) by defaulting through `dict`. */ -}} +{{- if and $existing (hasKey ($existing.data | default dict) "body.json") -}} +{{- (fromJson (index $existing.data "body.json")).idempotency_key -}} +{{- else -}} +{{ printf "%s-%s" .Release.Name (now | unixEpoch) }} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "ingestor.labels" -}} +app.kubernetes.io/name: ingestor +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} +{{- end -}} diff --git a/ingestor/templates/configmap-ingest-config.yaml b/ingestor/templates/configmap-ingest-config.yaml new file mode 100644 index 0000000..beca6ac --- /dev/null +++ b/ingestor/templates/configmap-ingest-config.yaml @@ -0,0 +1,53 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + # Holds the customer's ingest.yaml and the pre-rendered JSON body that + # the post-install hook POSTs. Both keys are populated at helm-template + # time; the runtime hook never has to construct JSON itself, which + # eliminates an entire class of shell-escaping bugs (bugbot caught the + # original python3-based encoder). + # + # Lifecycle: created by `helm install`, removed by `helm uninstall`. + # The resulting ingestor Job (created by jobs-manager) does NOT depend + # on this ConfigMap — jobs-manager creates its own per-Job-named + # ConfigMap before spawning the Job — so deleting this one mid-run + # does not interrupt ingestion. + name: {{ include "ingestor.configMapName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "ingestor.labels" . | nindent 4 }} + annotations: + # Hook ordering: this ConfigMap must exist before the post-install + # hook Job mounts it. helm.sh/hook-weight orders sibling hook + # resources; lower runs first. + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "0" +data: + # Verbatim ingest.yaml body (useful for `kubectl get cm -o yaml` + # debugging). The left-trim action delimiter (dash inside the action + # braces) eats the whitespace before the action so we don't render a + # leading blank line into the block scalar — bugbot caught this on + # the first pass; without the trim, the customer's YAML got a leading + # "\n" prefix that block-scalar parsers tolerate but is still wrong. + ingest.yaml: |- + {{- required "ingestConfig must be set (use --set-file ingestConfig=path/to/ingest.yaml)" .Values.ingestConfig | nindent 4 }} + # Pre-rendered JSON body that the post-install hook POSTs to + # jobs-manager. Computed at helm-template time so the hook is a + # one-line `curl --data-binary @body.json` — no python, no jq, no + # shell-escaping the multi-line YAML scalar. + # + # `image_digest` is OPTIONAL in the POST body (client-runtime#40): + # when omitted, jobs-manager uses the cluster's configured default + # (set by the tracebloc client chart's `images.ingestor.digest`, + # kept current by the auto-upgrade flow). Including it here only + # when the customer explicitly pinned via `--set image.digest=...` + # means the dominant install path tracks the cluster's current + # version automatically; the override path is preserved. + body.json: |- + {{- $cfg := required "ingestConfig must be set (use --set-file ingestConfig=path/to/ingest.yaml)" .Values.ingestConfig -}} + {{- $key := include "ingestor.idempotencyKey" . -}} + {{- $body := dict "ingest_config" $cfg "idempotency_key" $key -}} + {{- if .Values.image.digest -}} + {{- $_ := set $body "image_digest" .Values.image.digest -}} + {{- end -}} + {{- $body | toJson | nindent 4 }} diff --git a/ingestor/templates/post-install-job.yaml b/ingestor/templates/post-install-job.yaml new file mode 100644 index 0000000..6c650c5 --- /dev/null +++ b/ingestor/templates/post-install-job.yaml @@ -0,0 +1,130 @@ +apiVersion: batch/v1 +kind: Job +metadata: + # client#86: post-install hook that POSTs the ingest.yaml to + # jobs-manager. Hook lifecycle: + # + # - post-install + post-upgrade fires the Job. + # - hook-weight 1 ensures the ConfigMap (weight 0) is in place first. + # - hook-delete-policy "before-hook-creation" cleans up the previous + # Job on a re-install of the same release, so we don't trip on a + # leftover completed Job. We DO NOT use "hook-succeeded" because + # keeping the Job around lets operators read the POST result via + # `kubectl logs` after install. + name: {{ include "ingestor.hookJobName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "ingestor.labels" . | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "1" + helm.sh/hook-delete-policy: before-hook-creation +spec: + backoffLimit: 0 + # Bound: jobs-manager validates synchronously; if it hasn't responded + # in this window something is genuinely wrong and the install should + # fail loudly rather than hang. + activeDeadlineSeconds: {{ .Values.hookTimeoutSeconds | default 30 }} + ttlSecondsAfterFinished: 600 + template: + metadata: + labels: + {{- include "ingestor.labels" . | nindent 8 }} + spec: + # The hook talks to one in-cluster endpoint with one specific SA + # token. Hardened defaults match the tracebloc client chart's + # training-pod template — non-root, all caps dropped. + automountServiceAccountToken: true # we DO need the SA token to authenticate the POST + serviceAccountName: {{ include "ingestor.serviceAccountName" . }} + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: submit + image: {{ printf "%s:%s" .Values.hookImage.repository .Values.hookImage.tag | quote }} + imagePullPolicy: {{ .Values.hookImage.pullPolicy | default "IfNotPresent" }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + {{- toYaml .Values.hookResources | nindent 12 }} + env: + # client#127: default to the jobs-manager Service in this + # release's own namespace rather than hardcoding "tracebloc". + # Releases in any other namespace would otherwise hit + # `curl: Could not resolve host` at the hook. Customers + # whose ingestor release sits in a different namespace from + # the tracebloc release set `jobsManager.endpoint` + # explicitly to override. + - name: JOBS_MANAGER_ENDPOINT + value: {{ .Values.jobsManager.endpoint | default (printf "http://jobs-manager.%s.svc.cluster.local:8080" .Release.Namespace) | quote }} + - name: SUBMIT_PATH + value: {{ .Values.jobsManager.submitPath | quote }} + command: ["/bin/sh", "-c"] + # The script: + # 1. Reads the SA token from the projected volume (k8s adds + # it automatically when automountServiceAccountToken: true). + # 2. POSTs the pre-rendered JSON body from + # /etc/ingest/body.json — Helm built the JSON at template + # time so the script doesn't have to construct or escape + # anything itself. Earlier versions used a python3 / jq + # escape pipeline at runtime; both failed in different + # ways (curlimages/curl strips python3 from the runtime + # layer, and shell-side JSON escaping a multi-line YAML + # scalar is its own bug farm). Bugbot caught both. + # 3. Non-2xx fails the Job, which fails the helm install with + # the response body visible in the install output. + args: + - | + set -eu + + TOKEN_PATH=/var/run/secrets/kubernetes.io/serviceaccount/token + BODY_FILE=/etc/ingest/body.json + + if [ ! -r "$TOKEN_PATH" ]; then + echo "ERROR: SA token not found at $TOKEN_PATH" >&2 + exit 1 + fi + if [ ! -r "$BODY_FILE" ]; then + echo "ERROR: request body not found at $BODY_FILE — ConfigMap mount failed" >&2 + exit 1 + fi + + URL="${JOBS_MANAGER_ENDPOINT}${SUBMIT_PATH}" + echo "POST $URL" + echo "Body source: $BODY_FILE" + + HTTP_STATUS=$(curl -sS -o /tmp/resp.json -w "%{http_code}" \ + --max-time {{ .Values.hookTimeoutSeconds | default 30 }} \ + -X POST "$URL" \ + -H "Authorization: Bearer $(cat "$TOKEN_PATH")" \ + -H "Content-Type: application/json" \ + --data-binary "@$BODY_FILE") + + echo "HTTP $HTTP_STATUS" + echo "Response:" + cat /tmp/resp.json + echo + + # 200 = replay (existing run for this key); 201 = freshly + # created. Both are success for the customer's helm install. + if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then + exit 1 + fi + volumeMounts: + - name: ingest-config + mountPath: /etc/ingest + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: ingest-config + configMap: + name: {{ include "ingestor.configMapName" . }} + - name: tmp + emptyDir: {} diff --git a/ingestor/templates/serviceaccount.yaml b/ingestor/templates/serviceaccount.yaml new file mode 100644 index 0000000..2a0cede --- /dev/null +++ b/ingestor/templates/serviceaccount.yaml @@ -0,0 +1,15 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + # The post-install hook Job runs as this SA. Its token is the credential + # jobs-manager validates via TokenReview (client-runtime#21). The SA's + # (namespace, name) must match an entry in the tracebloc release's + # `ingestionAuthz.allowed` ConfigMap; the default chart entry is + # `(release-namespace, "ingestor")` so this default name works + # out-of-the-box. + name: {{ include "ingestor.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "ingestor.labels" . | nindent 4 }} +{{- end }} diff --git a/ingestor/values.yaml b/ingestor/values.yaml new file mode 100644 index 0000000..91a2ab1 --- /dev/null +++ b/ingestor/values.yaml @@ -0,0 +1,110 @@ +# ============================================================ +# tracebloc/ingestor — values.yaml +# ============================================================ +# Customer-facing chart. The dominant install is: +# +# helm install my-dataset tracebloc/ingestor --namespace tracebloc \ +# --set-file ingestConfig=./my-ingest.yaml \ +# --set image.digest=sha256: +# +# The chart submits the YAML to jobs-manager; jobs-manager owns the +# resulting Job. `helm uninstall` removes the ConfigMap + hook but +# leaves the Job and ingested data alone (see README.md). + +# -- The ingest.yaml body. REQUIRED. Set via --set-file ingestConfig=path/to/ingest.yaml +# or by overriding in a values file. Validated by jobs-manager against the +# v1 schema from tracebloc/data-ingestors#44; install fails with a +# line-numbered error if invalid. +ingestConfig: "" + +# -- Image of the official tracebloc/ingestor that will run the dataset. +# +# Default behaviour (client-runtime#40 / client#125): leave `digest` +# empty and let the cluster's auto-upgrade flow pick the version. +# jobs-manager reads `INGESTOR_IMAGE_DIGEST` (set by the tracebloc +# client chart's `images.ingestor.digest` value) and uses it when +# spawning the ingestor Job. New ingestor releases roll out +# automatically when the cluster's daily auto-upgrade cronjob bumps the +# chart — no per-dataset chart change needed. +# +# Set `digest` here ONLY when you need to pin a specific version, e.g. +# reproducing an older run or testing a new release before cluster-wide +# rollout. Must be the full canonical sha256 digest; tags are rejected. +image: + # Repository defaults to the GHCR-published image. Customers running an + # air-gapped mirror override this. + repository: ghcr.io/tracebloc/ingestor + # Optional override. Empty (default) means "use whatever the cluster + # is currently rolling". When set must be sha256:<64 lowercase hex>. + digest: "" + +# -- jobs-manager Service hostname + port to POST to. When left empty +# (default), the post-install hook resolves the endpoint to +# `http://jobs-manager..svc.cluster.local:8080`, +# which is correct for the common case where the ingestor release is +# installed in the same namespace as the tracebloc client release. +# Override only when the two releases live in different namespaces, or +# when you need a non-standard port/host (e.g., port-forward testing). +jobsManager: + endpoint: "" + # The path on jobs-manager that accepts ingestion submissions. Stable + # API; only change if you're testing against a forked jobs-manager. + submitPath: /internal/submit-ingestion-run + +# -- ServiceAccount that the post-install hook runs as. Its token is the +# credential jobs-manager validates via TokenReview, and the SA's name + +# namespace are matched against the ingestionAuthz policy in the +# tracebloc client release. +# +# As of 0.2.0 (tracebloc/client#129) the SA is owned by the parent +# `tracebloc/client` chart (templated as `ingestor` by default, or +# whatever `ingestionAuthz.serviceAccountName` resolves to). The SA is +# shared by every ingestor subchart release in the namespace, so per- +# release ownership here collided with Helm on concurrent installs and +# made uninstalls rip the SA out from under sibling releases. +# +# Default: `create: false`. The chart consumes the existing SA created +# by the parent client release. The `name` value is still required so +# the post-install hook Job knows which SA's token to mount. +# +# Override `create: true` ONLY when running against a parent client +# chart older than 1.3.4 that didn't yet own the SA — in that case make +# sure no other ingestor release in the same namespace also has +# `create: true`, or you'll reproduce client#129. +serviceAccount: + create: false + name: ingestor + +# -- Image for the post-install hook Job itself (a thin curl wrapper). +# Pinned by digest for reproducibility; bumping is a separate chart +# release. +hookImage: + # curlimages/curl is small, official, and pinned by tag here for + # readability. Customers who need digest pinning override this. + repository: curlimages/curl + tag: "8.10.1" + pullPolicy: IfNotPresent + +# -- Resources for the post-install hook. Tiny — it makes one POST. +hookResources: + requests: + cpu: "10m" + memory: "32Mi" + limits: + cpu: "100m" + memory: "64Mi" + +# -- Idempotency key used by jobs-manager to dedupe submissions. +# Defaults to `-` so each install is a fresh run — +# including reinstalls under the same release name. (A revision-derived +# default would collide on `helm uninstall && helm install`, because +# Helm resets revisions to 1 on the next install; jobs-manager would +# then reject the second attempt with a 409 if anything dedupe-relevant +# changed in between.) Override to a stable UUID only when you want +# at-most-once semantics across reinstalls of the same release name. +idempotencyKey: "" + +# -- How long the post-install hook waits for the POST to return before +# failing the install. Includes jobs-manager schema validation + +# k8s API calls; usually completes in <2s. +hookTimeoutSeconds: 30