diff --git a/.github/workflows/helm-lint.yml b/.github/workflows/helm-lint.yml new file mode 100644 index 0000000..4e4fabe --- /dev/null +++ b/.github/workflows/helm-lint.yml @@ -0,0 +1,32 @@ +name: helm-lint + +on: + pull_request: + paths: + - 'charts/**' + - 'scripts/lint-charts.sh' + - '.github/workflows/helm-lint.yml' + push: + branches: + - main + - development + paths: + - 'charts/**' + - 'scripts/lint-charts.sh' + - '.github/workflows/helm-lint.yml' + +jobs: + lint: + name: lint and render charts + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v3.16.4 + + - name: Lint and render all charts + run: ./scripts/lint-charts.sh diff --git a/.gitignore b/.gitignore index 459f893..948db04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,23 @@ +# macOS +.DS_Store + +# Helm dependency artifacts (regenerated by `helm dependency update`) +charts/**/charts/ +charts/**/Chart.lock +*.tgz + +# Rendered output +rendered/ + +# Editor/IDE +.vscode/ +.idea/ +*.swp + + # Local scratch: a standalone registry someone ran by hand, with a real .env in # it. Not part of this repo, and its .env holds live credentials -- it was # committed once by a `git add -A` and must not be again. registry/ + diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..daff3d6 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,123 @@ +# Chart conventions + +Rules every chart in this repository follows. They exist so that a service chart +is predictable to read, safe to upgrade, and traceable back to the change that +produced it. + +## Naming + +| Thing | Rule | Example | +|---|---|---| +| Chart directory and `name` | `oan-`, kebab-case, matching the service's repo/deployment name | `oan-registry-service` | +| Library chart | `oan-common` — the only library chart; every service chart depends on it | `oan-common` | +| Reference chart | `oan-template` — copied to start a new chart, never deployed as-is | `oan-template` | +| A deployed component | Named after the **role it plays in OAN**, no `oan-` prefix — the prefix is for the shared library and the starter chart | `registry`, `keycloak` | +| Two charts for the same role | Add the distinguishing implementation as a suffix, only when there is something to distinguish | `postgresql-cnpg` | +| Release name | The service name without the `oan-` prefix, so resources read `registry-service-...` not `oan-registry-service-oan-registry-service` | `helm install registry-service charts/oan-registry-service` | +| Template helpers | Chart-local helpers are namespaced by chart name: `.` | `oan-registry-service.fullname` | +| Value keys | camelCase, matching Kubernetes field names where one exists | `podSecurityContext`, `envFromSecrets` | +| Env var keys in `envConfig` | SCREAMING_SNAKE_CASE | `LOG_LEVEL` | +| Custom labels/annotations | Prefixed with a domain we own | `oan.in/environment: dev` | + +Component charts are named after the role the component plays in OAN, not after +the software that happens to implement it: `registry` and `keycloak`, not +`registry-sunbird-rc` or `keycloak-sunbird-rc`. The implementation is an +implementation detail, and one that can change without the role changing. + +Add an implementation suffix only when it actually distinguishes something — +`postgresql-cnpg` carries `-cnpg` because a plain `postgresql` chart could +reasonably mean several different operators, and which one is in use changes how +the chart is configured and operated. + +The `oan-` prefix is reserved for the shared library (`oan-common`) and the +starter chart (`oan-template`). Every chart, prefixed or not, depends on +`oan-common` and carries the standard OAN labels, including +`app.kubernetes.io/part-of: oan`. + +Chart directory name, `name` in `Chart.yaml`, and the prefix of the chart-local +helpers must all agree. A mismatch is the most common cause of a chart that +lints clean but renders the wrong resource names. + +## Versioning + +Two independent version fields, both required in every `Chart.yaml`: + +- **`version`** — the version of the *chart*, following + [Semantic Versioning](https://semver.org/). Bumped on every chart change, + even a comment-only one. +- **`appVersion`** — the version of the *application image* the chart deploys by + default, quoted. It is the fallback for `image.tag`, so it must be a real, + pullable tag. Bumping it is a chart change, and so requires a `version` bump + too. + +`version` bump rules: + +| Change | Bump | +|---|---| +| New value key with a backward-compatible default; new optional resource | MINOR | +| Bug fix in a template; doc/comment change; `appVersion` bump | PATCH | +| Removing or renaming a value key or helper; changing a default that alters live behaviour; changing an immutable field such as a selector label | MAJOR | + +For `oan-common` specifically: consumers pin `version: "0.1.x"`, so helper +additions ship as PATCH/MINOR and MAJOR is reserved for renaming or changing the +behaviour of an existing helper. A MAJOR bump of `oan-common` means every +consuming chart's pin has to be updated deliberately. + +Pre-1.0.0 charts are still in flux; once a chart is deployed to production it +goes to `1.0.0` and the rules above are binding. + +## Changelog + +Every chart keeps a `CHANGELOG.md` in +[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. A chart change +is not complete without both the `version` bump and the matching changelog +entry. This is what makes "which chart version introduced this?" answerable. + +## Required in every service chart + +The deployment epic requires these on every component, and `oan-common` +enforces the first two at render time rather than leaving them to review: + +1. **Resource requests and limits** — `oan-common.resources` fails the render + when `.Values.resources` is empty. This applies to data stores too, where the + requests land on the operator-managed pods. +2. **Liveness and readiness probes** — enabled by default in `oan-template`. + `oan-common.probeSpec` fails the render when an enabled probe declares no + handler, or declares more than one (which the API server would otherwise + reject at apply time, long after the render looked fine). +3. **A ServiceAccount per service** — never the namespace `default`. Attach IRSA + role ARNs via `serviceAccount.annotations`. +4. **Standard labels** — from `oan-common.labels`, giving every resource + `app.kubernetes.io/*` plus `app.kubernetes.io/part-of: oan`. + +## Secrets + +No secret value is ever committed to this repository — not in `values.yaml`, not +in a per-environment values file. + +- **No chart in this repository renders a Secret.** Charts only *reference* + Secrets by name, so the question of how secret material gets into the cluster + is answered once, outside the charts, rather than differently per chart. +- `envFromSecrets` lists names of Secrets whose keys become environment + variables. `secretEnv` maps one Secret key to one variable name, for when the + producing key and the expected variable differ. +- `envConfig` is for non-secret configuration only. It lands in a ConfigMap. + +## Validation + +`./scripts/lint-charts.sh` runs `helm lint --strict` on every chart and +`helm template` on every application chart. CI runs the same script on pull +requests and on pushes to `main` and `development`, so a chart that fails +locally fails the same way in CI. + +Because service charts depend on `oan-common` through +`file://../oan-common`, and the packaged dependency is not committed, an edit to +`oan-common` is only visible to a consuming chart after +`helm dependency update charts/` (or a run of the lint script). + +## Traceability + +Chart work follows the repository's git conventions: branch +`feat/-`, commits carrying `[#]`, PR body closing the issue. +The chart's changelog entry and the issue number are the two ends of the same +thread. diff --git a/README.md b/README.md index 81364f6..9ce9ac8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,148 @@ # helmcharts -Helm charts for deploying and managing platform services + +Helm charts for deploying and managing OpenAgriNet (OAN) platform services. + +## Charts + +| Chart | Type | Purpose | +|---|---|---| +| [`oan-common`](charts/oan-common) | library | Shared template helpers — names, labels, image refs, probes, resources, service account, env config, dependency waits. Renders nothing; never installed directly. | +| [`oan-template`](charts/oan-template) | application | Complete, working starter chart built on `oan-common`. Copy it to bootstrap a service chart. | +| [`postgresql-cnpg`](charts/postgresql-cnpg) | application | CloudNativePG-managed PostgreSQL cluster. One release per database. Requires the CNPG operator. | +| [`postgresql-migration`](charts/postgresql-migration) | application | Flyway migrations as a Job. Creates the per-service databases and applies versioned SQL. | +| [`keycloak`](charts/keycloak) | application | Auth for the registry, on the Sunbird RC Keycloak image. Imports the realm the registry expects. | +| [`registry`](charts/registry) | application | The OAN participant registry, on Sunbird RC core. Needs `postgresql-cnpg` and `keycloak`. | +| [`discovery`](charts/discovery) | application | The OAN Beckn discover-and-publish service. Needs `postgresql-cnpg` **with pgvector**. | +| [`adapter-service`](charts/adapter-service) | application | The OAN Beckn adapters. One chart, installed once per `role` — `provider`, `network` or `experience`. Needs `registry`. | + +## How they fit together + +``` +charts/ +├── oan-common/ # library chart — shared helpers +├── oan-template/ # starter chart — copy this to build a service chart +├── postgresql-cnpg/ # data store +├── postgresql-migration/# schema migrations (Flyway Job) +├── keycloak/ # auth for the registry +├── registry/ # the participant registry +├── discovery/ # the Beckn discover-and-publish service +└── adapter-service/ # the Beckn adapters — one release per role +``` + +Every chart depends on `oan-common` via `file://../oan-common`. + +## The registry stack + +Three charts, deployed in this order — the ordering is not optional: + +```bash +# 1. Database cluster. Creates BOTH databases: `registry` via bootstrap.initdb +# and `keycloak` via a CNPG Database object, each owned by its own role. +helm install registry-db charts/postgresql-cnpg -n oan-registry -f charts/postgresql-cnpg/examples/registry-db.dev.yaml +# 2. Keycloak — imports the sunbird-rc realm it ships with, on first start +helm install keycloak charts/keycloak -n oan-registry -f charts/keycloak/examples/keycloak.dev.yaml +# MANUAL STEP: regenerate the admin-api client secret in the Keycloak console — +# the realm export ships it masked, so the registry cannot authenticate without this. +# 3. Registry +helm install registry charts/registry -n oan-registry -f charts/registry/examples/registry.dev.yaml +``` + +`postgresql-migration` is deliberately not in that list: both databases come from +the cluster chart, and Sunbird RC and Keycloak each manage their own schema, so +there is nothing for Flyway to apply yet. It joins the flow when OAN adds schemas +of its own. + +Their configuration is ported from the verified `registry/docker-compose.yml` +stack at exact environment-variable parity (32 for the registry, 10 for +Keycloak), so a cluster deploy reproduces what was tested locally. Each chart's +README documents where it deliberately deviates and why. Full walkthrough: +[`charts/registry/README.md`](charts/registry/README.md). + +## The discovery service + +Two charts, and one prerequisite that is easy to miss: + +```bash +# 1. Its own database — pgvector on PostgreSQL 16, with the `vector` extension +# created at bootstrap. Both are required: no stock CNPG operand image has +# pgvector, and `vector` is not a trusted extension, so the owner the service +# connects as cannot create it itself. +helm install discovery-db charts/postgresql-cnpg -n oan-discovery -f charts/postgresql-cnpg/examples/discovery-db.dev.yaml +# 2. The service +helm install discovery charts/discovery -n oan-discovery -f charts/discovery/examples/discovery.dev.yaml +``` + +It shares no database and no Keycloak with the registry stack, so the two are +independent installs. Full walkthrough, including how the DSN and the Beckn +specification are supplied: +[`charts/discovery/README.md`](charts/discovery/README.md). + +Service charts depend on `oan-common` and call its helpers through thin +chart-local wrappers. That keeps naming, labelling, probe, resource, and secret +conventions identical across every OAN chart, and means a convention change is +one edit in the library rather than one edit per chart. + +## Quick start + +Build a service chart from the template: + +```bash +# 1. Copy the starter chart +cp -r charts/oan-template charts/oan-my-service + +# 2. In charts/oan-my-service/Chart.yaml set name: oan-my-service +# and appVersion to the image tag you deploy by default. +# Keep the oan-common dependency. + +# 3. Rename the chart-local helpers to your service name. Change only the left +# side of each define in templates/_helpers.tpl (oan-template.* -> +# oan-my-service.*); the oan-common.* include inside the body stays. This +# renames the defines and the include calls together: +grep -rl 'oan-template\.' charts/oan-my-service | xargs sed -i '' 's/oan-template\./oan-my-service./g' +# (sed -i '' is the macOS form; on Linux use sed -i) + +# 4. Set image, ports, probe paths, resources and envConfig in +# charts/oan-my-service/values.yaml + +# 5. Validate +./scripts/lint-charts.sh +helm template oan-my-service charts/oan-my-service +``` + +See [`charts/oan-common/README.md`](charts/oan-common/README.md) for the full +helper reference and +[`charts/oan-template/README.md`](charts/oan-template/README.md) for the +step-by-step adaptation guide. + +## Validation + +```bash +./scripts/lint-charts.sh +``` + +Runs `helm lint --strict` on every chart and `helm template` on every +application chart. CI runs the identical script +([`.github/workflows/helm-lint.yml`](.github/workflows/helm-lint.yml)) on pull +requests and on pushes to `main` and `development`. + +Because charts depend on `oan-common` through `file://../oan-common` and the +packaged dependency is not committed, an edit to `oan-common` only reaches a +consuming chart after `helm dependency update charts/` — or a run of the +lint script, which does it for you. + +## Conventions + +Chart naming, `version`/`appVersion` rules, changelog requirements, what every +service chart must declare, and how secrets are handled are documented in +[`CONVENTIONS.md`](CONVENTIONS.md). + +Two of those rules are enforced at render time rather than at review time: a +chart with empty `resources` fails to render, and so does an enabled probe with +no handler or with more than one. + +## Secrets + +No secret value belongs in this repository, and **no chart here renders a +Secret**. Charts reference Secrets by name; creating them is deliberately left +outside the charts, so that decision is made once rather than per chart. See +[`CONVENTIONS.md`](CONVENTIONS.md#secrets). diff --git a/charts/adapter-service/CHANGELOG.md b/charts/adapter-service/CHANGELOG.md new file mode 100644 index 0000000..78b9592 --- /dev/null +++ b/charts/adapter-service/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this chart are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this chart adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Renamed from `network-adapter` to `adapter-service`, and generalised from one + adapter to all three (#2). provider, network and experience run the same image and + the same config format, so `role` now selects what differed between them + rather than each needing its own chart. +- `role` is required and validated against `provider|network|experience`. It has no + default: a default would hand one adapter another one's handler role and step + list, which renders, starts, reports Ready and then mis-handles every request. +- `handler.role` and `handler.steps` derive from `role` and are overridable. + `experience` gets `bap` and `[addRoute, sign]` — it sits inside the trust boundary + and takes unsigned requests, so it has no signature to validate. The other + two get `bpp` and `[validateSign, addRoute, sign]`. +- `discovery.url` replaced by `routing.rules`, a list. `provider` fans out to + several upstreams; a single target could not express that. +- Config placeholders renamed `__NETWORK_*` to `__ADAPTER_*`, and the routing + file to `routing-.yaml`. +- `appName` and `otel.serviceName` default to `-adapter` and + `oan--adapter`, matching the compose stack's service and OTEL names. +- `http.timeout` is now a value rather than hardcoded. + +### Added + +- `examples/{provider,network,experience}.yaml` — one values file per role. +- `ci/otel-ingress-values.yaml` now renders the `experience` role, so the role + branches that `ci/lint-values.yaml` (network) does not reach are covered. +- NOTES print the role, resolved step list and routing targets, and warn that + an Ingress on the `experience` role exposes an unauthenticated entry point. + +## [0.1.0] - 2026-09-04 + +### Added + +- Initial chart, modelled on the `network-adapter` service in + `docker-deployment/docker-compose.yml` (#2). +- Config rendered from a ConfigMap of placeholders plus an identity Secret, + substituted by an init container into an `emptyDir` — so the keypair is + never written to a ConfigMap and never appears in a process environment. +- Render-time failures for the five values whose absence would otherwise + produce a pod that runs, reports Ready, and does not work. +- Optional HPA, PodDisruptionBudget, Ingress and upstream readiness gates, all + off by default. diff --git a/charts/adapter-service/Chart.yaml b/charts/adapter-service/Chart.yaml new file mode 100644 index 0000000..01b62b1 --- /dev/null +++ b/charts/adapter-service/Chart.yaml @@ -0,0 +1,32 @@ +apiVersion: v2 +name: adapter-service +description: >- + One chart for the OAN Beckn adapters. provider, network and experience are the same + image and config format, so `role` selects the handler role, the step list + and the routing target; install once per role. Stateless: an adapter's + identity is a keypair held in a Secret, and everything else it reads from + the registry. +type: application +version: 0.1.0 +# The adapter binary, not this chart. `/health` reports the build it is +# actually running, which is the number to trust over this one. +appVersion: "v1.9.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - beckn + - adapter + - provider + - network + - experience +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts + - https://github.com/OpenAgriNet/network-adapter +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/adapter-service/README.md b/charts/adapter-service/README.md new file mode 100644 index 0000000..d149456 --- /dev/null +++ b/charts/adapter-service/README.md @@ -0,0 +1,135 @@ +# adapter-service + +One chart for all three Beckn adapters. `provider`, `network` and `experience` +are the same image and the same config format, so `role` selects the handler +role, the step list and where requests are routed. + +``` + experience ──unsigned──▶ network ──▶ discovery + │ + provider ──signed─────────┤ + │ └── registry (whose key signed this?) + └──▶ mandi / agmarknet upstreams +``` + +| Role | Handler | Steps | Routes to | +|---|---|---|---| +| `provider` | `bpp` | `validateSign → addRoute → sign` | upstream APIs (several) | +| `network` | `bpp` | `validateSign → addRoute → sign` | discovery | +| `experience` | `bap` | `addRoute → sign` | network | + +`experience` is the only one that accepts **unsigned** requests: the experience +app is inside the trust boundary, so there is no network signature to check. +That is why it has no `validateSign`, and why an Ingress on it exposes an +unauthenticated entry point. + +Stateless. Each adapter's identity is a keypair in a Secret; everything else it +reads from the registry at request time. + +## Install + +Once per role. **Keep the release name per-role** — it is what the Service is +called, which is the DNS name the other adapters address. + +```sh +helm dependency build charts/adapter-service + +helm upgrade --install provider-adapter charts/adapter-service -n oan \ + -f charts/adapter-service/examples/provider.yaml +helm upgrade --install network-adapter charts/adapter-service -n oan \ + -f charts/adapter-service/examples/network.yaml +helm upgrade --install experience-adapter charts/adapter-service -n oan \ + -f charts/adapter-service/examples/experience.yaml +``` + +Each example sets `fullnameOverride` to `-adapter`. Without it the Service +would be named `-adapter-service`, and nothing routing by name would +resolve. Rename a release and you must update whatever routes to it — `experience` +points at the network adapter, `network` points at discovery. + +The Secret comes first — the render fails without it. + +## What you must decide + +Six values have no default, and the chart fails rather than guessing. Each one +is something that produces a *silent* failure if wrong, which is why it is a +render error rather than a default: + +| Value | Why there is no default | +|---|---| +| `image.repository` | An empty one renders `ghcr.io/:tag`, which Helm and the API server both accept and which surfaces later as `ImagePullBackOff` | +| `keys.existingSecret.name` | Without an identity the pod starts, serves `/health`, reports Ready, and fails every signature it makes — in a peer's logs | +| `registry.url` | The adapter verifies every caller against the registry, so with none it can verify nobody | +| `role` | It decides the handler role, the step list and the routing target. A default would give one adapter another one's behaviour | +| `routing.rules` | An adapter with no route accepts requests and has nowhere to send them | +| `otel.endpoint` | Only when `otel.enabled` — an enabled exporter with nowhere to send logs a failure every interval | + +## The identity Secret + +Six keys, all required: + +```sh +kubectl -n oan create secret generic -adapter-keys \ + --from-literal=subscriberId=... \ + --from-literal=keyId=... \ + --from-literal=signingPrivateKey=... \ + --from-literal=signingPublicKey=... \ + --from-literal=encrPrivateKey=... \ + --from-literal=encrPublicKey=... +``` + +`keyId` is the **key's osid as the registry assigned it**, not a name you +choose — that is what a verifier looks the key up by. + +In the compose stack `bin/setup.py` generates these and writes them to +`keys/keys.json`. Check the field names there before scripting the command +above; that file's shape belongs to `setup.py`, not to this chart. + +### How the keys reach the process + +The adapter reads one config file and wants the keys inline in it. A plain +ConfigMap would therefore hold private keys, so instead: + +1. the **ConfigMap** holds the config with `__NETWORK_*__` placeholders — the + same shape as the `.tmpl` in the compose stack; +2. the **Secret** is mounted as files, not env vars, so the values are not + readable from `/proc//environ` of anything in the pod; +3. an **init container** substitutes one into the other and writes the result + to an `emptyDir` that dies with the pod. + +It fails loudly if a key is empty or a placeholder survives. That check exists +because the alternative is an adapter that runs, looks healthy, and produces +signatures nobody can verify — a failure that shows up in someone else's logs. + +## Key rotation needs a restart + +The keys Secret is not rendered by this chart, so its contents cannot go into +the config checksum, so changing it restarts nothing: + +```sh +kubectl -n oan rollout restart deploy/-adapter +``` + +Changing anything else — log level, upstreams, telemetry — rolls the pods on +its own. + +## The registry row + +This chart cannot verify the half of the setup that lives outside the cluster. +The adapter's `subscriberId` needs a `Participant` row in the registry carrying +the **public** half of the keypair. Missing, or carrying a different key, and +every peer rejects what this adapter signs while the pod stays perfectly +healthy. + +## Values + +See `values.yaml` — every field is commented with what it does and what breaks +without it. `examples/{provider,network,experience}.yaml` are working dev deployments; +`ci/` holds the two files lint renders, one minimal and one with every switch +turned on. + +## Related + +- `charts/discovery` — where `discover` and `publish` are forwarded +- `charts/registry` — what signatures are verified against +- `docker-deployment/` — the compose stack this chart was modelled on diff --git a/charts/adapter-service/ci/lint-values.yaml b/charts/adapter-service/ci/lint-values.yaml new file mode 100644 index 0000000..339280d --- /dev/null +++ b/charts/adapter-service/ci/lint-values.yaml @@ -0,0 +1,29 @@ +# Minimum that renders. Every value here is one the chart fails without, which +# makes this file a list of what a real deployment must decide. +# +# role is first among them: it has no default, because a default would hand one +# adapter another one's handler role and step list. +role: network +fullnameOverride: network-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "v1.9.0" + +keys: + existingSecret: + name: network-adapter-keys + +registry: + url: http://registry:8081/api/v1 + +routing: + rules: + - version: "2.0.0" + targetType: url + target: + url: http://discovery:8080 + endpoints: + - discover + - publish diff --git a/charts/adapter-service/ci/otel-ingress-values.yaml b/charts/adapter-service/ci/otel-ingress-values.yaml new file mode 100644 index 0000000..2eebf87 --- /dev/null +++ b/charts/adapter-service/ci/otel-ingress-values.yaml @@ -0,0 +1,51 @@ +# The other side of every switch, so lint covers the branches the default +# values never reach. +# +# Uses the experience role on purpose: it is the one with a different handler role and +# a shorter step list, so this case renders the role branches that +# ci/lint-values.yaml (network) does not. +role: experience +fullnameOverride: experience-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "v1.9.0" + +keys: + existingSecret: + name: experience-adapter-keys + +registry: + url: http://registry:8081/api/v1 + +routing: + rules: + - version: "2.0.0" + targetType: url + target: + url: http://network-adapter:9201 + endpoints: + - discover + - publish + +otel: + enabled: true + endpoint: otel-collector.observability.svc.cluster.local:4317 + +autoscaling: + enabled: true +podDisruptionBudget: + enabled: true + +waitFor: + enabled: true + +ingress: + enabled: true + className: alb + hosts: + - host: network.oan.example.com + paths: + - path: / + pathType: Prefix diff --git a/charts/adapter-service/examples/experience.yaml b/charts/adapter-service/examples/experience.yaml new file mode 100644 index 0000000..bcd15a0 --- /dev/null +++ b/charts/adapter-service/examples/experience.yaml @@ -0,0 +1,64 @@ +# =========================================================================== +# experience role -- the experience adapter, and the caller. +# +# The only adapter that takes UNSIGNED requests: the experience app is inside +# the trust boundary, so there is no network signature to check. That is what +# makes the stack testable with a plain curl, and it is why this role has no +# validateSign in its steps and runs as bap rather than bpp. +# +# It is also why the edge should rate-limit this host and nothing else. +# +# helm install experience-adapter . -f examples/experience.yaml +# +# routing.rules points at the network adapter by RELEASE name. Rename that +# release and this must change with it. +# =========================================================================== +role: experience + +# The Service name, and therefore the in-cluster DNS name other adapters use. +# Set explicitly because fullname is otherwise "-" -- release +# experience-adapter plus chart adapter-service would render +# experience-adapter-adapter-service, which is not what anything routes to. +fullnameOverride: experience-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "latest" + +keys: + existingSecret: + name: experience-adapter-keys + +registry: + url: http://registry:8081/api/v1 + +service: + port: 9202 + targetPort: 9202 + +# No validateSign: nothing that reaches this adapter carries a network +# signature. Set explicitly rather than left to the role default so the +# omission reads as deliberate. +handler: + role: bap + steps: + - addRoute + - sign + +routing: + rules: + - version: "2.0.0" + targetType: url + target: + url: http://network-adapter:9201 + endpoints: + - discover + - publish + +waitFor: + enabled: false + +otel: + enabled: false + endpoint: "" diff --git a/charts/adapter-service/examples/network.yaml b/charts/adapter-service/examples/network.yaml new file mode 100644 index 0000000..3f158e2 --- /dev/null +++ b/charts/adapter-service/examples/network.yaml @@ -0,0 +1,55 @@ +# =========================================================================== +# network role -- the network layer of the Beckn network. +# +# Verifies that the caller's signature checks out against the key the registry +# publishes for them, hands discover and publish to the discovery service, and +# signs on the way out. It answers neither action itself. +# +# helm install network-adapter . -f examples/network.yaml +# +# Keep the release name `network-adapter`: experience routes to this Service by name. +# =========================================================================== +role: network + +# The Service name, and therefore the in-cluster DNS name other adapters use. +# Set explicitly because fullname is otherwise "-" -- release +# network-adapter plus chart adapter-service would render +# network-adapter-adapter-service, which is not what anything routes to. +fullnameOverride: network-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "latest" + +keys: + existingSecret: + name: network-adapter-keys + +registry: + url: http://registry:8081/api/v1 + +service: + port: 9201 + targetPort: 9201 + +# Both actions share one rule because they share a target: publish arrives +# from the provider adapter, discover from the experience adapter, and neither +# is answered here. +routing: + rules: + - version: "2.0.0" + targetType: url + target: + url: http://discovery:8080 + endpoints: + - discover + - publish + +# The upstreams this adapter cannot serve a request without. +waitFor: + enabled: false + +otel: + enabled: false + endpoint: "" diff --git a/charts/adapter-service/examples/provider.yaml b/charts/adapter-service/examples/provider.yaml new file mode 100644 index 0000000..b567d0b --- /dev/null +++ b/charts/adapter-service/examples/provider.yaml @@ -0,0 +1,66 @@ +# =========================================================================== +# provider role -- the seller side. +# +# Receives from the network, verifies the caller, and fans the action out to +# the upstream APIs that actually answer it. Unlike the other two roles it has +# more than one target, which is why routing.rules is a list. +# +# helm install provider-adapter . -f examples/provider.yaml +# +# MANDI_TOKEN is the mandi upstream's credential. It is named here and read at +# call time, so it never lands in a config file or in the registry -- put it in +# a Secret and reference it, do not inline it. +# =========================================================================== +role: provider + +# The Service name, and therefore the in-cluster DNS name other adapters use. +# Set explicitly because fullname is otherwise "-" -- release +# provider-adapter plus chart adapter-service would render +# provider-adapter-adapter-service, which is not what anything routes to. +fullnameOverride: provider-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "latest" + +keys: + existingSecret: + name: provider-adapter-keys + +registry: + url: http://registry:8081/api/v1 + +service: + port: 9200 + targetPort: 9200 + +# One rule per upstream. Each target.url is a bare host and port -- the router +# appends the action, so no path and no trailing slash. +routing: + rules: + - version: "2.0.0" + targetType: url + target: + url: http://mockimd:8080 + endpoints: + - search + - select + - version: "2.0.0" + targetType: url + target: + url: http://mockagmarknet:8080 + endpoints: + - search + +# The mandi credential. envFromSecrets pulls the whole Secret in as env vars, +# so MANDI_TOKEN is a key inside it rather than a literal here. +envFromSecrets: + - provider-adapter-upstream + +waitFor: + enabled: false + +otel: + enabled: false + endpoint: "" diff --git a/charts/adapter-service/templates/NOTES.txt b/charts/adapter-service/templates/NOTES.txt new file mode 100644 index 0000000..17ac5e6 --- /dev/null +++ b/charts/adapter-service/templates/NOTES.txt @@ -0,0 +1,55 @@ +{{ .Chart.Name }} {{ .Chart.Version }} — {{ include "adapter-service.fullname" . }} + + role {{ include "adapter-service.role" . }} ({{ include "adapter-service.handlerRole" . }}) + steps {{ include "adapter-service.steps" . | fromYamlArray | join " -> " }} + image {{ include "adapter-service.image" . }} + service {{ include "adapter-service.fullname" . }}:{{ .Values.service.port }} + registry {{ .Values.registry.url }} + routes to {{ range $i, $r := .Values.routing.rules }}{{ if $i }}, {{ end }}{{ $r.target.url }}{{ end }} + telemetry {{ if .Values.otel.enabled }}{{ .Values.otel.endpoint }}{{ else }}off{{ end }} + +This Service is reachable in-cluster as {{ include "adapter-service.fullname" . }}:{{ .Values.service.port }}. +That name comes from the RELEASE name — whatever routes here must use it, so +renaming this release means updating the adapter that points at it. + +Check it is serving: + + kubectl -n {{ include "oan-common.namespace" . }} port-forward svc/{{ include "adapter-service.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + curl localhost:{{ .Values.service.port }}/health + +/health reports the build actually running, which is worth more than this +chart's appVersion. + +TWO THINGS THIS CHART CANNOT CHECK FOR YOU + +1. The Participant row. This adapter signs as {{ .Values.keys.existingSecret.name }}'s + subscriberId, and every peer verifies that signature against the public key + the registry publishes for it. If the row is missing, or carries a different + key, this pod runs and reports Ready while every peer rejects what it signs + — and the error appears in their logs, not yours. + +2. Key rotation. The keys live in Secret/{{ include "adapter-service.keysSecretName" . }}, + which this chart does not render and therefore cannot include in the config + checksum. Changing that Secret does not restart anything: + + kubectl -n {{ include "oan-common.namespace" . }} rollout restart deploy/{{ include "adapter-service.fullname" . }} + +{{- if not .Values.otel.enabled }} + +Telemetry is off, which is the right setting until a collector exists: with it +on and nothing listening the exporter logs a failure every interval. +{{- end }} +{{- if .Values.ingress.enabled }} + +Ingress is ON. +{{- if eq (include "adapter-service.role" .) "experience" }} +This is the experience role, which accepts UNSIGNED requests — it is inside the trust +boundary and has no validateSign step. An Ingress therefore exposes an +unauthenticated entry point to the network: rate-limit this host, and do not +route it from anywhere you would not trust to call the network directly. +{{- else }} +This adapter verifies signatures on the way in, so what keeps it safe is the +registry's contents rather than this route — every caller must hold a key the +registry publishes for them. +{{- end }} +{{- end }} diff --git a/charts/adapter-service/templates/_helpers.tpl b/charts/adapter-service/templates/_helpers.tpl new file mode 100644 index 0000000..db3d018 --- /dev/null +++ b/charts/adapter-service/templates/_helpers.tpl @@ -0,0 +1,204 @@ +{{/* +# ============================================================================ +# ADAPTER SERVICE CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers delegating to oan-common, plus the role, +# identity, upstream and config-rendering wiring an adapter needs. +# +# One chart, three roles. provider, network and experience are the same image and the +# same config format; the role decides the handler role, the step list and +# where requests are routed. Install once per role, and keep the RELEASE name +# per-role -- the release name is what becomes the Service DNS name that the +# other adapters address. +# ============================================================================ +*/}} + +{{/* +The adapter's role: provider | network | experience. + +Required, with no default. A default would silently give one role's step list +and handler role to a different adapter -- which renders, starts, reports Ready +and then mis-handles every request, at the far end, in a peer's logs. +*/}} +{{- define "adapter-service.role" -}} +{{- $role := .Values.role | default "" -}} +{{- if not $role -}} +{{- fail (printf "%s: role is required -- one of provider, network, experience. It decides the handler role, the step list and the routing target, so there is no safe default. See examples/ for a values file per role." .Chart.Name) -}} +{{- end -}} +{{- if not (has $role (list "provider" "network" "experience")) -}} +{{- fail (printf "%s: role must be one of provider, network, experience (got %q)." .Chart.Name $role) -}} +{{- end -}} +{{- $role -}} +{{- end }} + +{{/* +The name the adapter calls itself in its config, its logs and its module. +Defaults to -adapter, matching the compose stack's service names. +*/}} +{{- define "adapter-service.appName" -}} +{{- .Values.appName | default (printf "%s-adapter" (include "adapter-service.role" .)) -}} +{{- end }} + +{{/* +OTEL service name. Defaults to oan--adapter, matching OTEL_SERVICE_NAME +in the compose stack so traces from either deployment line up. +*/}} +{{- define "adapter-service.otelServiceName" -}} +{{- .Values.otel.serviceName | default (printf "oan-%s-adapter" (include "adapter-service.role" .)) -}} +{{- end }} + +{{/* +bap originates a call, bpp receives one. experience is the caller; network and +provider receive. Overridable, because the role is a shorthand for a default +rather than a constraint. +*/}} +{{- define "adapter-service.handlerRole" -}} +{{- if .Values.handler.role -}} +{{- .Values.handler.role -}} +{{- else if eq (include "adapter-service.role" .) "experience" -}} +bap +{{- else -}} +bpp +{{- end -}} +{{- end }} + +{{/* +The step list, as a YAML array. + +experience sits inside the trust boundary and accepts unsigned requests, so it has no +signature to validate; the other two receive from the network and must verify +first. Set handler.steps to override. +*/}} +{{- define "adapter-service.steps" -}} +{{- if .Values.handler.steps -}} +{{- toYaml .Values.handler.steps -}} +{{- else if eq (include "adapter-service.role" .) "experience" -}} +{{- toYaml (list "addRoute" "sign") -}} +{{- else -}} +{{- toYaml (list "validateSign" "addRoute" "sign") -}} +{{- end -}} +{{- end }} + +{{/* +The routing config filename. One file per role so a rendered config is +self-describing when you exec into a pod. +*/}} +{{- define "adapter-service.routingFile" -}} +routing-{{ include "adapter-service.role" . }}.yaml +{{- end }} + +{{/* +The routing rules, as a YAML array. + +A list rather than one rule: provider fans out to several upstreams, while +network and experience each have a single target. Every rule needs a target url, +and a trailing slash on one produces //discover once the router appends the +action -- so that is caught here rather than at request time. +*/}} +{{- define "adapter-service.routingRules" -}} +{{- $rules := .Values.routing.rules | default list -}} +{{- if not $rules -}} +{{- fail (printf "%s: routing.rules is required and must hold at least one rule. An adapter with no route accepts requests and has nowhere to send them. See examples/%s.yaml." .Chart.Name (include "adapter-service.role" .)) -}} +{{- end -}} +{{- range $i, $rule := $rules -}} +{{- $url := $rule.target.url | default "" -}} +{{- if not $url -}} +{{- fail (printf "%s: routing.rules[%d].target.url is required -- e.g. http://discovery:8080. In-cluster this is the RELEASE name of the target, which is what its Service is called." $.Chart.Name $i) -}} +{{- end -}} +{{- if hasSuffix "/" $url -}} +{{- fail (printf "%s: routing.rules[%d].target.url must not end in a slash (%q). The router appends the action to it, so a trailing slash produces //discover." $.Chart.Name $i $url) -}} +{{- end -}} +{{- if not $rule.endpoints -}} +{{- fail (printf "%s: routing.rules[%d].endpoints is required -- e.g. [discover, publish]. A rule with no endpoints matches nothing." $.Chart.Name $i) -}} +{{- end -}} +{{- end -}} +{{- toYaml $rules -}} +{{- end }} + +{{- define "adapter-service.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "adapter-service.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "adapter-service.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{- define "adapter-service.selectorLabels" -}} +{{- include "oan-common.selectorLabels" . -}} +{{- end }} + +{{- define "adapter-service.serviceAccountName" -}} +{{- include "oan-common.serviceAccount.name" . -}} +{{- end }} + +{{- define "adapter-service.image" -}} +{{- include "oan-common.image" . -}} +{{- end }} + +{{/* +The Secret holding this adapter's keypair. + +Failing here rather than letting the render succeed is the point. Without it +the config would carry the literal placeholders through to the process, which +starts, serves /health, reports Ready, and then fails every signature it +attempts -- at the far end, in a peer's logs, with nothing in this pod saying +why. +*/}} +{{- define "adapter-service.keysSecretName" -}} +{{- $name := .Values.keys.existingSecret.name | default "" -}} +{{- if not $name -}} +{{- fail (printf "%s: keys.existingSecret.name is required. This adapter's identity is a keypair, this chart renders no Secrets, and a pod without one starts and reports Ready while failing every signature it makes. See values.yaml for the six keys it must hold." .Chart.Name) -}} +{{- end -}} +{{- $name -}} +{{- end }} + +{{/* +The registry API base. Required for the same reason as the keys: absent, the +adapter builds a plugin pointed at nothing and every signature verification +fails on a lookup rather than on the signature. +*/}} +{{- define "adapter-service.registryUrl" -}} +{{- $url := .Values.registry.url | default "" -}} +{{- if not $url -}} +{{- fail (printf "%s: registry.url is required -- e.g. http://registry:8081/api/v1. The adapter verifies every caller against the key the registry publishes for them, so with no registry it can verify nobody." .Chart.Name) -}} +{{- end -}} +{{- $url -}} +{{- end }} + +{{/* +OTLP endpoint, required only when telemetry is on. An enabled exporter with +no endpoint dials nothing and logs a failure per export interval, which is +the noise the three enable flags exist to avoid. +*/}} +{{- define "adapter-service.otlpEndpoint" -}} +{{- if .Values.otel.enabled -}} +{{- $ep := .Values.otel.endpoint | default "" -}} +{{- if not $ep -}} +{{- fail (printf "%s: otel.endpoint is required when otel.enabled is true -- e.g. otel-collector.observability.svc.cluster.local:4317. An enabled exporter with nowhere to send logs a failure every export interval." .Chart.Name) -}} +{{- end -}} +{{- $ep -}} +{{- end -}} +{{- end }} + +{{/* +Where the rendered config lands. An emptyDir, because the init container +writes it and readOnlyRootFilesystem means there is nowhere else to write. +*/}} +{{- define "adapter-service.configDir" -}} +/app/config +{{- end }} + +{{/* +Annotation that rolls the pods when the config changes. + +The keys are not in this checksum and must not be: the Secret is not rendered +by this chart, so its content is not knowable at template time. Rotating a key +therefore needs a rollout restart -- which the NOTES print. +*/}} +{{- define "adapter-service.configChecksum" -}} +{{- include (print $.Template.BasePath "/configmap.yaml") . | sha256sum -}} +{{- end }} diff --git a/charts/adapter-service/templates/configmap.yaml b/charts/adapter-service/templates/configmap.yaml new file mode 100644 index 0000000..bda9deb --- /dev/null +++ b/charts/adapter-service/templates/configmap.yaml @@ -0,0 +1,131 @@ +{{/* +The adapter's two config files. + +adapter.yaml carries PLACEHOLDERS where the keypair goes, not the keypair -- +this is a ConfigMap, and a ConfigMap is world-readable to anything with get on +the namespace. The init container substitutes the real values from the Secret +into an emptyDir at start-up. + +Everything that differs between the provider, network and experience adapters is a +value: they are one image with one config format, and the role decides the +identity, the handler role, the step list and where requests are routed. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "adapter-service.fullname" . }}-config + labels: + {{- include "adapter-service.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + adapter.yaml: | + # Rendered by the adapter-service chart for the {{ include "adapter-service.role" . }} role. + # Do not edit in place: the init container rewrites a copy of this, and + # this object is replaced on every helm upgrade. + appName: {{ include "adapter-service.appName" . | quote }} + + log: + level: {{ .Values.logLevel | quote }} + destinations: + - type: stdout + contextKeys: [transaction_id, message_id, subscriber_id, module_id] + + http: + port: {{ .Values.service.targetPort }} + timeout: + read: {{ .Values.http.timeout.read }} + write: {{ .Values.http.timeout.write }} + idle: {{ .Values.http.timeout.idle }} + + pluginManager: + root: ./plugins + + plugins: + otelsetup: + id: otelsetup + config: + serviceName: {{ include "adapter-service.otelServiceName" . | quote }} + environment: {{ .Values.otel.environment | quote }} + otlpEndpoint: {{ include "adapter-service.otlpEndpoint" . | quote }} + enableMetrics: {{ .Values.otel.enabled | quote }} + enableTracing: {{ .Values.otel.enabled | quote }} + enableLogs: {{ .Values.otel.enabled | quote }} + + modules: + - name: {{ include "adapter-service.appName" . | quote }} + # A subtree: every action lands here and the payload says which one. + path: {{ .Values.handler.path | quote }} + handler: + type: std + {{/* + bap originates a call, bpp receives one. It decides which declared + identity validateSign would compare a signer against -- so it is not + cosmetic, and it is why experience (which originates) differs from network + and provider (which receive). + */}} + role: {{ include "adapter-service.handlerRole" . | quote }} + subscriberId: __ADAPTER_SUBSCRIBER_ID__ + + plugins: + registry: + id: oanregistry + config: + url: {{ include "adapter-service.registryUrl" . | quote }} + entity: {{ .Values.registry.entity | quote }} + providerEntity: {{ .Values.registry.providerEntity | quote }} + + keyManager: + id: simplekeymanager + config: + subscriberId: __ADAPTER_SUBSCRIBER_ID__ + # The KEY's osid, not a friendly name: that is what the + # registry indexes keys by, and what a verifier looks up. + keyId: __ADAPTER_KEY_ID__ + signingPrivateKey: "__ADAPTER_SIGNING_PRIVATE__" + signingPublicKey: "__ADAPTER_SIGNING_PUBLIC__" + encrPrivateKey: "__ADAPTER_ENCR_PRIVATE__" + encrPublicKey: "__ADAPTER_ENCR_PUBLIC__" + + signer: + id: signer + signValidator: + id: signvalidator + + schemaValidator: + id: schemav2validator + config: + type: url + location: {{ .Values.becknSpec.url | quote }} + cacheTTL: {{ .Values.becknSpec.cacheTTL | quote }} + extendedSchema_enabled: {{ .Values.becknSpec.extendedSchema.enabled | quote }} + extendedSchema_cacheTTL: {{ .Values.becknSpec.extendedSchema.cacheTTL | quote }} + extendedSchema_maxCacheSize: {{ .Values.becknSpec.extendedSchema.maxCacheSize | quote }} + extendedSchema_downloadTimeout: {{ .Values.becknSpec.extendedSchema.downloadTimeout | quote }} + extendedSchema_allowedDomains: {{ .Values.becknSpec.extendedSchema.allowedDomains | quote }} + + router: + id: router + config: + routingConfig: {{ include "adapter-service.configDir" . }}/{{ include "adapter-service.routingFile" . }} + + {{/* + The step list is role-specific and the difference matters. experience is + inside the trust boundary and takes unsigned requests, so it has no + validateSign to run; network and provider receive from the network + and must verify before they do anything else. + */}} + steps: + {{- range include "adapter-service.steps" . | fromYamlArray }} + - {{ . }} + {{- end }} + + {{ include "adapter-service.routingFile" . }}: | + # Where this adapter hands requests on. + # + # targetType "url" appends the action to whatever base is given, so a base + # must be the bare host and port with no path. + routingRules: + {{- include "adapter-service.routingRules" . | nindent 6 }} diff --git a/charts/adapter-service/templates/deployment.yaml b/charts/adapter-service/templates/deployment.yaml new file mode 100644 index 0000000..52505bf --- /dev/null +++ b/charts/adapter-service/templates/deployment.yaml @@ -0,0 +1,186 @@ +apiVersion: {{ include "oan-common.deployment.apiVersion" . }} +kind: Deployment +metadata: + name: {{ include "adapter-service.fullname" . }} + labels: + {{- include "adapter-service.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "adapter-service.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "adapter-service.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + {{/* + Rolls the pods when the config changes. Deliberately does NOT cover the + keys: that Secret is not rendered here, so its content is unknowable at + template time. Rotating a key needs `kubectl rollout restart`. + */}} + checksum/config: {{ include "adapter-service.configChecksum" . }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.serviceAccount.enabled }} + serviceAccountName: {{ include "adapter-service.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- end }} + {{- with (include "oan-common.imagePullSecrets" . | trim) }} + {{- . | nindent 6 }} + {{- end }} + {{- with (include "oan-common.podSecurityContext" .) }} + securityContext: + {{- . | nindent 8 }} + {{- end }} + initContainers: + {{- include "oan-common.waitFor" (dict "ctx" . "tcp" list "http" list) | nindent 8 }} + {{/* + Substitutes the keypair into the config. + + Why an init container at all: the adapter reads one file and wants the + keys inline in it, so the alternative is a ConfigMap holding private + keys. This keeps them in a Secret, mounted as files rather than env + vars so they are not readable from /proc//environ, and writes the + result to an emptyDir that dies with the pod. + + `|` as the sed delimiter, not `/`: these values are base64 and base64 + contains `/`. It does not contain `|`. + */}} + - name: render-config + image: "{{ .Values.configRenderer.image.registry }}/{{ .Values.configRenderer.image.repository }}:{{ .Values.configRenderer.image.tag }}" + imagePullPolicy: {{ .Values.configRenderer.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + command: + - /bin/sh + - -c + - | + set -eu + cp /config-template/adapter.yaml /rendered/adapter.yaml + cp /config-template/{{ include "adapter-service.routingFile" . }} /rendered/{{ include "adapter-service.routingFile" . }} + + subst() { + # $1 placeholder, $2 file holding the value + if [ ! -s "$2" ]; then + echo "render-config: $(basename "$2") is missing or empty in the keys Secret" >&2 + exit 1 + fi + sed -i "s|$1|$(cat "$2")|g" /rendered/adapter.yaml + } + + subst __ADAPTER_SUBSCRIBER_ID__ /keys/{{ .Values.keys.existingSecret.subscriberIdKey }} + subst __ADAPTER_KEY_ID__ /keys/{{ .Values.keys.existingSecret.keyIdKey }} + subst __ADAPTER_SIGNING_PRIVATE__ /keys/{{ .Values.keys.existingSecret.signingPrivateKey }} + subst __ADAPTER_SIGNING_PUBLIC__ /keys/{{ .Values.keys.existingSecret.signingPublicKey }} + subst __ADAPTER_ENCR_PRIVATE__ /keys/{{ .Values.keys.existingSecret.encrPrivateKey }} + subst __ADAPTER_ENCR_PUBLIC__ /keys/{{ .Values.keys.existingSecret.encrPublicKey }} + + # A placeholder that survived means a key was substituted with + # nothing. Catch it here rather than at the far end, in a peer's + # logs, as a signature that will not verify. + if grep -q '__ADAPTER_' /rendered/adapter.yaml; then + echo "render-config: placeholders remain after substitution:" >&2 + grep -o '__ADAPTER_[A-Z_]*__' /rendered/adapter.yaml | sort -u >&2 + exit 1 + fi + echo "render-config: config rendered for the {{ include "adapter-service.role" . }} role" + volumeMounts: + - name: config-template + mountPath: /config-template + readOnly: true + - name: keys + mountPath: /keys + readOnly: true + - name: config + mountPath: /rendered + resources: + {{- toYaml .Values.configRenderer.resources | nindent 12 }} + {{- with .Values.extraInitContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "adapter-service.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + {{/* + The image's CMD is `./server --config=${CONFIG_FILE}`, so this is + not decoration -- unset, the binary is handed an empty --config. + */}} + - name: CONFIG_FILE + value: {{ include "adapter-service.configDir" . }}/adapter.yaml + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.envFromSecrets }} + envFrom: + {{- range . }} + - secretRef: + name: {{ . }} + {{- end }} + {{- end }} + {{- with (include "oan-common.probes" . | trim) }} + {{- . | nindent 10 }} + {{- end }} + resources: + {{- include "oan-common.resources" . | nindent 12 }} + volumeMounts: + - name: config + mountPath: {{ include "adapter-service.configDir" . }} + readOnly: true + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: config-template + configMap: + name: {{ include "adapter-service.fullname" . }}-config + - name: keys + secret: + secretName: {{ include "adapter-service.keysSecretName" . }} + defaultMode: 0400 + {{/* + The rendered config. emptyDir rather than anything durable: it is + derived from a ConfigMap and a Secret and is rebuilt on every start, + so persisting it would only let a stale copy outlive a key rotation. + */}} + - name: config + emptyDir: + sizeLimit: 1Mi + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/adapter-service/templates/hpa.yaml b/charts/adapter-service/templates/hpa.yaml new file mode 100644 index 0000000..6f69699 --- /dev/null +++ b/charts/adapter-service/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "adapter-service.fullname" . }} + labels: + {{- include "adapter-service.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "adapter-service.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- with .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} +{{- end }} diff --git a/charts/adapter-service/templates/ingress.yaml b/charts/adapter-service/templates/ingress.yaml new file mode 100644 index 0000000..8546a11 --- /dev/null +++ b/charts/adapter-service/templates/ingress.yaml @@ -0,0 +1,44 @@ +{{- if .Values.ingress.enabled }} +{{/* +Off by default, and worth a thought before turning on. + +This adapter takes SIGNED requests -- validateSign is its first step -- so +publishing it is not the same risk as publishing an adapter that does not. But +it is still a Beckn peer endpoint, and the thing that makes it safe is that +every caller must hold a key the registry publishes. That is a property of the +registry's contents, not of this Ingress. +*/}} +apiVersion: {{ include "oan-common.ingress.apiVersion" . }} +kind: Ingress +metadata: + name: {{ include "adapter-service.fullname" . }} + labels: + {{- include "adapter-service.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.ingress.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ include "adapter-service.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/adapter-service/templates/poddisruptionbudget.yaml b/charts/adapter-service/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..16f298f --- /dev/null +++ b/charts/adapter-service/templates/poddisruptionbudget.yaml @@ -0,0 +1,26 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{/* +Both set is a config error rather than a preference: the API server rejects a +PDB carrying minAvailable and maxUnavailable together, and it does so at apply +time, which is later and less legible than here. +*/}} +{{- if and .Values.podDisruptionBudget.minAvailable .Values.podDisruptionBudget.maxUnavailable }} +{{- fail (printf "%s: set podDisruptionBudget.minAvailable or maxUnavailable, not both." .Chart.Name) }} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "adapter-service.fullname" . }} + labels: + {{- include "adapter-service.labels" . | nindent 4 }} +spec: + {{- with .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ . }} + {{- end }} + {{- with .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ . }} + {{- end }} + selector: + matchLabels: + {{- include "adapter-service.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/adapter-service/templates/service.yaml b/charts/adapter-service/templates/service.yaml new file mode 100644 index 0000000..35e76ea --- /dev/null +++ b/charts/adapter-service/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "adapter-service.fullname" . }} + labels: + {{- include "adapter-service.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.service.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "adapter-service.selectorLabels" . | nindent 4 }} diff --git a/charts/adapter-service/templates/serviceaccount.yaml b/charts/adapter-service/templates/serviceaccount.yaml new file mode 100644 index 0000000..0464530 --- /dev/null +++ b/charts/adapter-service/templates/serviceaccount.yaml @@ -0,0 +1,18 @@ +{{- if .Values.serviceAccount.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "adapter-service.serviceAccountName" . }} + labels: + {{- include "adapter-service.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.serviceAccount.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{/* +false by default. This adapter calls the registry and the discovery service +over HTTP and never talks to the API server, so a projected token here would +be a credential nothing uses and anything reading the filesystem could take. +*/}} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/adapter-service/values.yaml b/charts/adapter-service/values.yaml new file mode 100644 index 0000000..3447c21 --- /dev/null +++ b/charts/adapter-service/values.yaml @@ -0,0 +1,341 @@ +# =========================================================================== +# adapter-service +# +# One chart for all three Beckn adapters. provider, network and experience are the +# same image and the same config format; `role` below decides the handler +# role, the step list and where requests are routed. +# +# INSTALL ONCE PER ROLE, and keep the RELEASE name per-role: +# +# helm install provider-adapter . -f examples/provider.yaml +# helm install network-adapter . -f examples/network.yaml +# helm install experience-adapter . -f examples/experience.yaml +# +# The release name is what the Service is called, which is the DNS name the +# other adapters address. Rename a release and you must update whatever routes +# to it -- experience routes to the network adapter, provider is routed to by network. +# +# Two things every role needs that this chart cannot give it: +# +# 1. an identity -- a keypair, and the osid the registry indexes it by. That +# is `keys.existingSecret` below, and this chart renders no Secrets. +# 2. a Participant row in the registry carrying the public half. Without it +# every peer that verifies this adapter's signature fails, and the +# symptom is a signature error at the far end rather than anything here. +# =========================================================================== + +# --------------------------------------------------------------------------- +# Role: provider | network | experience +# +# Required, with no default. A default would hand one role's handler role and +# step list to a different adapter, which renders, starts, reports Ready and +# then mis-handles every request. See examples/ for a values file per role. +# --------------------------------------------------------------------------- +role: "" + +# Overrides for what the role implies. Leave empty to take the role's default. +appName: "" + +replicaCount: 1 + +# --------------------------------------------------------------------------- +# Image +# +# One image serves all three adapters -- provider, network and experience -- +# and the config decides which one a pod is. So the repository here is the +# adapter image, not a network-adapter-specific build. +# +# repository is EMPTY on purpose: the render fails while it is, rather than +# producing "ghcr.io/:v1.9.0", which Helm and the API server both accept and +# which only surfaces later as an ImagePullBackOff. Set it per environment. +# +# The runtime is cgr.dev/chainguard/wolfi-base. It has a shell -- the image's +# own CMD is `sh -c` -- so unlike the discovery service this one is not +# distroless, and the security context below is doing real work rather than +# restating what the image already guarantees. +# --------------------------------------------------------------------------- +image: + registry: ghcr.io + repository: openagrinet/network-adapter # one image for all three roles + tag: "latest" + digest: "" + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + enabled: true + name: "" + annotations: {} + automountServiceAccountToken: false + +# --------------------------------------------------------------------------- +# Identity +# +# The private half of this adapter's keypair. It is a Secret, it is provided +# rather than generated, and the chart never renders one -- the same rule the +# rest of these charts follow. +# +# Create it with ExternalSecrets, or by hand from what bin/setup.py wrote into +# keys/keys.json in the compose stack: +# +# kubectl create secret generic -adapter-keys \ +# --from-literal=subscriberId=... \ +# --from-literal=keyId=... \ +# --from-literal=signingPrivateKey=... \ +# --from-literal=signingPublicKey=... \ +# --from-literal=encrPrivateKey=... \ +# --from-literal=encrPublicKey=... +# +# keyId is the KEY's osid as the registry assigned it, not a name you choose: +# that is what a verifier looks the key up by. Getting it wrong fails at the +# far end, when a peer cannot find the key that signed. +# +# HOW IT REACHES THE PROCESS. The adapter reads one config file and wants the +# keys inline in it, so a plain ConfigMap would put private keys in an object +# that is not a Secret. Instead the ConfigMap holds the config with +# placeholders -- the same shape as the .tmpl in the compose stack -- and an +# init container substitutes the Secret into an emptyDir that only this pod +# can see. The Secret is mounted as files rather than env vars so the values +# are not readable from /proc//environ of any process in the pod. +# --------------------------------------------------------------------------- +keys: + existingSecret: + name: "" + # Key names inside that Secret. Override only if yours are named + # differently -- there is no reason to rename them otherwise. + subscriberIdKey: subscriberId + keyIdKey: keyId + signingPrivateKey: signingPrivateKey + signingPublicKey: signingPublicKey + encrPrivateKey: encrPrivateKey + encrPublicKey: encrPublicKey + +# The image that renders the config. busybox, matching what waitFor already +# uses, so a cluster that can run one can run the other. +configRenderer: + image: + registry: docker.io + repository: busybox + tag: "1.37" + pullPolicy: IfNotPresent + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + +# --------------------------------------------------------------------------- +# Upstreams +# +# In-cluster address, read at request time rather than at +# start-up -- so a registry that is briefly unreachable produces failed +# requests, not a crash loop. +# --------------------------------------------------------------------------- +registry: + # The SunbirdRC registry API base. /api/v1 included: the plugin appends the + # entity name to it. + url: "" + entity: Participant + providerEntity: ProviderSchema + +# --------------------------------------------------------------------------- +# Handler +# +# role: bap originates a call, bpp receives one. Left empty, experience gets bap and +# the other two get bpp. +# steps: left empty, experience gets [addRoute, sign] and the other two get +# [validateSign, addRoute, sign]. experience sits inside the trust boundary and +# accepts unsigned requests, so it has no signature to validate -- that +# difference is the reason this is a value and not a constant. +# --------------------------------------------------------------------------- +handler: + path: / + role: "" + steps: [] + +# --------------------------------------------------------------------------- +# Routing +# +# A list, because provider fans out to several upstreams while network and experience +# each have one target. Required: an adapter with no route accepts requests and +# has nowhere to send them. +# +# target.url is a bare host and port with NO path -- targetType "url" appends +# the action, so a trailing slash produces //discover. In-cluster the host is +# the RELEASE name of the target, which is what its Service is called. +# --------------------------------------------------------------------------- +routing: + rules: [] + +# --------------------------------------------------------------------------- +# Beckn schema validation +# +# The base v2 schema, pinned to the LTS tag. Fetched once and cached for +# cacheTTL seconds. +# +# extendedSchema is off, and turning it on is a real decision rather than a +# stricter setting: it fetches each resource's own @context and validates +# against that, which is a network call per payload and a second thing that +# can fail on the request path. +# --------------------------------------------------------------------------- +becknSpec: + url: "https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml" + cacheTTL: "3600" + extendedSchema: + enabled: false + cacheTTL: "86400" + maxCacheSize: "100" + downloadTimeout: "30" + allowedDomains: "beckn.org,example.com,raw.githubusercontent.com" + +# --------------------------------------------------------------------------- +# Telemetry +# +# All three flags move together on purpose. With every one false the plugin +# builds no exporter and returns a no-op provider -- it does not dial, so it +# cannot log a connection failure every few seconds. Pointing a cluster with +# no collector at one is exactly how you get that, so `enabled: false` is the +# right setting until a collector exists. +# +# serviceVersion is deliberately not settable: left empty the plugin fills in +# the adapter's own build version, which is truer than anything written here +# and does not go stale. +# --------------------------------------------------------------------------- +otel: + enabled: false + # OTLP/gRPC, e.g. "otel-collector.observability.svc.cluster.local:4317" + endpoint: "" + environment: dev + # Left empty, defaults to oan--adapter, matching OTEL_SERVICE_NAME in + # the compose stack so traces from either deployment line up. + serviceName: "" + +logLevel: info + +http: + timeout: + read: 30 + write: 30 + idle: 30 + +# The compose stack uses 9200 for provider, 9201 for network and 9202 for experience. +# Nothing in-cluster requires that -- each adapter has its own Service -- but +# keeping them distinct means a rendered config reads the same in both places. +service: + type: ClusterIP + port: 9201 + # The adapter's http.port. Changing this changes what the config tells the + # binary to listen on, not just what the Service points at. + targetPort: 9201 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + tls: [] + +# --------------------------------------------------------------------------- +# Wait for the upstreams before starting. +# +# Off by default. The adapter reads the registry per request rather than at +# boot, so it starts happily without one and answers 5xx until it is there -- +# which is usually what you want from a rolling update. Turn it on where a +# pod that is Ready but cannot serve is worse than a pod that is slow to +# arrive. +# --------------------------------------------------------------------------- +waitFor: + enabled: false + timeoutSeconds: 300 + intervalSeconds: 3 + image: + registry: docker.io + repository: busybox + tag: "1.37" + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# Both hit /health, which the adapter serves from its own mux and which does +# not touch the registry or discovery -- so a readiness failure here means +# this process is unwell, not that something downstream is. +probes: + liveness: + enabled: true + path: /health + initialDelaySeconds: 10 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + enabled: true + path: /health + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 75 + targetMemoryUtilizationPercentage: "" + +podDisruptionBudget: + enabled: false + minAvailable: 1 + maxUnavailable: "" + +# The image declares no USER, so it would run as root without this. 65532 is +# nobody in the distroless convention and the image has no files owned by it, +# which is fine: everything written at runtime goes to the emptyDir below. +podSecurityContext: + enabled: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + enabled: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + +nodeSelector: {} +tolerations: [] +affinity: {} +podLabels: {} +podAnnotations: {} +commonLabels: {} +commonAnnotations: {} + +extraInitContainers: [] +extraVolumes: [] +extraVolumeMounts: [] +extraEnv: [] +envFromSecrets: [] diff --git a/charts/discovery/.helmignore b/charts/discovery/.helmignore new file mode 100644 index 0000000..3027bb2 --- /dev/null +++ b/charts/discovery/.helmignore @@ -0,0 +1,13 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig + +# Example values are documentation, not part of the package +examples/ diff --git a/charts/discovery/CHANGELOG.md b/charts/discovery/CHANGELOG.md new file mode 100644 index 0000000..873dda1 --- /dev/null +++ b/charts/discovery/CHANGELOG.md @@ -0,0 +1,82 @@ +# Changelog + +All notable changes to the `discovery` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-09-01 + +Initial release. Configuration ported from the verified +`discovery-service/docker-compose.yml` stack, built on the `oan-common` library. + +### Added +- Deployment, Service, env ConfigMap, ServiceAccount, and optional Ingress, + HorizontalPodAutoscaler and PodDisruptionBudget. +- Two ways to supply `DATABASE_URL`, exactly one of which must be set: + `database.urlSecret` for the whole DSN from one Secret key — which is what + CloudNativePG's generated `-app` Secret already provides under `uri` + — or `database.host/port/name/user/sslMode` plus `passwordSecret`, from which + the chart assembles a DSN with the password injected through Kubernetes' + `$(VAR)` expansion so it stays in the Secret and out of the rendered manifest. +- Every other environment variable derived from structured values rather than + restated, so `SERVER_PORT` cannot drift from the container port and + `DATABASE_URL` cannot drift from `database.*`. +- Beckn specification wiring: `becknSpec.url` for the fetch path, + `becknSpec.existingConfigMap` for the air-gapped one, and a volume mounted at + the cache path either way — `/app` is not writable by uid 65532, so with + `readOnlyRootFilesystem` on the fetch would have nowhere to cache. +- Split probes: liveness and startup on `/healthz`, readiness on `/readyz`. + Liveness on `/readyz` would restart every pod during a database blip, turning + a recoverable outage into a crashloop across the Deployment. +- Render-time validation of `app.networkId`, the database DSN, the Beckn spec + source, and the endpoint that a non-default `embeddings.provider` or + `otel.exporter` requires. Each names the value and why the boot needs it. +- `helm test` check against `/readyz` rather than `/healthz`: `/healthz` is 200 + with the database down and would pass on a deployment that cannot serve a + single discover. +- `extraVolumes` / `extraVolumeMounts` / `extraInitContainers` escape hatches. +- No init container waiting on PostgreSQL, unlike the `registry` chart. The + service fails fast when it cannot connect and Kubernetes' restart backoff is + already the retry, so a wait container buys a tidier first install at the cost + of a second thing to configure and keep pointed at the right host. +- Per-environment example values for dev and production. Production mounts the + Beckn document from a ConfigMap rather than fetching it, sets a + PodDisruptionBudget and pod anti-affinity, and leaves `image.repository` as a + documented TODO. + +### Changed from the compose stack +- `DATABASE_AUTO_MIGRATE` defaults to `false`. The compose file states the + intent directly: migrating is a step someone decides to take. The README + documents the scale-to-one-replica upgrade that does it. +- The rate limiter is left unset, so the service's own 20 rps / 40 burst + defaults apply. Compose's 100000/100000 is a local-stack ceiling, not a + deployment value. +- `envConfig` is empty. The image already carries the reviewed + `config/common.yaml` defaults and the environment layer sits above it; + restating them here would be a second copy of decisions this chart did not + make. +- Both security contexts are on by default, unlike the other OAN service + charts. The image is distroless/static as uid 65532, so + `readOnlyRootFilesystem` costs nothing once the spec cache is a volume. +- `automountServiceAccountToken` is false: the service calls no Kubernetes API. + +### Known prerequisite +- PostgreSQL must have **pgvector**, and must create the `vector` extension at + bootstrap. `postgresql-cnpg` 0.2.1 ships + `examples/discovery-db.dev.yaml`, which does both: it pins + `dhi.io/pgvector:0.8-pg16` and creates the extension in + `postInitApplicationSQL`. + + The bootstrap half is not optional. `vector` is not a trusted extension, so + the database owner this service connects as cannot create it - only a + superuser can, and `enableSuperuserAccess` is false. Without it the first + statement of the migration fails and the pod crashloops on an error that + reads like a credentials problem. `pg_trgm` is trusted and would have + succeeded, which is what makes the failure look selective and confusing. + + Neither condition is detectable at render time; the README describes both + failure modes. +- No image is published for `discovery-service` — its CI builds and scans one + but pushes nothing — so `image.repository` is empty and the render fails until + an environment supplies a tag. diff --git a/charts/discovery/Chart.yaml b/charts/discovery/Chart.yaml new file mode 100644 index 0000000..8bed016 --- /dev/null +++ b/charts/discovery/Chart.yaml @@ -0,0 +1,29 @@ +apiVersion: v2 +name: discovery +description: >- + The OAN Beckn v2.0.0 discover-and-publish service. Holds published catalogs + and answers discover queries over them. PostgreSQL is its only datastore, and + it needs the `vector` and `pg_trgm` extensions. +type: application +version: 0.1.0 +# No image is published for this service yet - CI builds and scans one but +# pushes nothing - so this is the version the chart deploys by default once one +# exists, and `image.repository` is empty until then. See values.yaml. +appVersion: "0.1.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - discovery + - publish + - beckn +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts + - https://github.com/OpenAgriNet/discovery-service +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/discovery/README.md b/charts/discovery/README.md new file mode 100644 index 0000000..4fa3efa --- /dev/null +++ b/charts/discovery/README.md @@ -0,0 +1,320 @@ +# discovery + +The OAN [discover-and-publish +service](https://github.com/OpenAgriNet/discovery-service) — a Beckn v2.0.0 +`/publish` and `/discover` implementation in Go. PostgreSQL is its only +datastore; there is no spatial extension and no separate search engine. + +Configuration is ported from the verified `discovery-service/docker-compose.yml` +stack, with the two settings that file marks as local-only put back to their +deployment values (see [Differences from compose](#differences-from-compose)). + +## What it renders + +| Resource | Notes | +|---|---| +| Deployment | Probes on `/healthz` and `/readyz`, resources mandatory | +| Service | `ClusterIP` on 8080 | +| ConfigMap (env) | Overrides from `envConfig`. Empty by default — see [Configuration](#configuration) | +| ServiceAccount | `automountServiceAccountToken: false` — the service calls no Kubernetes API | +| HorizontalPodAutoscaler | Optional, off by default | +| PodDisruptionBudget | Optional, off by default | +| Ingress | Optional, off by default | +| Test Pod | `helm test` check against `/readyz` | + +No Secret and no ConfigMap holding the Beckn document. Both are referenced by +name and created outside the chart. + +## Install + +```bash +# 1. The database — pgvector, plus `vector` created at bootstrap. Both matter; see below. +helm install discovery-db charts/postgresql-cnpg -n oan-discovery \ + -f charts/postgresql-cnpg/examples/discovery-db.dev.yaml + +# 2. The service +helm install discovery charts/discovery -n oan-discovery \ + -f charts/discovery/examples/discovery.dev.yaml + +helm test discovery -n oan-discovery +``` + +## The database + +The first migration creates two extensions: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; -- pgvector: the embedding column and its HNSW index +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- contrib: trigram matching for lexical retrieval +``` + +No stock CloudNativePG operand image carries pgvector, so the cluster needs an +image that does. [`postgresql-cnpg`](../postgresql-cnpg) ships a ready example: + +```bash +helm install discovery-db charts/postgresql-cnpg -n oan-discovery \ + -f charts/postgresql-cnpg/examples/discovery-db.dev.yaml +``` + +It pins `dhi.io/pgvector:0.8-pg16` — pgvector 0.8.6 and pg_trgm 1.6 on +PostgreSQL 16, amd64 and arm64. PostgreSQL **16** because that is what this +service is verified against: the design doc names it, `tests/dbtest` pins +`pgvector/pgvector:0.8.0-pg16`, and `EXPLAIN (GENERIC_PLAN)` in that suite is +PG16-and-later only. The schema does apply cleanly to 14, so this is about +staying on the tested major rather than a hard incompatibility. + +Two settings in that example are load-bearing: + +### `vector` is not a trusted extension + +`pg_trgm` is marked `trusted = true`, so a database owner may create it. +`vector` is not: + +``` +ERROR: permission denied to create extension "vector" +HINT: Must be superuser to create this extension. +``` + +This service connects as the bootstrap **owner**, not the superuser, and CNPG's +`enableSuperuserAccess` is false. So without help, the very first statement of +the migration fails — and the pod crashloops on an error that reads like a bad +password rather than a missing grant. + +The example creates both extensions in `bootstrap.postInitApplicationSQL`, which +CNPG runs as superuser inside the freshly created database. That is the one +moment superuser is available without granting it to anything long-lived; after +it, the service's own `IF NOT EXISTS` is a no-op and the rest of the migration +runs as the owner. + +### The image's postgres user is uid 70 + +CNPG defaults to 26 and applies it as the pod's `runAsUser` and `fsGroup`, so +the example sets `postgresUID`/`postgresGID` to 70. Left unset, the data +directory is handed to a user that does not exist in the image. + +### Not an operand image + +`dhi.io/pgvector` is a general-purpose PostgreSQL image. It satisfies CNPG's +documented requirements — `initdb`, `postgres`, `pg_ctl`, `pg_controldata`, +`pg_basebackup` and `du` are all on PATH, and CNPG overrides the image's own +entrypoint — but it is not built from `cloudnative-pg/postgres-containers` and +has not been run under the operator here. **Smoke-test the first cluster to +healthy before pointing this chart at it.** + +It also ships no barman-cloud, which rules out CNPG's in-core backup method. +The Barman Cloud Plugin brings its own sidecar and is unaffected. + +CNPG's generated app Secret carries `username`, `password`, `dbname`, `host`, +`port`, `uri` and `jdbc-uri`. `uri` is the whole DSN, which is why +`database.urlSecret` is the recommended way to wire this chart up. + +### Supplying the DSN + +Exactly one of these, or the render fails: + +```yaml +# Preferred — one Secret key holds the whole DSN. +database: + urlSecret: + name: discovery-db-app + key: uri +``` + +The DSN carries host, port, database and user, so `database.host` and the rest +go unread in this form. Leave them empty rather than setting a second copy that +nothing consumes and nothing keeps true. + +```yaml +# Assembled by the chart, with the password injected through Kubernetes' +# own $(VAR) expansion so it stays in the Secret. +database: + host: discovery-db-rw + port: 5432 + name: discovery + user: discovery + sslMode: prefer + passwordSecret: + name: discovery-db-app + key: password +``` + +The assembled form renders +`postgres://discovery:$(DATABASE_PASSWORD)@discovery-db-rw:5432/discovery?sslmode=prefer`, +and the kubelet substitutes the value of the `DATABASE_PASSWORD` env var — which +comes from the Secret — at container start. The password therefore never appears +in the rendered manifest, in `helm get manifest`, or in anything that logs a +template. + +The one caveat, and the reason `urlSecret` is preferred: that substitution is +textual, so a password containing `@ : / ? # %` must already be percent-encoded +in the Secret or it will silently produce a DSN that parses to the wrong thing. + +### Migrations + +The migrations are compiled into the binary. There is no Job, no sidecar and no +mounted directory: the service applies them on boot when `DATABASE_AUTO_MIGRATE` +is set, and treats "already at the latest version" as success. + +`database.autoMigrate` is **false** by default — migrating is a step someone +decides to take, not something that happens because a pod was rescheduled. + +```bash +helm upgrade discovery -n oan-discovery --reuse-values \ + --set database.autoMigrate=true --set replicaCount=1 +# verify the rollout, then upgrade back with autoMigrate=false +``` + +Scale to one replica first. The migration runs in-process on boot, so several +pods starting at once race to apply the same one. + +## The Beckn specification + +The service loads the Beckn v2.0.0 document **before** it serves and refuses to +start without it. The check is unconditional: turning L1 validation off does not +remove the requirement. + +It tries `becknSpec.url` first and falls back to the on-disk cache, so at least +one of `url` and `existingConfigMap` must be set. The render fails when both are +empty, because that configuration cannot boot. + +| Setting | What happens | +|---|---| +| `url` only | Fetched at boot and cached into an emptyDir. Needs egress, and refetches on every reschedule. The upstream URL tracks `main`, so this pins nothing. | +| `existingConfigMap` only | Mounted read-only at the cache path. No egress needed; reproducible. The boot logs one warning about the fetch it could not do — that warning is accurate and is not a failure. | +| both | Fetch first, ConfigMap as the fallback. | + +```bash +kubectl -n oan-discovery create configmap discovery-beckn-spec \ + --from-file=beckn.yaml=/tests/testdata/beckn-v2.0.0.yaml +``` + +The document is deliberately **not shipped in this chart**, for the same reason +the Dockerfile does not bake it into the image: a copy here is a second source of +truth that ages independently of the protocol. + +A volume is mounted at `becknSpec.cacheDir` either way — not only for the +air-gapped case. `/app` is not writable by uid 65532, and with +`readOnlyRootFilesystem` on, the fetch path would have nowhere to write its +cache. + +## Probes + +`/healthz` and `/readyz` answer different questions, and the split is +load-bearing: + +| Probe | Path | Behaviour | +|---|---|---| +| startup | `/healthz` | 60 × 5s. Covers the spec fetch, and migrations when they are on | +| liveness | `/healthz` | Depends on nothing. Stays 200 with the database down | +| readiness | `/readyz` | Pings PostgreSQL. 503 when it cannot reach it | + +Pointing liveness at `/readyz` would restart every pod during a database blip, +turning a recoverable outage into a crashloop across the whole Deployment. That +is why the two paths are different and why neither is configurable to the same +value by accident. + +There is no `exec` probe and no compose-style healthcheck: the runtime stage is +`gcr.io/distroless/static-debian12:nonroot`, which has no shell and no curl for +one to run. + +## Security context + +Both security contexts are **on by default**, unlike the other OAN service +charts. The image is distroless/static running as uid 65532 with a fully static +binary: there is no shell to escalate into, and the only path the process writes +is the spec cache, which is a mounted volume. `readOnlyRootFilesystem: true` +therefore costs nothing. + +If you add something that needs scratch space, add an `emptyDir` through +`extraVolumes` rather than turning this off. + +## Configuration + +`envConfig` is **empty by default**, and that is the deliberate part. + +The image already carries `config/common.yaml` — the project's reviewed defaults +for search sizing, validation, auth and the rest — and the environment layer sits +above it. Copying those values into this chart would create a second copy of +decisions the chart did not make, and it is the second copy that rots. + +Everything the chart genuinely owns is derived from structured values instead, so +`SERVER_PORT` cannot drift from the container port and `DATABASE_URL` cannot +drift from `database.*`: + +| Variable | Comes from | +|---|---| +| `DATABASE_URL` | `database.urlSecret`, or assembled from `database.host/port/name/user/sslMode` + `passwordSecret` | +| `DATABASE_AUTO_MIGRATE` | `database.autoMigrate` | +| `DATABASE_MAX_CONNS`, `DATABASE_MIN_CONNS` | `database.maxConns`, `database.minConns` | +| `APP_NETWORK_ID` | `app.networkId` — **required** | +| `APP_DEFAULT_TIMEZONE` | `app.defaultTimezone` | +| `SERVER_PORT` | `service.targetPort` | +| `LOG_LEVEL` | `logLevel` | +| `VALIDATION_SPEC_URL`, `VALIDATION_SPEC_CACHE_PATH` | `becknSpec.*` | +| `EMBEDDING_PROVIDER/MODEL/DIMENSIONS/ENDPOINT` | `embeddings.*` | +| `OTEL_EXPORTER`, `OTEL_EXPORTER_OTLP_ENDPOINT` | `otel.*` | +| `REPLICATION_TARGETS` | `replicationTargets`, joined with commas | + +Use `envConfig` to override a `common.yaml` default for one deployment. +Everything else the service reads: + +| Variable | Default | Notes | +|---|---|---| +| `SEARCH_DEFAULT_PAGE_SIZE` | 20 | What a request naming no limit gets | +| `SEARCH_MAX_PAGE_SIZE` | 100 | A larger limit is clamped to this | +| `SEARCH_MAX_CANDIDATES_PER_MODE` | 500 | Also the reachable pagination depth | +| `SEARCH_MAX_RADIUS_METERS` | 200000 | | +| `SEARCH_READ_DEADLINE` | 2s | | +| `SEARCH_FAIL_ON_UNAVAILABLE_MODE` | false | true turns a degraded mode into a 400 | +| `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST` | 20 / 40 | `burst >= rps > 0`; a bucket smaller than one second's refill can never fill | +| `SERVER_SHUTDOWN_TIMEOUT` | 15s | | +| `SERVER_MAX_REQUEST_BODY_BYTES` | 10485760 | | +| `VALIDATION_ENABLE_L1_SCHEMA` | true | | +| `VALIDATION_ENABLE_L2_CONTEXT` | false | Nothing reads it; `true` refuses the boot | +| `AUTH_ENABLE_SIGNATURE_VERIFICATION` | false | Phase 2; `true` refuses the boot | +| `EXT_ALLOW_NETWORK_FETCH` | false | A configured URL is trusted, one from a request body is not | +| `GEO_RESOLUTION_CELLS` | 8 | H3 resolution | +| `ERROR_INCLUDE_LEGACY_TYPE` | false | | + +A key that matches no config field **fails the boot**, so a typo in `envConfig` +stops the service rather than being silently ignored. + +`envConfig` lands in a ConfigMap. Never put a secret there — use `secretEnv` or +`envFromSecrets`. + +## Differences from compose + +| | compose | chart | Why | +|---|---|---|---| +| `DATABASE_AUTO_MIGRATE` | `true` | `false` | Compose says it: "left false in deployment, where migrating is a step someone decides to take" | +| `RATE_LIMIT_RPS` / `BURST` | `100000` | unset (20 / 40) | Compose says it: "effectively off for the local stack… deployments leave both unset and get the real defaults back" | +| Beckn document | bind-mounted from the working tree | fetched, or from a ConfigMap | There is no working tree to mount from | +| `LOG_LEVEL` | `debug` | `info` | `debug` in the dev example | +| PostgreSQL | `pgvector/pgvector:0.8.0-pg16` | `dhi.io/pgvector:0.8-pg16` under CNPG | Same major and same pgvector minor line, so the planner behaviour the design doc measured still holds. CNPG additionally needs `vector` created at bootstrap — see [The database](#the-database) | +| healthcheck | none — distroless has no shell | HTTP probes | The compose file notes the probes are HTTP and "for you rather than for Compose" | +| Superuser | `POSTGRES_USER=discovery` **is** the superuser | the service owns its own database and is not superuser | This is why the `vector` extension problem appears only in the cluster: in compose the migration creates it as superuser without anyone noticing it needed to be one. The cluster creates it at bootstrap instead | + +## Validation + +```bash +../../scripts/lint-charts.sh +``` + +Two `ci/*-values.yaml` files render as separate cases, deliberately covering +opposite branches: `lint-values.yaml` is the assembled DSN with a fetched spec, +`url-secret-values.yaml` is the whole-DSN Secret with a ConfigMap spec, plus +autoscaling-adjacent extras, Ingress and a PDB. A chart that only ever renders +its defaults has untested branches that fail on the day someone uses them. + +Render-time guardrails, all of which name the value and the reason: + +- `image.repository` empty +- `app.networkId` empty +- neither `database.urlSecret.name` nor `database.passwordSecret.name` +- `database.host` empty while the DSN is assembled +- neither `becknSpec.url` nor `becknSpec.existingConfigMap` +- `embeddings.provider` other than `noop` with no `embeddings.endpoint` +- `otel.exporter` other than `none` with no `otel.endpoint` +- `resources` empty (`oan-common`) +- a probe with no handler or with two (`oan-common`) +- a PodDisruptionBudget alongside a single replica diff --git a/charts/discovery/ci/lint-values.yaml b/charts/discovery/ci/lint-values.yaml new file mode 100644 index 0000000..5491ee8 --- /dev/null +++ b/charts/discovery/ci/lint-values.yaml @@ -0,0 +1,20 @@ +# Minimum values that let `helm template` run in CI. Dummy references only - +# see examples/ for real per-environment configuration. +# +# Covers the assembled-DSN path (database.passwordSecret). The whole-DSN path +# is covered by ci/url-secret-values.yaml. +image: + registry: ghcr.io + repository: openagrinet/discovery-service + tag: "0.1.0" + +app: + networkId: local-network + +database: + host: discovery-db-rw + passwordSecret: + name: discovery-db-app + +becknSpec: + url: https://example.invalid/beckn.yaml diff --git a/charts/discovery/ci/url-secret-values.yaml b/charts/discovery/ci/url-secret-values.yaml new file mode 100644 index 0000000..01508db --- /dev/null +++ b/charts/discovery/ci/url-secret-values.yaml @@ -0,0 +1,53 @@ +# The other half of the matrix: the DSN comes whole from a Secret key, the spec +# from a ConfigMap rather than a URL, and the optional resources are all on. +# +# Rendering this is what keeps the alternative paths honest - a chart that only +# ever renders its defaults has untested branches that fail on the day someone +# uses them. +image: + registry: ghcr.io + repository: openagrinet/discovery-service + tag: "0.1.0" + +replicaCount: 2 + +app: + networkId: local-network + +database: + urlSecret: + name: discovery-db-app + key: uri + autoMigrate: true + +becknSpec: + existingConfigMap: discovery-beckn-spec + +embeddings: + provider: ollama + endpoint: http://ollama:11434 + +otel: + exporter: otlp + endpoint: http://otel-collector:4317 + +replicationTargets: + - https://peer-a.example.invalid/publish + - https://peer-b.example.invalid/publish + +ingress: + enabled: true + className: alb + hosts: + - host: discovery.example.invalid + paths: + - path: / + pathType: Prefix + +podDisruptionBudget: + enabled: true + minAvailable: 1 + +envConfig: + RATE_LIMIT_RPS: "50" + RATE_LIMIT_BURST: "100" diff --git a/charts/discovery/examples/discovery.dev.yaml b/charts/discovery/examples/discovery.dev.yaml new file mode 100644 index 0000000..6fa09cd --- /dev/null +++ b/charts/discovery/examples/discovery.dev.yaml @@ -0,0 +1,87 @@ +# Example: the OAN discover-and-publish service, dev environment. +# +# helm install discovery charts/discovery \ +# -n oan-discovery -f charts/discovery/examples/discovery.dev.yaml +# +# Install the database FIRST, and with an image that has pgvector - see +# README.md, "The database". Starting before the database is up is not fatal - +# the pod crashloops until it can connect, and Kubernetes' own backoff is the +# retry - but a database without pgvector never becomes reachable in the sense +# that matters, because the migration fails on the missing extension. + +# No fullnameOverride needed: the chart is named "discovery", so a release +# named "discovery" already produces Service/discovery. + +# The package is private, so a cluster needs a docker-registry secret named in +# pullSecrets as well as this. Pinned rather than left to Chart.AppVersion, +# because a dev environment is where you want to run a build the chart was not +# written against. +image: + registry: ghcr.io + repository: openagrinet/discovery-service + tag: "0.1.0" + pullSecrets: [] + +app: + # The network this deployment serves. It fills an empty + # publishDirectives.visibleTo, so a catalog published without one is visible + # to this network and no other - which makes this the single most consequential + # value in the file. + networkId: mahavistar + +logLevel: debug + +database: + # CNPG generates Secret/-app with a ready-made `uri` key, so there is + # nothing to assemble and no password escaping to get wrong. The DSN carries + # host, port, database and user, so none of those are set here - a second copy + # would be one more thing to keep true. + urlSecret: + name: discovery-db-app + key: uri + + # Dev migrates on boot. Keep replicaCount at 1 while this is true: the + # migration runs in-process, so several pods starting at once race to apply + # the same one. + autoMigrate: true + + # Two retrieval modes today, one in-flight discover per mode. 32 is generous + # for dev and leaves room for the third mode when semantic search lands. + maxConns: 32 + minConns: 4 + +# Fetched at boot and cached in an emptyDir. For a cluster with no egress, or +# for a reproducible boot, create the ConfigMap instead: +# kubectl -n oan-discovery create configmap discovery-beckn-spec \ +# --from-file=beckn.yaml=/tests/testdata/beckn-v2.0.0.yaml +# and set existingConfigMap below. Note this URL tracks `main` and so pins +# nothing. +becknSpec: + url: https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/heads/main/api/v2.0.0/beckn.yaml + existingConfigMap: "" + +# Semantic search stays off until an Ollama deployment exists (A5). Discover +# still answers; it degrades the semantic mode and names it in X-Beckn-Degraded. +embeddings: + provider: noop + +otel: + exporter: none + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +ingress: + enabled: false + +# `helm test discovery -n oan-discovery` checks /readyz. +tests: + enabled: true + +commonLabels: + oan.in/environment: dev diff --git a/charts/discovery/examples/discovery.prod.yaml b/charts/discovery/examples/discovery.prod.yaml new file mode 100644 index 0000000..378d8fb --- /dev/null +++ b/charts/discovery/examples/discovery.prod.yaml @@ -0,0 +1,122 @@ +# Example: the OAN discover-and-publish service, production. +# +# helm install discovery charts/discovery \ +# -n oan-discovery -f charts/discovery/examples/discovery.prod.yaml +# +# NOT INSTALLABLE AS-IS. image.repository is empty and the render fails until +# it is filled in - see below. + +# --------------------------------------------------------------------------- +# TODO: the image OAN deploys in production is not decided. Prefer a DIGEST +# over a tag: a tag can be repointed at different bits, a digest cannot, which +# is what makes a rollback land on the same image it did before. +# +# image: +# registry: ghcr.io +# repository: openagrinet/discovery-service +# digest: sha256:... +# --------------------------------------------------------------------------- +image: + registry: ghcr.io + repository: "" + digest: "" + +replicaCount: 3 + +app: + networkId: mahavistar + +logLevel: info + +database: + urlSecret: + name: discovery-db-app + key: uri + + # OFF in production. Migrating is a step someone decides to take, on a + # release that was chosen for it - not something that happens because a pod + # was rescheduled at 3am. To migrate: + # + # helm upgrade discovery -n oan-discovery --reuse-values \ + # --set database.autoMigrate=true --set replicaCount=1 + # # verify, then upgrade back + autoMigrate: false + + # 3 replicas x 48 = 144 connections at full stretch. Check the cluster's + # max_connections before raising either number, and remember the autoscaler + # multiplies this by maxReplicas rather than by replicaCount. + maxConns: 48 + minConns: 8 + +# A ConfigMap rather than a fetch: production should not have its boot depend +# on GitHub being reachable, and the upstream URL tracks `main`, so a fetch +# pins nothing. Create it from a reviewed copy of the document: +# kubectl -n oan-discovery create configmap discovery-beckn-spec \ +# --from-file=beckn.yaml=beckn-v2.0.0.yaml +becknSpec: + url: "" + existingConfigMap: discovery-beckn-spec + key: beckn.yaml + +embeddings: + provider: noop + +# TODO: point at the cluster's collector once one is deployed. +otel: + exporter: none + endpoint: "" + +resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: "2" + memory: 1Gi + +ingress: + enabled: true + className: alb + annotations: + alb.ingress.kubernetes.io/scheme: internal + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/healthcheck-path: /readyz + hosts: + - host: discovery.oan.in + paths: + - path: / + pathType: Prefix + tls: [] + +# Safe with 3 replicas: at most one pod is disrupted at a time, so a node drain +# proceeds without taking the service below two. +podDisruptionBudget: + enabled: true + maxUnavailable: 1 + minAvailable: "" + +# Spread across nodes, so one node's loss is not the whole service. `preferred` +# rather than `required`: a hard rule leaves pods Pending when the cluster has +# fewer nodes than replicas, which turns a scale-up into an outage. +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: discovery + app.kubernetes.io/instance: discovery + +# Left OFF deliberately. Enabling it means capacity is decided by CPU, and the +# ceiling it puts on the database is maxReplicas x database.maxConns - raise +# them together, or not at all. +autoscaling: + enabled: false + +tests: + enabled: true + +commonLabels: + oan.in/environment: prod diff --git a/charts/discovery/templates/NOTES.txt b/charts/discovery/templates/NOTES.txt new file mode 100644 index 0000000..36ae0a4 --- /dev/null +++ b/charts/discovery/templates/NOTES.txt @@ -0,0 +1,59 @@ +{{ .Chart.Name }} installed as release "{{ .Release.Name }}". + +Resources in namespace "{{ .Release.Namespace }}": + - Deployment/{{ include "discovery.fullname" . }} ({{ if .Values.autoscaling.enabled }}autoscaled {{ .Values.autoscaling.minReplicas }}-{{ .Values.autoscaling.maxReplicas }}{{ else }}{{ .Values.replicaCount }} replica(s){{ end }}) + - Service/{{ include "discovery.fullname" . }} ({{ .Values.service.type }} on port {{ .Values.service.port }}) + - ConfigMap/{{ include "discovery.envConfigMapName" . }} +{{- if .Values.serviceAccount.enabled }} + - ServiceAccount/{{ include "discovery.serviceAccountName" . }} +{{- end }} +{{- if .Values.ingress.enabled }} + - Ingress/{{ include "discovery.fullname" . }} +{{- end }} +{{- if .Values.podDisruptionBudget.enabled }} + - PodDisruptionBudget/{{ include "discovery.fullname" . }} +{{- end }} + +Wired to: + network: {{ .Values.app.networkId }} + database: {{ if .Values.database.urlSecret.name }}DSN from Secret/{{ .Values.database.urlSecret.name }} key {{ .Values.database.urlSecret.key }}{{ else }}{{ .Values.database.host }}:{{ .Values.database.port }}/{{ .Values.database.name }} (as {{ .Values.database.user }}){{ end }} + migrations: {{ if .Values.database.autoMigrate }}APPLIED ON BOOT{{ else }}not applied - DATABASE_AUTO_MIGRATE is false{{ end }} + beckn spec: {{ if .Values.becknSpec.url }}{{ .Values.becknSpec.url }}{{ else }}no URL - cache only{{ end }} + cache {{ if .Values.becknSpec.existingConfigMap }}from ConfigMap/{{ .Values.becknSpec.existingConfigMap }}{{ else }}in an emptyDir (refetched on every reschedule){{ end }} at {{ include "discovery.specCachePath" . }} + embeddings: {{ .Values.embeddings.provider }}{{ if eq .Values.embeddings.provider "noop" }} - semantic search is OFF, and discover says so in X-Beckn-Degraded{{ end }} + +In-cluster endpoint: + http://{{ include "discovery.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} + +Check it: + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "discovery.fullname" . }} + helm test {{ .Release.Name }} -n {{ .Release.Namespace }} + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "discovery.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + curl http://localhost:{{ .Values.service.port }}/healthz # liveness - depends on nothing + curl http://localhost:{{ .Values.service.port }}/readyz # readiness - 503 when PostgreSQL is unreachable + +{{ if not .Values.database.autoMigrate -}} +Migrations are OFF. On a database that has never been migrated, every request +fails against missing tables while /healthz still answers 200. To migrate: + + helm upgrade {{ .Release.Name }} -n {{ .Release.Namespace }} --reuse-values \ + --set database.autoMigrate=true --set replicaCount=1 + # then turn it back off once the rollout is complete + +Scale to one replica first: the migration runs in-process on boot, so several +pods starting at once race to apply it. +{{ end -}} + +If the pods crashloop before serving, check these in order - the first two look +nothing like their cause in the logs: + 1. "permission denied to create extension vector" means the database was not + bootstrapped with it. `vector` is not a trusted extension, so the owner + this service connects as cannot create it - the cluster must, at bootstrap, + as superuser. See charts/postgresql-cnpg/examples/discovery-db.dev.yaml. + "could not open extension control file" instead means the image has no + pgvector at all; no stock CNPG operand image does. + 2. "load the validation spec" means neither the fetch nor the cache produced + a Beckn document. With becknSpec.url set and no ConfigMap, the pod needs + egress to that URL. + 3. A key in `envConfig` matching no config field fails the boot by design, so + a typo there stops the service rather than being ignored. diff --git a/charts/discovery/templates/_helpers.tpl b/charts/discovery/templates/_helpers.tpl new file mode 100644 index 0000000..fc5fc4f --- /dev/null +++ b/charts/discovery/templates/_helpers.tpl @@ -0,0 +1,178 @@ +{{/* +# ============================================================================ +# DISCOVERY SERVICE CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers delegating to oan-common, plus the database, +# Beckn specification and environment wiring this service needs. +# ============================================================================ +*/}} + +{{- define "discovery.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "discovery.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "discovery.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{- define "discovery.selectorLabels" -}} +{{- include "oan-common.selectorLabels" . -}} +{{- end }} + +{{- define "discovery.serviceAccountName" -}} +{{- include "oan-common.serviceAccount.name" . -}} +{{- end }} + +{{- define "discovery.image" -}} +{{- include "oan-common.image" . -}} +{{- end }} + +{{- define "discovery.envConfigMapName" -}} +{{- include "oan-common.envConfigMapName" . -}} +{{- end }} + +{{/* +Absolute path of the Beckn spec cache, VALIDATION_SPEC_CACHE_PATH. + +Always absolute and always set explicitly, rather than left to the service's +own default of ".cache/beckn/beckn.yaml": that one is relative to the working +directory, which makes the path a fact about the image rather than about this +chart, and the volume mount below has to name a directory that agrees with it. +*/}} +{{- define "discovery.specCacheDir" -}} +{{- .Values.becknSpec.cacheDir | trimSuffix "/" -}} +{{- end }} + +{{- define "discovery.specCachePath" -}} +{{- printf "%s/%s" (include "discovery.specCacheDir" .) .Values.becknSpec.key -}} +{{- end }} + +{{/* +Emits "true" when the spec cache is backed by a ConfigMap rather than by an +emptyDir the fetched document is written into. +*/}} +{{- define "discovery.specFromConfigMap" -}} +{{- if .Values.becknSpec.existingConfigMap -}} +{{- true -}} +{{- end -}} +{{- end }} + +{{/* +The DSN, when this chart assembles one. Empty when database.urlSecret is set, +because then the whole DSN comes from that Secret instead. + +The password is NOT interpolated here - it is referenced as $(DATABASE_PASSWORD) +and expanded by the kubelet against the secretKeyRef env var defined just above +it in the container spec. That keeps the password out of the rendered manifest, +out of `helm get values`, and out of anything that logs a template. + +Because that expansion is textual, a password containing a URL delimiter +(@ : / ? # %) must already be percent-encoded in the Secret. urlSecret avoids +the question entirely, which is why it is the documented default. +*/}} +{{- define "discovery.databaseURL" -}} +{{- $db := .Values.database -}} +{{- printf "postgres://%s:$(DATABASE_PASSWORD)@%s:%v/%s?sslmode=%s" $db.user $db.host $db.port $db.name $db.sslMode -}} +{{- end }} + +{{/* +Everything the service needs that this chart derives from its structured +values, as container env entries. These take precedence over the envConfig +ConfigMap injected with envFrom, which is what makes "derived here, overridable +there" safe rather than ambiguous. + +Every guardrail below refuses to render something the service would refuse to +boot on. The failure is the same either way; the difference is that this one +names the value and happens before anything is applied to the cluster. +*/}} +{{- define "discovery.env" -}} +{{- $db := .Values.database -}} +{{- $spec := .Values.becknSpec -}} +{{- if not .Values.app.networkId }} +{{- fail (printf "%s: app.networkId is required - it is APP_NETWORK_ID, which the boot refuses without, and it decides who a catalog published with no explicit visibleTo is visible to. Set the network this deployment serves, e.g. mahavistar." .Chart.Name) }} +{{- end }} +{{- if and (not $db.urlSecret.name) (not $db.passwordSecret.name) }} +{{- fail (printf "%s: the database DSN is unset. Set database.urlSecret.name (preferred - CNPG writes a ready-made `uri` key into Secret/-app), or database.passwordSecret.name together with database.host/name/user to have the chart assemble one. This chart renders no Secrets." .Chart.Name) }} +{{- end }} +{{- if and (not $db.urlSecret.name) (not $db.host) }} +{{- fail (printf "%s: database.host is required when the DSN is assembled - point it at the PostgreSQL primary service, e.g. discovery-db-rw" .Chart.Name) }} +{{- end }} +{{- if and (not $spec.url) (not $spec.existingConfigMap) }} +{{- fail (printf "%s: set becknSpec.url or becknSpec.existingConfigMap. The service loads the Beckn document before it serves and refuses to start without it, and with neither set there is nothing to fetch and nothing cached to fall back to." .Chart.Name) }} +{{- end }} +{{- if not $spec.key }} +{{- fail (printf "%s: becknSpec.key is required - it is both the key read from the ConfigMap and the filename the cache is written under" .Chart.Name) }} +{{- end }} +{{- if $db.urlSecret.name -}} +- name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $db.urlSecret.name }} + key: {{ $db.urlSecret.key }} +{{- else }} +{{- /* Defined FIRST: the kubelet expands $(VAR) only against variables that + precede it in this list. */ -}} +- name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $db.passwordSecret.name }} + key: {{ $db.passwordSecret.key }} +- name: DATABASE_URL + value: {{ include "discovery.databaseURL" . | quote }} +{{- end }} +- name: DATABASE_AUTO_MIGRATE + value: {{ $db.autoMigrate | quote }} +- name: DATABASE_MAX_CONNS + value: {{ $db.maxConns | quote }} +- name: DATABASE_MIN_CONNS + value: {{ $db.minConns | quote }} +- name: APP_NETWORK_ID + value: {{ .Values.app.networkId | quote }} +- name: APP_DEFAULT_TIMEZONE + value: {{ .Values.app.defaultTimezone | quote }} +{{- /* Derived from the port the container actually publishes, so the two + cannot drift into a Service that routes to a port nothing listens on. */}} +- name: SERVER_PORT + value: {{ .Values.service.targetPort | quote }} +- name: LOG_LEVEL + value: {{ .Values.logLevel | quote }} +- name: VALIDATION_SPEC_CACHE_PATH + value: {{ include "discovery.specCachePath" . | quote }} +{{- with $spec.url }} +- name: VALIDATION_SPEC_URL + value: {{ . | quote }} +{{- end }} +- name: EMBEDDING_PROVIDER + value: {{ .Values.embeddings.provider | quote }} +{{- if ne .Values.embeddings.provider "noop" }} +- name: EMBEDDING_MODEL + value: {{ .Values.embeddings.model | quote }} +- name: EMBEDDING_DIMENSIONS + value: {{ .Values.embeddings.dimensions | quote }} +{{- if not .Values.embeddings.endpoint }} +{{- fail (printf "%s: embeddings.provider is %q, so embeddings.endpoint is required - a provider with nowhere to call fails at the first publish, not at boot" .Chart.Name .Values.embeddings.provider) }} +{{- end }} +- name: EMBEDDING_ENDPOINT + value: {{ .Values.embeddings.endpoint | quote }} +{{- end }} +- name: OTEL_EXPORTER + value: {{ .Values.otel.exporter | quote }} +{{- if ne .Values.otel.exporter "none" }} +{{- if not .Values.otel.endpoint }} +{{- fail (printf "%s: otel.exporter is %q, so otel.endpoint is required - set it to the collector, e.g. http://otel-collector:4317" .Chart.Name .Values.otel.exporter) }} +{{- end }} +- name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.otel.endpoint | quote }} +{{- end }} +{{- with .Values.replicationTargets }} +- name: REPLICATION_TARGETS + value: {{ join "," . | quote }} +{{- end }} +{{- with (include "oan-common.env" . | trim) }} +{{ . }} +{{- end }} +{{- end }} diff --git a/charts/discovery/templates/configmap.yaml b/charts/discovery/templates/configmap.yaml new file mode 100644 index 0000000..eea3b40 --- /dev/null +++ b/charts/discovery/templates/configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "discovery.envConfigMapName" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- with (include "oan-common.envConfigMapData" . | trim) }} +data: + {{- . | nindent 2 }} +{{- else }} +data: {} +{{- end }} diff --git a/charts/discovery/templates/deployment.yaml b/charts/discovery/templates/deployment.yaml new file mode 100644 index 0000000..82b37b3 --- /dev/null +++ b/charts/discovery/templates/deployment.yaml @@ -0,0 +1,111 @@ +apiVersion: {{ include "oan-common.deployment.apiVersion" . }} +kind: Deployment +metadata: + name: {{ include "discovery.fullname" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "discovery.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "discovery.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/env-config: {{ include "oan-common.checksumAnnotation" . }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.serviceAccount.enabled }} + serviceAccountName: {{ include "discovery.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- end }} + {{- with (include "oan-common.imagePullSecrets" . | trim) }} + {{- . | nindent 6 }} + {{- end }} + {{- with (include "oan-common.podSecurityContext" .) }} + securityContext: + {{- . | nindent 8 }} + {{- end }} + {{- with .Values.extraInitContainers }} + initContainers: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "discovery.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + {{- include "discovery.env" . | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "discovery.envConfigMapName" . }} + {{- range .Values.envFromSecrets }} + - secretRef: + name: {{ . }} + {{- end }} + {{- with (include "oan-common.probes" . | trim) }} + {{- . | nindent 10 }} + {{- end }} + resources: + {{- include "oan-common.resources" . | nindent 12 }} + volumeMounts: + {{- /* + The Beckn spec cache. Mounted either way, and not only for the + air-gapped case: with readOnlyRootFilesystem the fetch path has + nowhere to write its cache without a volume here, and /app is not + writable by uid 65532 in any case. + */}} + - name: beckn-spec + mountPath: {{ include "discovery.specCacheDir" . }} + readOnly: {{ if include "discovery.specFromConfigMap" . }}true{{ else }}false{{ end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: beckn-spec + {{- if include "discovery.specFromConfigMap" . }} + configMap: + name: {{ .Values.becknSpec.existingConfigMap }} + items: + - key: {{ .Values.becknSpec.key }} + path: {{ .Values.becknSpec.key }} + {{- else }} + emptyDir: + sizeLimit: 16Mi + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/discovery/templates/hpa.yaml b/charts/discovery/templates/hpa.yaml new file mode 100644 index 0000000..9687662 --- /dev/null +++ b/charts/discovery/templates/hpa.yaml @@ -0,0 +1,54 @@ +{{- if .Values.autoscaling.enabled }} +{{/* +The service is stateless - it holds no session state and writes everything to +PostgreSQL - so it scales horizontally. Two things to keep in mind: + + - Every replica opens its own pool of up to database.maxConns connections, so + the ceiling this chart puts on the server is maxReplicas x maxConns. Raise + the two together, against the server's `max_connections`, not separately. + - Autoscaling takes over `replicas`, so replicaCount is ignored once this is + enabled. The Deployment deliberately omits `replicas` in that case, or the + two would fight on every reconcile. + +Do NOT enable this for the install that migrates: DATABASE_AUTO_MIGRATE with +several pods starting at once has them race to apply the same migration. +*/}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "discovery.fullname" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "discovery.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- with .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/discovery/templates/ingress.yaml b/charts/discovery/templates/ingress.yaml new file mode 100644 index 0000000..c6c9dfd --- /dev/null +++ b/charts/discovery/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: {{ include "oan-common.ingress.apiVersion" . }} +kind: Ingress +metadata: + name: {{ include "discovery.fullname" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.ingress.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- range . }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "discovery.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/discovery/templates/poddisruptionbudget.yaml b/charts/discovery/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..1fa8a56 --- /dev/null +++ b/charts/discovery/templates/poddisruptionbudget.yaml @@ -0,0 +1,33 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{/* +Caps how many pods can be voluntarily disrupted at once - node drains, cluster +upgrades, autoscaler scale-down. + +With replicaCount: 1 a PDB of minAvailable: 1 blocks drains entirely, because +evicting the only pod would breach it. That is why this is disabled by default +and should be enabled together with replicaCount >= 2. +*/}} +{{- if and (eq (int .Values.replicaCount) 1) (not .Values.podDisruptionBudget.allowSingleReplica) }} +{{- fail (printf "%s: podDisruptionBudget is enabled with replicaCount 1, which blocks node drains entirely. Raise replicaCount to 2+, or set podDisruptionBudget.allowSingleReplica=true if you accept that." .Chart.Name) }} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "discovery.fullname" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ . }} + {{- end }} + {{- with .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ . }} + {{- end }} + selector: + matchLabels: + {{- include "discovery.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/discovery/templates/service.yaml b/charts/discovery/templates/service.yaml new file mode 100644 index 0000000..37a301b --- /dev/null +++ b/charts/discovery/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "discovery.fullname" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.service.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "discovery.selectorLabels" . | nindent 4 }} diff --git a/charts/discovery/templates/serviceaccount.yaml b/charts/discovery/templates/serviceaccount.yaml new file mode 100644 index 0000000..90c4e8c --- /dev/null +++ b/charts/discovery/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if include "oan-common.serviceAccount.enabled" . }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "discovery.serviceAccountName" . }} + labels: + {{- include "discovery.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.serviceAccount.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/discovery/templates/tests/test-connection.yaml b/charts/discovery/templates/tests/test-connection.yaml new file mode 100644 index 0000000..7492d2f --- /dev/null +++ b/charts/discovery/templates/tests/test-connection.yaml @@ -0,0 +1,40 @@ +{{- if .Values.tests.enabled }} +{{/* +`helm test` check: the service answers on /readyz. + +/readyz rather than /healthz on purpose. /healthz depends on nothing and stays +200 with the database down, so a test against it would pass on a deployment +that cannot serve a single discover. /readyz pings the datastore, which is the +question worth asking after an install. + +Deliberately does not POST a discover: that needs a published catalog to find, +and a failure would be ambiguous between "the service is broken" and "nothing +has been published yet". +*/}} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "discovery.fullname" . }}-test-connection + labels: + {{- include "discovery.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: test + image: {{ printf "%s/%s:%s" .Values.tests.image.registry .Values.tests.image.repository .Values.tests.image.tag | quote }} + command: + - /bin/sh + - -c + - | + set -e + url="http://{{ include "discovery.fullname" . }}:{{ .Values.service.port }}/readyz" + echo "GET $url" + wget -q -O- --timeout=10 "$url" + echo "" + echo "OK: discovery is ready - it is serving and it can reach PostgreSQL" + resources: + {{- toYaml .Values.tests.resources | nindent 8 }} +{{- end }} diff --git a/charts/discovery/values.yaml b/charts/discovery/values.yaml new file mode 100644 index 0000000..fd4aa2c --- /dev/null +++ b/charts/discovery/values.yaml @@ -0,0 +1,427 @@ +# ============================================================================ +# discovery - default values +# +# The OAN Beckn v2.0.0 discover-and-publish service. Defaults mirror the +# verified compose stack (discovery-service/docker-compose.yml), with the two +# settings that file marks as local-only put back to their deployment values: +# DATABASE_AUTO_MIGRATE and the rate limiter. +# +# Depends on one thing already running: +# - PostgreSQL, WITH the `vector` and `pg_trgm` extensions -> database.* +# +# Minimum you must set: +# - image.repository +# - app.networkId +# - database.host, and either database.urlSecret.name or +# database.passwordSecret.name +# - becknSpec.url or becknSpec.existingConfigMap +# +# The render fails, with the reason, if any of those is missing. That is +# deliberate: every one of them is something the service refuses to boot +# without, and a pod that CrashLoopBackOffs reports the problem far later and +# far less clearly than `helm template` does. +# ============================================================================ + +replicaCount: 1 + +# --------------------------------------------------------------------------- +# Image +# +# ghcr.io/openagrinet/discovery-service, which CI now publishes -- it used to +# build and scan an image and push nothing, which is why this was empty. +# +# tag stays empty, and that is not an oversight: empty falls through to +# Chart.AppVersion, so the chart ships pointing at the app version it was +# written against, and an environment that wants a different build says so in +# its own values file. Pin it here only when the chart and the app stop +# versioning together. +# +# The package is PRIVATE. A cluster pulling it needs a docker-registry secret +# and that secret named in pullSecrets below -- otherwise the deploy looks +# clean and the pod sits in ImagePullBackOff: +# +# kubectl create secret docker-registry ghcr \ +# --docker-server=ghcr.io --docker-username= \ +# --docker-password= +# +# The runtime is gcr.io/distroless/static-debian12:nonroot - no shell, no +# package manager, uid 65532. That is what makes the security context defaults +# below safe to leave on. +# --------------------------------------------------------------------------- +image: + registry: ghcr.io + repository: openagrinet/discovery-service + tag: "latest" + digest: "" + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + enabled: true + name: "" + annotations: {} + automountServiceAccountToken: false + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: discovery.local + paths: + - path: / + pathType: Prefix + tls: [] + +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- +app: + # APP_NETWORK_ID. REQUIRED - the boot refuses without it, so the render does + # too. It fills an empty publishDirectives.visibleTo (C8), which means every + # catalog published without one is visible to this network and no other. + # There is no sensible default: "mahavistar", "bharatvistar" and the rest are + # different networks, and guessing wrong publishes to the wrong audience. + networkId: "" + # APP_DEFAULT_TIMEZONE. Validated with time.LoadLocation at startup - a typo + # fails the boot rather than silently shifting every daily validity window. + defaultTimezone: Asia/Kolkata + +# LOG_LEVEL +logLevel: info + +# --------------------------------------------------------------------------- +# Database +# +# The service needs PostgreSQL with two extensions its first migration creates: +# +# CREATE EXTENSION IF NOT EXISTS vector; -- pgvector, for the embedding column and its HNSW index +# CREATE EXTENSION IF NOT EXISTS pg_trgm; -- contrib, ships with every ordinary PostgreSQL build +# +# pg_trgm is contrib and is present in the CloudNativePG operand images. +# pgvector is NOT: the stock CNPG image cannot create the `vector` extension, +# and the migration - and therefore the boot - fails on it. Point +# postgresql-cnpg's `image.*` at an operand image that bundles pgvector before +# installing this chart. See README.md, "The database". +# +# Two ways to supply the DSN, and exactly one of them must be set: +# +# urlSecret one Secret key holding the whole DATABASE_URL. PREFERRED: +# CNPG already writes a ready-made `uri` key into the app +# Secret it generates, so there is nothing to assemble and no +# escaping to get wrong. +# +# host/port/name/user + passwordSecret +# the DSN is assembled from these, with the password injected +# through Kubernetes' own $(VAR) expansion so it stays in the +# Secret and never lands in a ConfigMap. Note the caveat: the +# password is substituted verbatim into a URL, so a password +# containing @ : / ? # or % must already be percent-encoded +# in the Secret. +# +# urlSecret makes host/port/name/user/sslMode unread - the DSN carries all of +# it - so leave them empty rather than setting a second copy that nothing +# consumes and nothing keeps true. +# --------------------------------------------------------------------------- +database: + # PostgreSQL primary service, e.g. "discovery-db-rw" + host: "" + port: 5432 + name: discovery + user: discovery + # disable | allow | prefer | require | verify-ca | verify-full. Only used + # when the DSN is assembled. + sslMode: prefer + # Whole-DSN Secret. Wins over the assembled form when set. + urlSecret: + name: "" + # CNPG writes `uri` into Secret/-app + key: uri + # Password Secret, for the assembled form. + passwordSecret: + name: "" + key: password + + # DATABASE_AUTO_MIGRATE. The migrations are compiled into the binary, so this + # needs no sidecar and no mounted directory - the service applies them on + # boot and treats "already at the latest version" as success. + # + # False here, and true in compose, is not a discrepancy: migrating is a step + # someone decides to take. Turn it on for the install that is meant to + # migrate, and note that with replicaCount > 1 several pods race to do it - + # scale to 1, migrate, scale back. + autoMigrate: false + + # Sized by the concurrency model, not guessed: discover runs its retrieval + # modes concurrently (A2), so one in-flight discover holds as many + # connections as it has enabled modes. + # + # maxConns >= (enabled modes) x (expected in-flight discovers) + # + # Two modes today, three once semantic lands. Multiply by replicaCount and + # keep the total under the server's max_connections. minConns is a warm-start + # knob only - idle backends cost the server memory to save a handshake. + maxConns: 32 + minConns: 4 + +# --------------------------------------------------------------------------- +# Beckn specification +# +# The service loads the Beckn v2.0.0 document BEFORE it serves and refuses to +# start without it. The check is unconditional: turning L1 validation off does +# not remove the requirement. +# +# It tries `url` first and falls back to the on-disk cache, so at least one of +# `url` and `existingConfigMap` must be set or the boot cannot succeed - the +# render fails when both are empty. +# +# The document is deliberately NOT shipped in this chart, for the same reason +# the Dockerfile does not bake it into the image: a copy here is a second +# source of truth that ages independently of the protocol. +# --------------------------------------------------------------------------- +becknSpec: + # VALIDATION_SPEC_URL. Upstream is + # https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/heads/main/api/v2.0.0/beckn.yaml + # which tracks `main` and therefore pins nothing. Prefer a pinned URL, or the + # ConfigMap below, for a reproducible boot. + url: "" + # A ConfigMap holding the document, mounted as the cache. Set this for an + # air-gapped deploy, or alongside `url` as the fallback when the fetch fails. + # kubectl -n create configmap discovery-beckn-spec \ + # --from-file=beckn.yaml=tests/testdata/beckn-v2.0.0.yaml + existingConfigMap: "" + # Key inside that ConfigMap, and the filename it is mounted under. + key: beckn.yaml + # Directory the cache lives in. VALIDATION_SPEC_CACHE_PATH is this plus key. + # An emptyDir is mounted here when no ConfigMap is supplied, so the fetched + # document can be cached - /app is not writable by uid 65532. + cacheDir: /app/.cache/beckn + +# --------------------------------------------------------------------------- +# Embeddings +# +# Semantic search is deferred (A5): the column, the HNSW index and the Embedder +# seam all ship, the provider does not. `noop` is the reviewed default and the +# one Phase 1 runs on - a discover request simply degrades and says so in +# X-Beckn-Degraded (C11). +# +# Set provider: ollama and an endpoint once an Ollama deployment exists. +# dimensions must match the column the migration created; changing it on a +# populated database is a reindex, not a config change. +# --------------------------------------------------------------------------- +embeddings: + provider: noop + model: nomic-embed-text + endpoint: "" + dimensions: 768 + +# --------------------------------------------------------------------------- +# OpenTelemetry +# +# `none` boots without a collector (T2). Set exporter: otlp and an endpoint to +# export. +# --------------------------------------------------------------------------- +otel: + exporter: none + endpoint: "" + +# REPLICATION_TARGETS - peer discovery endpoints a publish is fanned out to. +# Rendered as the comma-separated list the service parses. +replicationTargets: [] + +resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + +# --------------------------------------------------------------------------- +# Probes +# +# /healthz and /readyz answer different questions, and the split is load-bearing +# (src/app/router.go): /healthz depends on nothing and stays 200 with the +# database down, /readyz pings the database and returns 503 when it cannot. +# Pointing liveness at /readyz would restart every pod during a database blip - +# turning a recoverable outage into a crashloop across the whole Deployment. +# +# There is no `exec` option and no compose-style healthcheck: the runtime stage +# is distroless/static, which has no shell for one to run. +# --------------------------------------------------------------------------- +startupProbe: + enabled: true + httpGet: + path: /healthz + port: http + # Generous: the boot fetches and compiles the Beckn document, and may apply + # migrations, before it listens. + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 60 + +livenessProbe: + enabled: true + httpGet: + path: /healthz + port: http + initialDelaySeconds: 0 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 5 + +readinessProbe: + enabled: true + httpGet: + path: /readyz + port: http + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +# --------------------------------------------------------------------------- +# Security contexts +# +# On by default here, unlike the other OAN service charts. The image is +# distroless/static running as uid 65532 with a fully static binary: there is +# no shell to escalate into, and the only path the process writes is the spec +# cache, which is a mounted volume. readOnlyRootFilesystem therefore costs +# nothing - and if you add anything that needs scratch space, add an emptyDir +# through extraVolumes rather than turning this off. +# --------------------------------------------------------------------------- +podSecurityContext: + enabled: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + enabled: true + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + +nodeSelector: {} +tolerations: [] +affinity: {} +podLabels: {} +podAnnotations: {} + +# Extra init containers. Rendered verbatim. +extraInitContainers: [] + +# Extra volumes and mounts, rendered verbatim. +extraVolumes: [] +extraVolumeMounts: [] + +# --------------------------------------------------------------------------- +# PodDisruptionBudget +# +# Caps voluntary disruptions - node drains, cluster upgrades, autoscaler +# scale-down. Disabled by default because with a single replica a PDB of +# minAvailable: 1 blocks drains entirely; the render fails if you enable it +# anyway without acknowledging that. +# --------------------------------------------------------------------------- +podDisruptionBudget: + enabled: false + minAvailable: 1 + # Set one or the other, not both. + maxUnavailable: "" + # Enable a PDB with a single replica anyway, accepting that node drains block. + allowSingleReplica: false + +# --------------------------------------------------------------------------- +# Autoscaling +# +# The service is stateless - everything is in PostgreSQL - so it scales +# horizontally. Every replica opens its own pool of up to database.maxConns +# connections, so raise maxReplicas and maxConns together against the server's +# max_connections, not separately. When enabled, replicaCount is ignored. +# --------------------------------------------------------------------------- +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + # Set to a number to also scale on memory. + targetMemoryUtilizationPercentage: "" + # Scale-up/scale-down tuning, rendered verbatim into spec.behavior. + behavior: {} + +# --------------------------------------------------------------------------- +# `helm test` checks +# +# helm test -n +# +# Runs after install to confirm the service actually answers - which is the +# difference between "the pod is running" and "the deployment works". It checks +# /readyz rather than /healthz, because /healthz is 200 with the database down +# and would pass on a deployment that cannot serve a single request. +# --------------------------------------------------------------------------- +tests: + enabled: true + image: + registry: docker.io + repository: busybox + tag: "1.37" + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + +# --------------------------------------------------------------------------- +# ENVIRONMENT CONFIGURATION +# +# Rendered into a ConfigMap and injected with envFrom. EMPTY on purpose. +# +# The image already carries config/common.yaml - the project's reviewed +# defaults for search sizing, validation, auth and the rest - and the +# environment layer sits above it. Copying those values here would create a +# second copy of decisions this chart did not make, and it is the second copy +# that rots. Everything the chart genuinely owns is derived from the structured +# values above instead, so nothing here can drift from them. +# +# Use this to override a common.yaml default for one deployment. The full list +# is in README.md; the ones most likely to be wanted: +# +# SEARCH_DEFAULT_PAGE_SIZE, SEARCH_MAX_PAGE_SIZE, SEARCH_MAX_RADIUS_METERS +# SEARCH_MAX_CANDIDATES_PER_MODE, SEARCH_READ_DEADLINE +# SEARCH_FAIL_ON_UNAVAILABLE_MODE +# RATE_LIMIT_RPS, RATE_LIMIT_BURST (defaults 20 / 40; burst >= rps > 0) +# SERVER_SHUTDOWN_TIMEOUT, SERVER_MAX_REQUEST_BODY_BYTES +# VALIDATION_ENABLE_L1_SCHEMA, GEO_RESOLUTION_CELLS +# +# A key that matches no Config field fails the boot, so a typo here cannot +# silently do nothing. +# +# NEVER put secrets here. This is a ConfigMap. +# --------------------------------------------------------------------------- +envConfig: {} + +# Extra env vars from specific Secret keys, beyond DATABASE_URL. +secretEnv: {} +extraEnv: [] +envFromSecrets: [] + +commonLabels: {} +commonAnnotations: {} diff --git a/charts/keycloak/.helmignore b/charts/keycloak/.helmignore new file mode 100644 index 0000000..3027bb2 --- /dev/null +++ b/charts/keycloak/.helmignore @@ -0,0 +1,13 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig + +# Example values are documentation, not part of the package +examples/ diff --git a/charts/keycloak/CHANGELOG.md b/charts/keycloak/CHANGELOG.md new file mode 100644 index 0000000..3977df8 --- /dev/null +++ b/charts/keycloak/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to the `keycloak` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-08-31 + +### Removed +- External Secrets Operator integration: the `ExternalSecret` template and the + `externalSecrets` value block. ESO is not installed in any OAN cluster, so this + was configuration that could not be exercised, and a chart that renders a + Secret-producing resource invites the question of where secrets come from to be + answered differently per chart. + + Charts still reference Secrets by name - `envFromSecrets`, `secretEnv`, and the + per-chart `*Secret.name` settings are unchanged. Creating those Secrets is now + unambiguously outside the charts. + +## [0.1.0] - 2026-08-31 + +Initial release. Configuration ported from the verified `registry/docker-compose.yml` +stack, with the deployment shape taken from Sunbird's own Helm charts +(`deploy-as-code/helm/v2`). + +### Added +- Deployment, Service, env ConfigMap, realm ConfigMap, ServiceAccount, and + optional Ingress and ESO ExternalSecret, built on the `oan-common` library. +- All ten Keycloak environment variables from the compose stack, verified at + parity: `DB_VENDOR`, `DB_ADDR`, `DB_PORT`, `DB_DATABASE`, `DB_USER`, + `DB_PASSWORD`, `KEYCLOAK_USER`, `KEYCLOAK_PASSWORD`, `KEYCLOAK_IMPORT` and + `PROXY_ADDRESS_FORWARDING`. +- The `sunbird-rc` realm export shipped in `files/`, rendered into a ConfigMap + and mounted for `KEYCLOAK_IMPORT`, so `helm install` needs nothing prepared + beforehand. A `checksum/realm` annotation rolls the pod when it changes. +- `realmImport.existingConfigMap` to mount a realm ConfigMap managed outside the + chart instead, and `realmImport.realmJson` to supply one inline. +- Render-time validation that `database.host`, `database.passwordSecret.name` + and `admin.passwordSecret.name` are set, since the chart creates no passwords. +- `strategy: Recreate`, so two instances never run the realm import at once. + +- Init containers that wait for the database before starting, so a first install + does not crashloop while PostgreSQL comes up. Derived from `database.host`. +- Optional PodDisruptionBudget, which fails the render if enabled alongside a + single replica - that combination blocks node drains entirely. +- `helm test` check that `/auth` responds and the imported realm is served. +- `extraVolumes` / `extraVolumeMounts` / `extraInitContainers` escape hatches. +- Per-environment example values for dev and production. The production example + connects as a dedicated `keycloak` role rather than the superuser, pulls the + admin password from ESO, and documents why it stays at one replica: the legacy + WildFly distribution needs Infinispan cache clustering configured before a + second replica can share sessions. + +### Notes +- The shipped realm is a second copy of the compose stack's + `registry/imports/realm-export.json`, byte-identical at the time of writing. + Nothing enforces that: if the compose copy changes, this one needs updating and + a chart version bump. +- Editing the realm rolls the pod but does not re-import it. The legacy image + imports a realm only when it is absent, so changes to a live realm have to be + made in the Keycloak console or against a fresh database. + +### Changed from the compose stack +- Probes follow Sunbird's Helm chart, not the compose healthcheck: readiness and + startup check `/auth/` on 8080, liveness is a TCP check. Compose curls the + WildFly management port 9990, which this chart deliberately does not expose. +- Image tag defaults to `v1.0.0` rather than `latest`, matching the version + Sunbird pairs with `sunbird-rc-core:v2.0.0`. A chart should not deploy a + moving tag. +- Resource limits added. Sunbird's chart sets requests only; limits are + mandatory in this repo. diff --git a/charts/keycloak/Chart.yaml b/charts/keycloak/Chart.yaml new file mode 100644 index 0000000..d93c746 --- /dev/null +++ b/charts/keycloak/Chart.yaml @@ -0,0 +1,28 @@ +apiVersion: v2 +name: keycloak +description: >- + Keycloak for the OAN registry, on the Sunbird RC Keycloak image. Issues and + validates the tokens the registry authorises requests with, and imports the + realm, clients and roles the registry expects. +type: application +version: 0.2.0 +# Tag of ghcr.io/sunbird-rc/sunbird-rc-keycloak. Sunbird's own v2 deployment +# pairs this version with sunbird-rc-core v2.0.0. +appVersion: "v1.0.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - keycloak + - sunbird-rc + - authentication +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts + - https://github.com/Sunbird-RC/devops/tree/main/deploy-as-code +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/keycloak/README.md b/charts/keycloak/README.md new file mode 100644 index 0000000..d69576e --- /dev/null +++ b/charts/keycloak/README.md @@ -0,0 +1,194 @@ +# keycloak + +Keycloak for the OAN registry, on the [Sunbird RC Keycloak +image](https://github.com/Sunbird-RC/devops/tree/main/deploy-as-code). It issues +and validates the tokens the registry authorises requests with, and imports the +realm, clients and roles the registry expects. + +This is the **legacy (WildFly/JBoss) Keycloak distribution**: it serves under +`/auth` and takes `DB_VENDOR`-style environment variables, not the modern `KC_*` +ones. Charts written for upstream Keycloak will not work with this image, which +is why this chart exists rather than depending on one. + +## What it renders + +| Resource | Notes | +|---|---| +| Deployment | `strategy: Recreate` — one instance, and the realm import runs on startup. Init container waits for the database | +| Service | `ClusterIP` on 8080 | +| ConfigMap (env) | Extra non-secret config from `envConfig` | +| ConfigMap (realm) | The realm shipped in `files/`, mounted for `KEYCLOAK_IMPORT` | +| ServiceAccount | | +| PodDisruptionBudget | Optional, off by default | +| Ingress | Optional, off by default | +| Test Pod | `helm test` check on `/auth` and the imported realm | + +Configuration matches `registry/docker-compose.yml` exactly — all ten Keycloak +environment variables, same names and same values. + +## Install + +```bash +helm dependency update charts/keycloak +helm install keycloak charts/keycloak \ + -n oan-registry -f charts/keycloak/examples/keycloak.dev.yaml +``` + +Requires a reachable PostgreSQL — see [`postgresql-cnpg`](../postgresql-cnpg) — +and two Secrets that this chart never creates: the database password and the +Keycloak admin password. + +## The two-phase first install + +**The registry cannot authenticate immediately after this chart installs.** The +realm export ships with the `admin-api` client secret masked (`**********`), +which is not a working credential. So: + +1. Install this chart; it imports the realm on first start. +2. Reach the admin console: + ```bash + kubectl -n oan-registry port-forward svc/keycloak 8080:8080 + ``` + then open `http://localhost:8080/auth/admin`. +3. Realm `sunbird-rc` → Clients → `admin-api` → Credentials → **Regenerate + Secret**. +4. Store that value where the registry chart's + `keycloak.adminClientSecret` points. +5. Install the registry. + +There is no way for a chart to shortcut this while the realm is imported from a +masked export. Making it one-phase means managing clients declaratively instead +— `keycloak-config-cli` or the Keycloak Operator — which is a larger change. + +## Waiting for the database + +Kubernetes has no equivalent of compose's `depends_on: condition: +service_healthy`, so an init container waits for the database before Keycloak +starts: + +```yaml +waitFor: + enabled: true + database: true # host and port come from `database` +``` + +Without it, a first install crashloops while PostgreSQL comes up — which recovers +on its own, but looks broken. + +## Verifying an install + +```bash +helm test keycloak -n oan-registry +``` + +Checks that `/auth` responds and, when `realmImport.enabled`, that the realm is +actually being served. That second check is the one that matters: the registry +validates tokens against that exact realm URL. + +## Replicas and clustering + +**This chart stays at one replica, and that is deliberate.** The legacy WildFly +distribution needs its Infinispan cache cluster configured — JGroups discovery +and session replication — before a second replica is safe. Without it, a token +issued by one replica is not recognised by the other. This chart does not +configure that, so `replicaCount: 1` and `strategy: Recreate` are the honest +settings. + +The practical consequence: restarting Keycloak briefly interrupts token issuing. +The registry keeps serving requests whose tokens are already valid. + +Getting past this means either configuring Infinispan clustering, or moving to a +current Keycloak with the Keycloak Operator — which would also remove the masked +client-secret problem below. + +`podDisruptionBudget` is therefore off by default: over a single pod it blocks +node drains entirely. + +## Per-environment values + +| File | For | +|---|---| +| [`examples/keycloak.dev.yaml`](./examples/keycloak.dev.yaml) | Dev: superuser connection, hand-made Secrets, no ingress | +| [`examples/keycloak.prod.yaml`](./examples/keycloak.prod.yaml) | Production: dedicated `keycloak` role, JVM heap sized to the limit | + +## Realm management + +The chart ships `files/realm-export.json` — realm `sunbird-rc`, 8 clients, +including the `admin-api` and `registry-frontend` clients the registry uses — and +renders it into a ConfigMap mounted at `realmImport.mountPath`. The legacy image +imports it on startup via `KEYCLOAK_IMPORT`. + +That means **`helm install` needs nothing prepared beforehand**, and a realm +change rolls the pod through the `checksum/realm` annotation. + +| You want | Set | +|---|---| +| The shipped realm | nothing — this is the default | +| A realm managed outside the chart | `existingConfigMap: ` (must already exist; the chart renders none) | +| A different realm inline | `realmJson: ` | +| No import at all | `enabled: false` | + +### Two things to know + +**The shipped realm is a second copy.** It is byte-identical to the compose +stack's `registry/imports/realm-export.json`, which is deliberate — the cluster +should import the same realm that was verified locally. But nothing enforces +that: if the compose copy changes, this one has to be updated too, with a chart +version bump. That is the cost of the chart being self-contained. + +**Editing the realm does not re-import it.** The legacy image imports a realm +only when it is *absent*. Changing the JSON rolls the pod, but Keycloak will +leave an already-imported realm alone — so changes to a live realm have to be +made in the Keycloak console, or by starting from a fresh database. This catches +people out, because the pod restart makes it look like something happened. + +## Database## Database + +The chart's `values.yaml` defaults match compose — Keycloak sharing the +registry's database — but **the example values point it at its own `keycloak` +database**, which is how the stack is meant to be deployed. Sunbird's own Helm +charts do the same. Keycloak's realm tables have no reason to sit beside registry +records, and separating them frees the registry to connect as a non-superuser +owner. + +The `keycloak` database is created by +[`postgresql-migration`](../postgresql-migration)'s bootstrap target, which must +run before this chart. To reproduce compose exactly instead, set +`database.name: registry` and drop the migration step. + +## Probes + +These follow Sunbird's own chart rather than the compose healthcheck. Compose +curls the WildFly management port (9990); this chart does not expose 9990, +because nothing in-cluster needs it and the admin console is on 8080 under +`/auth/admin`. Readiness instead checks that Keycloak is really serving `/auth`. + +First start imports the realm and migrates the schema, so `startupProbe` carries +the slow path (up to 5 minutes by default) and liveness stays tight. + +## Image tag + +Compose uses `latest`. A chart must not deploy a moving tag, so this defaults to +`v1.0.0` — the version Sunbird's own v2 deployment pairs with +`sunbird-rc-core:v2.0.0`. Set `image.digest` to pin exactly. + +## Ingress and the issuer URL + +`proxyAddressForwarding` is on, matching compose, so Keycloak will honour +`X-Forwarded-Host` / `X-Forwarded-Proto`. If you enable ingress: + +- The controller must actually set those headers. +- The registry's `keycloak.url` must be whichever URL Keycloak puts in the `iss` + claim. A mismatch rejects every authenticated request, and the failure reads + like a permissions problem rather than a configuration one. + +## Configuration + +See [`values.yaml`](./values.yaml) for the full commented schema, and +[`examples/keycloak.dev.yaml`](./examples/keycloak.dev.yaml) for a per-environment +file. + +## Versioning + +Every change needs a `version` bump in `Chart.yaml` and an entry in +[`CHANGELOG.md`](./CHANGELOG.md) — see [`CONVENTIONS.md`](../../CONVENTIONS.md). diff --git a/charts/keycloak/ci/lint-values.yaml b/charts/keycloak/ci/lint-values.yaml new file mode 100644 index 0000000..85da7a6 --- /dev/null +++ b/charts/keycloak/ci/lint-values.yaml @@ -0,0 +1,12 @@ +# Minimum values that let `helm template` run in CI. +# +# This chart deliberately fails the render when the database and password +# Secrets are unset, so bare defaults cannot be templated. These are dummy +# references, not real configuration - see examples/ for that. +database: + host: registry-db-rw + passwordSecret: + name: registry-db-app +admin: + passwordSecret: + name: keycloak-admin diff --git a/charts/keycloak/examples/keycloak.dev.yaml b/charts/keycloak/examples/keycloak.dev.yaml new file mode 100644 index 0000000..fbc9cc3 --- /dev/null +++ b/charts/keycloak/examples/keycloak.dev.yaml @@ -0,0 +1,77 @@ +# Example: Keycloak for the registry, dev environment. +# +# helm install keycloak charts/keycloak \ +# -n oan-registry -f charts/keycloak/examples/keycloak.dev.yaml +# +# Install order: postgresql-cnpg -> THIS -> regenerate admin-api secret -> +# registry. See charts/registry/README.md. + +# No fullnameOverride needed: the chart is named "keycloak", so a release named +# "keycloak" already produces Service/keycloak. Set it only if you install under +# a different release name and still want the service to be reachable as +# "keycloak" - the registry validates the token issuer against this host. + +# Keycloak gets its OWN database and its OWN role, both created by the +# postgresql-cnpg chart (`databases` and `managed.roles`). This is where the +# cluster deployment deliberately improves on compose, which shares one +# `registry` database between both services as the postgres superuser: Keycloak's +# realm tables have no reason to sit beside registry records, and nothing here +# needs superuser rights. +# +# To reproduce compose exactly instead, set name: registry, user: postgres, and +# point passwordSecret at registry-db-superuser. +database: + host: registry-db-rw + port: 5432 + name: keycloak + user: keycloak + # The same Secret the managed role is reconciled against, so the password the + # role has and the password Keycloak sends cannot drift. + passwordSecret: + name: keycloak-db + key: password + +admin: + username: admin + # Create by hand until ESO is available: + # kubectl -n oan-registry create secret generic keycloak-admin \ + # --from-literal=keycloakAdminPassword='' + passwordSecret: + name: keycloak-admin + key: keycloakAdminPassword + +# Ships the realm the registry expects. Its admin-api client secret is masked +# and must be regenerated in the console after first start. +# Uses the realm shipped with the chart (files/realm-export.json), so nothing +# needs preparing before install. +realmImport: + enabled: true + +# Dev: reach it with `kubectl port-forward` rather than an ingress. Enabling +# ingress means the controller must set X-Forwarded-Host / X-Forwarded-Proto, +# and the registry's keycloak.url must then be the external URL. +ingress: + enabled: false + +resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + +commonLabels: + oan.in/environment: dev + +# Blocks startup until the database accepts connections, standing in for +# compose's `depends_on: condition: service_healthy`. Host and port are taken +# from `database` above. +waitFor: + enabled: true + database: true + +# `helm test keycloak -n oan-registry` checks /auth and the imported realm. +tests: + enabled: true + realm: sunbird-rc diff --git a/charts/keycloak/examples/keycloak.prod.yaml b/charts/keycloak/examples/keycloak.prod.yaml new file mode 100644 index 0000000..e64d40b --- /dev/null +++ b/charts/keycloak/examples/keycloak.prod.yaml @@ -0,0 +1,97 @@ +# Example: Keycloak for the registry, production. +# +# helm install keycloak charts/keycloak \ +# -n oan-registry -f charts/keycloak/examples/keycloak.prod.yaml + +image: + registry: ghcr.io + repository: sunbird-rc/sunbird-rc-keycloak + tag: "v1.0.0" + # digest: "sha256:..." # pin this before going live + +# NOTE: this is the legacy WildFly Keycloak distribution. Running two replicas +# needs its Infinispan cache cluster configured (JGroups discovery, session +# replication) or the two will not share sessions - a token issued by one is not +# recognised by the other. That is not configured by this chart, so production +# stays at one replica until either that is set up or the deployment moves to a +# current Keycloak with the Keycloak Operator. +# +# The practical consequence: a Keycloak restart briefly interrupts token issuing. +# The registry keeps serving requests whose tokens are already valid. +replicaCount: 1 + +database: + host: registry-db-rw + port: 5432 + name: keycloak + # Production uses a dedicated role owning only the keycloak database, not the + # superuser. Requires that role and database to exist - see + # postgresql-migration. + user: keycloak + passwordSecret: + name: keycloak-db + key: password + +admin: + username: admin + passwordSecret: + name: keycloak-admin + key: keycloakAdminPassword + +# The admin password Secret is created out of band, by whatever manages secrets +# in this environment. This chart never renders a password. +# kubectl -n oan-registry create secret generic keycloak-admin \ +# --from-literal=keycloakAdminPassword='' + +# Uses the realm shipped with the chart (files/realm-export.json), so nothing +# needs preparing before install. +realmImport: + enabled: true + +# Reachable from outside only if users need the login page. Keep it internal +# otherwise - the registry reaches Keycloak over cluster DNS. +# +# If you do expose it: proxyAddressForwarding below is already on, the controller +# must set X-Forwarded-Host and X-Forwarded-Proto, and the registry's +# keycloak.url must then be the EXTERNAL URL, because that is what Keycloak will +# put in the token issuer. +ingress: + enabled: false + # className: alb + # annotations: + # alb.ingress.kubernetes.io/scheme: internal + # hosts: + # - host: keycloak.oan.example + # paths: + # - path: /auth + # pathType: Prefix + # tls: + # - hosts: [keycloak.oan.example] + # secretName: keycloak-tls + +proxyAddressForwarding: true + +resources: + requests: + cpu: "1" + memory: 2Gi + limits: + cpu: "2" + memory: 3Gi + +# Not enabled: with replicaCount 1 a PDB blocks node drains entirely. Revisit +# together with the clustering note above. +podDisruptionBudget: + enabled: false + +podSecurityContext: + enabled: false +securityContext: + enabled: false + +envConfig: + # Keep the JVM heap inside the memory limit above. + JAVA_OPTS_APPEND: "-Xms1024m -Xmx2048m" + +commonLabels: + oan.in/environment: prod diff --git a/charts/keycloak/files/realm-export.json b/charts/keycloak/files/realm-export.json new file mode 100644 index 0000000..4b343c1 --- /dev/null +++ b/charts/keycloak/files/realm-export.json @@ -0,0 +1,2312 @@ +{ + "id": "sunbird-rc", + "realm": "sunbird-rc", + "displayName": "Sunbird Rc Core", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "8ce3f968-e251-4ea3-a815-c00f9a40815a", + "name": "default-roles-sunbird-rc", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "sunbird-rc", + "attributes": {} + }, + { + "id": "a772a1cd-7904-4e5c-a864-5041fa69d491", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "sunbird-rc", + "attributes": {} + }, + { + "id": "42dba8cf-f483-4668-a087-cba46ed86ad2", + "name": "admin", + "composite": false, + "clientRole": false, + "containerId": "sunbird-rc", + "attributes": {} + }, + { + "id": "5fa4077d-1686-4506-97a6-5bce1bce59dc", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "sunbird-rc", + "attributes": {} + }, + { + "name": "network_operator", + "description": "Network Operator - onboards and governs Providers", + "composite": false, + "clientRole": false, + "containerId": "sunbird-rc", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "270ecc82-3249-475c-a851-d3ea162059b8", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "0de39ec0-7602-4aa2-b54d-ab12e9bdb76f", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "4259031b-736e-49eb-9e70-4a312a48e211", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "9887e071-49b0-464b-b6fe-a1c585a709c7", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "7d61f967-1dce-482f-96e5-9eff79eb4851", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "view-events", + "manage-identity-providers", + "manage-clients", + "view-identity-providers", + "manage-authorization", + "view-users", + "manage-users", + "manage-events", + "manage-realm", + "impersonation", + "view-authorization", + "query-clients", + "create-client", + "view-clients", + "query-users", + "query-realms", + "view-realm", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "e93b1761-fb32-46c5-bfa2-4b853c7b5573", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "bc32a137-07a1-40f0-b9fd-a6e64e27f99b", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "4b5abd90-d6a2-4981-a50f-520292496f0b", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "99d2ed5f-00a9-44ed-8b9f-bdd7ba3facb8", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "3fbd2cd5-0698-490e-a52f-ef528d001a62", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "9b8b4f1c-5ed6-49ca-bec3-0a9a4867ad26", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "7e6341ff-a1d8-4400-af94-3a007a06706a", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "ad0c87da-9f34-4992-a83a-f6b924f1944d", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "badb0d4d-06da-45e8-a777-ef47f712d3ed", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "f8f48f0f-bd2a-4cb7-9b77-af69b9805c25", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "ca0b1e94-6578-4295-abf4-ae99f7df7595", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "ff7230eb-7dae-44a5-8f68-f68747f35589", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "890d054b-86f9-49f5-8dd9-14f62aa956de", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + }, + { + "id": "627f3f4c-58e3-49f3-9989-a05d4d0a8752", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "961a8a92-1598-48ff-adee-1e5fee0df757", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-api": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "2e9bfeff-129e-4072-9617-5847644aac24", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "34e4506c-ea71-4248-a8da-cc2054e9007c", + "attributes": {} + } + ], + "account": [ + { + "id": "5694c2d0-6d02-4182-bb09-78f4f5f1ec2d", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + }, + { + "id": "0612622f-dae7-48f8-8985-fe7e5ab8acc7", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + }, + { + "id": "eeefbd57-94b8-4d7d-bf2f-075c39ccb746", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + }, + { + "id": "9e9165b9-1170-47ab-802a-aecffefb3ab7", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + }, + { + "id": "a8d0a100-e382-49ba-ac42-48dbf815a2de", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + }, + { + "id": "08772792-146d-4676-ba2d-ce56b0104263", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + }, + { + "id": "0a2e7893-784e-47ef-ba35-4a26901350c0", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "01326f76-7838-47fc-ae62-399a75c5ae38", + "attributes": {} + } + ], + "registry-frontend": [] + } + }, + "groups": [], + "defaultRole": { + "id": "8ce3f968-e251-4ea3-a815-c00f9a40815a", + "name": "default-roles-sunbird-rc", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "sunbird-rc" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "3cc9ac60-b67d-4c57-8005-acd4d236b2dc", + "createdTimestamp": 1634296700339, + "username": "service-account-admin-api", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "admin-api", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-sunbird-rc", + "admin" + ], + "clientRoles": { + "realm-management": [ + "manage-users", + "manage-realm" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "username": "no-user", + "enabled": true, + "emailVerified": false, + "credentials": [ + { + "type": "password", + "value": "no-user-password", + "temporary": false + } + ], + "realmRoles": [ + "default-roles-sunbird-rc", + "network_operator" + ] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account" + ] + } + ] + }, + "clients": [ + { + "id": "01326f76-7838-47fc-ae62-399a75c5ae38", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/sunbird-rc/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/sunbird-rc/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f871d6fc-d997-4ac6-99fe-d797955bc9f0", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/sunbird-rc/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/sunbird-rc/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "8ab32c51-9aa0-4e28-80bf-0d6b53151354", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "25962708-6d45-47d9-8935-5db159234aac", + "clientId": "admin-api", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "*", + "http://localhost:4200/", + "http://localhost:4200/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "84ae9d6c-424f-47f0-9d4d-f2e98fed7339", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "98406938-b8db-4992-8519-917054f6ed0e", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + }, + { + "id": "90d6b17a-5a06-4546-8091-960301f8147e", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "b245b10b-606c-417c-bbc0-8f81a7a992a6", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "34e4506c-ea71-4248-a8da-cc2054e9007c", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "961a8a92-1598-48ff-adee-1e5fee0df757", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "69c04ae8-6669-48e7-8234-08986a7f490d", + "clientId": "registry-frontend", + "name": "Registry Frontend", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "login_theme": "sunbird-rc", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "true", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "b777b14f-b0e8-4da5-a802-092803319cbe", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/sunbird-rc/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/sunbird-rc/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "7160f35d-97d3-4730-9769-4b03b32e5191", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "b4695333-f842-4ef7-874e-99260e77b9cb", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "fc23d4b8-76c5-4e59-9305-10846b8bcefe", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "08f06ba5-3e60-4a0a-aaf9-f70bfc7ae99e", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e28227ee-cb54-4557-8908-01864f80055f", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "364a632f-b66a-4ca4-8bbe-ec2ce1af9df8", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "42cad815-4de0-4b67-abca-7f7aaf55e589", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "e3302def-d387-465c-a420-7ab01570e94a", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "63a3cb24-b124-428e-ac0f-253eb1fe485d", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "2eb041ca-970a-45fd-a167-2a497579bc8c", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "2c02e9ce-7d86-4a5b-84b8-cf93114ddf26", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "2d39b55e-46c4-4dec-bd83-f081c708f544", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "e869fffd-f801-492d-a6c7-d6c6143817e5", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "daeb863b-4773-4668-98fb-403e93414eb2", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "a98b9f93-ec39-4f3c-acb7-cd92161e3717", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "c59a379a-3934-4e6f-be20-1803b0786d97", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0a299a91-277c-4f38-95e7-6c520f892b63", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "d51531b3-a8ea-44e2-a48f-69991f9166cc", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "b4b33a89-01db-468e-9a4e-c5ac58304fed", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "d1727e08-fb90-49ce-bb7e-d7a55a50ee64", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "564ae79d-e505-416c-b794-ddd3a3c21fde", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "986d2d9e-0d0d-4317-92b3-a7a8d9bec4de", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "e14ec2e9-0d24-4960-8779-00f769ccc01b", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "8f044609-b615-4522-b9e7-8361cb08b0b3", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "341b838d-ba26-4280-b0af-3e5d3403c938", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "26a17e7e-1a6e-439f-a54a-05a63d1c91fb", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2489b2c0-5b3a-4404-8428-be4ce653da72", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "8c7e1d96-bf79-42e6-9360-b5e7b8dddc8d", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "01f959d8-123f-4263-ad6e-386e8b4d0e05", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "b47a30d6-3c49-4bbe-b15e-b0eb6cffc0f3", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "2cc8166d-6d77-4a85-9945-bc22b0f550e3", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "1d87a800-cc11-4d85-aa76-8a6d828e2269", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "42cc1538-f83a-4a94-b5a5-d16b80824a02", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "13ff6325-822e-4087-9e74-086de77fe89e", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e501527e-dec8-4fde-a539-8e77d86b5081", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "b4b519d8-070d-4dab-854e-d6e3b2b36205", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "ed42958b-6e78-42a9-9f40-2e40bd6c8dd0", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "saml-role-list-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "572219a7-3053-4940-87c5-ad94a6fb6dd3", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "8704a420-bf90-4e12-9e33-d21f39a2385b", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "8cf98455-916b-487a-8322-3f5d283400c2", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "0b43488d-108b-41f5-ab6d-56a4ac8ff63c", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "6f0ebf9b-900a-4ca9-8fea-90719f218689", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "b5a486b3-abf9-49a1-8dc6-dc5e20776681", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "be43420e-8ffc-4f53-b745-f2f0cd88f000", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "f749bd77-72f2-4dc4-a65e-dd89b255f12f", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "a541cbb1-8a27-4061-a389-9f24ba1c2eb1", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "18504bf7-63f1-4848-b565-6348fa6b0048", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "497d8386-9a74-4b7b-a4e6-78bbbbb5d795", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "d964973c-2106-4db3-a814-f7a34ae7a1ce", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "3ff1b250-85b1-4709-8719-3eabcb34493f", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "43f683be-52e7-43cd-aa9e-6318b8079ad0", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "5195e46e-b2c3-49e3-8987-db8b19c45fc5", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "aca5480d-842c-4fa9-aff1-b8af8d51d82a", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "66a00ea9-7ec1-4450-905a-14b7f3f8e4bf", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "a24445a4-1988-4b5b-bde6-fa36dbd07e03", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "31c2bc3b-6eb1-4c4f-8464-3528f7445ef7", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4ccc8da9-0e1e-4f30-99c0-e2139f671a80", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "fa1183d1-af7a-40dd-ba85-d7d37867639c", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f5b5e49b-7cc9-4011-b9fa-60f0ef65e735", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "5cf727d2-e25a-4c88-a55b-4eea9134adb1", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "3ec4f009-2f16-464b-8feb-a0bdc0dad195", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "a695a5e0-326f-4658-8518-a1769d97ad5f", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4611e0a9-a6a4-4e32-8500-e68877b464b1", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "55be727b-a17b-40c5-a5c3-c2d72c7f54cb", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "00ec3b72-3abb-4db3-ad2f-595bc2f7e086", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "82e4f3a3-744b-4d8a-8785-6eabaf9e05c9", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "0c0312b2-db7c-433c-ab15-20b18bfb5f4a", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "ee0faa63-999c-42e6-8189-c22a5cc14dc5", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "506eed8f-88c9-4978-b13a-886f1efc45c0", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5" + }, + "keycloakVersion": "14.0.0", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/charts/keycloak/templates/NOTES.txt b/charts/keycloak/templates/NOTES.txt new file mode 100644 index 0000000..f32bde6 --- /dev/null +++ b/charts/keycloak/templates/NOTES.txt @@ -0,0 +1,36 @@ +{{ .Chart.Name }} installed as release "{{ .Release.Name }}". + +Resources in namespace "{{ .Release.Namespace }}": + - Deployment/{{ include "keycloak.fullname" . }} ({{ .Values.replicaCount }} replica(s)) + - Service/{{ include "keycloak.fullname" . }} ({{ .Values.service.type }} on port {{ .Values.service.port }}) + - ConfigMap/{{ include "keycloak.envConfigMapName" . }} +{{- if include "keycloak.renderRealmConfigMap" . }} + - ConfigMap/{{ include "keycloak.realmConfigMapName" . }} (realm import) +{{- end }} +{{- if .Values.serviceAccount.enabled }} + - ServiceAccount/{{ include "keycloak.serviceAccountName" . }} +{{- end }} +{{- if .Values.ingress.enabled }} + - Ingress/{{ include "keycloak.fullname" . }} +{{- end }} + +Database: {{ .Values.database.user }}@{{ .Values.database.host }}:{{ .Values.database.port }}/{{ .Values.database.name }} + +Give the registry chart this URL: + keycloak.url: {{ include "keycloak.url" . }} + +{{ if .Values.realmImport.enabled }} +NEXT STEP - the registry cannot authenticate until you do this: + + The realm export ships with a masked admin-api client secret, so it is not a + working credential. Once Keycloak is up: + + 1. kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "keycloak.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + 2. Open http://localhost:{{ .Values.service.port }}/auth/admin and sign in as "{{ .Values.admin.username }}". + 3. Realm "sunbird-rc" -> Clients -> admin-api -> Credentials -> Regenerate Secret. + 4. Store that value, and set it as the registry's admin client secret. +{{- end }} + +Watch the rollout (first start imports the realm and migrates the schema, so +give it a few minutes): + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "keycloak.fullname" . }} diff --git a/charts/keycloak/templates/_helpers.tpl b/charts/keycloak/templates/_helpers.tpl new file mode 100644 index 0000000..8557b64 --- /dev/null +++ b/charts/keycloak/templates/_helpers.tpl @@ -0,0 +1,159 @@ +{{/* +# ============================================================================ +# KEYCLOAK (SUNBIRD RC) CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers delegating to oan-common, plus the realm-import +# and legacy-Keycloak database wiring this chart needs. +# ============================================================================ +*/}} + +{{- define "keycloak.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "keycloak.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "keycloak.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{- define "keycloak.selectorLabels" -}} +{{- include "oan-common.selectorLabels" . -}} +{{- end }} + +{{- define "keycloak.serviceAccountName" -}} +{{- include "oan-common.serviceAccount.name" . -}} +{{- end }} + +{{- define "keycloak.image" -}} +{{- include "oan-common.image" . -}} +{{- end }} + +{{- define "keycloak.envConfigMapName" -}} +{{- include "oan-common.envConfigMapName" . -}} +{{- end }} + +{{/* +The base URL other services use to reach this Keycloak, including the /auth +context path the legacy distribution serves under. The registry's issuer check +compares against exactly this, so it must match what the registry is given. +*/}} +{{- define "keycloak.url" -}} +{{- printf "http://%s.%s.svc.cluster.local:%v/auth" (include "keycloak.fullname" .) .Release.Namespace .Values.service.port -}} +{{- end }} + +{{/* +Name of the ConfigMap holding the realm export. + +The chart renders its own by default. An existingConfigMap wins, for the case +where the realm is managed outside the chart entirely. +*/}} +{{- define "keycloak.realmConfigMapName" -}} +{{- if .Values.realmImport.existingConfigMap -}} +{{- .Values.realmImport.existingConfigMap -}} +{{- else -}} +{{- printf "%s-realm" (include "keycloak.fullname" .) -}} +{{- end -}} +{{- end }} + +{{/* +Emits "true" when this chart renders the realm ConfigMap itself, i.e. whenever +the realm is not being supplied by an out-of-band ConfigMap. +*/}} +{{- define "keycloak.renderRealmConfigMap" -}} +{{- if and .Values.realmImport.enabled (not .Values.realmImport.existingConfigMap) -}} +{{- true -}} +{{- end -}} +{{- end }} + +{{/* +The realm JSON: an inline override if given, otherwise the file shipped in the +chart under files/. +*/}} +{{- define "keycloak.realmJson" -}} +{{- if .Values.realmImport.realmJson -}} +{{- .Values.realmImport.realmJson -}} +{{- else -}} +{{- $json := .Files.Get .Values.realmImport.file -}} +{{- if not $json -}} +{{- fail (printf "%s: realmImport is enabled but %q is not present in the chart, and neither realmImport.realmJson nor realmImport.existingConfigMap is set." .Chart.Name .Values.realmImport.file) -}} +{{- end -}} +{{- $json -}} +{{- end -}} +{{- end }} + +{{/* +Path the realm file is mounted at, which is what KEYCLOAK_IMPORT points to. +*/}} +{{- define "keycloak.realmImportPath" -}} +{{- printf "%s/%s" (.Values.realmImport.mountPath | trimSuffix "/") .Values.realmImport.fileName -}} +{{- end }} + +{{/* +Environment for the legacy (WildFly) Keycloak distribution. This image predates +Keycloak's KC_* variables, so it takes DB_VENDOR/DB_ADDR and friends. Values +mirror the compose stack. +*/}} +{{- define "keycloak.env" -}} +{{- $db := .Values.database }} +{{- if not $db.host }} +{{- fail (printf "%s: database.host is required - point it at the PostgreSQL primary service, e.g. registry-db-rw" .Chart.Name) }} +{{- end }} +{{- if not $db.passwordSecret.name }} +{{- fail (printf "%s: database.passwordSecret.name is required - this chart renders no passwords" .Chart.Name) }} +{{- end }} +{{- if not .Values.admin.passwordSecret.name }} +{{- fail (printf "%s: admin.passwordSecret.name is required - this chart renders no passwords" .Chart.Name) }} +{{- end }} +- name: DB_VENDOR + value: {{ $db.vendor | quote }} +- name: DB_ADDR + value: {{ $db.host | quote }} +- name: DB_PORT + value: {{ $db.port | quote }} +- name: DB_DATABASE + value: {{ $db.name | quote }} +- name: DB_USER + value: {{ $db.user | quote }} +{{- with $db.schema }} +- name: DB_SCHEMA + value: {{ . | quote }} +{{- end }} +- name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $db.passwordSecret.name }} + key: {{ $db.passwordSecret.key }} +- name: KEYCLOAK_USER + value: {{ .Values.admin.username | quote }} +- name: KEYCLOAK_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.admin.passwordSecret.name }} + key: {{ .Values.admin.passwordSecret.key }} +- name: PROXY_ADDRESS_FORWARDING + value: {{ .Values.proxyAddressForwarding | quote }} +{{- if .Values.realmImport.enabled }} +- name: KEYCLOAK_IMPORT + value: {{ include "keycloak.realmImportPath" . | quote }} +{{- end }} +{{- with (include "oan-common.env" . | trim) }} +{{ . }} +{{- end }} +{{- end }} + +{{/* +Dependency waits, derived from this chart's own settings so there is nothing to +keep in sync: the host comes from database.host, which is the same value the +container connects to. +*/}} +{{- define "keycloak.waitFor" -}} +{{- $tcp := list -}} +{{- if .Values.waitFor.database }} +{{- $tcp = append $tcp (dict "name" "database" "host" .Values.database.host "port" .Values.database.port) -}} +{{- end -}} +{{- $tcp = concat $tcp (.Values.waitFor.extraTcp | default list) -}} +{{- include "oan-common.waitFor" (dict "ctx" . "tcp" $tcp "http" (.Values.waitFor.extraHttp | default list)) -}} +{{- end }} diff --git a/charts/keycloak/templates/configmap-realm.yaml b/charts/keycloak/templates/configmap-realm.yaml new file mode 100644 index 0000000..6890099 --- /dev/null +++ b/charts/keycloak/templates/configmap-realm.yaml @@ -0,0 +1,27 @@ +{{- if include "keycloak.renderRealmConfigMap" . }} +{{/* +The realm the registry expects: realm `sunbird-rc`, the admin-api and +registry-frontend clients, and the roles the registry checks. Mounted at +realmImport.mountPath and imported on startup via KEYCLOAK_IMPORT. + +The exported admin-api client secret is masked ("**********"), not a working +credential - it has to be regenerated in the Keycloak console after the first +import. See this chart's README. + +Not rendered when realmImport.existingConfigMap points at a ConfigMap managed +outside the chart. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "keycloak.realmConfigMapName" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + {{ .Values.realmImport.fileName }}: |- + {{- include "keycloak.realmJson" . | nindent 4 }} +{{- end }} diff --git a/charts/keycloak/templates/configmap.yaml b/charts/keycloak/templates/configmap.yaml new file mode 100644 index 0000000..ed67084 --- /dev/null +++ b/charts/keycloak/templates/configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "keycloak.envConfigMapName" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- with (include "oan-common.envConfigMapData" . | trim) }} +data: + {{- . | nindent 2 }} +{{- else }} +data: {} +{{- end }} diff --git a/charts/keycloak/templates/deployment.yaml b/charts/keycloak/templates/deployment.yaml new file mode 100644 index 0000000..bca671f --- /dev/null +++ b/charts/keycloak/templates/deployment.yaml @@ -0,0 +1,126 @@ +apiVersion: {{ include "oan-common.deployment.apiVersion" . }} +kind: Deployment +metadata: + name: {{ include "keycloak.fullname" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: {{ .Values.replicaCount }} + # The legacy distribution is not clustered here, and the realm import runs on + # startup, so replicas are rolled one at a time with the old pod gone first. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "keycloak.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "keycloak.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/env-config: {{ include "oan-common.checksumAnnotation" . }} + {{- if include "keycloak.renderRealmConfigMap" . }} + {{/* + Rolls the pod when the realm changes. Only possible while the chart owns + the realm content - with realmImport.existingConfigMap, Helm cannot read + cluster state to checksum it, so restart Keycloak yourself after editing + that ConfigMap. + */}} + checksum/realm: {{ include "keycloak.realmJson" . | sha256sum }} + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.serviceAccount.enabled }} + serviceAccountName: {{ include "keycloak.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- end }} + {{- with (include "oan-common.imagePullSecrets" . | trim) }} + {{- . | nindent 6 }} + {{- end }} + {{- with (include "oan-common.podSecurityContext" .) }} + securityContext: + {{- . | nindent 8 }} + {{- end }} + {{- $init := include "keycloak.waitFor" . | trim }} + {{- $extraInit := "" }} + {{- with .Values.extraInitContainers }} + {{- $extraInit = toYaml . | trim }} + {{- end }} + {{- if or $init $extraInit }} + initContainers: + {{- with $init }} + {{- . | nindent 8 }} + {{- end }} + {{- with $extraInit }} + {{- . | nindent 8 }} + {{- end }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "keycloak.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + {{- include "keycloak.env" . | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "keycloak.envConfigMapName" . }} + {{- range .Values.envFromSecrets }} + - secretRef: + name: {{ . }} + {{- end }} + {{- with (include "oan-common.probes" . | trim) }} + {{- . | nindent 10 }} + {{- end }} + resources: + {{- include "oan-common.resources" . | nindent 12 }} + {{- if or .Values.realmImport.enabled .Values.extraVolumeMounts }} + volumeMounts: + {{- if .Values.realmImport.enabled }} + - name: realm + mountPath: {{ .Values.realmImport.mountPath }} + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- if or .Values.realmImport.enabled .Values.extraVolumes }} + volumes: + {{- if .Values.realmImport.enabled }} + - name: realm + configMap: + name: {{ include "keycloak.realmConfigMapName" . }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/keycloak/templates/ingress.yaml b/charts/keycloak/templates/ingress.yaml new file mode 100644 index 0000000..1e99eb8 --- /dev/null +++ b/charts/keycloak/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: {{ include "oan-common.ingress.apiVersion" . }} +kind: Ingress +metadata: + name: {{ include "keycloak.fullname" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.ingress.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- range . }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "keycloak.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/keycloak/templates/poddisruptionbudget.yaml b/charts/keycloak/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..f210bd2 --- /dev/null +++ b/charts/keycloak/templates/poddisruptionbudget.yaml @@ -0,0 +1,33 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{/* +Caps how many pods can be voluntarily disrupted at once - node drains, cluster +upgrades, autoscaler scale-down. + +With replicaCount: 1 a PDB of minAvailable: 1 blocks drains entirely, because +evicting the only pod would breach it. That is why this is disabled by default +and should be enabled together with replicaCount >= 2. +*/}} +{{- if and (eq (int .Values.replicaCount) 1) (not .Values.podDisruptionBudget.allowSingleReplica) }} +{{- fail (printf "%s: podDisruptionBudget is enabled with replicaCount 1, which blocks node drains entirely. Raise replicaCount to 2+, or set podDisruptionBudget.allowSingleReplica=true if you accept that." .Chart.Name) }} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "keycloak.fullname" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ . }} + {{- end }} + {{- with .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ . }} + {{- end }} + selector: + matchLabels: + {{- include "keycloak.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/keycloak/templates/service.yaml b/charts/keycloak/templates/service.yaml new file mode 100644 index 0000000..1a824dd --- /dev/null +++ b/charts/keycloak/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.fullname" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.service.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "keycloak.selectorLabels" . | nindent 4 }} diff --git a/charts/keycloak/templates/serviceaccount.yaml b/charts/keycloak/templates/serviceaccount.yaml new file mode 100644 index 0000000..83b8800 --- /dev/null +++ b/charts/keycloak/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if include "oan-common.serviceAccount.enabled" . }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "keycloak.serviceAccountName" . }} + labels: + {{- include "keycloak.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.serviceAccount.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/keycloak/templates/tests/test-connection.yaml b/charts/keycloak/templates/tests/test-connection.yaml new file mode 100644 index 0000000..5c31958 --- /dev/null +++ b/charts/keycloak/templates/tests/test-connection.yaml @@ -0,0 +1,39 @@ +{{- if .Values.tests.enabled }} +{{/* +`helm test` check: Keycloak is serving its realm endpoint. + +This is the check that matters for the stack - the registry validates tokens +against this exact URL, so if it does not answer, the registry cannot +authenticate no matter how healthy the pod looks. +*/}} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "keycloak.fullname" . }}-test-connection + labels: + {{- include "keycloak.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: test + image: {{ printf "%s/%s:%s" .Values.tests.image.registry .Values.tests.image.repository .Values.tests.image.tag | quote }} + command: + - /bin/sh + - -c + - | + set -e + base="http://{{ include "keycloak.fullname" . }}:{{ .Values.service.port }}/auth" + echo "GET $base/" + wget -q -O /dev/null --timeout=10 "$base/" + echo "OK: /auth is serving" + {{- if .Values.realmImport.enabled }} + echo "GET $base/realms/{{ .Values.tests.realm }}" + wget -q -O /dev/null --timeout=10 "$base/realms/{{ .Values.tests.realm }}" + echo "OK: realm {{ .Values.tests.realm }} is imported and serving" + {{- end }} + resources: + {{- toYaml .Values.tests.resources | nindent 8 }} +{{- end }} diff --git a/charts/keycloak/values.yaml b/charts/keycloak/values.yaml new file mode 100644 index 0000000..0391081 --- /dev/null +++ b/charts/keycloak/values.yaml @@ -0,0 +1,313 @@ +# ============================================================================ +# keycloak - default values +# +# Keycloak for the OAN registry, on the Sunbird RC Keycloak image. This is the +# legacy (WildFly/JBoss) Keycloak distribution: it serves under /auth and takes +# DB_VENDOR-style environment variables, not the modern KC_* ones. +# +# Defaults mirror the verified compose stack (registry/docker-compose.yml) so a +# cluster deploy reproduces what was tested locally. Where this chart deviates, +# it says so and why. +# +# Minimum you must set: +# - database.host (e.g. the CNPG primary, registry-db-rw) +# - database.passwordSecret.name +# - admin.passwordSecret.name +# ============================================================================ + +replicaCount: 1 + +image: + registry: ghcr.io + repository: sunbird-rc/sunbird-rc-keycloak + # Compose uses `latest`. A chart must not deploy a moving tag, so this pins + # the version Sunbird's own v2 deployment pairs with sunbird-rc-core v2.0.0. + tag: "v1.0.0" + # Digest pin; wins over tag. Preferred for anything past dev. + digest: "" + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: "" +# Only needed when the release name differs from "keycloak". The resulting name +# is the in-cluster DNS the registry connects to, and the registry validates the +# token issuer against exactly this host - so keep it stable once anything +# depends on it. +fullnameOverride: "" + +serviceAccount: + enabled: true + name: "" + annotations: {} + automountServiceAccountToken: true + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + annotations: {} +# Compose also publishes 9990, the WildFly management port. That exists only for +# the compose healthcheck; nothing in-cluster needs it, and the Keycloak admin +# console is on 8080 under /auth/admin. Not exposed here. + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: keycloak.local + paths: + # Legacy Keycloak serves everything under /auth + - path: /auth + pathType: Prefix + tls: [] + +# --------------------------------------------------------------------------- +# Database +# +# Defaults match compose: Keycloak shares the registry's database and connects +# as the same user. Sunbird's own Helm charts instead give Keycloak its own +# database ("keycloak") on the same host, which is the better arrangement - +# Keycloak's realm tables have no reason to sit beside registry records, and it +# frees the registry to connect as a non-superuser owner. To separate them: +# +# database: +# name: keycloak +# user: keycloak +# +# and provision that database and role in the cluster. +# --------------------------------------------------------------------------- +database: + vendor: postgres + # PostgreSQL primary service, e.g. "registry-db-rw" + host: "" + port: 5432 + name: registry + user: postgres + schema: "" + # Secret holding the database password. Required. + passwordSecret: + name: "" + key: password + +# --------------------------------------------------------------------------- +# Keycloak admin user (the console login) +# --------------------------------------------------------------------------- +admin: + username: admin + passwordSecret: + name: "" + key: keycloakAdminPassword + +# --------------------------------------------------------------------------- +# Realm import +# +# The chart ships the realm the registry expects: realm `sunbird-rc`, the +# admin-api and registry-frontend clients, and the roles the registry checks. +# The legacy image imports it on startup when the realm is not already present. +# +# IMPORTANT: the exported admin-api client secret is masked ("**********"), not +# a working credential. After the first import, regenerate it in the console +# (realm -> Clients -> admin-api -> Credentials -> Regenerate Secret) and hand +# that value to the registry chart. First install is therefore two-phase. +# --------------------------------------------------------------------------- +realmImport: + enabled: true + + # The realm shipped with this chart, rendered into a ConfigMap and mounted for + # KEYCLOAK_IMPORT. Copied verbatim from the compose stack's + # registry/imports/realm-export.json, so the cluster imports the same realm + # that was verified locally. + # + # Keeping it in the chart means `helm install` needs nothing prepared + # beforehand, and a realm change rolls the pod via the checksum annotation. + # The trade-off is that this is a second copy of that file: if the compose one + # changes, update this one too. + file: files/realm-export.json + + # Key in the ConfigMap, which becomes the filename on disk and the value of + # KEYCLOAK_IMPORT. + fileName: realm-export.json + + # Where the legacy image looks for imports. + mountPath: /opt/jboss/keycloak/imports + + # Use a ConfigMap managed outside this chart instead. When set, the chart + # renders no realm ConfigMap and mounts this name - so it must already exist. + existingConfigMap: "" + + # Inline realm JSON, overriding the shipped file. Useful for a test with a + # deliberately minimal realm. + realmJson: "" + +# Lets a caller behind an ingress obtain a token whose `iss` matches what the +# registry validates, via X-Forwarded-Host / X-Forwarded-Proto. Matches compose. +# If you enable ingress, the controller must actually set those headers. +proxyAddressForwarding: true + +# WildFly-based Keycloak is memory-hungry; Sunbird requests 500m/2G with no +# ceiling. Limits are mandatory in this repo, so it gets one. +resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + +# --------------------------------------------------------------------------- +# Probes +# +# These follow Sunbird's own Helm chart rather than the compose healthcheck: +# compose curls the WildFly management port (9990), which this chart does not +# expose. Readiness instead checks that Keycloak is really serving /auth. +# +# First start imports the realm and runs schema migrations, so startupProbe +# carries the slow path and liveness stays tight. +# --------------------------------------------------------------------------- +startupProbe: + enabled: true + httpGet: + path: /auth/ + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 10 + failureThreshold: 30 + +livenessProbe: + enabled: true + tcpSocket: + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 10 + +readinessProbe: + enabled: true + httpGet: + path: /auth/ + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 10 + +# This image runs as the jboss user and writes into its own directories, so +# readOnlyRootFilesystem needs extra volumes to work. Off by default. +podSecurityContext: + enabled: false + fsGroup: 1000 + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + +securityContext: + enabled: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + capabilities: + drop: + - ALL + +nodeSelector: {} +tolerations: [] +affinity: {} +podLabels: {} +podAnnotations: {} + +# --------------------------------------------------------------------------- +# Dependency waiting +# +# Kubernetes has no equivalent of compose's `depends_on: condition: +# service_healthy`. Without these init containers the pod starts before its +# dependencies are up, fails, and crashloops with backoff - which recovers on +# its own but makes a first install look broken. +# --------------------------------------------------------------------------- +waitFor: + enabled: true + image: + registry: docker.io + repository: busybox + tag: "1.37" + pullPolicy: IfNotPresent + timeoutSeconds: 300 + intervalSeconds: 3 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + # Wait for the database. Host and port come from `database` above, so there is + # nothing to keep in sync. + database: true + # Extra checks, if this Keycloak depends on anything else. + extraTcp: [] + extraHttp: [] + +# Extra init containers, appended after the waitFor ones. Rendered verbatim. +extraInitContainers: [] + +# Extra volumes and mounts, rendered verbatim. For certificates, extra config, +# or a writable scratch directory when readOnlyRootFilesystem is enabled. +extraVolumes: [] +extraVolumeMounts: [] + +# --------------------------------------------------------------------------- +# PodDisruptionBudget +# +# Caps voluntary disruptions - node drains, cluster upgrades, autoscaler +# scale-down. Disabled by default because with a single replica a PDB of +# minAvailable: 1 blocks drains entirely; the render fails if you enable it +# anyway without acknowledging that. +# --------------------------------------------------------------------------- +podDisruptionBudget: + enabled: false + minAvailable: 1 + # Set one or the other, not both. + maxUnavailable: "" + # Enable a PDB with a single replica anyway, accepting that node drains block. + allowSingleReplica: false + +# --------------------------------------------------------------------------- +# `helm test` checks +# +# helm test -n +# +# Runs after install to confirm the service actually answers, which is the +# difference between "the pod is running" and "the deployment works". +# --------------------------------------------------------------------------- +tests: + enabled: true + image: + registry: docker.io + repository: busybox + tag: "1.37" + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + # Realm the test expects to find. Only used when realmImport.enabled. + realm: sunbird-rc + + +# Extra non-secret configuration, exposed as a ConfigMap and injected with +# envFrom. The database and admin settings above render as explicit env vars, +# so they do not belong here. +envConfig: {} + # JAVA_OPTS_APPEND: "-Xms512m -Xmx1024m" + +# Extra env vars from specific Secret keys, beyond the two wired above. +secretEnv: {} +extraEnv: [] +envFromSecrets: [] + +commonLabels: {} +commonAnnotations: {} diff --git a/charts/oan-common/.helmignore b/charts/oan-common/.helmignore new file mode 100644 index 0000000..7e6c23d --- /dev/null +++ b/charts/oan-common/.helmignore @@ -0,0 +1,10 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig diff --git a/charts/oan-common/CHANGELOG.md b/charts/oan-common/CHANGELOG.md new file mode 100644 index 0000000..50a83af --- /dev/null +++ b/charts/oan-common/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to the `oan-common` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-08-31 + +### Added +- `oan-common.image` now fails the render when `image.repository` is empty. + Previously an empty repository produced a syntactically valid but meaningless + reference such as `ghcr.io/:v2.0.0`, which Helm and the API server both accept + and which only surfaces later as an `ImagePullBackOff` - long after the deploy + appeared to succeed. + +### Removed +- `oan-common.externalsecret`, `oan-common.externalSecret.enabled` and + `oan-common.externalSecret.targetName`, along with the `externalSecrets` value + block. External Secrets Operator is not installed in any OAN cluster, so no + consuming chart could exercise them. + + The helpers that reference existing Secrets - `oan-common.env` for `secretEnv`, + and each chart's own `*Secret.name` settings - are unchanged. + +## [0.1.0] - 2026-08-31 + +### Added +- Initial release of the `oan-common` library chart. +- Name, fullname, chart, standard label, selector label, annotation, and + namespace helpers. +- Image reference and image pull secret helpers, with digest pinning + (`image.digest`) taking precedence over `image.tag`. +- Service account name/enabled helpers. +- Env ConfigMap name/data helpers and an `envConfig` checksum annotation helper. +- `oan-common.env`, rendering container env entries from `secretEnv` (mapping an + env var name to a specific Secret key) and `extraEnv` (raw passthrough), and + failing the render when a `secretEnv` entry is missing its name or key. +- `oan-common.resources`, which fails the render when `resources` is empty so no + component can ship without a resource contract. +- `oan-common.probes` and `oan-common.probeSpec`, passing every probe field + through verbatim and failing the render when an enabled probe declares no + handler or more than one. +- Pod-level and container-level security context helpers, gated on `enabled`. +- `oan-common.externalsecret`, rendering a complete External Secrets Operator + `ExternalSecret`, with target name and enabled helpers, and render-time + validation of `secretStoreRef.name` and `data`/`dataFrom`. +- `oan-common.waitFor`, rendering init containers that block startup until TCP + and HTTP dependencies are reachable - the missing equivalent of compose's + `depends_on: condition: service_healthy`. Checks are passed in explicitly so a + consuming chart derives host and URL from its own settings rather than + duplicating them. +- Deployment and Ingress apiVersion helpers. diff --git a/charts/oan-common/Chart.yaml b/charts/oan-common/Chart.yaml new file mode 100644 index 0000000..87c04ac --- /dev/null +++ b/charts/oan-common/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: oan-common +description: Common Helm library chart for OpenAgriNet (OAN) services +type: library +version: 0.2.0 +appVersion: "0.1.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - common + - library +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts diff --git a/charts/oan-common/README.md b/charts/oan-common/README.md new file mode 100644 index 0000000..a254a00 --- /dev/null +++ b/charts/oan-common/README.md @@ -0,0 +1,117 @@ +# oan-common + +The common Helm **library chart** for OpenAgriNet (OAN) services. + +`oan-common` renders **no resources of its own** and is never installed +directly. Service charts declare it as a dependency and call its named template +helpers, so names, labels, image references, probes, resource contracts, and +secret wiring are identical across every OAN chart. + +## Using it in a chart + +1. Declare the dependency in your chart's `Chart.yaml`: + + ```yaml + dependencies: + - name: oan-common + version: "0.1.x" + repository: "file://../oan-common" + ``` + +2. Pull it in: + + ```bash + helm dependency update charts/ + ``` + + The packaged dependency is not committed, so re-run this after every edit to + `oan-common` — otherwise your chart keeps rendering against a stale copy. + +3. Define thin chart-local wrappers in your `templates/_helpers.tpl` that + delegate to the library: + + ```yaml + {{- define "my-service.fullname" -}} + {{- include "oan-common.fullname" . -}} + {{- end }} + ``` + + See [`oan-template`](../oan-template) for a complete, copy-ready example. + +## Helpers + +### Names and labels + +| Helper | Purpose | +|---|---| +| `oan-common.name` | Chart name, honoring `nameOverride` | +| `oan-common.fullname` | Fully qualified name (`-`), honoring `fullnameOverride` | +| `oan-common.chart` | `name-version` string for the `helm.sh/chart` label | +| `oan-common.labels` | Standard `app.kubernetes.io/*` labels, `part-of: oan`, plus `commonLabels` | +| `oan-common.selectorLabels` | Pod/Service selector labels (name + instance) | +| `oan-common.annotations` | Renders `commonAnnotations` | +| `oan-common.namespace` | Release namespace | + +### Workload + +| Helper | Purpose | +|---|---| +| `oan-common.image` | Full image ref from `image.registry`/`repository`/`tag`, falling back to `Chart.appVersion` then `latest`. `image.digest` pins by digest and wins over the tag. **Fails the render when `repository` is empty** | +| `oan-common.imagePullSecrets` | Renders the `imagePullSecrets` block from `image.pullSecrets` | +| `oan-common.resources` | Renders `resources`. **Fails the render when empty** — every OAN component must declare a resource contract | +| `oan-common.probes` | Renders every enabled probe block (startup, liveness, readiness) for a container spec | +| `oan-common.probeSpec` | Renders one probe. Takes `(dict "probe" "name" "chart" .Chart.Name)` | +| `oan-common.podSecurityContext` | Pod-level security context, only when `podSecurityContext.enabled` | +| `oan-common.securityContext` | Container-level security context, only when `securityContext.enabled` | + +Probes pass every field except `enabled` through verbatim, so any handler +(`httpGet`, `tcpSocket`, `exec`, `grpc`) and any timing field works. Two +render-time guardrails apply: + +- An enabled probe with **no** handler fails the render. +- An enabled probe with **more than one** handler fails the render. This is the + common trap: Helm merges maps, so overriding a default `httpGet` probe with + `tcpSocket` leaves both in the merged value and the API server rejects it at + apply time. Null out the default you are replacing: + + ```bash + --set livenessProbe.tcpSocket.port=http --set livenessProbe.httpGet=null + ``` + +### Service account + +| Helper | Purpose | +|---|---| +| `oan-common.serviceAccount.name` | Service account name (generated, overridden, or `default` when disabled) | +| `oan-common.serviceAccount.enabled` | Emits `true` when a ServiceAccount should be created | + +### Configuration and secrets + +| Helper | Purpose | +|---|---| +| `oan-common.env` | Container `env` entries from `secretEnv` (env var name -> secret key) and `extraEnv` (raw passthrough) | +| `oan-common.envConfigMapName` | Name of the env ConfigMap (`-env`) | +| `oan-common.envConfigMapData` | Renders `envConfig` into ConfigMap `data` entries | +| `oan-common.checksumAnnotation` | Checksum of `envConfig`, to roll pods when config changes | + +### apiVersions + +`oan-common.deployment.apiVersion` and `oan-common.ingress.apiVersion` keep those +in one place, so a Kubernetes upgrade is a single edit. + +## Value schema + +The helpers read the keys documented in [`values.yaml`](./values.yaml): +`nameOverride`, `fullnameOverride`, `image.*` (including `digest`), `serviceAccount.*`, `envConfig`, +`resources`, `secretEnv`, `extraEnv`, `livenessProbe`/`readinessProbe`/`startupProbe`, +`podSecurityContext`, `securityContext`, `waitFor`, `commonLabels`, and +`commonAnnotations`. A consuming chart inherits this schema and extends it with +its own keys (`replicaCount`, `service`, `ingress`, ...). + +## Versioning + +Consumers pin `version: "0.1.x"`. Ship helper additions as PATCH/MINOR; reserve +MAJOR for renaming or changing the behaviour of an existing helper, since that +forces every consuming chart to update its pin. Every change needs a `version` +bump in `Chart.yaml` and an entry in [`CHANGELOG.md`](./CHANGELOG.md) — see +[`CONVENTIONS.md`](../../CONVENTIONS.md). diff --git a/charts/oan-common/templates/_helpers.tpl b/charts/oan-common/templates/_helpers.tpl new file mode 100644 index 0000000..6504773 --- /dev/null +++ b/charts/oan-common/templates/_helpers.tpl @@ -0,0 +1,402 @@ +{{/* +# ============================================================================ +# OAN COMMON LIBRARY CHART - SHARED HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: reusable template helpers consumed by every OAN service chart. +# This chart renders no resources of its own. +# GitHub: https://github.com/OpenAgriNet/helmcharts/blob/main/charts/oan-common/templates/_helpers.tpl +# ============================================================================ +*/}} + +{{/* +Chart name, honoring nameOverride. +Usage: {{ include "oan-common.name" . }} +*/}} +{{- define "oan-common.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Fully qualified app name (-), honoring fullnameOverride. +Truncated to 63 chars for the DNS label limit. +Usage: {{ include "oan-common.fullname" . }} +*/}} +{{- define "oan-common.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart name and version for the helm.sh/chart label. +Usage: {{ include "oan-common.chart" . }} +*/}} +{{- define "oan-common.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Standard labels for every resource. +Usage: {{ include "oan-common.labels" . | nindent 4 }} +*/}} +{{- define "oan-common.labels" -}} +helm.sh/chart: {{ include "oan-common.chart" . }} +{{ include "oan-common.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: oan +{{- with .Values.commonLabels }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* +Selector labels for pods and services. These are immutable on a Deployment +selector, so nothing environment-specific belongs here. +Usage: {{ include "oan-common.selectorLabels" . | nindent 4 }} +*/}} +{{- define "oan-common.selectorLabels" -}} +app.kubernetes.io/name: {{ include "oan-common.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Common annotations for every resource. +Usage: {{ include "oan-common.annotations" . | nindent 4 }} +*/}} +{{- define "oan-common.annotations" -}} +{{- with .Values.commonAnnotations }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* +Name of the service account to use. +Usage: {{ include "oan-common.serviceAccount.name" . }} +*/}} +{{- define "oan-common.serviceAccount.name" -}} +{{- if .Values.serviceAccount.enabled }} +{{- default (include "oan-common.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Emits "true" when a ServiceAccount should be created. +Usage: {{ if include "oan-common.serviceAccount.enabled" . }} +*/}} +{{- define "oan-common.serviceAccount.enabled" -}} +{{- if .Values.serviceAccount.enabled }} +{{- true }} +{{- end }} +{{- end }} + +{{/* +Full image reference: /:, or +/@ when image.digest is set. + +A digest wins over a tag, so an environment can pin an exact image without +having to blank out the tag. + +Tag falls back to Chart.appVersion, then "latest". +Usage: {{ include "oan-common.image" . }} +*/}} +{{- define "oan-common.image" -}} +{{- $registry := .Values.image.registry | default "" }} +{{- $repository := .Values.image.repository | default "" }} +{{- if not $repository }} +{{/* +Without this, an empty repository renders a syntactically valid but meaningless +reference - "ghcr.io/:v2.0.0" - which Helm and the API server both accept. The +failure only surfaces later as an ImagePullBackOff, long after the deploy looked +successful. Fail here instead. +*/}} +{{- fail (printf "%s: image.repository is required - set image.registry/repository/tag for this environment" .Chart.Name) }} +{{- end }} +{{- if $registry }} +{{- $repository = printf "%s/%s" $registry $repository }} +{{- end }} +{{- with .Values.image.digest }} +{{- printf "%s@%s" $repository . }} +{{- else }} +{{- printf "%s:%s" $repository (.Values.image.tag | default $.Chart.AppVersion | default "latest") }} +{{- end }} +{{- end }} + +{{/* +imagePullSecrets block, rendered only when image.pullSecrets is non-empty. +Usage: {{ include "oan-common.imagePullSecrets" . | nindent 6 }} +*/}} +{{- define "oan-common.imagePullSecrets" -}} +{{- with .Values.image.pullSecrets }} +imagePullSecrets: +{{- range . }} + - name: {{ . }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Name of the env ConfigMap (-env). +Usage: {{ include "oan-common.envConfigMapName" . }} +*/}} +{{- define "oan-common.envConfigMapName" -}} +{{- printf "%s-env" (include "oan-common.fullname" .) }} +{{- end }} + +{{/* +envConfig rendered as ConfigMap data entries. +Usage: {{ include "oan-common.envConfigMapData" . | nindent 2 }} +*/}} +{{- define "oan-common.envConfigMapData" -}} +{{- range $key, $value := .Values.envConfig }} +{{ $key }}: {{ $value | quote }} +{{- end }} +{{- end }} + +{{/* +Checksum of envConfig, so a config change rolls the pods. +Usage: checksum/env-config: {{ include "oan-common.checksumAnnotation" . }} +*/}} +{{- define "oan-common.checksumAnnotation" -}} +{{- $envConfig := .Values.envConfig | default dict }} +{{- $envConfig | toJson | sha256sum }} +{{- end }} + +{{/* +Container env entries built from `secretEnv` and `extraEnv`. + +`secretEnv` maps an environment variable name to a secret key, for the common +case where the producing secret's key name differs from the variable the app +expects (CNPG writes `password`; Sunbird RC wants `connectionInfo_password`): + + secretEnv: + connectionInfo_password: + name: registry-db-app + key: password + optional: false # optional + +`extraEnv` is a raw list of env entries, passed through verbatim for anything +this schema does not cover (fieldRef, resourceFieldRef, plain values). + +Usage: + {{- with (include "oan-common.env" . | trim) }} + env: + {{- . | nindent 12 }} + {{- end }} +*/}} +{{- define "oan-common.env" -}} +{{- range $name, $ref := .Values.secretEnv }} +{{- if not $ref.name }} +{{- fail (printf "secretEnv.%s.name is required - it must name the Secret holding the value" $name) }} +{{- end }} +{{- if not $ref.key }} +{{- fail (printf "secretEnv.%s.key is required - it must name the key inside Secret %q" $name $ref.name) }} +{{- end }} +- name: {{ $name }} + valueFrom: + secretKeyRef: + name: {{ $ref.name }} + key: {{ $ref.key }} + {{- if hasKey $ref "optional" }} + optional: {{ $ref.optional }} + {{- end }} +{{- end }} +{{- with .Values.extraEnv }} +{{ toYaml . }} +{{- end }} +{{- end }} + +{{/* +Init containers that block startup until dependencies are reachable. + +Kubernetes has no equivalent of compose's `depends_on: condition: +service_healthy`. Without this a pod starts before its database or its identity +provider is up, fails, and crashloops with backoff - which recovers on its own +but makes a first install look broken and slows every restart. + +Takes the checks explicitly, so a consuming chart derives them from its own +settings (database.host, keycloak.url) instead of making the operator retype +values that would then be free to drift: + + {{- include "oan-common.waitFor" (dict "ctx" . "tcp" $tcp "http" $http) }} + +where $tcp entries are {name, host, port} and $http entries are {name, url}. + +TCP checks use `nc -z`; HTTP checks use `wget --spider`. Both loop until success +or waitFor.timeoutSeconds, then fail the pod so the reason is visible in +`kubectl describe` rather than buried in a crashloop. +*/}} +{{- define "oan-common.waitFor" -}} +{{- $ctx := .ctx -}} +{{- $w := $ctx.Values.waitFor | default dict -}} +{{- if $w.enabled -}} +{{- $img := $w.image | default dict -}} +{{- $image := printf "%s/%s:%s" ($img.registry | default "docker.io") ($img.repository | default "busybox") ($img.tag | default "latest") -}} +{{- $timeout := $w.timeoutSeconds | default 300 -}} +{{- $interval := $w.intervalSeconds | default 3 -}} +{{- $res := $w.resources | default dict -}} +{{- range .tcp }} +{{- if not .host }} +{{- fail (printf "waitFor: the %q check has no host. Set the setting it derives from (e.g. database.host), or disable the check." (.name | default "")) }} +{{- end }} +- name: {{ printf "wait-%s" (.name | default "tcp") | trunc 63 | trimSuffix "-" }} + image: {{ $image | quote }} + imagePullPolicy: {{ $img.pullPolicy | default "IfNotPresent" }} + command: + - /bin/sh + - -c + - | + deadline=$(( $(date +%s) + {{ $timeout }} )) + until nc -z {{ .host | quote }} {{ .port }}; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "timed out after {{ $timeout }}s waiting for {{ .name }} at {{ .host }}:{{ .port }}" + exit 1 + fi + echo "waiting for {{ .name }} at {{ .host }}:{{ .port }}" + sleep {{ $interval }} + done + echo "{{ .name }} is reachable" + resources: + {{- toYaml $res | nindent 4 }} +{{- end }} +{{- range .http }} +{{- if not .url }} +{{- fail (printf "waitFor: the %q check has no url. Set the setting it derives from (e.g. keycloak.url), or disable the check." (.name | default "")) }} +{{- end }} +- name: {{ printf "wait-%s" (.name | default "http") | trunc 63 | trimSuffix "-" }} + image: {{ $image | quote }} + imagePullPolicy: {{ $img.pullPolicy | default "IfNotPresent" }} + command: + - /bin/sh + - -c + - | + deadline=$(( $(date +%s) + {{ $timeout }} )) + until wget -q --spider --timeout=5 {{ .url | quote }}; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "timed out after {{ $timeout }}s waiting for {{ .name }} at {{ .url }}" + exit 1 + fi + echo "waiting for {{ .name }} at {{ .url }}" + sleep {{ $interval }} + done + echo "{{ .name }} is reachable" + resources: + {{- toYaml $res | nindent 4 }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Resources block. +Fails the render when resources is empty: every OAN component must declare a +resource contract so the scheduler and the cluster autoscaler have real numbers +to work with. +Usage: {{ include "oan-common.resources" . | nindent 10 }} +*/}} +{{- define "oan-common.resources" -}} +{{- if not .Values.resources -}} +{{- fail (printf "%s: .Values.resources is required - every OAN component must declare requests and limits (see CONVENTIONS.md)" .Chart.Name) -}} +{{- end -}} +{{- toYaml .Values.resources }} +{{- end }} + +{{/* +Render one probe. Everything except the `enabled` flag is passed through +verbatim, so any handler (httpGet, tcpSocket, exec, grpc) and any timing field +works. + +Guardrail: Helm merges maps, so overriding a chart's default httpGet probe with +tcpSocket would leave BOTH handlers in the merged value - which the API server +rejects at apply time, long after the render looked fine. This fails the render +instead and tells you to null out the default. + +Usage: {{ include "oan-common.probeSpec" + (dict "probe" .Values.livenessProbe "name" "livenessProbe" "chart" .Chart.Name) | nindent 2 }} +*/}} +{{- define "oan-common.probeSpec" -}} +{{- $probe := .probe -}} +{{- $name := .name | default "probe" -}} +{{- $chart := .chart | default "chart" -}} +{{- $handlers := list -}} +{{- range $h := list "httpGet" "tcpSocket" "exec" "grpc" -}} +{{- if index $probe $h -}} +{{- $handlers = append $handlers $h -}} +{{- end -}} +{{- end -}} +{{- if gt (len $handlers) 1 -}} +{{- fail (printf "%s: %s declares %d handlers (%s) but a probe may declare only one. Null out the one you do not want, e.g. --set %s.httpGet=null" $chart $name (len $handlers) (join ", " $handlers) $name) -}} +{{- end -}} +{{- if eq (len $handlers) 0 -}} +{{- fail (printf "%s: %s is enabled but declares no handler. Set one of httpGet, tcpSocket, exec or grpc." $chart $name) -}} +{{- end -}} +{{- toYaml (omit $probe "enabled") }} +{{- end }} + +{{/* +All enabled probe blocks (startup, liveness, readiness) for a container spec. +Usage: {{ include "oan-common.probes" . | nindent 8 }} +*/}} +{{- define "oan-common.probes" -}} +{{- if and .Values.startupProbe .Values.startupProbe.enabled }} +startupProbe: + {{- include "oan-common.probeSpec" (dict "probe" .Values.startupProbe "name" "startupProbe" "chart" .Chart.Name) | nindent 2 }} +{{- end }} +{{- if and .Values.livenessProbe .Values.livenessProbe.enabled }} +livenessProbe: + {{- include "oan-common.probeSpec" (dict "probe" .Values.livenessProbe "name" "livenessProbe" "chart" .Chart.Name) | nindent 2 }} +{{- end }} +{{- if and .Values.readinessProbe .Values.readinessProbe.enabled }} +readinessProbe: + {{- include "oan-common.probeSpec" (dict "probe" .Values.readinessProbe "name" "readinessProbe" "chart" .Chart.Name) | nindent 2 }} +{{- end }} +{{- end }} + +{{/* +Pod-level security context, rendered only when enabled. +Usage: {{ include "oan-common.podSecurityContext" . | nindent 8 }} +*/}} +{{- define "oan-common.podSecurityContext" -}} +{{- if and .Values.podSecurityContext .Values.podSecurityContext.enabled }} +{{- toYaml (omit .Values.podSecurityContext "enabled") }} +{{- end }} +{{- end }} + +{{/* +Container-level security context, rendered only when enabled. +Usage: {{ include "oan-common.securityContext" . | nindent 10 }} +*/}} +{{- define "oan-common.securityContext" -}} +{{- if and .Values.securityContext .Values.securityContext.enabled }} +{{- toYaml (omit .Values.securityContext "enabled") }} +{{- end }} +{{- end }} + +{{/* +Release namespace. +Usage: {{ include "oan-common.namespace" . }} +*/}} +{{- define "oan-common.namespace" -}} +{{- .Release.Namespace }} +{{- end }} + +{{/* +apiVersion helpers, so a bump lands in one place. +*/}} +{{- define "oan-common.deployment.apiVersion" -}} +apps/v1 +{{- end }} + +{{- define "oan-common.ingress.apiVersion" -}} +networking.k8s.io/v1 +{{- end }} diff --git a/charts/oan-common/values.yaml b/charts/oan-common/values.yaml new file mode 100644 index 0000000..f1517ee --- /dev/null +++ b/charts/oan-common/values.yaml @@ -0,0 +1,151 @@ +# ============================================================================ +# Default values for the oan-common library chart. +# +# A library chart renders no resources of its own. These keys define the value +# schema that consuming charts inherit, and are what the helpers in +# templates/_helpers.tpl read. Consuming charts may extend this schema with +# their own keys (service, ingress, replicaCount, ...). +# ============================================================================ + +# Override the chart name used in names and labels +nameOverride: "" +# Override the generated fullname (default: -) +fullnameOverride: "" + +# Image configuration. Consumed by `oan-common.image`. +image: + # Container registry, e.g. "ghcr.io" or ".dkr.ecr.ap-south-1.amazonaws.com" + registry: "" + # Image repository, e.g. "openagrinet/registry-service" + repository: "" + # Image tag. Falls back to Chart.appVersion, then "latest". + tag: "" + # Image digest (sha256:...). Takes precedence over tag when set, so an + # environment can pin an exact image without blanking the tag. + digest: "" + pullPolicy: IfNotPresent + # Names of image pull secrets in the release namespace + pullSecrets: [] + +# Service account configuration. Consumed by `oan-common.serviceAccount.*`. +serviceAccount: + # Create a dedicated ServiceAccount for this release + enabled: true + # Explicit name. Defaults to the chart fullname when empty. + name: "" + # Annotations, e.g. eks.amazonaws.com/role-arn for IRSA + annotations: {} + automountServiceAccountToken: true + +# Non-secret environment configuration. Rendered into a ConfigMap and exposed +# to the container with envFrom. NEVER put secrets here. +envConfig: {} + +# Resource requests and limits. +# REQUIRED on every OAN component: `oan-common.resources` fails the render when +# this is empty, so a chart cannot ship without a resource contract. +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +# Probes. REQUIRED on every OAN component that serves traffic. +# Every field other than `enabled` is passed through to the probe verbatim, so +# any handler (httpGet, tcpSocket, exec, grpc) and any timing field is allowed. +livenessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +startupProbe: + enabled: false + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + +# Pod-level security context. Every field other than `enabled` is passed +# through verbatim. +podSecurityContext: + enabled: false + fsGroup: 1000 + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +# Container-level security context. Every field other than `enabled` is passed +# through verbatim. +securityContext: + enabled: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + capabilities: + drop: + - ALL + +# Init containers that block startup until dependencies are reachable, standing +# in for compose's `depends_on: condition: service_healthy`, which Kubernetes has +# no equivalent of. Consumed by `oan-common.waitFor`. +waitFor: + enabled: false + image: + registry: docker.io + # Needs `nc`, `wget` and `date` - busybox has all three. + repository: busybox + tag: "1.37" + pullPolicy: IfNotPresent + # Give up after this long, so a genuinely missing dependency surfaces as a + # failed init container rather than an endless wait. + timeoutSeconds: 300 + intervalSeconds: 3 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + # Which dependencies to wait for is decided by the consuming chart, which + # derives host and URL from its own settings. See that chart's values. + +# Individual environment variables sourced from Secret keys, for when the +# secret's key name differs from the variable the application expects +# (CNPG writes `password`; Sunbird RC wants `connectionInfo_password`). +# Consumed by `oan-common.env`. +secretEnv: {} + # connectionInfo_password: + # name: registry-db-app + # key: password + +# Raw env entries, passed through verbatim (fieldRef, plain values, ...). +extraEnv: [] + +# Extra labels added to every resource +commonLabels: {} +# Extra annotations added to every resource +commonAnnotations: {} diff --git a/charts/oan-template/.helmignore b/charts/oan-template/.helmignore new file mode 100644 index 0000000..7e6c23d --- /dev/null +++ b/charts/oan-template/.helmignore @@ -0,0 +1,10 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig diff --git a/charts/oan-template/CHANGELOG.md b/charts/oan-template/CHANGELOG.md new file mode 100644 index 0000000..2dfa74c --- /dev/null +++ b/charts/oan-template/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to the `oan-template` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-08-31 + +### Removed +- External Secrets Operator integration: the `ExternalSecret` template and the + `externalSecrets` value block. ESO is not installed in any OAN cluster, so this + was configuration that could not be exercised, and a chart that renders a + Secret-producing resource invites the question of where secrets come from to be + answered differently per chart. + + Charts still reference Secrets by name - `envFromSecrets`, `secretEnv`, and the + per-chart `*Secret.name` settings are unchanged. Creating those Secrets is now + unambiguously outside the charts. + +## [0.1.0] - 2026-08-31 + +### Added +- Initial release of the `oan-template` reference/starter application chart. +- Depends on the `oan-common` library chart via `file://../oan-common`. +- Templates for Deployment, Service, ServiceAccount, env ConfigMap, optional + ESO ExternalSecret, and optional Ingress, all wired through `oan-common` + helpers. +- Liveness and readiness probes and resource requests/limits enabled by default, + per the deployment epic's requirement for every component. +- Pod-level and container-level security contexts, off by default and enabled + per environment. +- Scheduling controls (`nodeSelector`, `tolerations`, `affinity`) and extra + `podLabels` / `podAnnotations`. +- `envFromSecrets` for pre-existing Secrets not managed by ESO, plus `secretEnv` + and `extraEnv` for individual environment variables. +- Fully commented `values.yaml` and a `NOTES.txt` summarising what was rendered. diff --git a/charts/oan-template/Chart.yaml b/charts/oan-template/Chart.yaml new file mode 100644 index 0000000..6ec645e --- /dev/null +++ b/charts/oan-template/Chart.yaml @@ -0,0 +1,22 @@ +apiVersion: v2 +name: oan-template +description: Reference starter chart for OpenAgriNet (OAN) services, built on oan-common +type: application +version: 0.2.0 +appVersion: "1.0.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - template + - starter + - reference +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/oan-template/README.md b/charts/oan-template/README.md new file mode 100644 index 0000000..c85a25d --- /dev/null +++ b/charts/oan-template/README.md @@ -0,0 +1,121 @@ +# oan-template + +The **reference / starter** Helm chart for OpenAgriNet (OAN) services. + +`oan-template` is a complete, working example of an OAN service chart built on +the [`oan-common`](../oan-common) library chart. It is meant to be **copied and +adapted** — not installed as-is. + +## What it renders + +| Resource | File | Notes | +|---|---|---| +| Deployment | `templates/deployment.yaml` | Probes and resources always present; security contexts gated on `enabled` | +| Service | `templates/service.yaml` | `ClusterIP` by default | +| ServiceAccount | `templates/serviceaccount.yaml` | Created when `serviceAccount.enabled` | +| ConfigMap | `templates/configmap.yaml` | Built from `envConfig`; checksum rolls pods on change | +| Ingress | `templates/ingress.yaml` | Optional, off by default | + +Names, labels, image refs, probes, resources, and secret wiring all come from +`oan-common` helpers, so every chart derived from this template stays consistent. + +## How it depends on oan-common + +`Chart.yaml` declares: + +```yaml +dependencies: + - name: oan-common + version: "0.1.x" + repository: "file://../oan-common" +``` + +The chart-local helpers in `templates/_helpers.tpl` are thin wrappers that +delegate to the library (`oan-template.fullname` → `oan-common.fullname`). + +## Create your own chart from this template + +1. Copy the directory and rename it: + + ```bash + cp -r charts/oan-template charts/oan-my-service + ``` + +2. In `charts/oan-my-service/Chart.yaml`, set `name: oan-my-service` and + `appVersion` to the image tag you deploy by default. Keep the `oan-common` + dependency. + +3. Rename the chart-local helpers. Change only the **left side** of each + `define` in `templates/_helpers.tpl` — the `oan-common.*` include inside the + body stays, since that is the shared library you delegate to. Then update the + matching `include "oan-template..."` calls in the template YAML. Both at once, + from the repo root: + + ```bash + grep -rl 'oan-template\.' charts/oan-my-service | xargs sed -i '' 's/oan-template\./oan-my-service./g' + ``` + + (`sed -i ''` is the macOS form; on Linux use `sed -i`.) + +4. Set the real values in `values.yaml`: + - `image.registry` / `image.repository` / `image.tag` + - `service.port` / `service.targetPort` — your container's listen port + - `livenessProbe.httpGet.path` / `readinessProbe.httpGet.path` — your health endpoint + - `resources` — right-sized for the workload + - `envConfig` — non-secret configuration only + +5. Validate: + + ```bash + ./scripts/lint-charts.sh + helm template oan-my-service charts/oan-my-service + ``` + +## Try the template directly + +```bash +helm dependency update charts/oan-template +helm template demo charts/oan-template \ + --set image.repository=nginx --set image.tag=1.27 +``` + +## Configuration + +See [`values.yaml`](./values.yaml) for the full, commented schema. + +Two behaviours worth knowing before you override: + +- **`resources` is mandatory.** Emptying it fails the render by design. +- **Switching a probe handler needs the default nulled out.** Helm merges maps, + so replacing the default `httpGet` probe with `tcpSocket` leaves both handlers + in the merged value. The render fails with an explanatory error; null the one + you are replacing: + + ```bash + --set livenessProbe.tcpSocket.port=http --set livenessProbe.httpGet=null + ``` + +## Secrets + +Never put secret values in `envConfig` or in any committed values file. This +chart renders no Secrets at all — it only *references* them, so they are created +by whatever manages secrets in that environment. + +Two ways to consume one: + +```yaml +# whole Secret injected as env vars +envFromSecrets: + - my-service-db + +# a single key mapped to a specific variable name, for when they differ +secretEnv: + DB_PASSWORD: + name: my-service-db + key: password +``` + +## Versioning + +Every change needs a `version` bump in `Chart.yaml` and an entry in +[`CHANGELOG.md`](./CHANGELOG.md) — see [`CONVENTIONS.md`](../../CONVENTIONS.md). diff --git a/charts/oan-template/ci/lint-values.yaml b/charts/oan-template/ci/lint-values.yaml new file mode 100644 index 0000000..17cad2c --- /dev/null +++ b/charts/oan-template/ci/lint-values.yaml @@ -0,0 +1,9 @@ +# Minimum values that let `helm template` run in CI. +# +# This chart's image.repository is intentionally empty - it is a starter chart, +# and a chart with no image is not a deployable thing. The render fails without +# one, so CI supplies a placeholder. +image: + registry: docker.io + repository: nginx + tag: "1.27" diff --git a/charts/oan-template/templates/NOTES.txt b/charts/oan-template/templates/NOTES.txt new file mode 100644 index 0000000..1e8e8be --- /dev/null +++ b/charts/oan-template/templates/NOTES.txt @@ -0,0 +1,28 @@ +Thank you for installing {{ .Chart.Name }} (release: {{ .Release.Name }}). + +Rendered from the oan-template reference chart. + +Resources in namespace "{{ .Release.Namespace }}": + - Deployment/{{ include "oan-template.fullname" . }} ({{ .Values.replicaCount }} replica(s)) + - Service/{{ include "oan-template.fullname" . }} ({{ .Values.service.type }} on port {{ .Values.service.port }}) + - ConfigMap/{{ include "oan-template.envConfigMapName" . }} +{{- if .Values.serviceAccount.enabled }} + - ServiceAccount/{{ include "oan-template.serviceAccountName" . }} +{{- end }} +{{- if .Values.ingress.enabled }} + - Ingress/{{ include "oan-template.fullname" . }} +{{- end }} + +Image in use: + {{ include "oan-template.image" . }} +{{- if not .Values.image.repository }} + +WARNING: image.repository is empty. Set image.registry/repository/tag before a +real deployment, e.g. --set image.repository=openagrinet/my-service +{{- end }} + +Check the rollout: + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "oan-template.fullname" . }} + +Reach the service in-cluster: + {{ include "oan-template.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} diff --git a/charts/oan-template/templates/_helpers.tpl b/charts/oan-template/templates/_helpers.tpl new file mode 100644 index 0000000..29ec803 --- /dev/null +++ b/charts/oan-template/templates/_helpers.tpl @@ -0,0 +1,45 @@ +{{/* +# ============================================================================ +# OAN TEMPLATE CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers that delegate to oan-common. +# +# When you copy this chart, rename every "oan-template." define below to your +# service name and update the matching include calls in the template YAML. +# Change only the LEFT side of each define - the oan-common include inside the +# body is the shared library you delegate to. +# ============================================================================ +*/}} + +{{- define "oan-template.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "oan-template.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "oan-template.chart" -}} +{{- include "oan-common.chart" . -}} +{{- end }} + +{{- define "oan-template.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{- define "oan-template.selectorLabels" -}} +{{- include "oan-common.selectorLabels" . -}} +{{- end }} + +{{- define "oan-template.serviceAccountName" -}} +{{- include "oan-common.serviceAccount.name" . -}} +{{- end }} + +{{- define "oan-template.image" -}} +{{- include "oan-common.image" . -}} +{{- end }} + +{{- define "oan-template.envConfigMapName" -}} +{{- include "oan-common.envConfigMapName" . -}} +{{- end }} + diff --git a/charts/oan-template/templates/configmap.yaml b/charts/oan-template/templates/configmap.yaml new file mode 100644 index 0000000..656ef85 --- /dev/null +++ b/charts/oan-template/templates/configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "oan-template.envConfigMapName" . }} + labels: + {{- include "oan-template.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + {{- include "oan-common.envConfigMapData" . | trim | nindent 2 }} diff --git a/charts/oan-template/templates/deployment.yaml b/charts/oan-template/templates/deployment.yaml new file mode 100644 index 0000000..5de7fa3 --- /dev/null +++ b/charts/oan-template/templates/deployment.yaml @@ -0,0 +1,79 @@ +apiVersion: {{ include "oan-common.deployment.apiVersion" . }} +kind: Deployment +metadata: + name: {{ include "oan-template.fullname" . }} + labels: + {{- include "oan-template.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "oan-template.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "oan-template.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/env-config: {{ include "oan-common.checksumAnnotation" . }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.serviceAccount.enabled }} + serviceAccountName: {{ include "oan-template.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- end }} + {{- with (include "oan-common.imagePullSecrets" . | trim) }} + {{- . | nindent 6 }} + {{- end }} + {{- with (include "oan-common.podSecurityContext" .) }} + securityContext: + {{- . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "oan-template.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + {{- with (include "oan-common.env" . | trim) }} + env: + {{- . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "oan-template.envConfigMapName" . }} + {{- range .Values.envFromSecrets }} + - secretRef: + name: {{ . }} + {{- end }} + {{- with (include "oan-common.probes" . | trim) }} + {{- . | nindent 10 }} + {{- end }} + resources: + {{- include "oan-common.resources" . | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/oan-template/templates/ingress.yaml b/charts/oan-template/templates/ingress.yaml new file mode 100644 index 0000000..964e961 --- /dev/null +++ b/charts/oan-template/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: {{ include "oan-common.ingress.apiVersion" . }} +kind: Ingress +metadata: + name: {{ include "oan-template.fullname" . }} + labels: + {{- include "oan-template.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.ingress.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- range . }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "oan-template.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/oan-template/templates/service.yaml b/charts/oan-template/templates/service.yaml new file mode 100644 index 0000000..109557c --- /dev/null +++ b/charts/oan-template/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "oan-template.fullname" . }} + labels: + {{- include "oan-template.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.service.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "oan-template.selectorLabels" . | nindent 4 }} diff --git a/charts/oan-template/templates/serviceaccount.yaml b/charts/oan-template/templates/serviceaccount.yaml new file mode 100644 index 0000000..ca5974c --- /dev/null +++ b/charts/oan-template/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if include "oan-common.serviceAccount.enabled" . }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "oan-template.serviceAccountName" . }} + labels: + {{- include "oan-template.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.serviceAccount.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/oan-template/values.yaml b/charts/oan-template/values.yaml new file mode 100644 index 0000000..8a06373 --- /dev/null +++ b/charts/oan-template/values.yaml @@ -0,0 +1,155 @@ +# ============================================================================ +# oan-template - default values +# +# This is a REFERENCE / STARTER chart. Copy the whole `oan-template` directory, +# rename it to your service, then adjust the values below. It renders a standard +# stateless service: Deployment + Service + ServiceAccount + env ConfigMap + +# optional Ingress, all wired through oan-common. +# +# Minimum you must change for a real deployment: +# - image.registry / image.repository / image.tag +# - service.port / service.targetPort (your container's listen port) +# - livenessProbe / readinessProbe httpGet.path (your health endpoint) +# - resources (right-size for the workload) +# - envConfig (non-secret configuration only) +# ============================================================================ + +replicaCount: 1 + +# Image configuration. Override registry/repository/tag per environment. +image: + # e.g. "ghcr.io" or ".dkr.ecr.ap-south-1.amazonaws.com" + registry: "" + # e.g. "openagrinet/my-service" + repository: "" + # Leave empty to fall back to Chart.appVersion + tag: "" + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + enabled: true + name: "" + # For IRSA on EKS: + # eks.amazonaws.com/role-arn: arn:aws:iam:::role/ + annotations: {} + automountServiceAccountToken: true + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + annotations: {} + +# Ingress. Disabled by default; enable per environment. +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: my-service.local + paths: + - path: / + pathType: Prefix + tls: [] + +# Resource requests and limits. REQUIRED - the render fails if this is empty. +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +# Probes. Point httpGet.path at your service's health endpoint. +# Any handler (httpGet, tcpSocket, exec, grpc) and any timing field is passed +# through verbatim; only `enabled` is stripped. +livenessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +# Enable for slow-starting services so liveness does not kill them mid-boot. +startupProbe: + enabled: false + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + +# Pod-level security context. Off by default - enable once the image is +# verified to run as a non-root user. +podSecurityContext: + enabled: false + fsGroup: 1000 + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +# Container-level security context. Off by default; turn on +# readOnlyRootFilesystem once the app writes only to mounted volumes. +securityContext: + enabled: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + capabilities: + drop: + - ALL + +# Scheduling +nodeSelector: {} +tolerations: [] +affinity: {} +podLabels: {} +podAnnotations: {} + +# ============================================================================ +# ENVIRONMENT CONFIGURATION +# Rendered into a ConfigMap and exposed to the container via envFrom. Pods +# restart automatically when these change (checksum annotation). +# +# NEVER put secrets here. Reference Secrets via envFromSecrets or secretEnv. +# ============================================================================ +envConfig: + LOG_LEVEL: "info" + SERVICE_NAME: "oan-template" + +# Names of pre-existing Secrets in this namespace to inject via envFrom, for +# secrets not managed by ESO. +envFromSecrets: [] + +# Individual env vars sourced from specific Secret keys. Use this when the +# secret's key name differs from the env var the application expects. +secretEnv: {} + # DB_PASSWORD: + # name: my-service-db + # key: password + +# Raw env entries, passed through verbatim. +extraEnv: [] diff --git a/charts/postgresql-cnpg/.helmignore b/charts/postgresql-cnpg/.helmignore new file mode 100644 index 0000000..3027bb2 --- /dev/null +++ b/charts/postgresql-cnpg/.helmignore @@ -0,0 +1,13 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig + +# Example values are documentation, not part of the package +examples/ diff --git a/charts/postgresql-cnpg/CHANGELOG.md b/charts/postgresql-cnpg/CHANGELOG.md new file mode 100644 index 0000000..63ca611 --- /dev/null +++ b/charts/postgresql-cnpg/CHANGELOG.md @@ -0,0 +1,125 @@ +# Changelog + +All notable changes to the `postgresql-cnpg` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.1] - 2026-09-01 + +### Added +- `examples/discovery-db.dev.yaml` - the discovery service's database. It pins + `dhi.io/pgvector:0.8-pg16`, because discovery-service's first migration + creates the `vector` extension and no stock CloudNativePG operand image + carries pgvector. + + Two things in it are load-bearing rather than decorative, and both were + verified against the image rather than assumed: + + - `postInitApplicationSQL` creates `vector` and `pg_trgm`. `pg_trgm` is + marked `trusted`, so a database owner may create it; **`vector` is not**, + and `CREATE EXTENSION vector` as a non-superuser fails with "permission + denied to create extension". The service connects as the bootstrap owner + and `enableSuperuserAccess` is false, so without this the first statement + of its migration fails and the pod crashloops on what looks like a + credentials problem. CNPG runs this hook as superuser in the newly created + database, which is the one moment superuser is available without granting + it to anything long-lived. + - `postgresUID`/`postgresGID` are 70, the image's postgres user. CNPG + defaults to 26 and applies it as the pod's `runAsUser` and `fsGroup`. + + The image is a general-purpose PostgreSQL image, not a CNPG operand image. It + meets CNPG's documented requirements (initdb, postgres, pg_ctl, + pg_controldata, pg_basebackup and du on PATH; CNPG overrides its entrypoint) + but has not been run under the operator, and it ships no barman-cloud - which + rules out the in-core backup method, though not the Barman Cloud Plugin. + + No template changed, so this affects no existing release. + +## [0.2.0] - 2026-08-31 + +### Added +- `databases`, rendering a CNPG `Database` object per entry, so one cluster can + host several service databases. `bootstrap.initdb` creates only one; this covers + the rest. Requires CNPG >= 1.25. +- `managed.roles`, rendering `spec.managed.roles` so the operator creates and + reconciles a role per application. Together with `databases` this lets every + service connect as the owner of its own database, with no workload holding + superuser rights. +- Render-time validation: a database listed under `databases` that duplicates + `bootstrap.database`, a database with no name or owner, and a managed role with + neither `passwordSecret` nor `disablePassword`. + +### Removed +- External Secrets Operator integration: the `ExternalSecret` template and the + `externalSecrets` value block. ESO is not installed in any OAN cluster, so this + was configuration that could not be exercised, and a chart that renders a + Secret-producing resource invites the question of where secrets come from to be + answered differently per chart. + + Charts still reference Secrets by name - `envFromSecrets`, `secretEnv`, and the + per-chart `*Secret.name` settings are unchanged. Creating those Secrets is now + unambiguously outside the charts. + +## [0.1.0] - 2026-08-31 + +Initial release. Adapted from an existing CloudNativePG chart, with the +deployment-specific and GCP-specific parts removed and the remainder made +configurable. + +### Added +- CNPG `Cluster`, plus optional Barman Cloud `ObjectStore` and + `ScheduledBackup`, and an optional ESO `ExternalSecret` for owner credentials. +- Depends on the `oan-common` library chart for names, labels and the mandatory + `resources` contract. +- Object store provider is selectable — `s3` (default), `gcs` or `azure` — with + the URI scheme derived from the provider and credentials passed through + verbatim as the matching `*Credentials` field. S3 defaults to + `inheritFromIAMRole`, so no keys are stored anywhere. +- Render-time validation of provider, bucket/destination, credential keys, + retention policy format, and logical-import configuration. +- `enableSuperuserAccess` (default false), `superuserSecret` and + `primaryUpdateStrategy` exposed. +- `extraClusterSpec` escape hatch for CNPG fields not yet exposed. +- Example per-environment values file for the registry database. + +### Fixed +- `nodeSelector` and `tolerations` now render inside `affinity`, where CNPG + expects them. Previously they were emitted at the top level of the `Cluster` + spec, which is not part of the CRD schema — the API server silently pruned + them, so pod scheduling constraints were never applied. +- `postgresUID` and `postgresGID` are now declared in `values.yaml`. The + templates referenced them but no schema defined them, so they could not be set + without an undocumented override. +- `bootstrap` is omitted entirely when nothing under it is configured, instead of + rendering a null `initdb`. +- `bootstrap.import.source.dbname` defaults to the single entry in + `import.databases` rather than rendering an empty string. + +### Changed +- `cluster.resources` moved to top-level `resources`, so the shared + `oan-common.resources` helper and its "requests and limits are mandatory" + guardrail apply. +- `cluster.instances`, `cluster.storage`, `cluster.walStorage`, + `cluster.postgresql`, `cluster.affinity`, `cluster.monitoring` and + `cluster.bootstrap` flattened to the top level; the `cluster` wrapper is gone. +- `cluster.name` replaced by the conventional `nameOverride` / + `fullnameOverride` pair. +- Backup ServiceAccount annotations moved from `backup.serviceAccountAnnotations` + to `serviceAccount.annotations`, since IRSA is not backup-specific. +- `ScheduledBackup`'s `immediate` and `backupOwnerReference` are configurable + rather than hardcoded. + +### Removed +- GCS-only object store, including the hardcoded `gs://` scheme, + `gkeEnvironment: true` and the GKE Workload Identity service account + annotation example. +- The `premium-rwo-retain` GKE storage class default; `storageClass` now defaults + to empty (the cluster's default class). +- References to the previous deployment's bundle layout, per-bundle values files, + Docker Hardened Images, and named example databases and users. +- Bitnami-specific framing of the logical import, which is now documented as a + generic migration from any existing PostgreSQL, and the `TODO-CONFIRM` markers + left in the original. +- `app.kubernetes.io/part-of: cloudnative-pg` label, replaced by the standard OAN + labels from `oan-common`. diff --git a/charts/postgresql-cnpg/Chart.yaml b/charts/postgresql-cnpg/Chart.yaml new file mode 100644 index 0000000..80b8352 --- /dev/null +++ b/charts/postgresql-cnpg/Chart.yaml @@ -0,0 +1,31 @@ +apiVersion: v2 +name: postgresql-cnpg +description: >- + CloudNativePG-managed PostgreSQL cluster for OAN services (Cluster + + optional ScheduledBackup and Barman Cloud ObjectStore). One release per + database. Requires the CloudNativePG operator; backups additionally require + the Barman Cloud Plugin, both cluster-wide installs. +type: application +version: 0.2.1 +# The PostgreSQL major line this chart targets by default. Unlike other OAN +# charts, this is not an image tag: when image.repository is empty the operator +# supplies its own default image. Pin image.* per environment for reproducibility. +appVersion: "17" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - postgresql + - postgres + - cloudnative-pg + - cnpg +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts + - https://cloudnative-pg.io/ +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/postgresql-cnpg/README.md b/charts/postgresql-cnpg/README.md new file mode 100644 index 0000000..eb46ab5 --- /dev/null +++ b/charts/postgresql-cnpg/README.md @@ -0,0 +1,220 @@ +# postgresql-cnpg + +A [CloudNativePG](https://cloudnative-pg.io/)-managed PostgreSQL cluster for OAN +services. One Helm release per database. + +This chart renders CNPG custom resources — a `Cluster`, and optionally an +`ObjectStore` and `ScheduledBackup` for backups. It does **not** render a +StatefulSet: the operator owns the pods, failover, and switchover. It keeps the +name `postgresql-cnpg` rather than an `oan-` prefix because it deploys +third-party software rather than an OAN-authored service. + +## Prerequisites + +Neither is installed by this chart, and both are cluster-wide: + +1. **The CloudNativePG operator.** Without it the rendered `Cluster` is an + unrecognised resource and nothing happens. +2. **The Barman Cloud Plugin** — only for backups. Leave `backup.enabled` false + until its CRDs are present. + +> **Status in OAN:** as of this chart's first release, neither is installed in +> `infra-automation`, and no IRSA role exists for the backup bucket. The S3 +> bucket does exist (`oan-dev-pg-backup`, versioned, 30-day expiry), and its +> Terraform comment notes the IRSA role is deliberately deferred until the +> Postgres operator was chosen — this chart is that choice. Until the operator +> and role land, this chart renders and lints but cannot be usefully installed. + +## Install + +```bash +helm dependency update charts/postgresql-cnpg +helm install registry-db charts/postgresql-cnpg \ + -n oan-registry -f charts/postgresql-cnpg/examples/registry-db.dev.yaml +``` + +See [`examples/registry-db.dev.yaml`](./examples/registry-db.dev.yaml) for a +complete, commented per-environment values file. + +## Databases and roles + +A CNPG cluster's `bootstrap.initdb` creates **exactly one** database. Everything +else is a `Database` object the operator reconciles, so one cluster can host +several service databases without any of them sharing tables or credentials. + +```yaml +# the first database, created when the cluster is bootstrapped +bootstrap: + database: registry + owner: registry + ownerSecret: registry-db-app + +# roles for the others, created and reconciled by the operator +managed: + roles: + - name: keycloak + passwordSecret: + name: keycloak-db # Secret with `username` and `password` + +# the others +databases: + - name: keycloak + owner: keycloak +``` + +That renders `Cluster/registry-db` plus `Database/registry-db-keycloak`, giving: + +| Database | Owner | Created by | +|---|---|---| +| `registry` | `registry` | `bootstrap.initdb` | +| `keycloak` | `keycloak` | `Database` object | + +**The point of the separation:** every application connects as the owner of its +own database, so no workload needs superuser rights, and one service cannot read +another's tables. `enableSuperuserAccess` stays at the CNPG default of `false`. + +> **Requires CNPG >= 1.25**, where the `Database` CRD was introduced. On an older +> operator these objects are ignored *silently* — no database, and no error. If a +> database does not appear, check the operator version first. + +Each entry also supports `ensure` (`present`/`absent`), `databaseReclaimPolicy` +(defaults to `retain`, so deleting the object leaves the data), `encoding`, +`locale`, `template`, `allowConnections`, `connectionLimit`, `extensions` and +`schemas`. + +The render fails if you list the bootstrap database under `databases` — the +operator would be asked to create one that already exists — or if a database +names an owner with no `passwordSecret` and no `disablePassword`. + +## Connecting to it + +CNPG creates three services from the cluster name: + +| Service | Points at | +|---|---| +| `-rw` | The primary. Read-write. What applications normally use. | +| `-ro` | Replicas only. Read-only. | +| `-r` | Any instance, primary included. Read-only workloads that tolerate the primary. | + +**Set `fullnameOverride`.** The cluster name becomes the DNS other services +depend on, and renaming a cluster later means recreating it. With +`fullnameOverride: registry-db` the primary is +`registry-db-rw..svc.cluster.local:5432`; without it you get +`-postgresql-cnpg-rw`. + +## Credentials + +The owner password can come from three places: + +1. **CNPG generates it** — leave `bootstrap.ownerSecret` empty. Read it from + `Secret/-app`. Fine for local and dev. +2. **A Secret you create** — set `bootstrap.ownerSecret` to its name. It must + carry `username` and `password` keys. +3. **A Secret managed by whatever handles secrets in that environment** — point + `bootstrap.ownerSecret` at it. This chart renders no Secrets; it only + references them. + +Superuser access is off by default, matching the CNPG default: applications +connect as the database owner, not as `postgres`. + +## Backups + +Base backups plus continuous WAL archiving through the Barman Cloud Plugin, +which together give point-in-time recovery. Off by default. + +To enable, in this order: + +1. Install the Barman Cloud Plugin cluster-wide. +2. Set `backup.objectStore.bucket` (and `path` if several clusters share the + bucket). +3. Grant access. On EKS that is IRSA: an IAM role trusted by the cluster's + ServiceAccount, named in `serviceAccount.annotations` as + `eks.amazonaws.com/role-arn`. The default credentials block is + `{inheritFromIAMRole: true}`, so no keys go anywhere near the chart. +4. Set `backup.enabled: true`. + +`backup.retentionPolicy` must be **shorter** than any expiry the bucket's own +lifecycle policy applies. The OAN dev bucket expires objects after 30 days, so +the chart's `14d` default sits safely inside it. Get this backwards and S3 +deletes backups Barman still believes it has. + +`provider` supports `s3`, `gcs` and `azure`; `credentials` is passed through +verbatim as the matching `s3Credentials` / `googleCredentials` / +`azureCredentials` field. Because Helm merges maps, switching provider does not +remove the S3 default — the render fails and tells you to null it: + +```bash +--set backup.objectStore.provider=gcs \ +--set backup.objectStore.credentials.inheritFromIAMRole=null \ +--set backup.objectStore.credentials.gkeEnvironment=true +``` + +## Storage + +`storageClass` empty means the cluster's default class. On the OAN clusters that +is `gp3` with **`reclaimPolicy: Delete`**, which deletes the underlying EBS +volume when the PVC goes away. For any database you would miss, point +`storage.storageClass` and `walStorage.storageClass` at a Retain class. + +`walStorage` is a separate volume for the write-ahead log, enabled by default. +It is effectively required for healthy PITR throughput. + +## Migrating an existing database in + +`bootstrap.import` runs CNPG's logical import (`pg_dump`/`pg_restore`) against an +existing PostgreSQL when the cluster is **first created**. It has no effect on an +existing cluster, so it is a one-shot cutover tool, not a sync. + +```yaml +bootstrap: + database: registry + owner: registry + import: + enabled: true + databases: [registry] + source: + host: old-postgres.example + passwordSecret: + name: old-postgres-superuser +``` + +`source.dbname` defaults to the single entry in `databases`. + +## Render-time validation + +The chart fails the render rather than letting a misconfiguration reach the +cluster: + +| Condition | Why it matters | +|---|---| +| `resources` empty | Every OAN component must declare requests and limits (`oan-common.resources`) | +| `backup.enabled` with no `bucket` or `destinationPath` | The ObjectStore would have nowhere to write | +| `backup.objectStore.provider` not `s3`/`gcs`/`azure` | Would render an unknown credentials field | +| A credentials key not valid for the chosen provider | Catches the Helm map-merge trap described above | +| `retentionPolicy` not matching `^[1-9][0-9]*[dwm]$` | The CRD rejects it; `"14days"` fails here instead of at apply | +| `import.enabled` without `source.host` or `source.passwordSecret.name` | CNPG could not read the source | +| `import.databases` not exactly one entry | `type: microservice` permits one database | + +## Configuration + +See [`values.yaml`](./values.yaml) for the full commented schema. CNPG fields the +chart does not expose yet can be passed through `extraClusterSpec`, which is +merged into the `Cluster` spec. + +Note on `appVersion`: for this chart it documents the PostgreSQL major line +targeted by default rather than a pullable tag, because leaving `image.repository` +empty lets the operator supply its own default image. Pin `image.*` per +environment — a PostgreSQL major upgrade is a data migration, not a tag bump. + +## Known gaps + +- **No restore path.** `bootstrap.recovery` (restoring a new cluster from an + object store) is not implemented. Backups are only half a disaster-recovery + story; this needs its own change once backups are actually running. +- **No PodMonitor by default.** `monitoring.enablePodMonitor` requires the + Prometheus Operator CRDs, which are not installed yet. + +## Versioning + +Every change needs a `version` bump in `Chart.yaml` and an entry in +[`CHANGELOG.md`](./CHANGELOG.md) — see [`CONVENTIONS.md`](../../CONVENTIONS.md). diff --git a/charts/postgresql-cnpg/ci/lint-values.yaml b/charts/postgresql-cnpg/ci/lint-values.yaml new file mode 100644 index 0000000..05874c6 --- /dev/null +++ b/charts/postgresql-cnpg/ci/lint-values.yaml @@ -0,0 +1,10 @@ +# Exercises the backup and ESO paths in CI, which are off in the defaults. +fullnameOverride: ci-db +bootstrap: + database: ci + owner: ci +backup: + enabled: true + objectStore: + bucket: ci-bucket + path: /ci diff --git a/charts/postgresql-cnpg/examples/discovery-db.dev.yaml b/charts/postgresql-cnpg/examples/discovery-db.dev.yaml new file mode 100644 index 0000000..e876898 --- /dev/null +++ b/charts/postgresql-cnpg/examples/discovery-db.dev.yaml @@ -0,0 +1,139 @@ +# Example: the discovery service's database, dev environment. +# +# helm install discovery-db charts/postgresql-cnpg \ +# -n oan-discovery -f charts/postgresql-cnpg/examples/discovery-db.dev.yaml +# +# The discovery service then connects to: +# discovery-db-rw.oan-discovery.svc.cluster.local:5432 +# +# Separate from registry-db in every way - its own cluster, its own namespace, +# its own PostgreSQL major. Nothing here affects the registry stack. + +fullnameOverride: discovery-db + +# --------------------------------------------------------------------------- +# Image +# +# discovery-service needs pgvector, which the stock CloudNativePG operand image +# does not carry. This is the Docker Hardened Image of pgvector: pgvector 0.8.6 +# and pg_trgm 1.6 on PostgreSQL 16, published for both amd64 and arm64 (the OAN +# nodes are arm64). +# +# pg16 rather than the pg14 tag, because PostgreSQL 16 is what the service is +# actually verified against: its README and design doc name it, its +# testcontainers suite pins pgvector/pgvector:0.8.0-pg16, and every measured +# planner claim in the design doc - the 20 jsonpath shapes GIN captures, the +# index selection over 300k rows - was measured on 16. tests/dbtest also uses +# EXPLAIN (GENERIC_PLAN), which is PostgreSQL 16 and later only. +# +# The schema itself does apply cleanly to 14, so this is about staying on the +# major the service was tested on rather than about a hard incompatibility. +# +# CAVEAT, and it is the one to smoke-test first: this is a general-purpose +# PostgreSQL image, not a CNPG operand image. It satisfies CNPG's documented +# requirements - initdb, postgres, pg_ctl, pg_controldata, pg_basebackup and du +# are all on PATH, and CNPG overrides the image's own entrypoint - but it is +# not built from cloudnative-pg/postgres-containers and has not been run under +# the operator here. Verify the first cluster reaches "Cluster in healthy +# state" before pointing anything at it. +# +# It also ships no barman-cloud. That costs nothing while backups run through +# the Barman Cloud Plugin, which supplies its own sidecar, and nothing at all +# while backup.enabled is false - but the in-core backup method is not an +# option with this image. +image: + registry: dhi.io + repository: pgvector + tag: "0.8-pg16" + +# The image's postgres user is uid/gid 70. CNPG defaults to 26 and applies it +# as the pod's runAsUser and fsGroup, so leaving these unset gives the data +# directory to a user that does not exist in this image. +postgresUID: 70 +postgresGID: 70 + +# Dev: single instance. Use 2+ where an outage matters. +instances: 1 + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + +storage: + size: 20Gi + # Empty uses the cluster default (gp3, reclaimPolicy Delete). Point this at a + # Retain class before this database holds anything worth keeping. + storageClass: "" + +walStorage: + enabled: true + size: 2Gi + +bootstrap: + database: discovery + owner: discovery + # Omit ownerSecret and CNPG generates Secret/discovery-db-app itself, with a + # ready-made `uri` key the discovery chart consumes through + # database.urlSecret. Simplest correct thing for dev - nothing to create by + # hand and no password to escape. + ownerSecret: "" + + # --------------------------------------------------------------------------- + # This is load-bearing, not a convenience. + # + # discovery-service's first migration opens with: + # CREATE EXTENSION IF NOT EXISTS vector; + # CREATE EXTENSION IF NOT EXISTS pg_trgm; + # + # pg_trgm is marked `trusted = true`, so the database owner may create it. + # `vector` is NOT trusted, and creating it requires superuser: + # + # ERROR: permission denied to create extension "vector" + # HINT: Must be superuser to create this extension. + # + # The service connects as `discovery`, the bootstrap owner, and + # enableSuperuserAccess is false below - so with these lines absent, the very + # first statement of the migration fails and the pod crashloops on an error + # that reads like a bad password rather than a missing grant. + # + # CNPG runs postInitApplicationSQL as superuser inside the freshly created + # application database, which is the one moment superuser is available + # without granting it to anything long-lived. Afterwards the service's own + # `IF NOT EXISTS` is a no-op and the rest of the migration runs as the owner. + # + # Verified against dhi.io/pgvector: as the non-superuser owner the migration + # fails on statement one without these, and applies in full with them. + # --------------------------------------------------------------------------- + postInitApplicationSQL: + - CREATE EXTENSION IF NOT EXISTS vector + - CREATE EXTENSION IF NOT EXISTS pg_trgm + +# The service owns its own database, so nothing here needs superuser rights +# beyond the bootstrap moment above. +enableSuperuserAccess: false + +# One database, one owner - no managed.roles and no extra `databases` entries. +# Unlike registry-db, nothing else shares this cluster. + +# Off until the Barman Cloud Plugin is installed and an IRSA role exists. +# Note the image ships no barman-cloud of its own, so the plugin - which brings +# its own sidecar - is the only backup route here. +backup: + enabled: false + retentionPolicy: "14d" + objectStore: + bucket: oan-dev-pg-backup + path: /discovery + credentials: + inheritFromIAMRole: true + +serviceAccount: + annotations: {} + # eks.amazonaws.com/role-arn: arn:aws:iam:::role/oan-dev-pg-backup + +commonLabels: + oan.in/environment: dev diff --git a/charts/postgresql-cnpg/examples/registry-db.dev.yaml b/charts/postgresql-cnpg/examples/registry-db.dev.yaml new file mode 100644 index 0000000..2f950d0 --- /dev/null +++ b/charts/postgresql-cnpg/examples/registry-db.dev.yaml @@ -0,0 +1,100 @@ +# Example: the registry service's database, dev environment. +# +# helm install registry-db charts/postgresql-cnpg \ +# -n oan-registry -f charts/postgresql-cnpg/examples/registry-db.dev.yaml +# +# Other workloads then connect to: +# registry-db-rw.oan-registry.svc.cluster.local:5432 + +fullnameOverride: registry-db + +# Sunbird RC's registry is verified against PostgreSQL 14. Pin it rather than +# inheriting the operator default, so an operator upgrade cannot move the major. +image: + registry: ghcr.io + repository: cloudnative-pg/postgresql + tag: "14.13" + +# Dev: single instance. Use 2+ where an outage matters. +instances: 1 + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + +storage: + size: 10Gi + # Empty uses the cluster default (gp3, reclaimPolicy Delete). Point this at a + # Retain class before this database holds anything worth keeping. + storageClass: "" + +walStorage: + enabled: true + size: 2Gi + +# The registry's database, created by initdb when the cluster is bootstrapped. +# A cluster gets exactly one of these; everything else goes under `databases`. +bootstrap: + database: registry + owner: registry + # Secret carrying `username` and `password` for the owner role. + # kubectl -n oan-registry create secret generic registry-db-app \ + # --from-literal=username=registry --from-literal=password='' + # Or drop ownerSecret and let CNPG generate the password itself. + ownerSecret: registry-db-app + +# Keycloak's own role. The operator creates and reconciles it, so Keycloak never +# needs superuser rights. +# kubectl -n oan-registry create secret generic keycloak-db \ +# --from-literal=username=keycloak --from-literal=password='' +managed: + roles: + - name: keycloak + passwordSecret: + name: keycloak-db + +# Keycloak's own database, owned by that role. Keeping its realm tables out of +# the registry's database is the one place this deliberately improves on the +# compose stack, which shares a single `registry` database between both services. +# +# REQUIRES CNPG >= 1.25 for the Database CRD. +databases: + - name: keycloak + owner: keycloak + +# Left at the CNPG default (false). Each service connects as the owner of its own +# database, so nothing here needs superuser rights. +# +# The migration chart is the exception: it wants rights across every database it +# migrates. Set this true if you enable migration targets that actually have SQL. +enableSuperuserAccess: false + +# Off until the Barman Cloud Plugin is installed and the IRSA role for +# oan-dev-pg-backup exists (see infra-automation: buckets.pg_backup). +backup: + enabled: false + retentionPolicy: "14d" + objectStore: + bucket: oan-dev-pg-backup + path: /registry + credentials: + inheritFromIAMRole: true + +# The IRSA role the cluster pods assume in order to write those backups. +serviceAccount: + annotations: {} + # eks.amazonaws.com/role-arn: arn:aws:iam:::role/oan-dev-pg-backup + +# The owner Secret above is created out of band. Until a secrets manager is +# wired up, either create it by hand: +# kubectl -n oan-registry create secret generic registry-db-app \ +# --from-literal=username=registry --from-literal=password='' +# or drop bootstrap.ownerSecret entirely and let CNPG generate the password into +# Secret/registry-db-app itself, which is simpler for dev. + +commonLabels: + oan.in/environment: dev diff --git a/charts/postgresql-cnpg/templates/NOTES.txt b/charts/postgresql-cnpg/templates/NOTES.txt new file mode 100644 index 0000000..16c304f --- /dev/null +++ b/charts/postgresql-cnpg/templates/NOTES.txt @@ -0,0 +1,41 @@ +{{ .Chart.Name }} installed as release "{{ .Release.Name }}". + +CloudNativePG resources in namespace "{{ include "postgresql-cnpg.namespace" . }}": + - Cluster/{{ include "postgresql-cnpg.fullname" . }} ({{ .Values.instances }} instance(s)) +{{- if .Values.backup.enabled }} + - ObjectStore/{{ include "postgresql-cnpg.objectStoreName" . }} -> {{ include "postgresql-cnpg.objectStore.destinationPath" . }} + - ScheduledBackup/{{ include "postgresql-cnpg.fullname" . }}-scheduled ({{ .Values.backup.schedule }}, retention {{ .Values.backup.retentionPolicy }}) +{{- end }} + +Connect from another workload in the cluster: + host: {{ include "postgresql-cnpg.fullname" . }}-rw.{{ include "postgresql-cnpg.namespace" . }}.svc.cluster.local # primary, read-write + host: {{ include "postgresql-cnpg.fullname" . }}-ro.{{ include "postgresql-cnpg.namespace" . }}.svc.cluster.local # replicas, read-only + port: 5432 +{{- with .Values.bootstrap.database }} + database: {{ . }} +{{- end }} +{{- with .Values.bootstrap.owner }} + user: {{ . }} +{{- end }} + +{{ if not .Values.fullnameOverride }} +NOTE: fullnameOverride is not set, so the cluster is named +"{{ include "postgresql-cnpg.fullname" . }}" and other services must connect to +"{{ include "postgresql-cnpg.fullname" . }}-rw". Set fullnameOverride (e.g. +"registry-db") for a shorter, stable name - renaming later means recreating the +cluster. +{{- end }} +{{- if not .Values.bootstrap.ownerSecret }} + +NOTE: bootstrap.ownerSecret is not set, so CNPG generated the owner password +itself. Read it with: + kubectl -n {{ include "postgresql-cnpg.namespace" . }} get secret {{ include "postgresql-cnpg.fullname" . }}-app -o jsonpath='{.data.password}' | base64 -d +{{- end }} +{{- if not .Values.backup.enabled }} + +NOTE: backups are disabled. Enable backup.* once the Barman Cloud Plugin is +installed cluster-wide and the bucket and IRSA role exist. +{{- end }} + +Watch the cluster come up: + kubectl -n {{ include "postgresql-cnpg.namespace" . }} get cluster {{ include "postgresql-cnpg.fullname" . }} -w diff --git a/charts/postgresql-cnpg/templates/_helpers.tpl b/charts/postgresql-cnpg/templates/_helpers.tpl new file mode 100644 index 0000000..901143c --- /dev/null +++ b/charts/postgresql-cnpg/templates/_helpers.tpl @@ -0,0 +1,110 @@ +{{/* +# ============================================================================ +# OAN POSTGRESQL CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers delegating to oan-common, plus the CNPG-specific +# naming and object-store logic this chart needs. +# ============================================================================ +*/}} + +{{/* +Cluster name. Drives the CNPG service names -rw / -ro / -r, +which other charts connect to, so it should be short and stable - set +fullnameOverride (e.g. "registry-db") rather than relying on -. +*/}} +{{- define "postgresql-cnpg.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "postgresql-cnpg.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "postgresql-cnpg.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{/* +Namespace for the CNPG resources. Defaults to the release namespace. +*/}} +{{- define "postgresql-cnpg.namespace" -}} +{{- default (include "oan-common.namespace" .) .Values.namespace -}} +{{- end }} + +{{/* +Image reference, or empty when no image is configured - in which case the +Cluster omits imageName and the operator uses its own default image. +*/}} +{{- define "postgresql-cnpg.image" -}} +{{- if .Values.image.repository -}} +{{- include "oan-common.image" . -}} +{{- end -}} +{{- end }} + +{{/* +Barman ObjectStore name (defaults to -backup). +*/}} +{{- define "postgresql-cnpg.objectStoreName" -}} +{{- default (printf "%s-backup" (include "postgresql-cnpg.fullname" .)) .Values.backup.objectStore.name -}} +{{- end }} + +{{/* +Map the object store provider to its CNPG credentials field. +*/}} +{{- define "postgresql-cnpg.objectStore.credentialsField" -}} +{{- $field := dict "s3" "s3Credentials" "gcs" "googleCredentials" "azure" "azureCredentials" -}} +{{- index $field .Values.backup.objectStore.provider -}} +{{- end }} + +{{/* +Object store destination path. An explicit destinationPath wins; otherwise it is +built from the provider's URI scheme plus bucket and path. +*/}} +{{- define "postgresql-cnpg.objectStore.destinationPath" -}} +{{- $os := .Values.backup.objectStore -}} +{{- if $os.destinationPath -}} +{{- $os.destinationPath -}} +{{- else -}} +{{- $scheme := index (dict "s3" "s3://" "gcs" "gs://" "azure" "azure://") $os.provider -}} +{{- printf "%s%s%s" $scheme $os.bucket ($os.path | default "") -}} +{{- end -}} +{{- end }} + +{{/* +Validate the backup configuration. Called from the templates that need it so a +misconfiguration fails the render rather than producing an ObjectStore the +plugin silently cannot use. +*/}} +{{- define "postgresql-cnpg.validateBackup" -}} +{{- $os := .Values.backup.objectStore -}} +{{- $providers := list "s3" "gcs" "azure" -}} +{{- if not (has $os.provider $providers) -}} +{{- fail (printf "%s: backup.objectStore.provider must be one of %s, got %q" .Chart.Name (join ", " $providers) $os.provider) -}} +{{- end -}} +{{- if and (not $os.bucket) (not $os.destinationPath) -}} +{{- fail (printf "%s: backup.enabled is true but neither backup.objectStore.bucket nor backup.objectStore.destinationPath is set" .Chart.Name) -}} +{{- end -}} +{{- if not $os.credentials -}} +{{- fail (printf "%s: backup.enabled is true but backup.objectStore.credentials is empty. For S3 with IRSA use {inheritFromIAMRole: true}" .Chart.Name) -}} +{{- end -}} +{{/* +Credentials keys must belong to the chosen provider. Helm merges maps, so +switching provider away from the s3 default would otherwise carry +`inheritFromIAMRole` into googleCredentials, where the plugin rejects it. +*/}} +{{- $allowed := index (dict + "s3" (list "accessKeyId" "inheritFromIAMRole" "region" "secretAccessKey" "sessionToken") + "gcs" (list "applicationCredentials" "gkeEnvironment") + "azure" (list "connectionString" "inheritFromAzureAD" "storageAccount" "storageKey" "storageSasToken" "useDefaultAzureCredentials") + ) $os.provider -}} +{{- range $key, $_ := $os.credentials -}} +{{- if not (has $key $allowed) -}} +{{- fail (printf "%s: backup.objectStore.credentials.%s is not valid for provider %q (allowed: %s). If it came from this chart's default, null it out: --set backup.objectStore.credentials.%s=null" $.Chart.Name $key $os.provider (join ", " $allowed) $key) -}} +{{- end -}} +{{- end -}} +{{- with .Values.backup.retentionPolicy -}} +{{- if not (regexMatch "^[1-9][0-9]*[dwm]$" .) -}} +{{- fail (printf "backup.retentionPolicy must match ^[1-9][0-9]*[dwm]$ (days, weeks or months, e.g. \"14d\"), got %q" .) -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/charts/postgresql-cnpg/templates/cluster.yaml b/charts/postgresql-cnpg/templates/cluster.yaml new file mode 100644 index 0000000..01d1123 --- /dev/null +++ b/charts/postgresql-cnpg/templates/cluster.yaml @@ -0,0 +1,202 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: {{ include "postgresql-cnpg.fullname" . }} + namespace: {{ include "postgresql-cnpg.namespace" . }} + labels: + {{- include "postgresql-cnpg.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + instances: {{ .Values.instances }} + {{- with (include "postgresql-cnpg.image" .) }} + imageName: {{ . | quote }} + {{- end }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- range . }} + - name: {{ . }} + {{- end }} + {{- end }} + {{- with .Values.postgresUID }} + postgresUID: {{ . }} + {{- end }} + {{- with .Values.postgresGID }} + postgresGID: {{ . }} + {{- end }} + primaryUpdateStrategy: {{ .Values.primaryUpdateStrategy }} + enableSuperuserAccess: {{ .Values.enableSuperuserAccess }} + {{- with .Values.superuserSecret }} + superuserSecret: + name: {{ . }} + {{- end }} + + {{- with .Values.serviceAccount.annotations }} + serviceAccountTemplate: + metadata: + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + + storage: + size: {{ .Values.storage.size }} + {{- with .Values.storage.storageClass }} + storageClass: {{ . }} + {{- end }} + {{- if .Values.walStorage.enabled }} + walStorage: + size: {{ .Values.walStorage.size }} + {{- with .Values.walStorage.storageClass }} + storageClass: {{ . }} + {{- end }} + {{- end }} + + resources: + {{- include "oan-common.resources" . | nindent 4 }} + + {{- $affinity := omit .Values.affinity "nodeSelector" "tolerations" }} + {{- with .Values.affinity.nodeSelector }} + {{- $affinity = set $affinity "nodeSelector" . }} + {{- end }} + {{- with .Values.affinity.tolerations }} + {{- $affinity = set $affinity "tolerations" . }} + {{- end }} + {{- with $affinity }} + # CNPG nests nodeSelector and tolerations inside affinity; the Cluster spec + # has no top-level fields for them, and anything put there is silently + # dropped by the API server. + affinity: + {{- toYaml . | nindent 4 }} + {{- end }} + + {{- with .Values.managed.roles }} + {{/* + Roles created and reconciled by the operator. A database owner must exist + before the Database object referencing it can be applied, so any role named as + an owner under `databases` belongs here. + */}} + managed: + roles: + {{- range . }} + {{- if not .name }} + {{- fail (printf "%s: every entry in managed.roles needs a name" $.Chart.Name) }} + {{- end }} + - name: {{ .name | quote }} + ensure: {{ .ensure | default "present" }} + login: {{ .login | default true }} + {{- if hasKey . "superuser" }} + superuser: {{ .superuser }} + {{- end }} + {{- if hasKey . "createdb" }} + createdb: {{ .createdb }} + {{- end }} + {{- if hasKey . "createrole" }} + createrole: {{ .createrole }} + {{- end }} + {{- if hasKey . "inherit" }} + inherit: {{ .inherit }} + {{- end }} + {{- with .connectionLimit }} + connectionLimit: {{ . }} + {{- end }} + {{- with .inRoles }} + inRoles: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .comment }} + comment: {{ . | quote }} + {{- end }} + {{- if .passwordSecret }} + passwordSecret: + name: {{ .passwordSecret.name }} + {{- else if .disablePassword }} + disablePassword: true + {{- else }} + {{- fail (printf "%s: managed role %q has no passwordSecret. Set one (a Secret with username and password keys), or set disablePassword: true if the role should not log in with a password." $.Chart.Name .name) }} + {{- end }} + {{- end }} + {{- end }} + + {{- with .Values.postgresql.parameters }} + postgresql: + parameters: + {{- toYaml . | nindent 6 }} + {{- end }} + + {{- if .Values.monitoring.enablePodMonitor }} + monitoring: + enablePodMonitor: true + {{- end }} + + {{- if .Values.backup.enabled }} + {{- include "postgresql-cnpg.validateBackup" . }} + # Base backups and WAL archiving via the Barman Cloud Plugin (CNPG-I). + plugins: + - name: {{ .Values.backup.pluginName }} + isWALArchiver: true + parameters: + barmanObjectName: {{ include "postgresql-cnpg.objectStoreName" . }} + {{- end }} + + {{- $boot := .Values.bootstrap }} + {{- if or $boot.database $boot.owner $boot.ownerSecret $boot.postInitApplicationSQL $boot.import.enabled }} + bootstrap: + initdb: + {{- with .Values.bootstrap.database }} + database: {{ . | quote }} + {{- end }} + {{- with .Values.bootstrap.owner }} + owner: {{ . | quote }} + {{- end }} + {{- with .Values.bootstrap.ownerSecret }} + secret: + name: {{ . }} + {{- end }} + {{- if .Values.bootstrap.import.enabled }} + import: + type: {{ .Values.bootstrap.import.type }} + databases: + {{- toYaml .Values.bootstrap.import.databases | nindent 10 }} + source: + externalCluster: {{ include "postgresql-cnpg.fullname" . }}-source + {{- with .Values.bootstrap.postInitApplicationSQL }} + postImportApplicationSQL: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- else }} + {{- with .Values.bootstrap.postInitApplicationSQL }} + postInitApplicationSQL: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- end }} + {{- end }} + + {{- if .Values.bootstrap.import.enabled }} + {{- $src := .Values.bootstrap.import.source }} + {{- if not $src.host }} + {{- fail (printf "%s: bootstrap.import.enabled is true but bootstrap.import.source.host is empty" .Chart.Name) }} + {{- end }} + {{- if not (eq (len .Values.bootstrap.import.databases) 1) }} + {{- fail (printf "%s: bootstrap.import.type microservice needs exactly one entry in bootstrap.import.databases, got %d" .Chart.Name (len .Values.bootstrap.import.databases)) }} + {{- end }} + {{- if not $src.passwordSecret.name }} + {{- fail (printf "%s: bootstrap.import.enabled is true but bootstrap.import.source.passwordSecret.name is empty - CNPG needs credentials to read the source" .Chart.Name) }} + {{- end }} + # The existing PostgreSQL this cluster is imported from, read logically at + # creation time only. + externalClusters: + - name: {{ include "postgresql-cnpg.fullname" . }}-source + connectionParameters: + host: {{ $src.host | quote }} + port: {{ $src.port | quote }} + dbname: {{ $src.dbname | default (first .Values.bootstrap.import.databases) | quote }} + user: {{ $src.user | quote }} + password: + name: {{ $src.passwordSecret.name }} + key: {{ $src.passwordSecret.key }} + {{- end }} + {{- with .Values.extraClusterSpec }} + {{- toYaml . | nindent 2 }} + {{- end }} diff --git a/charts/postgresql-cnpg/templates/database.yaml b/charts/postgresql-cnpg/templates/database.yaml new file mode 100644 index 0000000..896845b --- /dev/null +++ b/charts/postgresql-cnpg/templates/database.yaml @@ -0,0 +1,64 @@ +{{- range .Values.databases }} +{{- if not .name }} +{{- fail (printf "%s: every entry in databases needs a name" $.Chart.Name) }} +{{- end }} +{{- if not .owner }} +{{- fail (printf "%s: database %q needs an owner. The role must exist - declare it under managed.roles, or use the cluster's bootstrap owner." $.Chart.Name .name) }} +{{- end }} +{{- if eq .name ($.Values.bootstrap.database | default "") }} +{{- fail (printf "%s: database %q is already created by bootstrap.database. Remove it from `databases`, or rename one of them - the operator would otherwise be asked to create a database that already exists." $.Chart.Name .name) }} +{{- end }} +--- +{{/* +A database created declaratively by the CNPG operator, alongside the single +database that bootstrap.initdb creates. + +Requires CNPG >= 1.25, which is where the Database CRD was introduced. On an +older operator this object is simply ignored - no database, no error - so check +the operator version if a database does not appear. +*/}} +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: {{ printf "%s-%s" (include "postgresql-cnpg.fullname" $) .name | trunc 63 | trimSuffix "-" }} + namespace: {{ include "postgresql-cnpg.namespace" $ }} + labels: + {{- include "postgresql-cnpg.labels" $ | nindent 4 }} + oan.in/database: {{ .name | quote }} + {{- with $.Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + cluster: + name: {{ include "postgresql-cnpg.fullname" $ }} + name: {{ .name | quote }} + owner: {{ .owner | quote }} + ensure: {{ .ensure | default "present" }} + # retain: dropping this object leaves the database in place. Databases hold + # data that outlives a Helm release, so deletion is deliberately not automatic. + databaseReclaimPolicy: {{ .databaseReclaimPolicy | default "retain" }} + {{- with .encoding }} + encoding: {{ . | quote }} + {{- end }} + {{- with .locale }} + locale: {{ . | quote }} + {{- end }} + {{- with .template }} + template: {{ . | quote }} + {{- end }} + {{- if hasKey . "allowConnections" }} + allowConnections: {{ .allowConnections }} + {{- end }} + {{- with .connectionLimit }} + connectionLimit: {{ . }} + {{- end }} + {{- with .extensions }} + extensions: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .schemas }} + schemas: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/postgresql-cnpg/templates/objectstore.yaml b/charts/postgresql-cnpg/templates/objectstore.yaml new file mode 100644 index 0000000..bf2a86f --- /dev/null +++ b/charts/postgresql-cnpg/templates/objectstore.yaml @@ -0,0 +1,41 @@ +{{- if .Values.backup.enabled }} +{{- include "postgresql-cnpg.validateBackup" . }} +{{- $os := .Values.backup.objectStore }} +# Barman Cloud Plugin ObjectStore - target for base backups and WAL archiving. +# Requires the Barman Cloud Plugin installed cluster-wide. +apiVersion: barmancloud.cnpg.io/v1 +kind: ObjectStore +metadata: + name: {{ include "postgresql-cnpg.objectStoreName" . }} + namespace: {{ include "postgresql-cnpg.namespace" . }} + labels: + {{- include "postgresql-cnpg.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.backup.retentionPolicy }} + retentionPolicy: {{ . | quote }} + {{- end }} + configuration: + destinationPath: {{ include "postgresql-cnpg.objectStore.destinationPath" . | quote }} + {{- with $os.endpointURL }} + endpointURL: {{ . | quote }} + {{- end }} + {{- with $os.serverName }} + serverName: {{ . | quote }} + {{- end }} + {{ include "postgresql-cnpg.objectStore.credentialsField" . }}: + {{- toYaml $os.credentials | nindent 6 }} + wal: + compression: {{ $os.wal.compression }} + {{- with $os.wal.encryption }} + encryption: {{ . }} + {{- end }} + data: + compression: {{ $os.data.compression }} + {{- with $os.data.encryption }} + encryption: {{ . }} + {{- end }} +{{- end }} diff --git a/charts/postgresql-cnpg/templates/scheduledbackup.yaml b/charts/postgresql-cnpg/templates/scheduledbackup.yaml new file mode 100644 index 0000000..252b2ee --- /dev/null +++ b/charts/postgresql-cnpg/templates/scheduledbackup.yaml @@ -0,0 +1,27 @@ +{{- if .Values.backup.enabled }} +# Scheduled base backups. WAL is archived continuously by the plugin, so these +# plus the archived WAL are what make point-in-time recovery possible. +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: {{ include "postgresql-cnpg.fullname" . }}-scheduled + namespace: {{ include "postgresql-cnpg.namespace" . }} + labels: + {{- include "postgresql-cnpg.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + # 6-field cron: sec min hour dom mon dow + schedule: {{ .Values.backup.schedule | quote }} + backupOwnerReference: {{ .Values.backup.backupOwnerReference }} + immediate: {{ .Values.backup.immediate }} + method: plugin + pluginConfiguration: + name: {{ .Values.backup.pluginName }} + parameters: + barmanObjectName: {{ include "postgresql-cnpg.objectStoreName" . }} + cluster: + name: {{ include "postgresql-cnpg.fullname" . }} +{{- end }} diff --git a/charts/postgresql-cnpg/values.yaml b/charts/postgresql-cnpg/values.yaml new file mode 100644 index 0000000..68c3958 --- /dev/null +++ b/charts/postgresql-cnpg/values.yaml @@ -0,0 +1,271 @@ +# ============================================================================ +# postgresql-cnpg - default values +# +# One CloudNativePG `Cluster` (plus optional backups) per database. Install one +# release per service that needs its own database, giving each its own +# credentials, storage and backup schedule: +# +# helm install registry-db charts/postgresql-cnpg \ +# -n oan-registry -f values/registry-db.dev.yaml +# +# PREREQUISITES (neither is installed by this chart): +# 1. The CloudNativePG operator, cluster-wide. +# 2. For backups only: the Barman Cloud Plugin, also cluster-wide. Leave +# backup.enabled false until its CRDs are present. +# +# Minimum you must set for a real deployment: +# - fullnameOverride (see below - it decides the DNS other services use) +# - bootstrap.database / bootstrap.owner / bootstrap.ownerSecret +# - storage.size and storage.storageClass +# ============================================================================ + +# Namespace for the CNPG resources. Defaults to the release namespace; set this +# only if you deliberately want them somewhere other than `helm -n`. +namespace: "" + +nameOverride: "" +# STRONGLY RECOMMENDED. The cluster name becomes the connection DNS other +# services use - -rw (primary), -ro (replicas), -r (any). +# Without this you get -postgresql-cnpg-rw. Set something short and +# stable, e.g. "registry-db" -> registry-db-rw..svc.cluster.local +fullnameOverride: "" + +# --------------------------------------------------------------------------- +# Image +# Leave repository empty to let the CNPG operator choose its own default +# PostgreSQL image. Pin it per environment for reproducible upgrades - a +# PostgreSQL major version change is a data migration, not a tag bump. +# --------------------------------------------------------------------------- +image: + # e.g. "ghcr.io" + registry: dhi.io + # e.g. "cloudnative-pg/postgresql" + repository: pgvector + tag: "0.8-pg16" + # sha256:... - wins over tag when set + digest: "" + pullSecrets: [] + +# Primary + (instances - 1) replicas. 1 is fine for local or dev; use at least +# 2 anywhere an outage matters. +instances: 2 + +# Resource requests and limits for each instance. REQUIRED - the render fails +# when this is empty (oan-common.resources). +resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + +# --------------------------------------------------------------------------- +# Storage +# storageClass empty means the cluster's default class. Note the OAN clusters +# default to gp3 with reclaimPolicy: Delete - for anything you cannot lose, +# point this at a Retain class so the volume survives PVC deletion. +# --------------------------------------------------------------------------- +storage: + size: 10Gi + storageClass: "" + +# Separate volume for the write-ahead log. Recommended, and effectively +# required for healthy point-in-time-recovery throughput. +walStorage: + enabled: true + size: 2Gi + storageClass: "" + +# PostgreSQL server parameters, merged onto the CNPG defaults. +postgresql: + parameters: {} + # max_connections: "200" + # shared_buffers: "256MB" + +# Pod scheduling. NOTE: in CNPG, nodeSelector and tolerations belong *inside* +# affinity - the Cluster spec has no top-level fields for them, and anything +# put there is silently dropped by the API server. +affinity: + enablePodAntiAffinity: true + topologyKey: kubernetes.io/hostname + nodeSelector: {} + tolerations: [] + +# Annotations for the ServiceAccount CNPG creates for the cluster pods. This is +# where IRSA goes when backups write to S3: +# eks.amazonaws.com/role-arn: arn:aws:iam:::role/ +serviceAccount: + annotations: {} + +# Prometheus PodMonitor. Requires the Prometheus Operator CRDs. +monitoring: + enablePodMonitor: false + +# Superuser access is disabled by default (CNPG default too): applications +# should connect as the database owner, not as postgres. +enableSuperuserAccess: false +# Name of a Secret with the superuser credentials. Only used when +# enableSuperuserAccess is true; CNPG generates one if left empty. +superuserSecret: "" + +# UID/GID the postgres process runs as. Empty uses the operator default (26). +postgresUID: "" +postgresGID: "" + +# How replicas are updated: "unsupervised" rolls automatically, +# "supervised" waits for a manual switchover. +primaryUpdateStrategy: unsupervised + +# --------------------------------------------------------------------------- +# Bootstrap: how the database is first created. +# --------------------------------------------------------------------------- +bootstrap: + # Application database and its owner. Both empty uses the CNPG defaults + # ("app"/"app"), which is rarely what you want - name them explicitly. + database: "" + owner: "" + # Name of a Secret with keys `username` and `password` for the owner. Leave + # empty to have CNPG generate the password into Secret/-app. + ownerSecret: "" + # SQL run once, after the database is created. + postInitApplicationSQL: [] + # - CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + # Logical import from an existing PostgreSQL, for migrating an existing + # database into CNPG. CNPG runs pg_dump/pg_restore against `source` when the + # cluster is created. One-time: it has no effect on an existing cluster. + import: + enabled: false + # microservice = one database per cluster (databases must list exactly one) + type: microservice + databases: [] + source: + host: "" + port: "5432" + dbname: "" + user: postgres + # Secret holding the source user's password + passwordSecret: + name: "" + key: password + +# --------------------------------------------------------------------------- +# Additional databases +# +# bootstrap.initdb creates exactly ONE database. Everything else goes here, as +# CNPG `Database` objects the operator reconciles. +# +# REQUIRES CNPG >= 1.25 (where the Database CRD was introduced). On an older +# operator these objects are ignored silently - no database, and no error. +# +# The owner must be a role that exists. Declare it under managed.roles below, or +# use the cluster's bootstrap owner. +# +# databases: +# - name: keycloak +# owner: keycloak +# extensions: +# - name: uuid-ossp +# +# Each entry supports: name, owner, ensure (present|absent), +# databaseReclaimPolicy (retain|delete), encoding, locale, template, +# allowConnections, connectionLimit, extensions, schemas. +# +# Listing the bootstrap database here fails the render - the operator would be +# asked to create a database that already exists. +# --------------------------------------------------------------------------- +databases: [] + +# --------------------------------------------------------------------------- +# Managed roles +# +# Roles the operator creates and reconciles. Give every application its own role +# owning its own database, so no workload needs superuser rights. +# +# passwordSecret names a Secret with `username` and `password` keys. The operator +# reconciles the role's password to match it. +# +# managed: +# roles: +# - name: keycloak +# passwordSecret: +# name: keycloak-db +# +# A role with neither passwordSecret nor disablePassword fails the render, since +# it would be a login role nobody can authenticate as. +# --------------------------------------------------------------------------- +managed: + roles: [] + +# --------------------------------------------------------------------------- +# Backups: base backups + continuous WAL archiving via the Barman Cloud Plugin. +# +# Disabled by default. Enabling it requires, in this order: +# 1. The Barman Cloud Plugin installed cluster-wide. +# 2. A bucket that the cluster can write to. +# 3. Credentials - on EKS, an IRSA role bound to the cluster ServiceAccount +# via serviceAccount.annotations above. +# --------------------------------------------------------------------------- +backup: + enabled: false + pluginName: barman-cloud.cloudnative-pg.io + + # Must be shorter than any expiry the bucket's own lifecycle policy applies, + # or the bucket deletes backups Barman still counts on. Form: d|w|m. + retentionPolicy: "14d" + + # CNPG uses 6-field cron (sec min hour dom mon dow). Default: daily at 02:00. + schedule: "0 0 2 * * *" + # Take a backup as soon as the ScheduledBackup is created. + immediate: true + backupOwnerReference: self + + objectStore: + # Defaults to -backup + name: "" + # s3 | gcs | azure + provider: s3 + # Bucket name only - the URI scheme comes from provider. + # OAN dev provisions "oan-dev-pg-backup" (see infra-automation). + bucket: "" + # Optional prefix so several clusters can share a bucket, e.g. "/registry" + path: "" + # Full destination path, overriding bucket/path. For S3-compatible stores. + destinationPath: "" + # For S3-compatible object stores (MinIO and similar) + endpointURL: "" + # Pins the folder inside the bucket. Defaults to the cluster name; set it + # explicitly before renaming a cluster that already has backups. + serverName: "" + + # Rendered verbatim as Credentials. The default is IRSA: the + # cluster's ServiceAccount carries the role, no keys anywhere. + # + # S3 with static keys instead (each value is a secret reference): + # credentials: + # accessKeyId: {name: pg-backup-creds, key: ACCESS_KEY_ID} + # secretAccessKey: {name: pg-backup-creds, key: SECRET_ACCESS_KEY} + # GCS on GKE: credentials: {gkeEnvironment: true} + # Azure workload identity: credentials: {inheritFromAzureAD: true} + credentials: + inheritFromIAMRole: true + + wal: + # bzip2 | gzip | lz4 | snappy | xz | zstd + compression: gzip + # AES256 | aws:kms. Empty uses the bucket's default encryption. + encryption: "" + data: + # bzip2 | gzip | lz4 | snappy + compression: gzip + encryption: "" + +# Escape hatch: extra keys merged into the Cluster spec, for CNPG fields this +# chart does not expose yet. Keys the chart already sets cannot be overridden +# here - that would render duplicate YAML keys. +extraClusterSpec: {} + +# Extra labels and annotations on every resource this chart renders +commonLabels: {} +commonAnnotations: {} diff --git a/charts/postgresql-migration/.helmignore b/charts/postgresql-migration/.helmignore new file mode 100644 index 0000000..3027bb2 --- /dev/null +++ b/charts/postgresql-migration/.helmignore @@ -0,0 +1,13 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig + +# Example values are documentation, not part of the package +examples/ diff --git a/charts/postgresql-migration/CHANGELOG.md b/charts/postgresql-migration/CHANGELOG.md new file mode 100644 index 0000000..05c805d --- /dev/null +++ b/charts/postgresql-migration/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +All notable changes to the `postgresql-migration` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-08-31 + +### Removed +- The `00-bootstrap` target and its `CREATE DATABASE` migration. Databases are now + created by the `postgresql-cnpg` chart, via `bootstrap.database` and the CNPG + `Database` CRD, which the operator reconciles properly. Doing it in Flyway meant + `CREATE DATABASE` outside a transaction (with a `.sql.conf` sidecar), no + `IF NOT EXISTS`, and a standing rule never to list the database the cluster + itself creates. Leaving both in place would have failed outright, since the + operator creates the database first. + + This chart now only migrates schemas inside existing databases, which is what + Flyway is for. + +### Removed +- External Secrets Operator integration: the `ExternalSecret` template and the + `externalSecrets` value block. ESO is not installed in any OAN cluster, so this + was configuration that could not be exercised, and a chart that renders a + Secret-producing resource invites the question of where secrets come from to be + answered differently per chart. + + Charts still reference Secrets by name - `envFromSecrets`, `secretEnv`, and the + per-chart `*Secret.name` settings are unchanged. Creating those Secrets is now + unambiguously outside the charts. + +## [0.1.0] - 2026-08-31 + +Initial release. Adapted from an existing Flyway migration chart, rebuilt on +`oan-common` with the previous deployment's specifics removed. + +### Added +- Flyway migration Job, env ConfigMap, generated-script ConfigMap, one ConfigMap + per target, ServiceAccount, and an optional ESO ExternalSecret. +- Multi-target migrations: each entry in `targets` is one database plus its + migration directory, applied in the declared order. `enabled: false` skips an + entry without removing it. +- A bootstrap migration that creates the `keycloak` database, shipping the + `.sql.conf` sidecar with `executeInTransaction=false` that PostgreSQL requires + for `CREATE DATABASE`. +- Runs as a Helm hook (`pre-install`, `pre-upgrade`) by default, so migrations + complete before dependent services start. `hook.enabled: false` installs it as + an ordinary release resource instead. +- `repairOnFailure` (default true) runs `flyway repair` and retries once. +- Render-time validation of `postgresql.host` and + `postgresql.passwordSecret.name`, and of every target: name and database + present, no duplicate names, and a matching directory in the chart. +- Targets with no `.sql` files are skipped at runtime with a log line, so a + database whose schema is managed elsewhere (Keycloak's Liquibase, Sunbird RC's + own DDL) can still be listed for inventory. +- `activeDeadlineSeconds` (default 900), so an unreachable database fails the Job + rather than blocking `helm install` until Helm's timeout. + +### Fixed +- **The database password is no longer written to a ConfigMap.** The original + chart rendered `FLYWAY_PASSWORD` and `PGPASSWORD` into ConfigMap data via its + `configs/env.yaml`, which put a live credential in plaintext in an object that + is not treated as sensitive. It is now injected from a `secretKeyRef`, and the + chart renders no passwords at all. +- Volume mounts for migration directories are now inside the block that emits + the `volumeMounts:` key. Previously the per-folder `range` sat outside the + `if configmap.enabled` guard, so disabling the ConfigMap while keeping + migration folders emitted orphaned list items with no parent key — invalid + YAML. The same applied to `volumes:`. +- `FLYWAY_LOCATIONS` no longer disagrees with the actual mount path. The original + set it to `filesystem:/flyway/migrations` while the script passed + `-locations=filesystem:/migrations/$folder` explicitly and the volumes mounted + at `/migrations/`, so the environment variable was misleading dead + configuration. +- Target ordering is explicit in `targets` rather than derived from + `ls /migrations | sort -n`, which silently depended on directory names carrying + numeric prefixes. + +### Changed +- Built on `oan-common`, replacing the external `common` library chart pulled + from a third-party Helm repository. +- The migration script is generated from `targets` rather than shipped as a + static file that parsed a directory listing, so each target gets its own JDBC + URL and one Job can migrate several databases. +- Script is POSIX `sh`, not `bash`, so it runs on the Alpine-based Flyway image. +- Image tag pinned (`11.10.0-alpine`) rather than defaulting to `latest`. +- Resource requests and limits are now mandatory, via `oan-common.resources`. + +### Removed +- `Service`, `Ingress`, `autoscaling`/HPA, `serviceMonitor`, and liveness and + readiness probes. This chart renders a Job; none of them apply to one. +- `replicaCount`, which a Job does not have. +- Configuration belonging to the previous deployment and unrelated to database + migration: `superset_oauth_clientid`, `superset_oauth_client_secret`, + `kong_ingress_domain`, `gf_auth_generic_oauth_client_id`, + `gf_auth_generic_oauth_client_secret`, `web_console_user`, + `web_console_password`, `web_console_login`, and the `system_settings` block + (`encryption_key`, `default_dataset_id`, `max_event_size`, `dedup_period`). +- The `global.postgresql.password` value, along with `global.*` indirection and + the `base.namespace` override mechanism that let a parent chart retarget the + namespace per subchart. +- The discovery-specific migrations (`02-discover-db`), which belong to a + different service and a different database. diff --git a/charts/postgresql-migration/Chart.yaml b/charts/postgresql-migration/Chart.yaml new file mode 100644 index 0000000..938a0b7 --- /dev/null +++ b/charts/postgresql-migration/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v2 +name: postgresql-migration +description: >- + Flyway schema migrations for OAN PostgreSQL databases, run as a Job. Creates + the per-service databases and applies versioned SQL to each. Runs as a Helm + hook so it completes before the services that depend on it start. +type: application +version: 0.2.0 +# Flyway CLI image tag +appVersion: "11.10.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - postgresql + - flyway + - migration +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts + - https://documentation.red-gate.com/flyway +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/postgresql-migration/README.md b/charts/postgresql-migration/README.md new file mode 100644 index 0000000..adf2a78 --- /dev/null +++ b/charts/postgresql-migration/README.md @@ -0,0 +1,160 @@ +# postgresql-migration + +Flyway schema migrations for OAN PostgreSQL databases, run as a Kubernetes Job. + +It does two things, in the order you declare: + +1. **Creates the per-service databases** the cluster does not create itself. +2. **Applies versioned SQL** to each database. + +By default it runs as a Helm hook, so `helm install` waits for migrations to +finish before the services that depend on them start. + +## Install + +```bash +helm dependency update charts/postgresql-migration +helm install migrate charts/postgresql-migration \ + -n oan-registry -f charts/postgresql-migration/examples/registry-stack.dev.yaml +``` + +Run it **after** the database cluster and **before** Keycloak — Keycloak needs the +`keycloak` database to exist. See +[`charts/registry/README.md`](../registry/README.md) for the full stack order. + +## Targets + +A target is one database plus the migration directory applied to it. They run in +the order listed in `targets`: + +```yaml +targets: + - name: 01-registry # directory under files/migrations/ + database: registry # database to connect to (must already exist) + - name: 02-keycloak + database: keycloak +``` + +The list order is what runs; directory names are numbered only so they read in +the same order on disk. A target whose directory holds no `.sql` files is +**skipped**, and the Job logs that it skipped it — so a database can be listed +for inventory purposes without inventing migrations for it. + +Set `enabled: false` on an entry to skip it without deleting it. + +## This chart does not create databases + +Databases are created by the [`postgresql-cnpg`](../postgresql-cnpg) chart — +`bootstrap.database` for the first, and `databases` (CNPG `Database` objects) for +the rest. The operator reconciles them. + +That is deliberate. Creating them here would mean `CREATE DATABASE` outside a +transaction, no `IF NOT EXISTS` to make it idempotent, and a standing rule never +to list the database the cluster already made. The operator does it properly, and +Flyway goes back to what it is for: **migrating schemas inside databases that +already exist.** + +So a target here names a database that must already exist. Pointing one at a +missing database fails with a connection error, not a helpful message. + +## Adding a migration## Adding a migration + +Drop a file into the target's directory, following Flyway's naming +(`V__.sql`): + +``` +charts/postgresql-migration/files/migrations/01-registry/V1__add_lookup_index.sql +``` + +Then bump the chart version and add a CHANGELOG entry. Two rules that Flyway +enforces and this chart cannot soften for you: + +- **Never edit a migration that has already run.** Flyway stores a checksum; + changing the file makes the next run fail validation. Add a new version + instead. +- **Versions must not collide.** Two `V1__` files in the same target is an error. + +Keycloak's own tables are the exception to all of this: it manages its schema +with Liquibase, and a second tool writing those tables would fight it. Do not add +Keycloak table DDL here. + +## The connection + +One host, one role, many databases — each target reuses the connection with a +different database name. + +The role needs: + +- **CREATEDB** for the bootstrap target. +- **Ownership or superuser rights** on each database it migrates. + +In the OAN dev layout that is the CNPG superuser, whose password CNPG generates +into `Secret/-superuser` when the database chart sets +`enableSuperuserAccess: true`. + +`postgresql.host` must be the **primary** (`-rw`). Migrations write, so +a read-only replica service fails — and fails in a way that reads like a +permissions problem. + +## Secrets + +The password is injected as `FLYWAY_PASSWORD` from a `secretKeyRef`. It is +**never** written into a ConfigMap — that was a real flaw in the chart this was +adapted from, where it landed in ConfigMap data in plaintext. + +This chart renders no Secrets; the one it references is created by whatever +manages secrets in that environment. In the OAN dev layout CNPG generates it +(`Secret/-superuser`). + +## Running as a hook, or not + +| | `hook.enabled: true` (default) | `hook.enabled: false` | +|---|---|---| +| When it runs | During `helm install`/`helm upgrade`, before other resources | As an ordinary release resource | +| Helm waits for it | Yes | No | +| `helm uninstall` removes it | No — `deletePolicy` handles cleanup | Yes | +| Repeated upgrades | Works: `before-hook-creation` deletes the old Job first | Fails if the pod template changed — Jobs are immutable | + +`hook.weight: "-5"` orders this ahead of any other hook that needs the schema. +The database cluster must already be running when the hook fires, which holds in +the OAN layout because the cluster is a separate, earlier release. + +## Failure handling + +`repairOnFailure: true` runs `flyway repair` and retries once. Repair rewrites +the schema history to match the migrations on disk, which fixes the common case — +a previous run that failed partway and left a checksum mismatch — but it will +also happily paper over a migration someone edited. Fine for dev; consider +turning it off where you want a failed migration to stay failed until someone +looks at it. + +The Job's `backoffLimit` retries the pod. That is safe: Flyway skips +already-applied migrations. + +`activeDeadlineSeconds` (default 900) stops a Job that cannot reach the database +from blocking `helm install` until Helm's own timeout. + +## Reading what it did + +```bash +kubectl -n oan-registry logs job/migrate-postgresql-migration +``` + +The log names each target, the URL, the migration files it found, and whether it +skipped, succeeded or failed. Per-database history lives in +`flyway_schema_history` in each database. + +Note that with the default hook `deletePolicy: before-hook-creation`, the Job and +its logs survive until the next install or upgrade replaces it. + +## Configuration + +See [`values.yaml`](./values.yaml) for the full commented schema and +[`examples/registry-stack.dev.yaml`](./examples/registry-stack.dev.yaml) for a +per-environment file. + +## Versioning + +Every change — **including adding or editing a migration** — needs a `version` +bump in `Chart.yaml` and an entry in [`CHANGELOG.md`](./CHANGELOG.md). See +[`CONVENTIONS.md`](../../CONVENTIONS.md). diff --git a/charts/postgresql-migration/ci/lint-values.yaml b/charts/postgresql-migration/ci/lint-values.yaml new file mode 100644 index 0000000..fcf4a69 --- /dev/null +++ b/charts/postgresql-migration/ci/lint-values.yaml @@ -0,0 +1,7 @@ +# Minimum values that let `helm template` run in CI. This chart deliberately +# fails the render when the host and password Secret are unset, so bare defaults +# cannot be templated. Dummy references only - see examples/ for real config. +postgresql: + host: registry-db-rw + passwordSecret: + name: registry-db-superuser diff --git a/charts/postgresql-migration/ci/no-hook-values.yaml b/charts/postgresql-migration/ci/no-hook-values.yaml new file mode 100644 index 0000000..d19c047 --- /dev/null +++ b/charts/postgresql-migration/ci/no-hook-values.yaml @@ -0,0 +1,11 @@ +# Second CI case: the Job as an ordinary release resource rather than a Helm +# hook, which is how you would run migrations on their own. +postgresql: + host: registry-db-rw + passwordSecret: + name: registry-db-superuser +hook: + enabled: false +job: + ttlSecondsAfterFinished: 3600 +repairOnFailure: false diff --git a/charts/postgresql-migration/examples/registry-stack.dev.yaml b/charts/postgresql-migration/examples/registry-stack.dev.yaml new file mode 100644 index 0000000..b5880f2 --- /dev/null +++ b/charts/postgresql-migration/examples/registry-stack.dev.yaml @@ -0,0 +1,55 @@ +# Example: migrations for the registry stack, dev environment. +# +# helm install migrate charts/postgresql-migration \ +# -n oan-registry -f charts/postgresql-migration/examples/registry-stack.dev.yaml +# +# NOTE: with both databases now created by the postgresql-cnpg chart, and with +# Sunbird RC and Keycloak both managing their own schemas, this chart currently +# has nothing to do for the registry stack - every target below is empty and gets +# skipped. It is here for when OAN adds schemas of its own (catalog publish and +# discovery, per engineering-tracker #30), so the targets and their history exist +# from the start rather than being retrofitted. +# +# Run it after the database cluster. + +postgresql: + # The CNPG primary. Migrations write, so this must not be a replica. + host: registry-db-rw + port: 5432 + # Needs rights on each database it migrates. The CNPG superuser has them + # everywhere; a per-database owner would need one release per database. + # + # NOTE: the dev database example leaves enableSuperuserAccess false, since no + # application needs it. Turn it on there before enabling any target that + # actually has SQL, or point this at a per-database owner instead. + user: postgres + passwordSecret: + name: registry-db-superuser + key: password + +targets: + # No .sql files yet: Sunbird RC creates its own tables. The target is kept so + # anything OAN adds alongside the registry has a versioned home. + - name: 01-registry + database: registry + + # No .sql files, and there should not be: Keycloak manages its own schema with + # Liquibase. Listed so the database appears in this chart's inventory. + - name: 02-keycloak + database: keycloak + +# Dev convenience. Consider turning this off in production, so a failed +# migration stays failed until someone reads the log rather than being repaired +# automatically. +repairOnFailure: true + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +commonLabels: + oan.in/environment: dev diff --git a/charts/postgresql-migration/files/migrations/01-registry/README.md b/charts/postgresql-migration/files/migrations/01-registry/README.md new file mode 100644 index 0000000..bd90952 --- /dev/null +++ b/charts/postgresql-migration/files/migrations/01-registry/README.md @@ -0,0 +1,13 @@ +# registry migrations + +Versioned SQL applied to the `registry` database, in Flyway naming order +(`V1__…`, `V2__…`). + +**This directory is intentionally empty of migrations.** Sunbird RC core creates +its own tables from the entity definitions it is given, so the registry needs no +Flyway-managed schema today. The target exists so that anything OAN adds +alongside the registry — a view, an index, a lookup table — has an obvious home +and a versioned history. + +A target whose directory contains no `.sql` files is skipped, and the Job logs +that it skipped it. diff --git a/charts/postgresql-migration/files/migrations/02-keycloak/README.md b/charts/postgresql-migration/files/migrations/02-keycloak/README.md new file mode 100644 index 0000000..8d8f291 --- /dev/null +++ b/charts/postgresql-migration/files/migrations/02-keycloak/README.md @@ -0,0 +1,10 @@ +# keycloak migrations + +Versioned SQL applied to the `keycloak` database. + +**This directory is intentionally empty of migrations.** Keycloak manages its own +schema with Liquibase on startup, and a second migration tool writing the same +tables would fight it. Do not add table DDL for Keycloak's own tables here. + +The target exists only so the database appears in this chart's inventory. A +target whose directory contains no `.sql` files is skipped. diff --git a/charts/postgresql-migration/templates/NOTES.txt b/charts/postgresql-migration/templates/NOTES.txt new file mode 100644 index 0000000..c015735 --- /dev/null +++ b/charts/postgresql-migration/templates/NOTES.txt @@ -0,0 +1,41 @@ +{{- $targets := include "postgresql-migration.enabledTargets" . | fromJsonArray }} +{{ .Chart.Name }} installed as release "{{ .Release.Name }}". + +{{ if .Values.hook.enabled -}} +The migration Job runs as a Helm hook ({{ join ", " .Values.hook.events }}), so it +has already completed - `helm install` would have failed otherwise. +{{- else -}} +The migration Job is an ordinary release resource, so it is starting now. +{{- end }} + +Targets, in order: +{{- range $targets }} + - {{ .name }} -> database {{ .database }}{{ if not (include "postgresql-migration.targetHasSql" (dict "ctx" $ "name" .name)) }} (no .sql files - will be skipped){{ end }} +{{- end }} + +Connection: {{ .Values.postgresql.user }}@{{ .Values.postgresql.host }}:{{ .Values.postgresql.port }} + +Read what it did: + kubectl -n {{ .Release.Namespace }} logs job/{{ include "postgresql-migration.fullname" . }} + +{{ if .Values.hook.enabled -}} +Because the Job is a hook with deletePolicy "{{ .Values.hook.deletePolicy }}", it +stays until the next install or upgrade replaces it - so the logs above are +readable now, but will be gone after the next run. +{{- end }} + +Check the schema history Flyway keeps, per database: +{{- range $targets }} +{{- if include "postgresql-migration.targetHasSql" (dict "ctx" $ "name" .name) }} + psql -h {{ $.Values.postgresql.host }} -U {{ $.Values.postgresql.user }} -d {{ .database }} \ + -c 'SELECT version, description, success, installed_on FROM flyway_schema_history ORDER BY installed_rank' +{{- end }} +{{- end }} + +If a target failed, the log names which one. The usual causes: + - The role lacks CREATEDB (bootstrap target) or rights on the database. + - A database listed in the bootstrap migration already exists, created by the + PostgreSQL cluster itself. Remove it from the migration - the cluster's + bootstrap database must not be created here. + - A migration was edited after it ran; Flyway rejects the checksum change. + {{ if .Values.repairOnFailure }}repairOnFailure is on, so it retried once after `flyway repair`.{{ else }}repairOnFailure is off, so it failed without retrying.{{ end }} diff --git a/charts/postgresql-migration/templates/_helpers.tpl b/charts/postgresql-migration/templates/_helpers.tpl new file mode 100644 index 0000000..d8bbb21 --- /dev/null +++ b/charts/postgresql-migration/templates/_helpers.tpl @@ -0,0 +1,146 @@ +{{/* +# ============================================================================ +# POSTGRESQL-MIGRATION CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers delegating to oan-common, plus the Flyway target +# and JDBC wiring this chart needs. +# ============================================================================ +*/}} + +{{- define "postgresql-migration.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "postgresql-migration.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "postgresql-migration.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{- define "postgresql-migration.selectorLabels" -}} +{{- include "oan-common.selectorLabels" . -}} +{{- end }} + +{{- define "postgresql-migration.serviceAccountName" -}} +{{- include "oan-common.serviceAccount.name" . -}} +{{- end }} + +{{- define "postgresql-migration.image" -}} +{{- include "oan-common.image" . -}} +{{- end }} + +{{- define "postgresql-migration.envConfigMapName" -}} +{{- include "oan-common.envConfigMapName" . -}} +{{- end }} + +{{/* +Targets that are enabled, in the order given. `enabled` defaults to true when +the key is absent, so a target can be written as just name + database. +*/}} +{{- define "postgresql-migration.enabledTargets" -}} +{{- $out := list -}} +{{- range .Values.targets -}} +{{- if or (not (hasKey . "enabled")) .enabled -}} +{{- $out = append $out . -}} +{{- end -}} +{{- end -}} +{{- $out | toJson -}} +{{- end }} + +{{/* +Validate the connection and every target. Called from the Job and the ConfigMap +so a misconfiguration fails the render rather than the Job. +*/}} +{{- define "postgresql-migration.validate" -}} +{{- $pg := .Values.postgresql -}} +{{- if not $pg.host -}} +{{- fail (printf "%s: postgresql.host is required - point it at the PostgreSQL PRIMARY service, e.g. registry-db-rw. Migrations write, so a read-only replica will not do." .Chart.Name) -}} +{{- end -}} +{{- if not $pg.passwordSecret.name -}} +{{- fail (printf "%s: postgresql.passwordSecret.name is required - this chart renders no passwords" .Chart.Name) -}} +{{- end -}} +{{- $targets := include "postgresql-migration.enabledTargets" . | fromJsonArray -}} +{{- if not $targets -}} +{{- fail (printf "%s: no enabled entries in targets - there is nothing to migrate" .Chart.Name) -}} +{{- end -}} +{{- $seen := dict -}} +{{- range $targets -}} +{{- if not .name -}} +{{- fail (printf "%s: every entry in targets needs a name, matching a directory under files/migrations/" $.Chart.Name) -}} +{{- end -}} +{{- if not .database -}} +{{- fail (printf "%s: target %q needs a database" $.Chart.Name .name) -}} +{{- end -}} +{{- if hasKey $seen .name -}} +{{- fail (printf "%s: target %q is listed twice" $.Chart.Name .name) -}} +{{- end -}} +{{- $seen = set $seen .name true -}} +{{- $glob := printf "files/migrations/%s/*" .name -}} +{{- if not ($.Files.Glob $glob) -}} +{{- fail (printf "%s: target %q has no directory files/migrations/%s in the chart" $.Chart.Name .name .name) -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* +JDBC URL for one database. +*/}} +{{- define "postgresql-migration.jdbcUrl" -}} +{{- $pg := .ctx.Values.postgresql -}} +{{- printf "jdbc:postgresql://%s:%v/%s%s" $pg.host $pg.port .database $pg.jdbcParams -}} +{{- end }} + +{{/* +Name of the ConfigMap holding the migration files for one target. +*/}} +{{- define "postgresql-migration.targetConfigMapName" -}} +{{- printf "%s-%s" (include "postgresql-migration.fullname" .ctx) .name | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{/* +Where a target's migrations are mounted. +*/}} +{{- define "postgresql-migration.targetMountPath" -}} +{{- printf "/migrations/%s" .name -}} +{{- end }} + +{{/* +Migration files for one target, as ConfigMap data. + +Only .sql and .conf are included: the directories also hold READMEs explaining +what belongs in them, and those are not migrations. +*/}} +{{- define "postgresql-migration.targetFiles" -}} +{{- $ctx := .ctx -}} +{{- $name := .name -}} +{{- range $path, $_ := $ctx.Files.Glob (printf "files/migrations/%s/*.sql" $name) }} +{{ base $path }}: |- + {{- $ctx.Files.Get $path | nindent 2 }} +{{- end }} +{{- range $path, $_ := $ctx.Files.Glob (printf "files/migrations/%s/*.conf" $name) }} +{{ base $path }}: |- + {{- $ctx.Files.Get $path | nindent 2 }} +{{- end }} +{{- end }} + +{{/* +Does this target actually have migrations to apply? +*/}} +{{- define "postgresql-migration.targetHasSql" -}} +{{- if .ctx.Files.Glob (printf "files/migrations/%s/*.sql" .name) -}} +{{- true -}} +{{- end -}} +{{- end }} + +{{/* +Hook annotations, when the Job runs as a Helm hook. +*/}} +{{- define "postgresql-migration.hookAnnotations" -}} +{{- if .Values.hook.enabled }} +"helm.sh/hook": {{ join "," .Values.hook.events | quote }} +"helm.sh/hook-weight": {{ .Values.hook.weight | quote }} +"helm.sh/hook-delete-policy": {{ .Values.hook.deletePolicy | quote }} +{{- end }} +{{- end }} diff --git a/charts/postgresql-migration/templates/configmap-migrations.yaml b/charts/postgresql-migration/templates/configmap-migrations.yaml new file mode 100644 index 0000000..4bfd143 --- /dev/null +++ b/charts/postgresql-migration/templates/configmap-migrations.yaml @@ -0,0 +1,29 @@ +{{- $targets := include "postgresql-migration.enabledTargets" . | fromJsonArray }} +{{- range $targets }} +{{- $files := include "postgresql-migration.targetFiles" (dict "ctx" $ "name" .name) | trim }} +--- +{{/* +Migration files for one target. Rendered even when empty, so the mount exists and +the Job can report "no .sql files" rather than "not mounted". +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "postgresql-migration.targetConfigMapName" (dict "ctx" $ "name" .name) }} + labels: + {{- include "postgresql-migration.labels" $ | nindent 4 }} + oan.in/migration-target: {{ .name | quote }} + annotations: + {{- include "postgresql-migration.hookAnnotations" $ | nindent 4 }} + {{- with $.Values.commonAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- if $files }} +data: + {{- $files | nindent 2 }} +{{- else }} +# No .sql or .conf files in files/migrations/{{ .name }} - this database's schema +# is managed outside Flyway. The Job skips this target. +data: {} +{{- end }} +{{- end }} diff --git a/charts/postgresql-migration/templates/configmap-script.yaml b/charts/postgresql-migration/templates/configmap-script.yaml new file mode 100644 index 0000000..6e148eb --- /dev/null +++ b/charts/postgresql-migration/templates/configmap-script.yaml @@ -0,0 +1,87 @@ +{{- include "postgresql-migration.validate" . }} +{{- $targets := include "postgresql-migration.enabledTargets" . | fromJsonArray }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "postgresql-migration.fullname" . }}-script + labels: + {{- include "postgresql-migration.labels" . | nindent 4 }} + annotations: + {{- include "postgresql-migration.hookAnnotations" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +data: + migrate.sh: |- + #!/bin/sh + # Generated by the postgresql-migration chart. Do not edit in the cluster - + # change values.yaml and upgrade instead. + # + # POSIX sh, not bash: the flyway alpine image has no bash. + # + # One `flyway migrate` per target, in the order declared in values.yaml. + # Flyway records what it has applied in each database's own + # flyway_schema_history, so re-running this is safe: applied migrations are + # skipped, not repeated. + set -eu + + fail=0 + + run_target() { + name="$1" + url="$2" + dir="/migrations/$name" + + echo "==========================================================" + echo "target: $name" + echo "url: $url" + + if [ ! -d "$dir" ]; then + echo " SKIP: $dir is not mounted" + return 0 + fi + # A target with no .sql is a placeholder for a database whose schema is + # managed elsewhere (Keycloak's Liquibase, Sunbird RC's own DDL). + if [ -z "$(find "$dir" -maxdepth 1 -name '*.sql' -print -quit)" ]; then + echo " SKIP: no .sql files in $dir" + return 0 + fi + + echo " migrations:" + for f in "$dir"/*.sql; do + echo " $(basename "$f")" + done + + if flyway -url="$url" -locations="filesystem:$dir" migrate; then + echo " OK: $name" + return 0 + fi + + {{- if .Values.repairOnFailure }} + echo " migrate failed for $name - running repair and retrying once" + if ! flyway -url="$url" -locations="filesystem:$dir" repair; then + echo " FAILED: repair failed for $name" + return 1 + fi + if flyway -url="$url" -locations="filesystem:$dir" migrate; then + echo " OK: $name (after repair)" + return 0 + fi + echo " FAILED: $name still failing after repair" + return 1 + {{- else }} + echo " FAILED: $name (repairOnFailure is disabled, so not retrying)" + return 1 + {{- end }} + } + + {{- range $targets }} + run_target {{ .name | quote }} {{ include "postgresql-migration.jdbcUrl" (dict "ctx" $ "database" .database) | quote }} || fail=1 + {{- end }} + + echo "==========================================================" + if [ "$fail" -ne 0 ]; then + echo "one or more targets FAILED" + exit 1 + fi + echo "all targets completed" diff --git a/charts/postgresql-migration/templates/configmap.yaml b/charts/postgresql-migration/templates/configmap.yaml new file mode 100644 index 0000000..c50910b --- /dev/null +++ b/charts/postgresql-migration/templates/configmap.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "postgresql-migration.envConfigMapName" . }} + labels: + {{- include "postgresql-migration.labels" . | nindent 4 }} + annotations: + {{- include "postgresql-migration.hookAnnotations" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +data: + # Flyway settings. The password is NOT here - it comes from a Secret, injected + # as FLYWAY_PASSWORD in the Job. + FLYWAY_USER: {{ .Values.postgresql.user | quote }} + FLYWAY_BASELINE_ON_MIGRATE: {{ .Values.flyway.baselineOnMigrate | quote }} + FLYWAY_BASELINE_VERSION: {{ .Values.flyway.baselineVersion | quote }} + FLYWAY_VALIDATE_ON_MIGRATE: {{ .Values.flyway.validateOnMigrate | quote }} + FLYWAY_OUT_OF_ORDER: {{ .Values.flyway.outOfOrder | quote }} + {{- range $k, $v := .Values.flyway.extraEnv }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- range $k, $v := .Values.envConfig }} + {{ $k }}: {{ $v | quote }} + {{- end }} diff --git a/charts/postgresql-migration/templates/job.yaml b/charts/postgresql-migration/templates/job.yaml new file mode 100644 index 0000000..585b954 --- /dev/null +++ b/charts/postgresql-migration/templates/job.yaml @@ -0,0 +1,105 @@ +{{- include "postgresql-migration.validate" . }} +{{- $targets := include "postgresql-migration.enabledTargets" . | fromJsonArray }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "postgresql-migration.fullname" . }} + labels: + {{- include "postgresql-migration.labels" . | nindent 4 }} + annotations: + {{- include "postgresql-migration.hookAnnotations" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + backoffLimit: {{ .Values.job.backoffLimit }} + {{- with .Values.job.activeDeadlineSeconds }} + activeDeadlineSeconds: {{ . }} + {{- end }} + {{- with .Values.job.ttlSecondsAfterFinished }} + ttlSecondsAfterFinished: {{ . }} + {{- end }} + template: + metadata: + labels: + {{- include "postgresql-migration.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + restartPolicy: {{ .Values.job.restartPolicy }} + {{- if .Values.serviceAccount.enabled }} + serviceAccountName: {{ include "postgresql-migration.serviceAccountName" . }} + {{- end }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- with (include "oan-common.imagePullSecrets" . | trim) }} + {{- . | nindent 6 }} + {{- end }} + {{- with (include "oan-common.podSecurityContext" .) }} + securityContext: + {{- . | nindent 8 }} + {{- end }} + containers: + - name: flyway + image: {{ include "postgresql-migration.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + command: + - /bin/sh + - /script/migrate.sh + env: + - name: FLYWAY_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgresql.passwordSecret.name }} + key: {{ .Values.postgresql.passwordSecret.key }} + {{- with (include "oan-common.env" . | trim) }} + {{- . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "postgresql-migration.envConfigMapName" . }} + {{- range .Values.envFromSecrets }} + - secretRef: + name: {{ . }} + {{- end }} + resources: + {{- include "oan-common.resources" . | nindent 12 }} + volumeMounts: + - name: script + mountPath: /script + readOnly: true + {{- range $targets }} + - name: {{ printf "migrations-%s" .name | trunc 63 | trimSuffix "-" }} + mountPath: {{ include "postgresql-migration.targetMountPath" (dict "name" .name) }} + readOnly: true + {{- end }} + volumes: + - name: script + configMap: + name: {{ include "postgresql-migration.fullname" . }}-script + defaultMode: 0555 + {{- range $targets }} + - name: {{ printf "migrations-%s" .name | trunc 63 | trimSuffix "-" }} + configMap: + name: {{ include "postgresql-migration.targetConfigMapName" (dict "ctx" $ "name" .name) }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/postgresql-migration/templates/serviceaccount.yaml b/charts/postgresql-migration/templates/serviceaccount.yaml new file mode 100644 index 0000000..0097ee0 --- /dev/null +++ b/charts/postgresql-migration/templates/serviceaccount.yaml @@ -0,0 +1,17 @@ +{{- if include "oan-common.serviceAccount.enabled" . }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "postgresql-migration.serviceAccountName" . }} + labels: + {{- include "postgresql-migration.labels" . | nindent 4 }} + annotations: + {{- include "postgresql-migration.hookAnnotations" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.commonAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/postgresql-migration/values.yaml b/charts/postgresql-migration/values.yaml new file mode 100644 index 0000000..d54e215 --- /dev/null +++ b/charts/postgresql-migration/values.yaml @@ -0,0 +1,194 @@ +# ============================================================================ +# postgresql-migration - default values +# +# Flyway migrations for OAN PostgreSQL databases, run as a Job. It does two +# things, in order: +# +# 1. Creates the per-service databases the cluster does not create itself. +# 2. Applies versioned SQL to each database. +# +# It runs as a Helm hook by default, so `helm install` of a service chart waits +# for migrations to finish before the service starts. +# +# Minimum you must set: +# - postgresql.host +# - postgresql.passwordSecret.name +# ============================================================================ + +image: + registry: docker.io + repository: flyway/flyway + # Pinned; `latest` in a migration job means a tool upgrade you did not choose. + tag: "11.10.0-alpine" + digest: "" + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + enabled: true + name: "" + annotations: {} + # The Job talks to PostgreSQL, not the Kubernetes API. + automountServiceAccountToken: false + +# --------------------------------------------------------------------------- +# Connection +# +# One host, one role, many databases: each target below reuses this connection +# with a different database name. +# +# The role needs ownership or superuser rights on each database it migrates, in +# order to create objects and the flyway_schema_history table. +# +# This chart does NOT create databases. That is the postgresql-cnpg chart's job - +# `bootstrap.database` for the first one and `databases` for the rest, both +# reconciled by the CNPG operator. Creating them here instead would mean +# CREATE DATABASE outside a transaction, no IF NOT EXISTS, and a rule about never +# listing the database the cluster already made. The operator does it properly. +# --------------------------------------------------------------------------- +postgresql: + # PostgreSQL primary service, e.g. "registry-db-rw". Must be the primary: + # migrations write. + host: "" + port: 5432 + user: postgres + # Secret holding the password. Required - the chart renders no passwords. + passwordSecret: + name: "" + key: password + # Appended to every JDBC URL, e.g. "?sslmode=require" + jdbcParams: "" + +# --------------------------------------------------------------------------- +# Targets +# +# Each entry is one database and the migration directory applied to it, run in +# the order listed. The order in this list is what runs - directory names are +# numbered only so they read in the same order on disk. +# +# name: the directory under files/migrations/ +# database: the database to connect to +# enabled: set false to skip without removing the entry +# +# A target whose directory has no .sql files is skipped, and the Job says so. +# --------------------------------------------------------------------------- +targets: + - name: 01-registry + database: registry + enabled: true + - name: 02-keycloak + database: keycloak + enabled: true + +# Run `flyway repair` and retry once when a migration fails. +# +# Repair rewrites the schema history to match the migrations on disk. It fixes +# the common case - a previous run that failed partway and left a checksum +# mismatch - but it will also happily paper over a genuinely edited migration. +# Leave it on for dev; consider turning it off where you want a failed migration +# to stay failed until someone looks at it. +repairOnFailure: true + +# Flyway's own settings, passed through as environment variables. Anything +# Flyway reads from FLYWAY_* can go here. +flyway: + # Create the schema history table on a database that has objects but no + # history yet, instead of refusing to run. + baselineOnMigrate: true + # Version recorded for that baseline. + baselineVersion: "0" + # Reject a migration whose checksum no longer matches the recorded one. + validateOnMigrate: true + # Allow out-of-order migrations. Off: a lower version arriving late is an error. + outOfOrder: false + # Extra FLYWAY_* environment variables, as name -> value. + extraEnv: {} + # FLYWAY_CONNECT_RETRIES: "10" + +# --------------------------------------------------------------------------- +# Helm hook +# +# As a hook, the Job runs during `helm install`/`helm upgrade` and Helm waits for +# it before proceeding. That is what orders migrations ahead of the services. +# +# Two consequences worth knowing: +# - A hook resource is not tracked in the release, so `helm uninstall` does not +# remove it; deletePolicy handles the cleanup instead. +# - The database cluster must already be running when the hook fires. That +# holds in the OAN layout, where the cluster is a separate, earlier release. +# +# Set enabled: false to install the Job as an ordinary release resource, e.g. to +# run migrations by themselves. +# --------------------------------------------------------------------------- +hook: + enabled: true + events: + - pre-install + - pre-upgrade + # Lower weights run first, so keep this below any other hook that needs the + # schema to exist. + weight: "-5" + # before-hook-creation deletes the previous Job before creating this one, + # which is what makes repeated upgrades work: a Job's pod template is + # immutable, so re-applying a changed one is rejected. + deletePolicy: before-hook-creation + +job: + # Pod-level retries. Flyway is idempotent - already-applied migrations are + # skipped - so a retry is safe. + backoffLimit: 3 + # Give up after this long. Without it a Job that cannot reach the database + # blocks `helm install` until Helm's own timeout. + activeDeadlineSeconds: 900 + # Delete the finished Job (and its pod, and its logs) this long after it ends. + # Empty keeps it until the next run, so the logs are there to read. + ttlSecondsAfterFinished: "" + restartPolicy: OnFailure + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +# Off by default, as elsewhere in this repo. Flyway only reads its migrations and +# writes to the database, so it is a good candidate for tightening - but verify +# the pinned image's own user before enabling runAsUser, since a mismatch shows +# up as a permission error on /flyway rather than anything obvious. +podSecurityContext: + enabled: false + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + +securityContext: + enabled: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + capabilities: + drop: + - ALL + +nodeSelector: {} +tolerations: [] +affinity: {} +podLabels: {} +podAnnotations: {} + +# Non-secret extra configuration, injected via envFrom. +envConfig: {} + +# Extra env vars from specific Secret keys. +secretEnv: {} +extraEnv: [] +envFromSecrets: [] + +commonLabels: {} +commonAnnotations: {} diff --git a/charts/registry/.helmignore b/charts/registry/.helmignore new file mode 100644 index 0000000..3027bb2 --- /dev/null +++ b/charts/registry/.helmignore @@ -0,0 +1,13 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +.idea/ +*.swp +*.bak +*.orig + +# Example values are documentation, not part of the package +examples/ diff --git a/charts/registry/CHANGELOG.md b/charts/registry/CHANGELOG.md new file mode 100644 index 0000000..3aaa256 --- /dev/null +++ b/charts/registry/CHANGELOG.md @@ -0,0 +1,74 @@ +# Changelog + +All notable changes to the `registry` chart are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-08-31 + +### Changed +- `image.repository` now defaults to empty rather than + `sunbird-rc/sunbird-rc-core`. The image OAN will deploy is not settled, and a + default was reading as a decision that had not been made. The render fails + while it is empty, so this cannot be missed. The dev example sets the + compose-verified image explicitly; the production example is a documented TODO. + +### Removed +- External Secrets Operator integration: the `ExternalSecret` template and the + `externalSecrets` value block. ESO is not installed in any OAN cluster, so this + was configuration that could not be exercised, and a chart that renders a + Secret-producing resource invites the question of where secrets come from to be + answered differently per chart. + + Charts still reference Secrets by name - `envFromSecrets`, `secretEnv`, and the + per-chart `*Secret.name` settings are unchanged. Creating those Secrets is now + unambiguously outside the charts. + +## [0.1.0] - 2026-08-31 + +Initial release. Configuration ported from the verified +`registry/docker-compose.yml` stack, with the deployment shape taken from +Sunbird's own Helm charts (`deploy-as-code/helm/v2`). + +### Added +- Deployment, Service, env ConfigMap, schemas ConfigMap, ServiceAccount, and + optional Ingress and ESO ExternalSecret, built on the `oan-common` library. +- All 32 registry environment variables from the compose stack, verified at + parity. Database and Keycloak variables are derived from structured values + rather than restated, so `OAUTH2_RESOURCES_0_URI` cannot drift from + `keycloak.url` and `connectionInfo_uri` cannot drift from `database.*`. +- Entity schemas shipped in `files/schemas/` and rendered into a ConfigMap + mounted read-only, with `existingConfigMap` and `inline` overrides. A + `checksum/schemas` annotation rolls the pod when a schema changes. +- Render-time validation of `database.host`, `database.passwordSecret.name`, + `keycloak.url`, `keycloak.adminClientSecret.name`, and + `defaultUserPasswordSecret.name` when `keycloakUserSetPassword` is true. + Also fails when no schema file matches, since a registry with no entity + definitions serves nothing. +- Every optional Sunbird RC subsystem off by default, matching compose: + encryption, events, idgen, claims, DIDs, signatures, certificates, file + storage, notifications, async and webhooks. + +- Init containers that wait for the database and for Keycloak's realm endpoint. + The Keycloak check is HTTP against the realm rather than TCP against the port, + because the realm is imported during Keycloak's first start and the port opens + well before the realm exists. +- Optional HorizontalPodAutoscaler. `replicas` is omitted from the Deployment + when it is enabled, so the two do not fight on every reconcile. +- Optional PodDisruptionBudget, which fails the render if enabled alongside a + single replica. +- `helm test` check that `/health` responds. +- `extraVolumes` / `extraVolumeMounts` / `extraInitContainers` escape hatches. +- Per-environment example values for dev and production. The production example + connects as the database owner rather than the superuser, pulls secrets from + ESO, sets a PodDisruptionBudget and pod anti-affinity, and enables the pod and + container security contexts. + +### Changed from the compose stack +- Probes use `/health` (as compose does) rather than Sunbird's + `/api/docs/swagger.json`, which would also require swagger to be enabled. + A `startupProbe` covers first-start schema migration. +- Resource limits added; Sunbird's chart sets requests only. +- The three secret values are read from named Secret keys instead of `.env` + variables, and the chart renders none of them. diff --git a/charts/registry/Chart.yaml b/charts/registry/Chart.yaml new file mode 100644 index 0000000..1b9ab4c --- /dev/null +++ b/charts/registry/Chart.yaml @@ -0,0 +1,26 @@ +apiVersion: v2 +name: registry +description: >- + The OAN participant registry, on Sunbird RC core. Holds participant records + and the key material inbound signed requests are verified against. Requires + PostgreSQL and Keycloak. +type: application +version: 0.2.0 +appVersion: "v2.0.0" +maintainers: + - name: OpenAgriNet Engineering Team + url: https://github.com/OpenAgriNet +keywords: + - oan + - openagrinet + - registry + - sunbird-rc + - participant +home: https://github.com/OpenAgriNet/helmcharts +sources: + - https://github.com/OpenAgriNet/helmcharts + - https://github.com/Sunbird-RC/sunbird-rc-core +dependencies: + - name: oan-common + version: "0.2.x" + repository: "file://../oan-common" diff --git a/charts/registry/README.md b/charts/registry/README.md new file mode 100644 index 0000000..9c38797 --- /dev/null +++ b/charts/registry/README.md @@ -0,0 +1,244 @@ +# registry + +The OAN participant registry, on [Sunbird RC +core](https://github.com/Sunbird-RC/sunbird-rc-core). It holds participant +records and the key material that inbound signed requests are verified against. + +Configuration is ported from the verified `registry/docker-compose.yml` stack — +all 32 environment variables, at parity. This deployment is **participant records +and lookup only**: every optional Sunbird RC subsystem (credentials, DIDs, +certificates, encryption, file storage, notifications, webhooks, async) is off, +exactly as in compose. + +## What it renders + +| Resource | Notes | +|---|---| +| Deployment | Probes on `/health`, resources mandatory, init containers wait for dependencies | +| Service | `ClusterIP` on 8081 | +| ConfigMap (env) | The non-secret flags from `envConfig` | +| ConfigMap (schemas) | Entity definitions, mounted read-only | +| ServiceAccount | | +| HorizontalPodAutoscaler | Optional, off by default | +| PodDisruptionBudget | Optional, off by default | +| Ingress | Optional, off by default | +| Test Pod | `helm test` check against `/health` | + +## Dependencies + +Two things must already be running: + +1. **PostgreSQL** → `database.host`. See [`postgresql-cnpg`](../postgresql-cnpg), + which creates the database and the owner role this chart connects as. +2. **Keycloak** → `keycloak.url`. See + [`keycloak`](../keycloak). + +## Image + +`image.repository` is **empty by default, on purpose.** The image OAN will +actually deploy is not decided yet — upstream `ghcr.io`, a mirror in our own +registry, or an OAN-built image — and a default here would read as a decision +that has not been made. + +The render **fails** while it is empty: + +``` +registry: image.repository is required - set image.registry/repository/tag for this environment +``` + +That is deliberate. An empty repository would otherwise render +`image: ghcr.io/:v2.0.0`, which Helm and the API server both accept, and which +only fails later as an `ImagePullBackOff`. + +| Values file | Image | +|---|---| +| `examples/registry.dev.yaml` | `ghcr.io/sunbird-rc/sunbird-rc-core:v2.0.0` — what the compose stack runs, so dev deploys what was verified locally | +| `examples/registry.prod.yaml` | Empty, with a TODO. Not installable until filled in | + +When you do fill in production, prefer `image.digest` over `image.tag`: a tag can +be repointed at different bits, a digest cannot, which is what makes a rollback +land on the same image it did before. + +## Waiting for dependencies + +Kubernetes has no equivalent of compose's `depends_on: condition: +service_healthy`, so the chart adds init containers: + +```yaml +waitFor: + enabled: true + database: true # TCP check, host/port from `database` + keycloak: true # HTTP check on /realms/ +``` + +Both are derived from settings you already set, so there is nothing to keep in +sync. + +The Keycloak check is deliberately **HTTP against the realm endpoint**, not TCP +against the port. Keycloak imports the realm during its first start, so the port +opens well before the realm exists — a TCP check would pass too early and the +registry would come up against a Keycloak that cannot yet issue it a usable +token. + +They give up after `waitFor.timeoutSeconds` (default 300) so a genuinely missing +dependency shows as a failed init container in `kubectl describe`, rather than an +endless wait. + +## Verifying an install + +```bash +helm test registry -n oan-registry +``` + +Runs a Pod that checks `/health`. It deliberately does not test an authenticated +endpoint: that needs a real token, and a failure there would be ambiguous between +"the registry is broken" and "the Keycloak wiring is wrong". + +## Scaling + +The registry is stateless, so `autoscaling.enabled` gives it an HPA. Two things +to know: + +- Every replica opens its own connection pool, so scaling out multiplies + connections against PostgreSQL. Check the cluster's `max_connections` before + raising `maxReplicas` much. +- When autoscaling is on, the Deployment omits `replicas` entirely, so + `replicaCount` is ignored. Otherwise Helm and the HPA fight on every reconcile. + +`podDisruptionBudget.enabled` keeps a replica serving through node drains. It +**fails the render** if enabled with `replicaCount: 1`, because a PDB of +`minAvailable: 1` over a single pod blocks drains completely — set +`allowSingleReplica: true` if you want that anyway. + +## Per-environment values + +| File | For | +|---|---| +| [`examples/registry.dev.yaml`](./examples/registry.dev.yaml) | Dev: superuser connection, hand-made Secrets, no ingress | +| [`examples/registry.prod.yaml`](./examples/registry.prod.yaml) | Production: owner connection, 2 replicas, PDB, anti-affinity, security contexts on | + +Everything that differs between environments lives in these files, not in +templates. + +## Install order + +The full stack, in the only order that works: + +```bash +# 1. Database cluster. Creates BOTH databases - `registry` via bootstrap.initdb, +# `keycloak` via a CNPG Database object - each owned by its own role. +helm install registry-db charts/postgresql-cnpg \ + -n oan-registry -f charts/postgresql-cnpg/examples/registry-db.dev.yaml + +# 2. Keycloak — imports the sunbird-rc realm on first start +helm install keycloak charts/keycloak \ + -n oan-registry -f charts/keycloak/examples/keycloak.dev.yaml + +# 3. MANUAL: regenerate the admin-api client secret. +# The realm export ships it masked ("**********"), so it does not work as-is. +# kubectl -n oan-registry port-forward svc/keycloak 8080:8080 +# http://localhost:8080/auth/admin -> realm sunbird-rc -> Clients +# -> admin-api -> Credentials -> Regenerate Secret +# kubectl -n oan-registry create secret generic registry-keycloak \ +# --from-literal=keycloakAdminClientSecret='' \ +# --from-literal=registryDefaultUserPassword='' + +# 4. Registry +helm install registry charts/registry \ + -n oan-registry -f charts/registry/examples/registry.dev.yaml +``` + +Step 3 is unavoidable while the realm is imported from a masked export — see +[`keycloak`](../keycloak#the-two-phase-first-install). + +## Entity schemas + +The registry treats every `.json` under its schema directory as an entity it +serves APIs for. The chart ships `files/schemas/Participant.json` and mounts the +rendered ConfigMap read-only at +`/home/sunbirdrc/config/public/_schemas`. A `checksum/schemas` annotation rolls +the pod when a schema changes. + +| You want | Set | +|---|---| +| The shipped schemas | nothing — this is the default | +| Schemas managed outside the chart | `schemas.existingConfigMap: ` | +| Extra or overriding schemas | `schemas.inline: {Name.json: ''}` | + +The render fails if no schema matches, since a registry with no entity +definitions serves nothing. + +> **The participant schema is not settled.** Per engineering-tracker #33, it was +> built and verified against the local stack but has no design issue of its own +> (#43 was closed as a duplicate of #69, which covers a different schema). +> Expect it to change, and a schema change is a chart release. + +## Authentication + +`authenticationEnabled: true` — role checks on `Participant` only apply while +this is true. Turning it off makes every endpoint unauthenticated; it is a +local-debugging switch, not a deployment option. + +Three settings must agree with Keycloak, and getting any of them wrong produces +401/403 responses that read like a permissions bug rather than a configuration +one: + +| Setting | Must be | +|---|---| +| `keycloak.url` | Exactly the issuer Keycloak puts in the `iss` claim, including `/auth` | +| `keycloak.realm` | A realm that is actually imported (`sunbird-rc`) | +| `keycloak.adminClientSecret` | The **regenerated** `admin-api` secret, never the masked one from the export | + +`OAUTH2_RESOURCES_0_URI` is derived as `/realms/`, so it +cannot drift from `keycloak.url`. + +## Database + +Defaults match compose: the registry connects to database `registry` as the +`postgres` superuser, sharing that database with Keycloak. + +**CNPG disables superuser access by default**, so reproducing the compose +arrangement requires `enableSuperuserAccess: true` on the database chart — which +the example values set, and which makes CNPG generate +`Secret/-superuser` for the password. + +Once you are past reproducing compose, the better arrangement is: leave +superuser access off, connect the registry as the cluster's bootstrap owner +(`database.user: registry`, password from `Secret/-app`), and give +Keycloak its own database and role. Then no workload holds superuser rights. + +## Secrets + +Three values, none of which this chart ever renders: + +| Value | Setting | +|---|---| +| Database password | `database.passwordSecret` | +| `admin-api` client secret | `keycloak.adminClientSecret` | +| Default password for Keycloak users the registry creates | `defaultUserPasswordSecret` | + +This chart renders none of them — it only references Secrets, which are created +by whatever manages secrets in that environment. For dev that is a +`kubectl create secret`; the example values show the exact command. + +## Probes + +`/health`, matching the compose healthcheck. Sunbird's own chart probes +`/api/docs/swagger.json`, which additionally requires swagger to be enabled; +`/health` is the narrower check. The registry migrates its schema on first start, +so `startupProbe` carries the slow path. + +## Configuration + +See [`values.yaml`](./values.yaml) for the full commented schema and +[`examples/registry.dev.yaml`](./examples/registry.dev.yaml) for a +per-environment file. + +The `*_enabled: "false"` flags in `envConfig` are deliberate. Each one that is +turned on pulls in another Sunbird RC service that is not deployed — the registry +will start, then fail at the first request that needs it. + +## Versioning + +Every change needs a `version` bump in `Chart.yaml` and an entry in +[`CHANGELOG.md`](./CHANGELOG.md) — see [`CONVENTIONS.md`](../../CONVENTIONS.md). diff --git a/charts/registry/ci/lint-values.yaml b/charts/registry/ci/lint-values.yaml new file mode 100644 index 0000000..dbdaf7b --- /dev/null +++ b/charts/registry/ci/lint-values.yaml @@ -0,0 +1,18 @@ +# Minimum values that let `helm template` run in CI. Dummy references only - +# see examples/ for real per-environment configuration. +database: + host: registry-db-rw + passwordSecret: + name: registry-db-app +keycloak: + url: http://keycloak:8080/auth + adminClientSecret: + name: registry-keycloak +defaultUserPasswordSecret: + name: registry-keycloak + +# image.repository is intentionally empty in the chart, so CI supplies one. +image: + registry: ghcr.io + repository: sunbird-rc/sunbird-rc-core + tag: "v2.0.0" diff --git a/charts/registry/examples/registry.dev.yaml b/charts/registry/examples/registry.dev.yaml new file mode 100644 index 0000000..94fa2aa --- /dev/null +++ b/charts/registry/examples/registry.dev.yaml @@ -0,0 +1,84 @@ +# Example: the OAN participant registry, dev environment. +# +# helm install registry charts/registry \ +# -n oan-registry -f charts/registry/examples/registry.dev.yaml +# +# Install this LAST - it needs the database, and it needs the regenerated +# admin-api client secret from Keycloak. See README.md for the full order. + +# No fullnameOverride needed: the chart is named "registry", so a release named +# "registry" already produces Service/registry. + +# The image the compose stack runs, so dev deploys what was verified locally. +# The chart default is empty on purpose - see values.yaml. +image: + registry: ghcr.io + repository: sunbird-rc/sunbird-rc-core + tag: "v2.0.0" + +# Connects as the OWNER of its own database, not as the superuser. Compose uses +# `postgres` because everything shares one database there; in the cluster the +# registry owns `registry` and Keycloak owns `keycloak`, so neither needs +# superuser rights. +database: + host: registry-db-rw + port: 5432 + name: registry + user: registry + # The Secret the cluster's bootstrap owner was created from. + passwordSecret: + name: registry-db-app + key: password + +keycloak: + # Must match the keycloak release's in-cluster DNS, including /auth, and must + # equal the issuer Keycloak puts in its tokens. + url: http://keycloak.oan-registry.svc.cluster.local:8080/auth + realm: sunbird-rc + adminClientId: admin-api + clientId: registry-frontend + # The REGENERATED admin-api secret. Until ESO is available: + # kubectl -n oan-registry create secret generic registry-keycloak \ + # --from-literal=keycloakAdminClientSecret='' \ + # --from-literal=registryDefaultUserPassword='' + adminClientSecret: + name: registry-keycloak + key: keycloakAdminClientSecret + +authenticationEnabled: true + +keycloakUserSetPassword: true +defaultUserPasswordSecret: + name: registry-keycloak + key: registryDefaultUserPassword + +# Ships files/schemas/*.json (currently just Participant.json) +schemas: + existingConfigMap: "" + +ingress: + enabled: false + +resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + +commonLabels: + oan.in/environment: dev + +# Blocks startup until the database is reachable AND Keycloak's realm endpoint +# answers. The Keycloak check is HTTP against the realm, not TCP against the +# port: the realm is imported during Keycloak's first start, so the port opens +# well before the realm the registry needs exists. +waitFor: + enabled: true + database: true + keycloak: true + +# `helm test registry -n oan-registry` checks /health. +tests: + enabled: true diff --git a/charts/registry/examples/registry.prod.yaml b/charts/registry/examples/registry.prod.yaml new file mode 100644 index 0000000..1a56e3b --- /dev/null +++ b/charts/registry/examples/registry.prod.yaml @@ -0,0 +1,144 @@ +# Example: the OAN participant registry, production. +# +# helm install registry charts/registry \ +# -n oan-registry -f charts/registry/examples/registry.prod.yaml +# +# The differences from dev are all here, per issue #30's requirement that +# environment differences live only in values files. + +# --------------------------------------------------------------------------- +# TODO: set the production image. Left empty on purpose - the image OAN will +# actually deploy is not decided yet (upstream ghcr, a mirror in our own +# registry, or an OAN-built image), and guessing here would be a guess that +# looks like a decision. +# +# The chart FAILS TO RENDER while repository is empty, which is deliberate: an +# empty repository would otherwise produce "ghcr.io/:v2.0.0" and only fail later +# as an ImagePullBackOff. So this example is not installable until it is filled +# in. +# +# When filling it in, prefer a digest over a tag. A tag can be repointed at +# different bits; a digest cannot, which is what makes a rollback land on the +# same image it did before. +# +# image: +# registry: +# repository: +# tag: "v2.0.0" +# digest: "sha256:..." +# --------------------------------------------------------------------------- +image: + registry: "" + repository: "" + tag: "" + digest: "" + +# Two replicas minimum: one can be drained without an outage, and the PDB below +# needs somewhere to move traffic. +replicaCount: 2 + +database: + host: registry-db-rw + port: 5432 + name: registry + # Production connects as the database OWNER, not the superuser. This is the + # main security difference from dev: the registry has no rights beyond its own + # database. Requires the cluster's bootstrap owner to be `registry`. + user: registry + passwordSecret: + name: registry-db-app + key: password + +keycloak: + url: http://keycloak.oan-registry.svc.cluster.local:8080/auth + realm: sunbird-rc + adminClientId: admin-api + clientId: registry-frontend + adminClientSecret: + name: registry-keycloak + key: keycloakAdminClientSecret + +authenticationEnabled: true + +keycloakUserSetPassword: true +defaultUserPasswordSecret: + name: registry-keycloak + key: registryDefaultUserPassword + +# Secrets are created out of band; this chart renders none of them. The Secret +# named above must carry both keys: +# kubectl -n oan-registry create secret generic registry-keycloak \ +# --from-literal=keycloakAdminClientSecret='' \ +# --from-literal=registryDefaultUserPassword='' + +resources: + requests: + cpu: "1" + memory: 2Gi + limits: + cpu: "2" + memory: 4Gi + +# Survives a single node drain. +podDisruptionBudget: + enabled: true + minAvailable: 1 + +# Off until there is load data to size it from. Note every replica opens its own +# connection pool, so check the cluster's max_connections before enabling. +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + +# Spread replicas across nodes, so one node failing does not take both. +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: registry + app.kubernetes.io/instance: registry + +# Hardened. Verify the image runs as non-root before enabling in a new +# environment - a mismatch shows up as a permission error at startup. +podSecurityContext: + enabled: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + +securityContext: + enabled: true + allowPrivilegeEscalation: false + runAsNonRoot: true + # Left false: the JVM writes to /tmp. Enabling it needs an emptyDir mounted + # there via extraVolumes, and verification that nothing else writes to disk. + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL + +ingress: + enabled: false + # When the AWS Load Balancer Controller is installed: + # className: alb + # annotations: + # alb.ingress.kubernetes.io/scheme: internal + # alb.ingress.kubernetes.io/target-type: ip + # hosts: + # - host: registry.oan.example + # paths: + # - path: / + # pathType: Prefix + +envConfig: + logging.level.root: WARN + +commonLabels: + oan.in/environment: prod diff --git a/charts/registry/files/schemas/Participant.json b/charts/registry/files/schemas/Participant.json new file mode 100644 index 0000000..748beb7 --- /dev/null +++ b/charts/registry/files/schemas/Participant.json @@ -0,0 +1,170 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "Participant": { + "$ref": "#/definitions/Participant" + } + }, + "required": [ + "Participant" + ], + "title": "Participant", + "definitions": { + "Participant": { + "$id": "#/properties/Participant", + "type": "object", + "title": "OAN network participant", + "comment": "Flat by design: nested objects would each become their own graph node with their own osid. Every field here is a scalar or an array of scalars, so one participant is one record with one osid.", + "required": [ + "participant_id", + "display_name", + "roles", + "status", + "record_version", + "updated_at", + "signing_public_key", + "endpoint_url", + "domain" + ], + "additionalProperties": false, + "properties": { + "participant_id": { + "type": "string", + "title": "Business identifier of the participant, unique across the network" + }, + "display_name": { + "type": "string", + "title": "Human-readable name of the participant" + }, + "roles": { + "type": "array", + "title": "Roles this participant holds on the network", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "network_operator", + "provider", + "consumer" + ] + } + }, + "status": { + "type": "string", + "title": "Lifecycle status of the participant", + "enum": [ + "active", + "inactive" + ] + }, + "domain": { + "type": "string", + "title": "Kind of participant, e.g. weather, market, credit, advisory" + }, + "record_version": { + "type": "integer", + "title": "Version counter of this record, starts at 1", + "minimum": 1 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "ISO-8601 timestamp of the last update" + }, + "endpoint_url": { + "type": "string", + "title": "Base URL where requests to this participant are sent once resolved" + }, + "endpoint_type": { + "type": "string", + "title": "What this endpoint serves", + "enum": [ + "onix", + "callback", + "api" + ] + }, + "signing_public_key": { + "type": "string", + "title": "Base64-encoded public key a receiver uses to verify this participant's signed requests" + }, + "signing_algorithm": { + "type": "string", + "title": "Algorithm of the signing key pair", + "enum": [ + "ed25519" + ] + }, + "key_valid_from": { + "type": "string", + "format": "date-time", + "title": "ISO-8601 timestamp from which the signing key is usable" + }, + "key_valid_until": { + "type": "string", + "format": "date-time", + "title": "ISO-8601 timestamp after which the signing key must not be trusted" + }, + "osid": { + "type": "string", + "title": "System id, assigned by the registry - accepted on update, never set by a client" + }, + "osOwner": { + "type": "array", + "items": { + "type": "string" + }, + "title": "System field: keycloak subject(s) that own this record" + }, + "osCreatedAt": { + "type": "string", + "title": "System field: creation timestamp" + }, + "osUpdatedAt": { + "type": "string", + "title": "System field: last update timestamp" + }, + "osCreatedBy": { + "type": "string", + "title": "System field: creator" + }, + "osUpdatedBy": { + "type": "string", + "title": "System field: last updater" + }, + "_status": { + "type": [ + "boolean", + "string", + "null" + ], + "title": "System field: storage-level soft-delete flag" + } + } + } + }, + "_osConfig": { + "systemFields": [ + "osCreatedAt", + "osUpdatedAt", + "osCreatedBy", + "osUpdatedBy" + ], + "uniqueIndexFields": [ + "participant_id" + ], + "indexFields": [ + "status", + "domain" + ], + "roles": [ + "admin", + "network_operator" + ], + "inviteRoles": [ + "admin", + "network_operator" + ] + } +} \ No newline at end of file diff --git a/charts/registry/templates/NOTES.txt b/charts/registry/templates/NOTES.txt new file mode 100644 index 0000000..6d6eee5 --- /dev/null +++ b/charts/registry/templates/NOTES.txt @@ -0,0 +1,35 @@ +{{ .Chart.Name }} installed as release "{{ .Release.Name }}". + +Resources in namespace "{{ .Release.Namespace }}": + - Deployment/{{ include "registry.fullname" . }} ({{ .Values.replicaCount }} replica(s)) + - Service/{{ include "registry.fullname" . }} ({{ .Values.service.type }} on port {{ .Values.service.port }}) + - ConfigMap/{{ include "registry.envConfigMapName" . }} +{{- if include "registry.renderSchemasConfigMap" . }} + - ConfigMap/{{ include "registry.schemasConfigMapName" . }} (entity schemas) +{{- end }} +{{- if .Values.serviceAccount.enabled }} + - ServiceAccount/{{ include "registry.serviceAccountName" . }} +{{- end }} +{{- if .Values.ingress.enabled }} + - Ingress/{{ include "registry.fullname" . }} +{{- end }} + +Wired to: + database: {{ include "registry.jdbcUrl" . }} (as {{ .Values.database.user }}) + keycloak: {{ .Values.keycloak.url }} (realm {{ .Values.keycloak.realm }}) + auth: {{ if .Values.authenticationEnabled }}enabled - role checks apply{{ else }}DISABLED - no role checks{{ end }} + +In-cluster endpoint: + http://{{ include "registry.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} + +Check it: + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "registry.fullname" . }} + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "registry.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + curl http://localhost:{{ .Values.service.port }}/health + +If authenticated calls come back 401 or 403, check these three in order - they +are the usual causes, and none of them look like an auth problem in the logs: + 1. keycloak.url must match the issuer Keycloak puts in its tokens, exactly. + 2. keycloak.adminClientSecret must be the REGENERATED admin-api secret; the + one in the realm export is masked and never works. + 3. The realm ({{ .Values.keycloak.realm }}) must actually be imported. diff --git a/charts/registry/templates/_helpers.tpl b/charts/registry/templates/_helpers.tpl new file mode 100644 index 0000000..d2df577 --- /dev/null +++ b/charts/registry/templates/_helpers.tpl @@ -0,0 +1,181 @@ +{{/* +# ============================================================================ +# REGISTRY (SUNBIRD RC) CHART HELPERS +# Owner: OpenAgriNet Engineering Team +# Purpose: chart-local helpers delegating to oan-common, plus the database, +# Keycloak and schema wiring the registry needs. +# ============================================================================ +*/}} + +{{- define "registry.name" -}} +{{- include "oan-common.name" . -}} +{{- end }} + +{{- define "registry.fullname" -}} +{{- include "oan-common.fullname" . -}} +{{- end }} + +{{- define "registry.labels" -}} +{{- include "oan-common.labels" . -}} +{{- end }} + +{{- define "registry.selectorLabels" -}} +{{- include "oan-common.selectorLabels" . -}} +{{- end }} + +{{- define "registry.serviceAccountName" -}} +{{- include "oan-common.serviceAccount.name" . -}} +{{- end }} + +{{- define "registry.image" -}} +{{- include "oan-common.image" . -}} +{{- end }} + +{{- define "registry.envConfigMapName" -}} +{{- include "oan-common.envConfigMapName" . -}} +{{- end }} + +{{/* +JDBC URL for the registry database. +*/}} +{{- define "registry.jdbcUrl" -}} +{{- $db := .Values.database -}} +{{- printf "jdbc:postgresql://%s:%v/%s" $db.host $db.port $db.name -}} +{{- end }} + +{{/* +Name of the ConfigMap holding the entity schemas. An existing ConfigMap wins, +so schemas can be managed outside this chart. +*/}} +{{- define "registry.schemasConfigMapName" -}} +{{- if .Values.schemas.existingConfigMap -}} +{{- .Values.schemas.existingConfigMap -}} +{{- else -}} +{{- printf "%s-schemas" (include "registry.fullname" .) -}} +{{- end -}} +{{- end }} + +{{/* +Emits "true" when this chart renders the schemas ConfigMap itself. +*/}} +{{- define "registry.renderSchemasConfigMap" -}} +{{- if not .Values.schemas.existingConfigMap -}} +{{- true -}} +{{- end -}} +{{- end }} + +{{/* +The schema files shipped with the chart, as ConfigMap data entries. The registry +reads every .json under its schema directory as an entity definition, so an +empty set means a registry that knows about no entities at all. +*/}} +{{- define "registry.schemasData" -}} +{{- $files := .Files.Glob .Values.schemas.filesGlob -}} +{{- if and (not $files) (not .Values.schemas.inline) -}} +{{- fail (printf "%s: no schema files matched %q in the chart and schemas.inline is empty. The registry needs at least one entity definition." .Chart.Name .Values.schemas.filesGlob) -}} +{{- end -}} +{{- range $path, $_ := $files }} +{{ base $path }}: |- + {{- $.Files.Get $path | nindent 2 }} +{{- end }} +{{- range $name, $content := .Values.schemas.inline }} +{{ $name }}: |- + {{- $content | nindent 2 }} +{{- end }} +{{- end }} + +{{/* +Environment the registry needs beyond the flags in envConfig: everything derived +from the database and Keycloak settings, plus the three secret values. + +Mirrors registry/docker-compose.yml. `sunbird_sso_url` and OAUTH2_RESOURCES_0_URI +must agree with the issuer Keycloak actually puts in its tokens, or every request +is rejected as unauthorised. +*/}} +{{- define "registry.env" -}} +{{- $db := .Values.database -}} +{{- $kc := .Values.keycloak -}} +{{- if not $db.host }} +{{- fail (printf "%s: database.host is required - point it at the PostgreSQL primary service, e.g. registry-db-rw" .Chart.Name) }} +{{- end }} +{{- if not $db.passwordSecret.name }} +{{- fail (printf "%s: database.passwordSecret.name is required - this chart renders no passwords" .Chart.Name) }} +{{- end }} +{{- if not $kc.url }} +{{- fail (printf "%s: keycloak.url is required, including the /auth context path, e.g. http://keycloak:8080/auth" .Chart.Name) }} +{{- end }} +{{- if not $kc.adminClientSecret.name }} +{{- fail (printf "%s: keycloak.adminClientSecret.name is required. Regenerate the admin-api client secret in the Keycloak console after the realm import, then store it." .Chart.Name) }} +{{- end }} +{{- if and .Values.keycloakUserSetPassword (not .Values.defaultUserPasswordSecret.name) }} +{{- fail (printf "%s: keycloakUserSetPassword is true, so defaultUserPasswordSecret.name is required - it is the password the registry sets on Keycloak users it creates" .Chart.Name) }} +{{- end }} +{{- $kcUrl := $kc.url | trimSuffix "/" }} +- name: connectionInfo_uri + value: {{ include "registry.jdbcUrl" . | quote }} +- name: connectionInfo_username + value: {{ $db.user | quote }} +- name: connectionInfo_password + valueFrom: + secretKeyRef: + name: {{ $db.passwordSecret.name }} + key: {{ $db.passwordSecret.key }} +- name: authentication_enabled + value: {{ .Values.authenticationEnabled | quote }} +- name: sunbird_sso_realm + value: {{ $kc.realm | quote }} +- name: sunbird_sso_url + value: {{ $kcUrl | quote }} +- name: OAUTH2_RESOURCES_0_URI + value: {{ printf "%s/realms/%s" $kcUrl $kc.realm | quote }} +- name: OAUTH2_RESOURCES_0_PROPERTIES_ROLES_PATH + value: {{ $kc.rolesPath | quote }} +- name: identity_provider + value: {{ $kc.identityProvider | quote }} +- name: sunbird_sso_admin_client_id + value: {{ $kc.adminClientId | quote }} +- name: sunbird_sso_client_id + value: {{ $kc.clientId | quote }} +- name: sunbird_sso_admin_client_secret + valueFrom: + secretKeyRef: + name: {{ $kc.adminClientSecret.name }} + key: {{ $kc.adminClientSecret.key }} +- name: sunbird_keycloak_user_set_password + value: {{ .Values.keycloakUserSetPassword | quote }} +{{- with .Values.defaultUserPasswordSecret.name }} +- name: sunbird_keycloak_user_password + valueFrom: + secretKeyRef: + name: {{ . }} + key: {{ $.Values.defaultUserPasswordSecret.key }} +{{- end }} +{{- with (include "oan-common.env" . | trim) }} +{{ . }} +{{- end }} +{{- end }} + +{{/* +Dependency waits, derived from this chart's own settings so there is nothing to +keep in sync. + +The Keycloak check is HTTP against the realm endpoint, not TCP against the port: +the realm is imported during Keycloak's first start, so the port opens well +before the realm the registry needs actually exists. A TCP check would pass too +early and the registry would start against a Keycloak that cannot yet issue it a +usable token. +*/}} +{{- define "registry.waitFor" -}} +{{- $tcp := list -}} +{{- $http := list -}} +{{- if .Values.waitFor.database }} +{{- $tcp = append $tcp (dict "name" "database" "host" .Values.database.host "port" .Values.database.port) -}} +{{- end -}} +{{- if .Values.waitFor.keycloak }} +{{- $url := printf "%s/realms/%s" (.Values.keycloak.url | trimSuffix "/") .Values.keycloak.realm -}} +{{- $http = append $http (dict "name" "keycloak" "url" $url) -}} +{{- end -}} +{{- $tcp = concat $tcp (.Values.waitFor.extraTcp | default list) -}} +{{- $http = concat $http (.Values.waitFor.extraHttp | default list) -}} +{{- include "oan-common.waitFor" (dict "ctx" . "tcp" $tcp "http" $http) -}} +{{- end }} diff --git a/charts/registry/templates/configmap-schemas.yaml b/charts/registry/templates/configmap-schemas.yaml new file mode 100644 index 0000000..d2ed8c8 --- /dev/null +++ b/charts/registry/templates/configmap-schemas.yaml @@ -0,0 +1,18 @@ +{{- if include "registry.renderSchemasConfigMap" . }} +{{/* +Entity definitions, mounted at schemas.mountPath. The registry treats every +.json here as an entity it serves APIs for. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "registry.schemasConfigMapName" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + {{- include "registry.schemasData" . | trim | nindent 2 }} +{{- end }} diff --git a/charts/registry/templates/configmap.yaml b/charts/registry/templates/configmap.yaml new file mode 100644 index 0000000..aa8ade7 --- /dev/null +++ b/charts/registry/templates/configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "registry.envConfigMapName" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- with (include "oan-common.envConfigMapData" . | trim) }} +data: + {{- . | nindent 2 }} +{{- else }} +data: {} +{{- end }} diff --git a/charts/registry/templates/deployment.yaml b/charts/registry/templates/deployment.yaml new file mode 100644 index 0000000..c03f6f5 --- /dev/null +++ b/charts/registry/templates/deployment.yaml @@ -0,0 +1,108 @@ +apiVersion: {{ include "oan-common.deployment.apiVersion" . }} +kind: Deployment +metadata: + name: {{ include "registry.fullname" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "registry.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "registry.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/env-config: {{ include "oan-common.checksumAnnotation" . }} + checksum/schemas: {{ include "registry.schemasData" . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.serviceAccount.enabled }} + serviceAccountName: {{ include "registry.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + {{- end }} + {{- with (include "oan-common.imagePullSecrets" . | trim) }} + {{- . | nindent 6 }} + {{- end }} + {{- with (include "oan-common.podSecurityContext" .) }} + securityContext: + {{- . | nindent 8 }} + {{- end }} + {{- $init := include "registry.waitFor" . | trim }} + {{- $extraInit := "" }} + {{- with .Values.extraInitContainers }} + {{- $extraInit = toYaml . | trim }} + {{- end }} + {{- if or $init $extraInit }} + initContainers: + {{- with $init }} + {{- . | nindent 8 }} + {{- end }} + {{- with $extraInit }} + {{- . | nindent 8 }} + {{- end }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: {{ include "registry.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with (include "oan-common.securityContext" .) }} + securityContext: + {{- . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + {{- include "registry.env" . | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "registry.envConfigMapName" . }} + {{- range .Values.envFromSecrets }} + - secretRef: + name: {{ . }} + {{- end }} + {{- with (include "oan-common.probes" . | trim) }} + {{- . | nindent 10 }} + {{- end }} + resources: + {{- include "oan-common.resources" . | nindent 12 }} + volumeMounts: + - name: schemas + mountPath: {{ .Values.schemas.mountPath }} + readOnly: true + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: schemas + configMap: + name: {{ include "registry.schemasConfigMapName" . }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/registry/templates/hpa.yaml b/charts/registry/templates/hpa.yaml new file mode 100644 index 0000000..4157d8d --- /dev/null +++ b/charts/registry/templates/hpa.yaml @@ -0,0 +1,51 @@ +{{- if .Values.autoscaling.enabled }} +{{/* +The registry is stateless - it holds no session state and writes everything to +PostgreSQL - so it scales horizontally. Two things to keep in mind: + + - Every replica opens its own connection pool, so scaling out multiplies + connections against the database. Check `max_connections` on the cluster + before raising maxReplicas much. + - Autoscaling takes over `replicas`, so replicaCount is ignored once this is + enabled. The Deployment deliberately omits `replicas` in that case, or the + two would fight on every reconcile. +*/}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "registry.fullname" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "registry.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- with .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/registry/templates/ingress.yaml b/charts/registry/templates/ingress.yaml new file mode 100644 index 0000000..285444a --- /dev/null +++ b/charts/registry/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: {{ include "oan-common.ingress.apiVersion" . }} +kind: Ingress +metadata: + name: {{ include "registry.fullname" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.ingress.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- range . }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "registry.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/registry/templates/poddisruptionbudget.yaml b/charts/registry/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..d9ae912 --- /dev/null +++ b/charts/registry/templates/poddisruptionbudget.yaml @@ -0,0 +1,33 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{/* +Caps how many pods can be voluntarily disrupted at once - node drains, cluster +upgrades, autoscaler scale-down. + +With replicaCount: 1 a PDB of minAvailable: 1 blocks drains entirely, because +evicting the only pod would breach it. That is why this is disabled by default +and should be enabled together with replicaCount >= 2. +*/}} +{{- if and (eq (int .Values.replicaCount) 1) (not .Values.podDisruptionBudget.allowSingleReplica) }} +{{- fail (printf "%s: podDisruptionBudget is enabled with replicaCount 1, which blocks node drains entirely. Raise replicaCount to 2+, or set podDisruptionBudget.allowSingleReplica=true if you accept that." .Chart.Name) }} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "registry.fullname" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with .Values.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ . }} + {{- end }} + {{- with .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ . }} + {{- end }} + selector: + matchLabels: + {{- include "registry.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/registry/templates/service.yaml b/charts/registry/templates/service.yaml new file mode 100644 index 0000000..13ccd91 --- /dev/null +++ b/charts/registry/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "registry.fullname" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.service.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "registry.selectorLabels" . | nindent 4 }} diff --git a/charts/registry/templates/serviceaccount.yaml b/charts/registry/templates/serviceaccount.yaml new file mode 100644 index 0000000..b80401c --- /dev/null +++ b/charts/registry/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if include "oan-common.serviceAccount.enabled" . }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "registry.serviceAccountName" . }} + labels: + {{- include "registry.labels" . | nindent 4 }} + {{- with (merge (dict) .Values.serviceAccount.annotations (.Values.commonAnnotations | default dict)) }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/charts/registry/templates/tests/test-connection.yaml b/charts/registry/templates/tests/test-connection.yaml new file mode 100644 index 0000000..b6ad6e0 --- /dev/null +++ b/charts/registry/templates/tests/test-connection.yaml @@ -0,0 +1,36 @@ +{{- if .Values.tests.enabled }} +{{/* +`helm test` check: the registry answers on /health. + +Deliberately does not test an authenticated endpoint: that needs a real token, +and a failure there would be ambiguous between "registry is broken" and +"Keycloak wiring is wrong". Health tells you the pod is serving; the NOTES +explain how to check the auth chain. +*/}} +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "registry.fullname" . }}-test-connection + labels: + {{- include "registry.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: test + image: {{ printf "%s/%s:%s" .Values.tests.image.registry .Values.tests.image.repository .Values.tests.image.tag | quote }} + command: + - /bin/sh + - -c + - | + set -e + url="http://{{ include "registry.fullname" . }}:{{ .Values.service.port }}/health" + echo "GET $url" + wget -q -O- --timeout=10 "$url" + echo "" + echo "OK: registry is serving /health" + resources: + {{- toYaml .Values.tests.resources | nindent 8 }} +{{- end }} diff --git a/charts/registry/values.yaml b/charts/registry/values.yaml new file mode 100644 index 0000000..f85b7cd --- /dev/null +++ b/charts/registry/values.yaml @@ -0,0 +1,353 @@ +# ============================================================================ +# registry - default values +# +# The OAN participant registry, on Sunbird RC core. Defaults mirror the verified +# compose stack (registry/docker-compose.yml): participant records and lookup +# only, with every optional Sunbird RC subsystem (credentials, DIDs, +# certificates, file storage, notifications, webhooks) switched off. +# +# Depends on two things already running: +# - PostgreSQL -> database.host +# - Keycloak -> keycloak.url +# +# Minimum you must set: +# - database.host, database.passwordSecret.name +# - keycloak.url, keycloak.adminClientSecret.name +# - defaultUserPasswordSecret.name +# ============================================================================ + +replicaCount: 1 + +# --------------------------------------------------------------------------- +# Image +# +# repository is intentionally EMPTY: the image OAN deploys is not settled yet, and +# a default here would look like a decision that has not been made. The render +# fails while it is empty rather than producing "ghcr.io/:v2.0.0", which would +# only surface later as an ImagePullBackOff. +# +# The compose stack runs ghcr.io/sunbird-rc/sunbird-rc-core:v2.0.0, which is what +# the dev example values use. Set this per environment. +# --------------------------------------------------------------------------- +image: + registry: ghcr.io + repository: "" + # RELEASE_VERSION in the compose stack + tag: "v2.0.0" + digest: "" + pullPolicy: IfNotPresent + pullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + enabled: true + name: "" + annotations: {} + automountServiceAccountToken: true + +service: + type: ClusterIP + port: 8081 + targetPort: 8081 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: registry.local + paths: + - path: / + pathType: Prefix + tls: [] + +# --------------------------------------------------------------------------- +# Database +# +# Compose runs the registry as the postgres superuser against the same database +# Keycloak uses. On a cluster, prefer a dedicated owner role: set user to the +# CNPG cluster's bootstrap owner and give Keycloak its own database. +# --------------------------------------------------------------------------- +database: + # PostgreSQL primary service, e.g. "registry-db-rw" + host: "" + port: 5432 + name: registry + user: postgres + # Secret holding the database password. Required. + passwordSecret: + name: "" + key: password + +# --------------------------------------------------------------------------- +# Keycloak +# +# `url` must include the /auth context path the legacy Keycloak serves under, +# and must be the URL Keycloak itself puts in the `iss` claim. If they disagree, +# every authenticated request is rejected - and the failure looks like a +# permissions problem, not a configuration one. +# +# The keycloak chart prints the exact value to use in its NOTES. +# --------------------------------------------------------------------------- +keycloak: + # e.g. http://keycloak.oan-registry.svc.cluster.local:8080/auth + url: "" + realm: sunbird-rc + # Confidential client the registry uses for admin operations on Keycloak + adminClientId: admin-api + # Client tokens are issued to + clientId: registry-frontend + rolesPath: realm_access.roles + identityProvider: dev.sunbirdrc.auth.keycloak.KeycloakProviderImpl + # Secret holding the admin-api client secret. + # + # The realm export ships this masked, so it is NOT usable until someone + # regenerates it: Keycloak console -> realm sunbird-rc -> Clients -> admin-api + # -> Credentials -> Regenerate Secret. First install is two-phase because of + # this. + adminClientSecret: + name: "" + key: keycloakAdminClientSecret + +# Role checks on Participant only apply while this is true. +authenticationEnabled: true + +# Whether the registry sets a password on Keycloak users it creates. +keycloakUserSetPassword: true +# The password it sets. Required while keycloakUserSetPassword is true. +defaultUserPasswordSecret: + name: "" + key: registryDefaultUserPassword + +# --------------------------------------------------------------------------- +# Entity schemas +# +# Every .json under the registry's schema directory is an entity definition. +# The chart ships the participant schema and mounts it at mountPath. +# +# NOTE: this schema was built and verified against the local stack, but per +# engineering-tracker #33 it has no design issue of its own yet. Expect it to +# change - a schema change is a chart release. +# --------------------------------------------------------------------------- +schemas: + filesGlob: "files/schemas/*.json" + mountPath: /home/sunbirdrc/config/public/_schemas + # Manage schemas outside this chart instead of shipping them + existingConfigMap: "" + # Extra or overriding schemas, as filename -> JSON string + inline: {} + +resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + +# --------------------------------------------------------------------------- +# Probes +# +# /health is what the compose healthcheck uses. Sunbird's own Helm chart probes +# /api/docs/swagger.json instead, which additionally requires swagger to be +# enabled; /health is the narrower, more honest check. +# +# The registry runs schema migrations on first start, so startupProbe carries +# the slow path. +# --------------------------------------------------------------------------- +startupProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 10 + failureThreshold: 30 + +livenessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 0 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 5 + +readinessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + +podSecurityContext: + enabled: false + fsGroup: 1000 + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + +securityContext: + enabled: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + capabilities: + drop: + - ALL + +nodeSelector: {} +tolerations: [] +affinity: {} +podLabels: {} +podAnnotations: {} + +# --------------------------------------------------------------------------- +# Dependency waiting +# +# Kubernetes has no equivalent of compose's `depends_on: condition: +# service_healthy`. Without these init containers the pod starts before its +# dependencies are up, fails, and crashloops with backoff - which recovers on +# its own but makes a first install look broken. +# --------------------------------------------------------------------------- +waitFor: + enabled: true + image: + registry: docker.io + repository: busybox + tag: "1.37" + pullPolicy: IfNotPresent + timeoutSeconds: 300 + intervalSeconds: 3 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + # Wait for the database. Host and port come from `database` above. + database: true + # Wait for Keycloak's REALM endpoint, derived from keycloak.url and + # keycloak.realm - not just its port. The realm is imported during Keycloak's + # first start, so the port opens well before the realm exists, and a TCP check + # would let the registry start against a Keycloak that cannot yet issue it a + # usable token. + keycloak: true + extraTcp: [] + extraHttp: [] + +# Extra init containers, appended after the waitFor ones. Rendered verbatim. +extraInitContainers: [] + +# Extra volumes and mounts, rendered verbatim. For certificates, extra config, +# or a writable scratch directory when readOnlyRootFilesystem is enabled. +extraVolumes: [] +extraVolumeMounts: [] + +# --------------------------------------------------------------------------- +# PodDisruptionBudget +# +# Caps voluntary disruptions - node drains, cluster upgrades, autoscaler +# scale-down. Disabled by default because with a single replica a PDB of +# minAvailable: 1 blocks drains entirely; the render fails if you enable it +# anyway without acknowledging that. +# --------------------------------------------------------------------------- +podDisruptionBudget: + enabled: false + minAvailable: 1 + # Set one or the other, not both. + maxUnavailable: "" + # Enable a PDB with a single replica anyway, accepting that node drains block. + allowSingleReplica: false + +# --------------------------------------------------------------------------- +# `helm test` checks +# +# helm test -n +# +# Runs after install to confirm the service actually answers, which is the +# difference between "the pod is running" and "the deployment works". +# --------------------------------------------------------------------------- +tests: + enabled: true + image: + registry: docker.io + repository: busybox + tag: "1.37" + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 50m + memory: 32Mi + +# --------------------------------------------------------------------------- +# Autoscaling +# +# The registry is stateless, so it scales horizontally. Note that every replica +# opens its own connection pool, so check the database's max_connections before +# raising maxReplicas much. When enabled, replicaCount is ignored. +# --------------------------------------------------------------------------- +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + # Set to a number to also scale on memory. + targetMemoryUtilizationPercentage: "" + # Scale-up/scale-down tuning, rendered verbatim into spec.behavior. + behavior: {} + + +# --------------------------------------------------------------------------- +# ENVIRONMENT CONFIGURATION +# +# Rendered into a ConfigMap and injected with envFrom. These are exactly the +# non-secret, non-derived variables from the compose stack. Database and +# Keycloak variables are derived from the sections above instead, so they are +# not repeated here. +# +# The *_enabled flags are all false on purpose: this deployment is participant +# records and lookup only. Turning any of them on pulls in another Sunbird RC +# service that is not deployed - the registry will start and then fail at the +# first request that needs it. +# +# NEVER put secrets here. +# --------------------------------------------------------------------------- +envConfig: + search_providerName: dev.sunbirdrc.registry.service.NativeSearchService + manager_type: DefinitionsManager + expand_reference: "false" + encryption_enabled: "false" + event_enabled: "false" + idgen_enabled: "false" + claims_enabled: "false" + did_enabled: "false" + signature_enabled: "false" + certificate_enabled: "false" + filestorage_enabled: "false" + notification_enabled: "false" + notification_async_enabled: "false" + async_enabled: "false" + webhook_enabled: "false" + registry_base_apis_enable: "false" + swagger_title: OAN Registry + logging.level.root: INFO + +# Extra env vars from specific Secret keys, beyond the three wired above. +secretEnv: {} +extraEnv: [] +envFromSecrets: [] + +commonLabels: {} +commonAnnotations: {} diff --git a/scripts/lint-charts.sh b/scripts/lint-charts.sh new file mode 100755 index 0000000..dddf9a2 --- /dev/null +++ b/scripts/lint-charts.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Lint and render every chart under charts/. +# +# Run locally exactly as CI runs it: +# ./scripts/lint-charts.sh +# +# Charts depending on oan-common via file://../oan-common carry no committed +# dependency artifact, so the dependency is rebuilt here before linting. That +# also means a local edit to oan-common is only picked up after this runs (or +# after `helm dependency update `). +# +# A chart whose defaults deliberately fail the render - because it requires a +# database host or a secret reference that has no safe default - supplies the +# minimum needed to template in ci/lint-values.yaml. Every file matching +# ci/*-values.yaml is rendered as a separate case, so a chart can cover several +# configurations. +set -euo pipefail + +cd "$(dirname "$0")/.." + +failed=0 + +for chart_dir in charts/*/; do + chart_dir="${chart_dir%/}" + [[ -f "$chart_dir/Chart.yaml" ]] || continue + + name="$(basename "$chart_dir")" + chart_type="$(helm show chart "$chart_dir" 2>/dev/null | awk '/^type:/ {print $2}')" + chart_type="${chart_type:-application}" + + echo "==> $name (type: $chart_type)" + + if grep -q '^dependencies:' "$chart_dir/Chart.yaml"; then + helm dependency update "$chart_dir" >/dev/null + fi + + # Render once per ci/*-values.yaml, or once with plain defaults if there are none. + shopt -s nullglob + ci_values=("$chart_dir"/ci/*-values.yaml) + shopt -u nullglob + + # Pass the same values to lint. Without them, lint renders bare defaults and + # reports the chart's own "this value is required" failures as [INFO] Fail + # lines - noise that looks like a broken chart but is the guardrail working. + # Written this way for bash 3.2 (macOS), where "${empty_array[@]}" under + # `set -u` is an unbound-variable error rather than an empty expansion. + lint_args=() + if [[ ${#ci_values[@]} -gt 0 ]]; then + for values in "${ci_values[@]}"; do + lint_args+=(-f "$values") + done + fi + + if ! helm lint --strict "$chart_dir" "${lint_args[@]+"${lint_args[@]}"}"; then + echo "!!! helm lint failed for $name" + failed=1 + continue + fi + + # Library charts render no resources of their own, so there is nothing to template. + if [[ "$chart_type" == "library" ]]; then + continue + fi + + if [[ ${#ci_values[@]} -eq 0 ]]; then + if ! helm template "$name" "$chart_dir" >/dev/null; then + echo "!!! helm template failed for $name" + failed=1 + fi + else + for values in "${ci_values[@]}"; do + echo " render: $(basename "$values")" + if ! helm template "$name" "$chart_dir" -f "$values" >/dev/null; then + echo "!!! helm template failed for $name with $(basename "$values")" + failed=1 + fi + done + fi +done + +if [[ "$failed" -ne 0 ]]; then + echo "chart validation FAILED" + exit 1 +fi + +echo "all charts linted and rendered successfully"