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 new file mode 100644 index 0000000..948db04 --- /dev/null +++ b/.gitignore @@ -0,0 +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/postman-collection/api-collection.json b/postman-collection/api-collection.json new file mode 100644 index 0000000..bf322c2 --- /dev/null +++ b/postman-collection/api-collection.json @@ -0,0 +1,419 @@ +{ + "info": { + "_postman_id": "7458e3b4-dd16-4ec1-84ce-006f2e423183", + "name": "OAN API \u2014 publish, discover, select", + "description": "The whole stack from Postman: the registry writes that set it up, updates for the fields that change, the reads that show what is there, and the three flows that use it.\n\nONE FOLDER PER CAPABILITY. Inside each, Publish seeds the catalogue Discover looks for, so run a folder top to bottom the first time; after that any request works on its own.\n\nTHERE ARE NO REGISTRY REQUESTS HERE, deliberately. The registry has no route through the gateway and publishes on loopback only, so nothing in a shared collection could reach it. bin/setup.py seeds all of it -- five participants and both capability bindings -- from the same .env the adapter configs are rendered from, which is what keeps the two from disagreeing.\n\nNOTHING HERE CHANGES THE STACK ON A DEFAULT RUN. The creates report \"already present\" once setup.py has run, because the registry is append-only. The updates write back the same values the variables already hold -- edit a variable to actually change a row.\n\nTHE UPDATES NEED A SCHEMA THAT PERMITS THEM. The registry re-validates the merged document on update, and that document carries its own osid and osUpdatedAt, so additionalProperties: false rejects the registry's own fields. Participant and ProviderSchema in config/registry/schemas/ must allow additional properties, and the registry reads schemas only at startup.\n\nEvery URL here is loopback, because the deployment publishes these ports on the VM's loopback only and nothing else is routable. Open a tunnel first and the defaults work unchanged:\n\n ssh -L 9202:127.0.0.1:9202 -L 9200:127.0.0.1:9200 \\\n -L 8081:127.0.0.1:8081 -L 8080:127.0.0.1:8080 -N you@the-vm\n\nNo VM hostname or address appears in this file.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "42114807" + }, + "item": [ + { + "name": "1. WeatherObservation", + "item": [ + { + "name": "1. Publish", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"200\", () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test(\"catalog/on_publish\", () => pm.expect(b.context.action).to.eql(\"catalog/on_publish\"));", + "pm.test(\"ACCEPTED\", () => pm.expect(b.message.results[0].status).to.eql(\"ACCEPTED\"));" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": {\n \"action\": \"catalog/publish\",\n \"version\": \"2.0.0\",\n \"transactionId\": \"b1f0c2d3-4e5a-4b6c-8d9e-0f1a2b3c4d5e\",\n \"messageId\": \"c2e1d3f4-5a6b-4c7d-9e0f-1a2b3c4d5e6f\",\n \"timestamp\": \"2026-09-01T06:00:00Z\"\n },\n \"message\": {\n \"catalogs\": [\n {\n \"id\": \"cat-mausamgram-point-forecast-v2\",\n \"isActive\": true,\n \"descriptor\": {\n \"code\": \"IMD-NWP-01\",\n \"name\": \"IMD Mausamgram point weather forecast\",\n \"shortDesc\": \"Five-day point weather forecast from IMD Mausamgram NWP\",\n \"longDesc\": \"Rainfall, temperature, humidity and wind forecast for a single point, five days ahead, from the India Meteorological Department's Mausamgram numerical weather prediction service.\"\n },\n \"provider\": {\n \"id\": \"{{providerId}}\",\n \"descriptor\": {\n \"code\": \"IMD-NWP-01\",\n \"name\": \"IMD Mausamgram NWP\"\n }\n },\n \"validity\": {\n \"startDate\": \"2026-01-01T00:00:00Z\",\n \"endDate\": \"2027-12-31T23:59:59Z\"\n },\n \"resources\": [\n {\n \"id\": \"res:mausamgram:point-forecast\",\n \"descriptor\": {\n \"code\": \"WX-POINT-FORECAST\",\n \"name\": \"Point weather forecast\",\n \"shortDesc\": \"Five-day weather forecast for a single point\",\n \"longDesc\": \"Daily rainfall, minimum and maximum temperature, minimum and maximum humidity and wind speed for a requested latitude and longitude.\"\n },\n \"resourceAttributes\": {\n \"@context\": \"https://raw.githubusercontent.com/OpenAgriNet/network-specs/schema-packs-v0.1/schema/WeatherObservation/v0.1/context.jsonld\",\n \"@type\": \"openagrinet:WeatherObservation\",\n \"informationMode\": \"OnDemand\",\n \"subjectCategories\": [\n \"Weather\"\n ],\n \"supportedObservationTypes\": [\n \"Forecast\"\n ],\n \"supportedParameters\": [\n \"Rainfall\",\n \"Temperature\",\n \"Humidity\",\n \"WindSpeed\",\n \"WindDirection\",\n \"Alert\"\n ],\n \"forecastHorizon\": \"P5D\",\n \"updateFrequency\": \"PT24H\",\n \"geographicGranularities\": [\n \"Point\"\n ],\n \"languages\": [\n \"en\"\n ],\n \"coverageAreas\": [\n {\n \"codeScheme\": \"ISO-3166-1\",\n \"areaCode\": \"IN\",\n \"areaLevel\": \"Country\",\n \"areaName\": \"India\"\n },\n {\n \"type\": \"Polygon\",\n \"coordinates\": [\n [\n [\n 68.0,\n 6.0\n ],\n [\n 98.0,\n 6.0\n ],\n [\n 98.0,\n 38.0\n ],\n [\n 68.0,\n 38.0\n ],\n [\n 68.0,\n 6.0\n ]\n ]\n ]\n }\n ]\n }\n }\n ]\n }\n ],\n \"publishDirectives\": [\n {\n \"catalogId\": \"cat-mausamgram-point-forecast-v2\",\n \"catalogType\": \"REGULAR\",\n \"updateMode\": \"MERGE\",\n \"visibleTo\": [\n \"{{networkId}}\"\n ]\n }\n ]\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{providerAdapterUrl}}/publish", + "host": [ + "{{providerAdapterUrl}}" + ], + "path": [ + "publish" + ] + }, + "description": "Enters at the PROVIDER adapter, which signs it as itself and forwards to the network layer; the network layer verifies that signature and hands it to the discovery service. Posting straight at the discovery service would skip both adapters, and so skip the part worth testing.\n\ncontext.action is catalog/publish, the name the Beckn v2 spec gives this action at /catalog/publish. A bare \"publish\" is not a spec action. The callback is catalog/on_publish.\n\nThe caller signs nothing and the body names no party: identity travels in the Authorization header's keyId, from the adapter's own keyManager config.\n\nThe catalogue carries no offers -- nothing requires them, and select does not read them: it carries its own offer in the request." + }, + "response": [] + }, + { + "name": "2. Discover", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"200\", () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test(\"on_discover\", () => pm.expect(b.context.action).to.eql(\"on_discover\"));", + "pm.test(\"at least one catalogue\", () => pm.expect((b.message.catalogs || []).length).to.be.above(0));" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": {\n \"version\": \"2.0.0\",\n \"action\": \"discover\",\n \"transactionId\": \"9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44\",\n \"messageId\": \"7d41b9e0-52a6-4c18-8b73-1e9f0a4c6d22\",\n \"timestamp\": \"2026-08-30T06:12:01.330Z\",\n \"networkId\": \"{{networkId}}\",\n \"schemaContext\": [\n \"https://raw.githubusercontent.com/OpenAgriNet/network-specs/schema-packs-v0.1/schema/WeatherObservation/v0.1/context.jsonld#openagrinet:WeatherObservation\"\n ]\n },\n \"message\": {\n \"intent\": {\n \"textSearch\": \"weather forecast\",\n \"filters\": {\n \"type\": \"jsonpath\",\n \"expression\": \"$.catalogs[*].resources[*] ? (@.resourceAttributes.subjectCategories[*] == \\\"Weather\\\")\"\n },\n \"spatial\": [\n {\n \"op\": \"S_DWITHIN\",\n \"targets\": \"$.catalogs[*].resources[*].resourceAttributes.coverageAreas[*]\",\n \"geometry\": {\n \"type\": \"Point\",\n \"coordinates\": [\n 73.7898,\n 19.9975\n ]\n },\n \"distanceMeters\": 100000,\n \"quantifier\": \"ANY\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{expAdapterUrl}}/discover", + "host": [ + "{{expAdapterUrl}}" + ], + "path": [ + "discover" + ] + }, + "description": "Text search across published catalogs.\n\nRoute: **exp adapter \u2192 network adapter \u2192 discovery service.** No provider plugin is involved, and no upstream API is called. The answer comes from the discovery service's own index.\n\nReturns the catalog published in step 3. An empty list almost always means `networkId` or `domain` did not match what was published." + }, + "response": [] + }, + { + "name": "3. Select \u2014 per-day forecast", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 404) {", + " console.log(\"404: the provider adapter serves no capability matching this payload.\",", + " \"offer.provider.id and resourceAttributes.@type must equal the deployed\",", + " \"binding key -- check request 2 against PROVIDER_PARTICIPANT_ID in .env\");", + "}", + "", + "pm.test(\"200\", () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test(\"on_select\", () => pm.expect(b.context.action).to.eql(\"on_select\"));", + "const c = b.message.contract.commitments[0];", + "pm.test(\"a resource per forecast day\", () => pm.expect(c.resources.length).to.be.above(0));", + "", + "// Spec conformance of the answer, which is the mapping's job and so the", + "// thing that silently regresses when the published mapping changes.", + "pm.test(\"status is in the spec enum\", () => {", + " pm.expect([\"DRAFT\",\"ACTIVE\",\"CLOSED\"]).to.include(c.status.descriptor.code);", + "});", + "pm.test(\"every resource carries a quantity\", () => {", + " c.resources.forEach(r => pm.expect(r, r.id).to.have.property(\"quantity\"));", + "});", + "pm.test(\"the offer references only resources returned\", () => {", + " const ids = c.resources.map(r => r.id);", + " (c.offer.resourceIds || []).forEach(i => pm.expect(ids).to.include(i));", + "});", + "pm.test(\"no party named in the answer\", () => {", + " [\"bapId\",\"bapUri\",\"bppId\",\"bppUri\"].forEach(f => pm.expect(b.context[f]).to.be.undefined);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": {\n \"version\": \"2.0.0\",\n \"action\": \"select\",\n \"networkId\": \"{{networkId}}\",\n \"transactionId\": \"9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44\",\n \"messageId\": \"7d41b9e0-52a6-4c18-8b73-1e9f0a4c6d22\",\n \"timestamp\": \"2026-08-30T06:12:01.330Z\"\n },\n \"message\": {\n \"contract\": {\n \"commitments\": [\n {\n \"status\": {\n \"descriptor\": {\n \"code\": \"DRAFT\",\n \"name\": \"Draft\"\n }\n },\n \"resources\": [\n {\n \"id\": \"res:mausamgram:point-forecast\",\n \"resourceAttributes\": {\n \"@context\": \"https://raw.githubusercontent.com/OpenAgriNet/network-specs/schema-packs-v0.1/schema/WeatherObservation/v0.1/context.jsonld\",\n \"@type\": \"openagrinet:WeatherObservation\",\n \"subjectCategories\": [\n \"Weather\"\n ],\n \"location\": {\n \"type\": \"Point\",\n \"coordinates\": [\n 73.7898,\n 19.9975\n ]\n },\n \"informationMode\": \"OnDemand\",\n \"supportedObservationTypes\": [\n \"Forecast\"\n ],\n \"supportedParameters\": [\n \"Rainfall\",\n \"Temperature\",\n \"Humidity\",\n \"WindSpeed\"\n ],\n \"geographicGranularities\": [\n \"Point\"\n ]\n },\n \"quantity\": 1\n }\n ],\n \"offer\": {\n \"id\": \"offer:mausamgram:open-data\",\n \"resourceIds\": [\n \"res:mausamgram:point-forecast\"\n ],\n \"provider\": {\n \"id\": \"{{providerId}}\",\n \"descriptor\": {\n \"code\": \"IMD-NWP-01\",\n \"name\": \"IMD Mausamgram NWP\"\n }\n }\n }\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{expAdapterUrl}}/select", + "host": [ + "{{expAdapterUrl}}" + ], + "path": [ + "select" + ] + }, + "description": "Asks for a priced quote on the resource discover returned.\n\nRoute: **exp adapter \u2192 provider adapter \u2192 mock IMD.** This is where the plugins do their work:\n\n1. `validateSign` verifies the caller's key, fetched from the registry \u2014 the row you saw in step 1\n2. the provider plugin builds the binding key from the payload, asks the registry for the call plan \u2014 the row from step 2 \u2014 resolves the coordinates, calls the upstream, and maps the answer back\n3. `signAck` signs the answer\n\n**The answer is the HTTP response \u2014 there is no callback.** A bare `ACK` here would mean nothing served the request; the adapter now returns `404 NET_ENTITY_NOT_FOUND` in that case rather than pretending to accept it.\n\nThe response quotes **one** resource, carrying the id this request selected, and follows the same schema pack in `informationMode: Direct` \u2014 which requires `observationType`, `source`, `location`, `generatedAt` and `parameters`.\n\n**Two fields here are not in the pack, both deliberately.** The pack carries one validity and one flat `parameters` array per resource, so it cannot express a five-day forecast in the one resource the request selected \u2014 hence `observations`. And its parameter entry is `parameter`/`value`/`unit` only, so `aggregation` is ours, because this provider reports a minimum *and* a maximum for temperature and humidity. Both validate: the pack sets no `additionalProperties`." + }, + "response": [] + } + ], + "description": "The weather capability end to end: publish its catalogue, find it, then ask for a forecast.\n\nRUN IN ORDER the first time -- Publish seeds the catalogue Discover looks for. After that Select works on its own.\n\nSelect is the interesting one. It goes to the same endpoint on the same adapter as Mandi's, and a different domain package answers it: each provider step builds a binding key from the payload, serves the request if the key is its own, and passes it through untouched if not. A 404 NET_ENTITY_NOT_FOUND here means no step claimed this payload -- compare {{providerId}} and the capability @type against the bindings in Registry." + }, + { + "name": "2. MandiPrice", + "item": [ + { + "name": "1. Publish", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"200\", () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test(\"catalog/on_publish\", () => pm.expect(b.context.action).to.eql(\"catalog/on_publish\"));", + "const r = b.message.results[0];", + "pm.test(\"ACCEPTED\", () => pm.expect(r.status).to.eql(\"ACCEPTED\"));", + "pm.test(\"the catalogue id comes back\", () => pm.expect(r.catalogId).to.eql(\"cat-agmarknet-mandi-prices\"));" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": {\n \"action\": \"catalog/publish\",\n \"version\": \"2.0.0\",\n \"transactionId\": \"d3a1b2c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"messageId\": \"e4b2c3d5-6f7a-4b8c-9d0e-1f2a3b4c5d6e\",\n \"timestamp\": \"2026-09-01T06:05:00Z\"\n },\n \"message\": {\n \"catalogs\": [\n {\n \"id\": \"cat-agmarknet-mandi-prices\",\n \"isActive\": true,\n \"descriptor\": {\n \"code\": \"AGMARKNET-01\",\n \"name\": \"Agmarknet mandi prices\",\n \"shortDesc\": \"Daily commodity prices by market from Agmarknet\",\n \"longDesc\": \"Minimum, maximum and modal prices per commodity per market, reported daily by the Agmarknet Vistaar service.\"\n },\n \"provider\": {\n \"id\": \"{{mandiProviderId}}\",\n \"descriptor\": {\n \"code\": \"AGMARKNET-01\",\n \"name\": \"Agmarknet Vistaar\"\n }\n },\n \"validity\": {\n \"startDate\": \"2026-01-01T00:00:00Z\",\n \"endDate\": \"2027-12-31T23:59:59Z\"\n },\n \"resources\": [\n {\n \"id\": \"res:agmarknet:mandi-price\",\n \"descriptor\": {\n \"code\": \"MANDI-PRICE\",\n \"name\": \"Mandi commodity price\",\n \"shortDesc\": \"Prices for a commodity at a named market\"\n },\n \"resourceAttributes\": {\n \"@context\": \"https://raw.githubusercontent.com/OpenAgriNet/network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/context.jsonld\",\n \"@type\": \"openagrinet:MandiPrice\",\n \"informationMode\": \"OnDemand\",\n \"subjectCategories\": [\n \"Market\"\n ],\n \"supportedCommodities\": [\n {\n \"code\": \"2\",\n \"name\": \"Paddy(Common)\"\n },\n {\n \"code\": \"78\",\n \"name\": \"Tomato\"\n }\n ],\n \"supportedPriceFields\": [\n \"Minimum\",\n \"Maximum\",\n \"Modal\"\n ],\n \"historicalDataAvailable\": true,\n \"historyPeriod\": \"P1Y\",\n \"updateFrequency\": \"P1D\",\n \"languages\": [\n \"en\"\n ],\n \"coverageAreas\": [\n {\n \"codeScheme\": \"ISO-3166-1\",\n \"areaCode\": \"IN\",\n \"areaLevel\": \"Country\",\n \"areaName\": \"India\"\n },\n {\n \"type\": \"Polygon\",\n \"coordinates\": [\n [\n [\n 68.0,\n 6.0\n ],\n [\n 98.0,\n 6.0\n ],\n [\n 98.0,\n 38.0\n ],\n [\n 68.0,\n 38.0\n ],\n [\n 68.0,\n 6.0\n ]\n ]\n ]\n }\n ]\n }\n }\n ]\n }\n ],\n \"publishDirectives\": [\n {\n \"catalogId\": \"cat-agmarknet-mandi-prices\",\n \"catalogType\": \"REGULAR\",\n \"updateMode\": \"MERGE\",\n \"visibleTo\": [\n \"{{networkId}}\"\n ]\n }\n ]\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{providerAdapterUrl}}/publish", + "host": [ + "{{providerAdapterUrl}}" + ], + "path": [ + "publish" + ] + }, + "description": "The second catalogue, entering the same way as the first: the provider adapter signs it and the network layer forwards it to discovery. Publishing is capability-agnostic -- no provider step runs, and nothing on this path knows what a MandiPrice is.\n\nThe resource is OnDemand, which is what a catalogue entry should be: it advertises the commodities and price fields this provider CAN answer for. The pack forbids `prices` in that mode, so a catalogue cannot carry stale numbers -- those appear only in the Direct answer to a select, request 8." + }, + "response": [] + }, + { + "name": "2. Discover", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"200\", () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test(\"on_discover\", () => pm.expect(b.context.action).to.eql(\"on_discover\"));", + "const cats = b.message.catalogs || [];", + "pm.test(\"at least one catalogue\", () => pm.expect(cats.length).to.be.above(0));", + "// The mandi catalogue specifically, so this cannot pass on the weather one.", + "pm.test(\"the mandi catalogue is discoverable\", () => {", + " pm.expect(cats.map(c => c.id)).to.include(\"cat-agmarknet-mandi-prices\");", + "});", + "// A catalogue entry advertises a capability rather than carrying data.", + "pm.test(\"it advertises OnDemand and carries no prices\", () => {", + " const mandi = cats.find(c => c.id === \"cat-agmarknet-mandi-prices\");", + " const ra = mandi.resources[0].resourceAttributes;", + " pm.expect(ra.informationMode).to.eql(\"OnDemand\");", + " pm.expect(ra).to.not.have.property(\"prices\");", + " pm.expect(ra.supportedCommodities.length).to.be.above(0);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": {\n \"version\": \"2.0.0\",\n \"action\": \"discover\",\n \"transactionId\": \"a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"messageId\": \"b2c3d4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e\",\n \"timestamp\": \"2026-09-01T06:10:00Z\",\n \"networkId\": \"{{networkId}}\",\n \"schemaContext\": [\n \"https://raw.githubusercontent.com/OpenAgriNet/network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/context.jsonld#openagrinet:MandiPrice\"\n ]\n },\n \"message\": {\n \"intent\": {\n \"textSearch\": \"mandi commodity price\",\n \"filters\": {\n \"type\": \"jsonpath\",\n \"expression\": \"$.catalogs[*].resources[*] ? (@.resourceAttributes.subjectCategories[*] == \\\"Market\\\")\"\n },\n \"spatial\": [\n {\n \"op\": \"S_DWITHIN\",\n \"targets\": \"$.catalogs[*].resources[*].resourceAttributes.coverageAreas[*]\",\n \"geometry\": {\n \"type\": \"Point\",\n \"coordinates\": [\n 73.7898,\n 19.9975\n ]\n },\n \"distanceMeters\": 100000,\n \"quantifier\": \"ANY\"\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{expAdapterUrl}}/discover", + "host": [ + "{{expAdapterUrl}}" + ], + "path": [ + "discover" + ] + }, + "description": "The same route as request 5 -- exp adapter, network adapter, discovery service -- looking for the other catalogue. No provider plugin and no upstream call: discovery answers from its own index.\n\nAn empty list here almost always means networkId or domain did not match what was published, rather than the text search failing." + }, + "response": [] + }, + { + "name": "3. Select \u2014 prices per market day", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 404) {", + " console.log(\"404: no step matched. offer.provider.id and resourceAttributes.@type must\",", + " \"equal a configured binding key -- check MANDI_PARTICIPANT_ID in .env and\",", + " \"that mandi is in the provider adapter's steps: list, not just providerSteps\");", + "}", + "", + "pm.test(\"200\", () => pm.response.to.have.status(200));", + "const b = pm.response.json();", + "pm.test(\"on_select\", () => pm.expect(b.context.action).to.eql(\"on_select\"));", + "const c = b.message.contract.commitments[0];", + "pm.test(\"a resource per price record\", () => pm.expect(c.resources.length).to.be.above(0));", + "", + "const first = c.resources[0].resourceAttributes;", + "pm.test(\"MandiPrice in Direct mode\", () => {", + " pm.expect(first[\"@type\"]).to.eql(\"openagrinet:MandiPrice\");", + " pm.expect(first.informationMode).to.eql(\"Direct\");", + "});", + "// Direct requires all six of these in the pack.", + "pm.test(\"the pack's Direct fields are all present\", () => {", + " [\"source\",\"commodity\",\"market\",\"arrivalDate\",\"prices\",\"generatedAt\"].forEach(", + " f => pm.expect(first, f).to.have.property(f));", + "});", + "// The upstream sends prices as strings; the pack requires numbers.", + "pm.test(\"prices are numbers, not strings\", () => {", + " pm.expect(first.prices.modal).to.be.a(\"number\");", + " pm.expect(first.prices.currency).to.eql(\"INR\");", + "});", + "// dd-MM-yyyy upstream, ISO in the answer.", + "pm.test(\"arrivalDate is ISO\", () => {", + " pm.expect(first.arrivalDate).to.match(/^\\d{4}-\\d{2}-\\d{2}$/);", + "});", + "pm.test(\"status is in the spec enum\", () => {", + " pm.expect([\"DRAFT\",\"ACTIVE\",\"CLOSED\"]).to.include(c.status.descriptor.code);", + "});", + "pm.test(\"every resource carries a quantity\", () => {", + " c.resources.forEach(r => pm.expect(r, r.id).to.have.property(\"quantity\"));", + "});", + "// A record the market reported partially must come back partial, not zeroed:", + "// \"no minimum reported\" and \"a minimum of zero\" are different facts.", + "pm.test(\"an unreported price is absent, not zero\", () => {", + " const last = c.resources[c.resources.length - 1].resourceAttributes.prices;", + " if (c.resources.length > 1) {", + " pm.expect(last).to.not.have.property(\"minimum\");", + " pm.expect(last).to.not.have.property(\"maximum\");", + " }", + " pm.expect(last.modal).to.be.a(\"number\");", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": {\n \"version\": \"2.0.0\",\n \"action\": \"select\",\n \"networkId\": \"{{networkId}}\",\n \"transactionId\": \"9f2c1a8e-4b70-4d31-9c55-6f2e0b1d7a44\",\n \"messageId\": \"7d41b9e0-52a6-4c18-8b73-1e9f0a4c6d22\",\n \"timestamp\": \"2026-08-30T06:12:01.330Z\"\n },\n \"message\": {\n \"contract\": {\n \"commitments\": [\n {\n \"status\": {\n \"descriptor\": {\n \"code\": \"DRAFT\",\n \"name\": \"Draft\"\n }\n },\n \"resources\": [\n {\n \"id\": \"res:agmarknet:price-enquiry\",\n \"quantity\": 1,\n \"resourceAttributes\": {\n \"@context\": \"https://raw.githubusercontent.com/OpenAgriNet/network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/context.jsonld\",\n \"@type\": \"openagrinet:MandiPrice\",\n \"informationMode\": \"OnDemand\",\n \"subjectCategories\": [\n \"Market\"\n ],\n \"supportedCommodities\": [\n {\n \"code\": \"2\",\n \"name\": \"Paddy(Common)\"\n }\n ],\n \"supportedPriceFields\": [\n \"Minimum\",\n \"Maximum\",\n \"Modal\"\n ],\n \"market\": {\n \"marketName\": \"Kasdol APMC\",\n \"marketCode\": \"2056\",\n \"district\": \"96\",\n \"state\": \"CG\"\n },\n \"validity\": {\n \"startsAt\": \"2025-08-20T00:00:00+05:30\",\n \"endsAt\": \"2025-08-21T23:59:59+05:30\"\n }\n }\n }\n ],\n \"offer\": {\n \"id\": \"offer:agmarknet:open-data\",\n \"resourceIds\": [\n \"res:agmarknet:price-enquiry\"\n ],\n \"provider\": {\n \"id\": \"{{mandiProviderId}}\",\n \"descriptor\": {\n \"code\": \"AGMARKNET-01\",\n \"name\": \"Agmarknet Vistaar\"\n }\n }\n }\n }\n ]\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{expAdapterUrl}}/select", + "host": [ + "{{expAdapterUrl}}" + ], + "path": [ + "select" + ] + }, + "description": "The same endpoint as request 5, the same adapter, a different capability. Nothing routes this: the payload's provider id and resourceAttributes @type form a binding key, the mandi step recognises it and the weather step passes it through.\n\nThe answer is a Direct openagrinet:MandiPrice per price record. Its prices arrive from the upstream as STRINGS with Title Case keys containing spaces, so the mapping converts them; and a record that reported no minimum or maximum must come back with those absent rather than zeroed, which is what the last assertion checks.\n\nThe upstream's credential is a query parameter, which the adapter adds from an environment variable and redacts from the URL it logs." + }, + "response": [] + } + ], + "description": "The mandi capability end to end: publish its catalogue, find it, then ask for prices.\n\nRUN IN ORDER the first time -- Publish seeds the catalogue Discover looks for.\n\nNothing here is shared with Weather except the adapter, the registry client and the mapper. A different upstream, a different mapping, a different binding key. The mapping is doing more work than weather's: it converts ISO dates to the dd-MM-yyyy Agmarknet wants, sends marketcode only when the request carried one, turns price strings into numbers, and omits a price that was not reported rather than sending a zero." + } + ], + "variable": [ + { + "key": "expAdapterUrl", + "value": "http://localhost:9202", + "description": "Takes unsigned requests -- the app is inside the trust boundary." + }, + { + "key": "providerAdapterUrl", + "value": "http://localhost:9200", + "description": "Where publish enters." + }, + { + "key": "networkAdapterUrl", + "value": "http://localhost:9201", + "description": "Used by no request here, on purpose. Discover reaches this adapter through the experience adapter and publish through the provider adapter. It is listed because it is the other adapter a deployment exposes, and both its /publish and /discover verify signatures -- so a network peer calls it directly. Postman does not sign, so those calls are not scripted." + }, + { + "key": "providerId", + "value": "mausamgram-mock", + "description": "The weather upstream. Half of its binding key." + }, + { + "key": "mandiProviderId", + "value": "agmarknet-mock", + "description": "The mandi upstream. Half of its binding key." + }, + { + "key": "networkId", + "value": "oan-dev" + } + ] +} diff --git a/postman-collection/local_postman_environment.json b/postman-collection/local_postman_environment.json new file mode 100644 index 0000000..2cd8167 --- /dev/null +++ b/postman-collection/local_postman_environment.json @@ -0,0 +1,49 @@ +{ + "id": "oan-dev-environment", + "name": "OAN dev", + "values": [ + { + "key": "expAdapterUrl", + "value": "http://localhost:9202", + "enabled": true, + "type": "default", + "description": "Takes unsigned requests -- the app is inside the trust boundary." + }, + { + "key": "providerAdapterUrl", + "value": "http://localhost:9200", + "enabled": true, + "type": "default", + "description": "Where publish enters." + }, + { + "key": "providerId", + "value": "mausamgram-mock", + "enabled": true, + "type": "default", + "description": "The weather upstream. Half of its binding key." + }, + { + "key": "mandiProviderId", + "value": "agmarknet-mock", + "enabled": true, + "type": "default", + "description": "The mandi upstream. Half of its binding key." + }, + { + "key": "networkId", + "value": "oan-dev", + "enabled": true, + "type": "default" + }, + { + "key": "networkAdapterUrl", + "value": "http://localhost:9201", + "enabled": true, + "type": "default", + "description": "Used by no request here, on purpose. Discover reaches this adapter through the experience adapter and publish through the provider adapter. It is listed because it is the other adapter a deployment exposes, and both its /publish and /discover verify signatures -- so a network peer calls it directly. Postman does not sign, so those calls are not scripted." + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_using": "hand-written, tracked in this repo" +} diff --git a/quick-start/.env.example b/quick-start/.env.example new file mode 100644 index 0000000..7747a82 --- /dev/null +++ b/quick-start/.env.example @@ -0,0 +1,201 @@ +# Copy to .env and read it through once. +# +# This is a DEV deployment on a VM, not a laptop sandbox. Every credential +# below is a shipped default, which means it is public -- change all of them +# before the stack is reachable by anyone but you. The adapter keypairs are the +# exception: bin/setup.py generates those into keys/keys.json, and they are +# never written here. + +# ---- who can reach it ------------------------------------------------------ +# Every published port except the gateway's 80 and 443 is bound to 127.0.0.1, +# written literally in docker-compose.yml rather than taken from a variable. +# That is deliberate: one variable that moves every port to the public +# interface at once is a footgun, and the ports that should be reachable are +# reachable through the gateway instead. +# +# So from a workstation, a tunnel: +# +# ssh -L 9202:127.0.0.1:9202 -L 8081:127.0.0.1:8081 you@the-vm +# +# Behind those loopback ports sit Keycloak's admin console and a registry whose +# write token any reader of this file can mint. Publishing one means editing +# docker-compose.yml, which is where that argument belongs. + +# ---- the images ------------------------------------------------------------ +# Nothing is built here. `docker compose up -d` pulls these and starts them. +# +# Set both to the tags published for this environment. There is deliberately no +# working default: an unset or wrong value fails on pull, naming the tag it +# tried, which is a better failure than silently running something else. +# +# If they live in a private registry, log in on the VM first: +# docker login ghcr.io +# TAG pins the discovery service; unset means latest. Set it to deploy a known +# build rather than whatever latest points at today: +# TAG=v0.3.1 docker compose up -d +# +# THE ADAPTER IMAGE AND THE ADAPTER CONFIGS IN THIS REPO MOVE TOGETHER. The +# configs name plugins by id -- WeatherObservation, MandiPrice -- and an id is +# the basename of a .so inside the image. Point this at an image built before +# those were renamed and every adapter dies at startup with +# +# unrecognized step: WeatherObservation +# +# which reads like a config typo and is not. +# +# This follows latest, which behaves less dynamically than it reads: +# `pull_policy: missing` in docker-compose.yml means a tag already on disk is +# never re-fetched, so `make up` does NOT pick up a newer latest and a stack +# can sit on a stale image indefinitely. Taking a new one is deliberate: +# +# docker compose pull provider-adapter network-adapter exp-adapter +# docker compose up -d --force-recreate provider-adapter network-adapter exp-adapter +# +# Both halves matter: the pull without the recreate leaves the old container +# running, and the recreate without the pull recreates it on the old image. +ADAPTER_IMAGE=ghcr.io/ameersohel45/oan-adapter:latest +DISCOVERY_IMAGE=ghcr.io/nisargabd/discovery-service:${TAG:-latest} + +# The two mock upstreams. Their sources are in mock-server/, to be built and +# published once rather than built here -- see mock-server/README.md for the build +# commands and for what they deliberately get wrong. +MOCKIMD_IMAGE=ghcr.io/nisargabd/oan-mockimd:latest +MOCKAGMARKNET_IMAGE=ghcr.io/nisargabd/oan-mockagmarknet:latest + +# ---- ports ----------------------------------------------------------------- +REGISTRY_PORT=8081 +KEYCLOAK_PORT=8080 +KEYCLOAK_ADMIN_PORT=9990 +DISCOVERY_PORT=8090 +PROVIDER_ADAPTER_PORT=9200 +NETWORK_ADAPTER_PORT=9201 +EXP_ADAPTER_PORT=9202 +# The mocks are published on loopback for looking at them directly while +# debugging. The adapter reaches them by compose service name, not through +# these. +MOCKIMD_PORT=9100 +MOCKAGMARKNET_PORT=9101 + +# ---- registry -------------------------------------------------------------- +# CHANGE every credential in this block before the stack is exposed. +REGISTRY_VERSION=v2.0.0 +POSTGRES_DB=registry +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +KEYCLOAK_REALM=sunbird-rc +KEYCLOAK_ADMIN_USER=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_SECRET=1b46c0f2-6a1c-4a2f-8b0f-2b6b30bcd2b2 +KEYCLOAK_ADMIN_CLIENT_ID=admin-api +KEYCLOAK_CLIENT_ID=registry-frontend +REGISTRY_DEFAULT_USER_PASSWORD=abcd@123 +REGISTRY_USER=no-user +REGISTRY_PASSWORD=no-user-password + +# ---- telemetry ------------------------------------------------------------- +# The three adapters ship metrics, traces and logs over OTLP/gRPC to the +# collector named here. hyperdx is ClickStack, on the observability profile, +# which `make up` starts and `make up-core` does not. +# +# ONE SWITCH, THREE SIGNALS. With OTEL_ENABLED=false the plugin builds no +# exporter and never dials -- so it cannot log a refused connection every few +# seconds. Set it false for `make up-core`, or for any stack whose collector +# is not running; leaving it true against a missing collector is the noisy +# case, not a broken one. +# +# Changing any of these means re-running bin/setup.py and recreating the +# adapters: they are rendered into the configs, not read at runtime. +OTEL_ENABLED=true +OTLP_ENDPOINT=hyperdx:4317 +OTEL_ENVIRONMENT=dev + +# ---- discovery ------------------------------------------------------------- +APP_NETWORK_ID=oan-dev +BECKN_SPEC_URL=https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml + +# ---- the three adapter identities ------------------------------------------ +# bin/setup.py registers exactly these three in the registry and generates a +# keypair for each. +# +# A participant id IS the network identity -- the id a signature is verified +# against -- so the registry requires it to be hostname-shaped. +# They are never resolved by DNS: routing between the adapters is the router +# plugin's config, which uses the compose service names. +# +# Pick these once and deliberately. The registry is append-only: there is no +# update, delete is soft, and a soft-deleted id keeps the unique index -- so an +# id can never be reused. Name them for the environment they are, so a dev +# identity cannot be mistaken for a real one on a shared network. +EXP_SUBSCRIBER_ID=exp.oan.dev +NETWORK_SUBSCRIBER_ID=network.oan.dev +PROVIDER_SUBSCRIBER_ID=provider.oan.dev + +# ---- the two upstream providers -------------------------------------------- +# bin/setup.py creates BOTH of these in the registry, along with their +# capability bindings -- five participants and two bindings in all. It has to: +# the registry is not reachable from outside this stack, so there is no second +# way to create them. +# +# WHY THESE MUST MATCH THE ROWS. The provider adapter decides whether a request +# is its own by building a binding key out of the incoming payload -- the +# provider id and the capability @type it carries -- and comparing it against +# the keys in its own config. setup.py renders these values into that config +# AND seeds the registry from them, which is what keeps the two from +# disagreeing. Change one here and re-run setup.py; changing only one side is +# how the failures below happen. +# +# A disagreement fails, and how depends on which side is wrong: +# +# the payload names a provider no step is configured for +# 404 NET_ENTITY_NOT_FOUND, "this module serves no capability matching the +# request". Each step passes through what is not its own -- which is what +# lets one adapter host both capabilities -- and nothing behind them +# answers. +# +# a step IS configured for the key but the registry has no matching +# ProviderSchema row +# 404 too, now naming the binding with no active record. Before that it was +# a 500 with the reason only in the log. +# +# The base URLs are compose service names: both upstreams are mocks called from +# inside this network and reached from nowhere else. Pointing a capability at a +# real API means a new Participant and ProviderSchema row, written from inside +# the stack, and the id here updated to match. +PROVIDER_PARTICIPANT_ID=mausamgram-mock +PROVIDER_CAPABILITY=openagrinet:WeatherObservation +MAUSAMGRAM_BASE_URL=http://mockimd:9100 +MAUSAMGRAM_PATH=/get-daily + +MANDI_PARTICIPANT_ID=agmarknet-mock +MANDI_CAPABILITY=openagrinet:MandiPrice +MANDI_BASE_URL=http://mockagmarknet:9101 +MANDI_PATH=/v1/fetch-agmarknet-vistaar + +# Agmarknet's Vistaar API takes its token as a QUERY parameter, which is why +# the adapter needs authScheme query for it. The value lives here and reaches +# the adapter as an environment variable; it is never in a config file or in +# the registry. The mock answers 401 without it, which is what proves the +# adapter sent one. +MANDI_TOKEN=local-mandi-token + +# How many records each mock answers with. The mappings read however many +# arrive, so these are the knobs for checking that they do. mockagmarknet's +# last record reports no minimum or maximum, as the real data sometimes does. +MOCKIMD_DAYS=3 +MOCKAGMARKNET_DAYS=2 + +# The mappings each provider adapter fetches, request and response in one file +# per binding-action. The registry row holds the full URL and the adapter +# fetches it verbatim. +# +# This repo's own copies, served over the raw CDN -- the same files that sit in +# config/mappings/ beside these configs, so what the adapter fetches and what a +# reader reviews are one file and cannot drift. +# +# Note the branch in the paths. Once this merges, change it to the default +# branch, or pin a tag so a deployment is not following a moving file. +# +# To change a mapping: edit config/mappings/, push, and the next cache expiry +# picks it up -- about a minute for the adapter plus a few for GitHub's CDN. +MAPPING_URL=https://raw.githubusercontent.com/OpenAgriNet/helmcharts/feat/4-docker-compose/quick-start/config/mappings/mausamgram/weather-observation.select.yaml +MANDI_MAPPING_URL=https://raw.githubusercontent.com/OpenAgriNet/helmcharts/feat/4-docker-compose/quick-start/config/mappings/agmarknet/mandi-price.select.yaml diff --git a/quick-start/.gitignore b/quick-start/.gitignore new file mode 100644 index 0000000..015355a --- /dev/null +++ b/quick-start/.gitignore @@ -0,0 +1,28 @@ +# Real values, and the ports/refs one machine happens to use. +.env + +# Private keys. Generated by bin/setup.py, never committed. +keys/ + +# Rendered from the .tmpl files beside them, and they carry the private key +# material that keys/ holds. The templates are the tracked source. +config/adapters/experience.yaml +config/adapters/network.yaml +config/adapters/provider.yaml + +# A local override for the discovery service, if you make one. +config/discovery/instance.yaml + +# HyperDX's optional extra environment, if you bring one. +.env.docker + +# Any copy of .env. The pattern above is an exact match, so a `cp .env +# .env.bak` before an edit -- which is the natural thing to do -- produces a +# file holding every live credential that git will happily offer to commit. +.env.* +!.env.example + +# Nginx Proxy Manager keeps its routing table, its account and its +# certificates in the npm-data and npm-letsencrypt volumes, so there is +# nothing here to ignore -- and nothing here to review either. Back those +# volumes up; they are the only copy. diff --git a/quick-start/CERTIFICATES.md b/quick-start/CERTIFICATES.md new file mode 100644 index 0000000..8e7e3e2 --- /dev/null +++ b/quick-start/CERTIFICATES.md @@ -0,0 +1,101 @@ +# Certificates + +Two different mechanisms, and which one you need depends on whether the name +you are certifying is reachable from the public internet. + +| | local (this laptop) | the VM | +|----------------|--------------------------|-------------------------------| +| issuer | mkcert, a private CA | Let's Encrypt, a public CA | +| trusted by | only machines you set up | everything | +| validation | none -- you own the CA | HTTP-01, inbound from LE | +| works offline | yes | no | + +The dividing line is not preference. A public CA will only certify a name it +can verify from the outside, and a loopback address is definitionally outside +that. `127.0.0.1.sslip.io` resolves correctly -- to `127.0.0.1` -- which is +precisely why Let's Encrypt cannot validate it: their servers resolve that name +and connect to their own loopback. They also refuse by policy to issue for +names in reserved IP space, so this fails as a rejection and not a timeout. + +## Local, with mkcert + + brew install mkcert + +### 1. Create and trust the CA + + mkcert -install + +Run this in a REAL terminal window. It needs an admin password, and a sudo +prompt with no TTY fails silently -- which leaves the CA generated but not +trusted, and every certificate signed by it then fails in every browser while +looking perfectly well-formed in `openssl`. + +Verify before going further. This is the whole gate: + + security verify-cert -c "$(mkcert -CAROOT)/rootCA.pem" + +`Cert Verify Result: Success` and nothing else. `CSSMERR_TP_NOT_TRUSTED` means +the CA is present but carries no trust setting, which is the same as absent. + +### 2. Generate the leaf + + mkdir -p ~/oan-local-certs && cd ~/oan-local-certs + mkcert -cert-file oan-local.pem -key-file oan-local-key.pem \ + "127.0.0.1.sslip.io" "*.127.0.0.1.sslip.io" \ + "oan.test" "*.oan.test" localhost 127.0.0.1 ::1 + +Name everything up front; adding one later means regenerating and re-uploading. + +The sslip.io forms are worth having over the `.test` ones: sslip.io resolves +`.127.0.0.1.sslip.io` to `127.0.0.1` on its own, so those names need +no /etc/hosts entry. X.509 wildcards match one level only -- `exp.127.0.0.1.sslip.io` +is covered, `a.b.127.0.0.1.sslip.io` is not. + +### 3. Upload, attach, restart + +NPM -> SSL Certificates -> Add Certificate -> **Custom**. Key first, then cert. + +Then per proxy host: Edit -> SSL -> select it -> Force SSL -> Save. This is not +global; a host you forget serves plain HTTP and reports no error. + +Then QUIT the browser -- fully, not just the window. Chromium and Safari read +root certificates at process start, so a reload after `mkcert -install` shows +the old answer. + +## On the VM, with Let's Encrypt + +1. Elastic IP attached. Not optional with sslip.io: the hostname CONTAINS the + address, so a stop/start invalidates every proxy host and every certificate + at once, rather than needing one A record updated. + +2. Security group: 80 and 443 open to `0.0.0.0/0`. Not to your address -- + HTTP-01 validation arrives from Let's Encrypt's own servers, whose addresses + you do not get to enumerate. Scoping it to yourself fails with a challenge + timeout that reads like a DNS problem. + +3. `make reverse-proxy`, then create the proxy host with `..sslip.io`. + No DNS record to create -- that is the entire point of sslip.io here. + +4. Confirm it answers over plain HTTP first. Let's Encrypt allows 5 failed + validations per hostname per hour, and debugging a misconfigured route + through the certificate flow is how you spend them. + +5. SSL tab -> Request a new SSL Certificate -> HTTP Validation -> Force SSL. + +DNS-01 is unavailable with sslip.io -- you do not control that zone, so certbot +cannot write the TXT record. It is the better option on a domain you do own: it +needs no inbound reachability at all, and it is the only way to get a wildcard. + +Renewal uses whatever method issued the certificate, so an SG rule that was +only temporarily correct fails in sixty days with nothing to announce it. + +## Reading the failure + +| symptom | cause | +|---|---| +| `CSSMERR_TP_NOT_TRUSTED` | CA not in the trust store -- step 1 | +| Safari: `"" certificate is not trusted` | same, named after the cert's first SAN rather than the site | +| Brave: `Not Secure`, https struck through | same, with less detail. Safari's message is the useful one | +| `curl` verify=0 but browser red | `--cacert` bypasses the trust store; it proves the chain, not the trust | +| `tlsv1 unrecognized name`, SNI alert 112 | no 443 server block for that name -- the host has no certificate attached | +| cert fine, browser still red | browser not restarted since `mkcert -install` | diff --git a/quick-start/Makefile b/quick-start/Makefile new file mode 100644 index 0000000..1aae647 --- /dev/null +++ b/quick-start/Makefile @@ -0,0 +1,52 @@ +# The front door. Every target is one line of delegation to bin/stack.sh, which +# is where the reasoning lives -- a Makefile is a bad place to explain why the +# startup order matters, and a good place to make the right order the shortest +# thing to type. +# +# make up the whole stack, in the order it has to start +# make up-core the same minus the reverse proxy and hyperdx +# make down stop everything, keep the data +# make help the rest +# +# Anchored to this file's own directory rather than $(PWD), so `make -C +# quick-start up` works from the repo root. + +STACK := $(dir $(realpath $(firstword $(MAKEFILE_LIST))))bin/stack.sh + +.PHONY: help up up-core down destroy setup reverse-proxy observability pull restart restart-edge ps logs + +# Default target: running a bare `make` in a directory that can delete a +# Postgres volume should print the menu, not pick something from it. +help: + @$(STACK) --help + +# ---------------------------------------------------------------- the stack + +up: ; @$(STACK) up +up-core: ; @$(STACK) up-core +down: ; @$(STACK) down +destroy: ; @$(STACK) destroy + +# --------------------------------------------- the optional tiers, on their own +# +# `make up` already starts both. These are for starting one without the other, +# or restarting one after a config change. + +reverse-proxy: ; @$(STACK) reverse-proxy +observability: ; @$(STACK) observability + +# ------------------------------------------------------------------ the rest + +setup: ; @$(STACK) setup + +# NPM chowns the files it is given, so a plain `git pull` fails on them. +pull: ; @$(STACK) pull + +# The app tier only -- registry, discovery, the three adapters. Not keycloak, +# not the databases, not the edge. +restart: ; @$(STACK) restart +restart-edge: ; @$(STACK) restart-edge +ps: ; @$(STACK) ps + +# `make logs` follows everything; `make logs SVC=registry` follows one service. +logs: ; @$(STACK) logs $(SVC) diff --git a/quick-start/README.md b/quick-start/README.md new file mode 100644 index 0000000..ab75ead --- /dev/null +++ b/quick-start/README.md @@ -0,0 +1,838 @@ +# OAN quick-start + +The whole OAN stack in Docker Compose: a registry, a discovery service, three +adapters, and two mock upstreams standing in for real provider APIs. + +Nothing is built here — the images are pulled. Locally that is about ten +minutes, most of it waiting for Keycloak. + +## The stack in one picture + +``` + consumer + │ + ▼ + experience adapter the only one a consumer calls + │ + ├── discover ──► network adapter ──► discovery service + │ + └── select ──► provider adapter ──► mockimd + └─► mockagmarknet +``` + +`publish` runs the other way: the provider adapter sends it to the network +adapter, which files the catalogue in discovery. + +All three adapters read the registry — who signed this request, and where this +capability's provider lives. + +A `select` answers in the same HTTP round trip — there is no callback. + +That is the request order. **Startup order is the reverse**: the experience +adapter depends on the other two, so Compose brings them up first. The steps +below follow startup order, so they work top to bottom. + +## Which path are you on? + +| | Local | VM | +|----------------------|--------------------------|-----------------------------| +| Reached over | `localhost` | a public hostname, TLS | +| Public edge (Nginx) | not started | started, on 80 and 443 | +| Observability | not started | optional, wants 2–4 GB | +| Credentials | shipped defaults are ok | **must all be changed** | +| Command | `make up-core` | `make up` | + +**Local** is Part 1. **VM** is Part 1, then Part 2 for the differences. + +--- + +# Part 1 — Run it locally + +## Step 1 — Prerequisites + +- `git` +- Docker with **Compose v2** — `docker compose version` must work, not `docker-compose` +- `python3` with `cryptography` — `pip install cryptography` +- `curl` + +## Step 2 — Get the repo + +```sh +git clone -b feat/4-docker-compose https://github.com/OpenAgriNet/helmcharts.git +cd helmcharts/quick-start +``` + +**Every command below runs from `quick-start/`** — it is where the compose file +and the Makefile live. + +## Step 3 — Configure + +```sh +cp .env.example .env +``` + +Locally, nothing in it needs changing. It publishes these on `localhost`: + +``` +8081 registry 9200 provider adapter 9100 mockimd +8080 keycloak 9201 network adapter 9101 mockagmarknet +9990 keycloak admin 9202 experience adapter +8090 discovery +``` + +If one is already taken, change it here — that is the only edit a local run +needs. Adapters reach each other by Compose service name, so it only changes +what you type into Postman. + +## Step 4 — Start it + +```sh +make up-core +``` + +Three tiers, in the only order that works: registry and discovery, then +`bin/setup.py`, then the mocks and the three adapters. Allow up to five minutes +the first time — Keycloak on a cold volume. + +`setup.py` generates a keypair per adapter, registers five participants and two +capability bindings, and renders the three adapter configs. Nothing needs +creating by hand. → Appendix C for why the order matters, Appendix D for what +it wrote. + +```sh +make ps +``` + +## Step 5 — Provider layer + +Answers `select`, and the only layer that calls an upstream. Serves both +capabilities from one adapter. + +`.env` keys: `PROVIDER_SUBSCRIBER_ID`, and the two pairs that become binding +keys — `PROVIDER_PARTICIPANT_ID` + `PROVIDER_CAPABILITY`, +`MANDI_PARTICIPANT_ID` + `MANDI_CAPABILITY` — plus `MANDI_TOKEN`. + +```sh +docker compose logs provider-adapter | grep 'Processor steps initialized' +``` + +Both capability steps should be listed by name. + +## Step 6 — Network layer + +Fronts discovery: verifies the caller, passes `discover` and `publish` on, and +re-signs as itself. + +`.env` keys: `NETWORK_SUBSCRIBER_ID`. `APP_NETWORK_ID` belongs to the discovery +service behind it — a `discover` naming a different network finds nothing. + +```sh +docker compose logs network-adapter | grep 'Server listening' +``` + +## Step 7 — Experience layer + +The consumer's edge. Sends `discover` to the network layer and `select` +straight to the provider layer, per `config/adapters/routing-experience.yaml`. + +`.env` keys: `EXP_SUBSCRIBER_ID`. + +```sh +docker compose logs exp-adapter | grep 'Server listening' +``` + +`setup.py` renders all three configs from these keys. **Do not edit the +rendered `config/adapters/*.yaml`** — they are regenerated and hold private +keys. Change `.env`, re-run `make up`. + +## Step 8 — Verify end to end + +Import both files from `../postman-collection/` into Postman — +`api-collection.json` and `local_postman_environment.json`, which already +points at localhost. Or: + +```sh +newman run ../postman-collection/api-collection.json +``` + +**6 requests, 32 assertions**, one folder per capability. Each folder +publishes, discovers, then selects, so run publish before discover the first +time. Green means the registry is seeded, signatures verify both ways, both +mappings work and discovery is indexing. + +To point it at another deployment, edit the **environment** file, not the +collection. → Appendix N. + +--- + +# Part 2 — Run it on a VM + +Four differences. **V1 comes before Part 1 Step 2**, because it installs `git`. + +## Step V1 — Prepare the VM + +Nothing is cloned yet, so fetch the script rather than running it from the repo: + +```sh +curl -fsSL https://raw.githubusercontent.com/OpenAgriNet/helmcharts/feat/4-docker-compose/quick-start/bin/bootstrap-ubuntu.sh | bash +``` + +Installs `git`, `make`, `python3-cryptography` and Docker from Docker's own apt +repo, then adds you to the `docker` group — **log out and back in** for that to +take effect. Idempotent, and it deliberately does not clone, write `.env` or +start anything. + +**8 GB** runs the stack; **16 GB** for the observability tier. + +Then Part 1 Steps 2 and 3 as written. + +## Step V2 — Change every credential + +`.env.example` ships working defaults, which means they are public: + +``` +POSTGRES_PASSWORD KEYCLOAK_ADMIN_PASSWORD KEYCLOAK_SECRET +REGISTRY_DEFAULT_USER_PASSWORD +``` + +Change all four before the VM is reachable by anyone but you. Adapter keypairs +are the exception — `setup.py` generates those and never writes them to `.env`. + +## Step V3 — Start it + +```sh +make up +``` + +`make up`, not `make up-core`: two more tiers on top of Part 1's three — +nginx-proxy-manager on 80 and 443, and HyperDX. **This is the step that makes +the VM reachable from the internet.** + +## Step V4 — Decide what is exposed + +Everything except the edge's 80 and 443 is bound to `127.0.0.1`, written +literally in `docker-compose.yml` rather than taken from a variable. So the +registry, Keycloak and the databases are not publicly reachable — deliberately. + +Proxy hosts, certificates, and the `/publish` deny every host gets → +**Appendix B**. Read it before pointing DNS at the box. + +## Step V5 — Reach the loopback ports + +```sh +ssh -L 9202:127.0.0.1:9202 -L 9200:127.0.0.1:9200 \ + -L 8081:127.0.0.1:8081 -L 8080:127.0.0.1:8080 -N you@the-vm +``` + +The collection's defaults then work unchanged, since they already point at +loopback. + +## Step V6 — Observability (optional) + +```sh +make observability +``` + +HyperDX on `127.0.0.1:8085`, OTLP on 4317/4318. → Appendix H, which is honest +about how much actually arrives. + +--- + +## If something is wrong + +- **`unrecognized step: `** — `ADAPTER_IMAGE` predates the config. + Appendix G. +- **404 `NET_ENTITY_NOT_FOUND`** — no provider step claimed the payload; a + binding key disagrees with `.env`. Appendix F. +- **404 naming a binding with no active record** — the step matched but the + registry has no `ProviderSchema` row for it. Appendix F. +- **502 from a `select`** — the upstream answered non-2xx. Appendix F. +- **NPM's default page, or a 502 that worked yesterday** — Appendix B. +- **An adapter config is a directory** — something started before + `setup.py`. Appendix F. + +Full set, with what to run for each → **Appendix F**. + +--- + +## Appendices + +Reference, read on demand. Nothing here is a step. + +| | | +|---|---| +| **A** | What is here, and what is not | +| **B** | Reaching it: the edge, routes, certificates, tunnels | +| **C** | Startup order, and why it is that order | +| **D** | What is in the registry, and why you did not create it | +| **E** | How a request flows | +| **F** | When it does not work | +| **G** | Updating a deployment that is already running | +| **H** | Telemetry | +| **I** | Schema validation | +| **J** | About the mapping files | +| **K** | The layout | +| **L** | Renaming this directory | +| **M** | Starting over | +| **N** | What the collection demonstrates | + +## Appendix A — What is here, and what is not + +Running: **registry** (SunbirdRC + Postgres + Keycloak), **discovery** +(catalogue search + Postgres), **three adapters** (same image, three configs), +**two mock upstreams** standing in for Mausamgram and Agmarknet. Behind +profiles: **nginx-proxy-manager** (`reverse-proxy`, the only container on a +routable interface) and **hyperdx** (`observability`, ClickStack — the +heaviest thing here). + +Deliberately absent: + +- **A route to the registry.** Reachable from inside the Compose network and + over an SSH tunnel, nowhere else. Nothing in front of it authenticates, and + SunbirdRC uses POST for reads *and* writes, so a route would expose creates + as readily as searches. That is why `setup.py` seeds everything — there is no + second way in. +- **A real provider API.** The mocks answer the same shapes. Pointing a + capability at something real is a registry write plus a base URL in `.env`; + the adapter reads the address per request rather than holding it. + +## Appendix B — Reaching it + +### The adapters, through Nginx Proxy Manager + +NPM owns 80 and 443 and is the whole public surface. Its routing table is +**rows in a SQLite database** in the `npm-data` volume, not a config file — so +setup is a one-time click-through and that volume is the only copy. Back it up. + +**First boot.** Tunnel to the admin UI and change the shipped login before +creating anything: + +```sh +ssh -L 81:127.0.0.1:81 -N you@the-vm # then http://127.0.0.1:81 +``` + +It logs in with `admin@example.com` / `changeme`, live from first boot. + +**One proxy host per adapter.** Hosts → Proxy Hosts → Add: + +| Domain | Forward Hostname | Port | Then | +|---|---|---|---| +| `exp.oan.example.com` | `exp-adapter` | 9202 | paste `config/reverse-proxy/npm-advanced/exp.conf` into **Advanced** | +| `network.oan.example.com` | `network-adapter` | 9201 | — | +| `provider.oan.example.com` | `provider-adapter` | 9200 | — | + +Scheme `http` for all three — TLS terminates at NPM and the hop inward is +inside `oan-edge`. Turn on **Block Common Exploits**; leave Websockets off. + +Three hosts rather than one with path prefixes, so a rate limit or a block +attaches to a hostname instead of a regex in a textarea, and each gets its own +certificate. An unknown `Host` gets NPM's default page, not an adapter. + +**Certificates.** SSL tab → Request a new certificate → Force SSL → HTTP +validation. Two things must be true, and both are easy to miss: + +- a public DNS **A record** per hostname, pointing at the VM — use a static + address unless you enjoy redoing this after every stop/start; +- **port 80 open to `0.0.0.0/0`**, not to your address. Let's Encrypt fetches + the challenge from its own servers, whose addresses you cannot enumerate. A + security group scoped to your IP fails with a challenge timeout that looks + nothing like a firewall problem. + +If 80 must stay closed, use **DNS validation** — NPM ships the Route 53 plugin, +so an access key with `route53:ChangeResourceRecordSets` needs no inbound +request. It is also the only option for a wildcard. Renewal reuses whichever +method you chose, so a temporarily-correct DNS record or SG rule fails silently +in sixty days. + +### `POST /publish` returns 403 on all three hosts + +Not optional hardening. The provider adapter's module at `/` verifies the +sender's signature; `oanProviderPublish`, on the exact path `/publish`, has +**no signature check at all**, because its intended caller is the provider's +own catalogue system inside the trust boundary. A proxy host pointed at +`provider-adapter:9200` therefore exposes `/publish` to anyone. + +NPM's UI cannot route a host while withholding one path, so the block lives in +`config/reverse-proxy/npm-custom/server_proxy.conf`, which NPM includes in +**every** proxy host's server block — a mounted file, not a click, so not +something to remember on one host out of three. + +To let a real catalogue system publish, give it a tunnel or put it in the VPC +and let it reach `provider-adapter:9200` directly. Do not turn that `deny` into +an `allow`. + +### Which nginx config loads itself, and which is a paste job + +| File | How it applies | +|---|---| +| `npm-custom/http_top.conf` | **Automatic**, top of the `http` block. The `exp` rate-limit zone and `limit_req_status 429`. | +| `npm-custom/server_proxy.conf` | **Automatic**, every server block. The `/publish` deny. | +| `npm-advanced/exp.conf` | **Manual** — paste into the experience host's Advanced tab. `limit_req` for that host only; a 10 r/s ceiling on signed peer traffic would throttle for no gain. | + +The manual one is in a file anyway because NPM's Advanced field is a textarea +in a database row — nothing diffs it and nothing reviews it. + +### Adding a route for another service + +Two steps, and the first is in git rather than the UI. NPM sits on `oan-edge`, +where only the three adapters resolve, so a proxy host pointed at `registry` or +`hyperdx` 502s rather than quietly working. **The UI alone cannot widen what is +public** — that is the property worth keeping. + +1. **Put the service on `oan-edge`** in `docker-compose.yml` + (`networks: [oan-internal, oan-edge]`), then + `docker compose up -d some-service nginx-proxy-manager`. NPM needs the + restart to resolve a name it could not see before. +2. **Add the proxy host.** Forward Hostname is the **Compose service name** + (`discovery`, not `oan-discovery`, not an IP); Forward Port is the + **container** port, not what loopback publishes it as. Then SSL, and a DNS + record before requesting the certificate. + +A service **not** in this Compose file needs no step 1 — NPM has egress, so put +its address straight into Forward Hostname. A **second path on an existing +domain** needs no new host either: Custom Locations, one certificate, one DNS +record. + +Anything answering unauthenticated needs an **Access List** on top. Discovery +does — `AUTH_ENABLE_SIGNATURE_VERIFICATION` is `false` in this build, so the +edge is the only authentication there is. Check both directions, because the +failure is silent: + +```sh +curl -s -o /dev/null -w '%{http_code}\n' https://discovery.oan.example.com/health -u user:pass # 200 +curl -s -o /dev/null -w '%{http_code}\n' https://discovery.oan.example.com/health # 401 +``` + +### The registry is the one you do not route + +It looks like the obvious candidate — `POST /api/v1/Participant/search` takes +no token and is exactly what a peer needs. But SunbirdRC uses POST for reads +*and* writes, so no method rule tells them apart and a proxy host forwards the +whole API. What keeps writes out today is not the route: it is that nothing +outside the VM can mint a Keycloak token, because Keycloak publishes on +loopback. A registry route would depend on that silently. + +## Appendix C — Startup order, and why it is that order + +``` +1. registry and discovery (also registry-db, keycloak, discovery-db) +2. bin/setup.py keys, five participants, two bindings, three configs +3. mock upstreams, then the three adapters +4. nginx-proxy-manager the public edge — 80 and 443, all interfaces +5. hyperdx ClickStack +``` + +`make up` runs all five; `make up-core` stops after 3, which is enough to +exercise the stack. + +**The order is not cosmetic.** An adapter config is a bind-mounted *file*, and +Docker creates a *directory* at any missing bind-mount source — so an adapter +started before step 2 wedges on `adapter.yaml: is a directory` and leaves a +directory where step 2 needs a file. This is why the Makefile exists rather +than a README line saying "run these in order". `setup.py` refuses with an +explanation if it finds one; delete them and re-run. + +Re-running is safe: `setup.py` reuses `keys/keys.json` and skips rows that +already exist. + +Verify: + +```sh +make ps +curl -s -X POST http://127.0.0.1:8081/api/v1/Participant/search \ + -H 'Content-Type: application/json' -d '{"filters":{}}' | python3 -m json.tool # five + +curl -s -o /dev/null -w '%{http_code}\n' https://provider.oan.example.com/publish # 403 +``` + +That 403 is the check worth repeating after **any** NPM change — it is the only +evidence `server_proxy.conf` is still mounted. + +## Appendix D — What is in the registry, and why you did not create it + +`setup.py` wrote all of it. Nothing here is a step; it is what to look at when +something does not match. + +**Three `node` rows, one per adapter** — an id, a role (`consumer`, `provider`, +`network`) and the public halves of a keypair. Private halves stay in +`keys/keys.json` and are never in the registry. Keys are published as bare +base64, no encoding label. + +**Two `upstream` rows, one per mock** — an ordinary HTTP API this deployment +calls. It signs nothing, so it needs no role and no keys. Holds a `baseUrl`, +here a Compose service name. No upstream credential lives in the registry +either: the adapter config names *environment variables*, not values. + +**Two `ProviderSchema` rows, one per capability** — which upstream answers +which capability and how to call it: method, path, timeout, retries, and the +mapping URL. Its `bindingKey` is `participantId|capabilityCode`: + +``` +mausamgram-mock|openagrinet:WeatherObservation +agmarknet-mock|openagrinet:MandiPrice +``` + +Those two strings are the hinge. The provider adapter builds the same key from +each payload — the provider id and the capability `@type` it carries — and a +step answers only when the key matches its own. `setup.py` renders those keys +into `provider.yaml` from the same `.env` it seeds the registry from, which is +what stops the two drifting. + +**Looking at it** — from the VM or a tunnel. Search takes no token; writes do, +and the token request has a trap: + +```sh +TOKEN=$(curl -s -X POST \ + "http://127.0.0.1:8080/auth/realms/sunbird-rc/protocol/openid-connect/token" \ + -H 'X-Forwarded-Host: keycloak:8080' -H 'X-Forwarded-Proto: http' \ + -d 'client_id=registry-frontend' -d 'grant_type=password' \ + -d 'username=no-user' -d 'password=no-user-password' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])') +``` + +Those `X-Forwarded-*` headers are not optional and `keycloak:8080` is the +**container-internal** address on purpose. Keycloak builds the token's issuer +from them and the registry validates it against the internal address; get it +wrong and you get a 401 with an empty body. + +**An upstream may carry its own `keys`.** `setup.py` adds none — the mocks have +no keypair — but the schema permits it, and the adapter accepts such a key as a +signer. With one, a provider signs its own catalogue and posts `/publish` +straight at the **network** adapter, which verifies against that row. The +provider adapter drops out of the publish path, and with it the +unauthenticated `/publish` it otherwise has to expose. + +## Appendix E — How a request flows + +``` +discover you -> exp -> network -> discovery service +select you -> exp -> provider -> the upstream that owns that capability +publish a catalogue system -> provider -> network -> discovery service +``` + +`discover` and `publish` both end at discovery and both go through the network +adapter, which fronts it, verifies the caller and re-signs. `select` never +touches it. + +Which upstream answers is in no routing table. The provider adapter runs a +chain of capability steps — `WeatherObservation`, then `MandiPrice` — each +building a binding key from the payload, serving the request if the key is its +own and passing it through untouched if not. So one adapter fronts both +capabilities, and a third is a plugin plus two registry rows, not a new route. + +**The action comes from the URL, not the payload.** The adapter strips the +module's mount path and matches what remains — `select`, `discover` — against +the routing config. The schema validator is the exception: it reads +`context.action` from the body and ignores the path. Nothing reconciles the +two, though a mismatch usually fails validation anyway. + +Publishing enters at the **provider** adapter, which signs and forwards: + +```sh +curl -s -X POST http://127.0.0.1:9200/publish \ + -H 'Content-Type: application/json' -d @your-catalog.json +``` + +Three things follow from that: + +- It is mounted on the **exact path** `/publish` while the Beckn surface takes + the whole subtree at `/`. Go's mux prefers the exact pattern, so `/select` + still reaches the capability module. Give both the same path and registration + panics at startup. +- **Hence `routing-provider.yaml` keys on an empty endpoint.** Stripping + `/publish` off `/publish` leaves nothing, so the empty string *is* the + endpoint — which is why `excludeAction: true` and a target URL written out in + full. +- **The body needs no `bapId`/`bppId` and the caller need not sign.** This + module verifies nothing inbound; it signs the forwarded request as itself, + and identity travels in the `Authorization` header's `keyId`. The network + adapter verifies that. + +## Appendix F — When it does not work + +**404 `NET_ENTITY_NOT_FOUND`, "no capability matching the request".** No +provider step recognised the payload, so each passed it through and nothing +answered. A step compares a key built from the payload — provider id at +`message.contract.commitments[].offer.provider.id`, capability at +`...resources[].resourceAttributes.@type` — against its own config. Compare the +payload with `.env`, then re-run `setup.py` and recreate the adapter. + +**404 naming a binding with no active record.** The other side: a step *is* +configured for the key, but no active `ProviderSchema` row carries it. + +```sh +curl -s -X POST http://127.0.0.1:8081/api/v1/ProviderSchema/search \ + -H 'Content-Type: application/json' -d '{"filters":{}}' \ + | python3 -c 'import json,sys; [print(r["bindingKey"], r.get("status")) for r in json.load(sys.stdin)]' +``` + +Compare character for character. The registry is append-only, so a mistyped row +cannot be edited, only superseded under a new id. + +**`unrecognized step: ` at startup.** Not a config typo. A step name that +is not built in is looked up among loaded plugins, and a plugin's id is the +basename of its `.so` — so `ADAPTER_IMAGE` predates the config. Check what the +image carries, then fix the tag rather than the config → Appendix G. + +```sh +docker run --rm --entrypoint sh $ADAPTER_IMAGE -c 'ls plugins/*.so' +``` + +**502 from a `select`, with an upstream status in it.** Not a binding problem — +the upstream answered non-2xx. 4xx is reported immediately, 5xx retried up to +`retryMax` from the ProviderSchema row. A non-2xx never reaches the mapping. + +**Adapters restart in a loop on the first `up`.** Expected before `setup.py` +has run. If it persists, look for a *directory* where a config file should be → +Appendix C. + +**`setup.py` says the registry did not come up.** Check `make ps`. Keycloak's +healthcheck allows five minutes on a cold volume. + +**`setup.py` says a participant is registered with a different key.** The +registry cannot update a published key and its delete is soft, so the id cannot +be reused. Restore the matching `keys/keys.json`, or pick a new +`*_SUBSCRIBER_ID` in `.env`. + +**The registry refuses a write with 401 and an empty body.** The token's issuer +does not match → the `X-Forwarded-*` headers in Appendix D. + +**A pull or a mapping fetch fails with "network is unreachable".** DNS returned +an IPv6 address the host cannot route. + +**NPM's default page, a 502 that worked yesterday, a failed certificate, or 429 +on the experience host** → Appendix B. + +## Appendix G — Updating a deployment that is already running + +**Config only** — a `.tmpl`, a routing file, `.env`: + +```sh +make pull # git pull, and fixes the ownership NPM leaves behind +make up # step 2 re-renders the configs, then recreates +``` + +`make restart` alone is not enough for a `.tmpl` change: adapters read a +rendered `.yaml`, and only `setup.py` writes it. + +**A new adapter image as well.** Any change to plugin ids is this case, because +an id is a `.so` basename. If `ADAPTER_IMAGE` names a **new tag**, set it before +`make up`. If it follows **`latest`**, `make up` is not enough — +`pull_policy: missing` means a tag already on disk is never re-fetched and +nothing in `stack.sh` pulls, so the stack quietly comes back on the old image: + +```sh +docker compose pull provider-adapter network-adapter exp-adapter +``` + +Build from the adapter repo at the commit the config expects, and check the +image before deploying it — this is the step that catches a wrong branch: + +```sh +docker run --rm --entrypoint sh -c 'ls plugins/*.so' +``` + +Rebuild **every** adapter image, not one. A partial rebuild presents as a config +typo in one adapter rather than a stale image in the others. + +**Payload shapes changed.** If `@context` moved, catalogues already in +discovery carry the old value and `schemaContext` is matched by exact string +equality — so discover alone returns zero. Run the collection top to bottom so +publish reseeds first; `updateMode: MERGE` updates in place. + +**Then check, cheapest first:** + +```sh +docker compose logs provider-adapter | grep 'Processor steps initialized' +docker compose logs provider-adapter | grep -iE '"level":"(error|fatal)"' +make ps +``` + +**Rolling back** is `git checkout `, `ADAPTER_IMAGE` back to the old +image, `make up` — both together, since the old image with the new config fails +at startup and the new image with the old config runs the old behaviour +silently. Note `latest` serves this badly: "the old image" has no name once the +tag has moved, so recovery is by digest. Pin a tag before a change you might +need to undo. + +## Appendix H — Telemetry + +`make observability` brings up HyperDX on `127.0.0.1:8085` with OTLP on +4317/4318. It is `clickstack-local`: single-user, no team to create and no +ingestion key to mint, which is what makes it one command — and also why it +must stay on loopback, since there is no login in front of it. + +**Less arrives than the wiring suggests**, which is worth knowing before +hunting for absent traces. Discovery reads the OTLP variables but nothing in +the current build consumes them, so `OTEL_EXPORTER` stays `none`. Whether the +adapter image's SDK reads them is unverified — nothing depends on the answer, +since an absent collector makes an exporter drop spans rather than fail a +request. And container logs go nowhere near HyperDX without a collector with a +`filelog` receiver, which is not in this stack; `docker compose logs -f` +remains the way to read them. + +Treat this profile as the destination being ready, not as observability being +switched on. + +## Appendix I — Schema validation + +Every adapter validates request bodies against the pinned Beckn v2 LTS spec. On +the **provider adapter** a second layer runs too: it walks the payload for +objects carrying `@context` and `@type`, resolves the schema `@type` names, and +validates against it. Base validation treats `resourceAttributes` as free-form, +so this is the only layer checking a capability's own attributes. + +The schemas are neither committed nor mounted. `@context` names the published +pack and the validator swaps `context.jsonld` for `attributes.yaml`: + +``` +@context .../network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/context.jsonld +fetched .../network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/attributes.yaml +``` + +So a payload names the revision it is judged against, and no copy here can +drift. Cached 24h, so only the first payload after a restart pays. Two +consequences: the adapter needs egress to `raw.githubusercontent.com`, and a +**failed fetch rejects the payload** rather than skipping validation. An +`@context` on any other host is refused before any fetch — +`extendedSchema_allowedDomains` is the list. + +**What it does not check: `if`/`then`/`else`.** The validator library parses +those keywords and never evaluates them, so every pack rule predicated on +`informationMode` is unenforced — a pass here is not pack conformance. It does +enforce types, string formats, `enum`, `const`, `required`, +`additionalProperties`, `not` and `allOf`/`anyOf`/`oneOf`. + +Three things that bite when writing a payload: + +- **Every resource under a commitment needs a `quantity`.** The spec requires + it while defining no `Quantity` schema at all — a defect upstream. Any value + satisfies it; without one every `select` is refused with + `SCH_REQUIRED_FIELD_MISSING`. +- **A `date-time` field will not take a bare date.** `validity.startsAt`/ + `endsAt` are `format: date-time`, so `2025-08-20` is refused and + `2025-08-20T00:00:00+05:30` accepted. `arrivalDate` is `format: date` and + wants the opposite. +- **`publish` is validated on the provider adapter**, because `validateSchema` + is in that module's `steps:` — declaring a validator is not enough, a plugin + missing from `steps:` never runs. The network adapter validates nothing: its + single module is `validateSign`, `addRoute`, `sign`. + +## Appendix J — About the mapping files + +`config/mappings/` holds the two this deployment uses, and `MAPPING_URL` / +`MANDI_MAPPING_URL` point at **this repo's own copies** over GitHub's raw CDN — +so the file a reader reviews and the file the adapter fetches are one file. + +Each has two halves: the request half turns the Beckn payload into what the +upstream expects, the response half turns the answer into resources. The mandi +one shows why this is not field renaming — ISO dates to `dd-MM-yyyy`, +`marketcode` sent only when the request carried one, price strings to numbers, +an unreported price omitted rather than sent as zero. + +It is a URL rather than a path because the registry publishes the full URL and +the adapter fetches it verbatim — so a mapping must be reachable before it can +be tested, and this stack exercises exactly what a consumer fetches. + +**Note the branch in those URLs.** Once this merges, point them at the default +branch or pin a tag. + +A real upstream answering with different field names, a different date format +or a nested envelope is a mapping edit and a cache expiry — no code. What is +*not* fixable here is anything depending on a response that never arrives: a +non-2xx fails the step first. Allow a few minutes for an edit to appear — +one minute of adapter cache plus about five of CDN. + +## Appendix K — The layout + +``` +docker-compose.yml the whole stack, in tiers -- the banner comments + are the structure +.env.example copy to .env +Makefile the front door; every target delegates to stack.sh +bin/ + bootstrap-ubuntu.sh docker and python on a fresh Ubuntu VM + stack.sh the startup order, and why it is that order + setup.py keys, five registry rows, the adapter configs +config/ + reverse-proxy/ + npm-custom/ mounted to /data/nginx/custom; NPM includes these + http_top.conf on its own -- rate-limit zone, and the /publish + server_proxy.conf deny every proxy host gets + npm-advanced/exp.conf NOT loaded. Paste into the Advanced tab; kept here + because a textarea in a database is not reviewable + adapters/ + experience.yaml.tmpl templates. setup.py renders these to .yaml, + network.yaml.tmpl filling in the keys it generated. The rendered + provider.yaml.tmpl files hold private keys and are gitignored + routing-experience.yaml which action goes where: experience sends discover + routing-network.yaml to the network layer and select to the provider; + routing-provider.yaml provider sends publish to the network layer + registry/ + schemas/ Participant, ProviderSchema, SchemaRegistry. + Read at startup -- a change needs a restart + imports/ the Keycloak realm + discovery/ optional instance override + mappings/ one file per binding-action, served over the raw CDN +../postman-collection/ the collection and its environment file +``` + +## Appendix L — Renaming this directory + +Compose takes its **project name from the directory holding the compose file**, +and every named volume is prefixed with it. So renaming this directory renames +all five volumes, and **Docker does not move the data**: a plain `make up` +afterwards starts on an empty registry, an empty catalogue, and an NPM with no +proxy hosts and no certificates. The old volumes are orphaned, not gone. + +`npm-letsencrypt` is the one to care about — re-issuing runs into Let's +Encrypt's duplicate limit, five per week for the same names. + +Copy the data across before starting. Stop the stack first, from whichever +name it is running under: + +```sh +make down +for v in registry-data discovery-data npm-data npm-letsencrypt hyperdx-data; do + docker volume create "quick-start_$v" >/dev/null + docker run --rm -v "old-name_$v:/from" -v "quick-start_$v:/to" alpine \ + sh -c 'cd /from && tar cf - . | (cd /to && tar xf -)' +done +``` + +Then `make up`, and confirm the five participants and your proxy hosts before +deleting anything. Keycloak shares `registry-data`, so its realm travels with +that volume — and equally does not survive if you skip it. + +## Appendix M — Starting over + +```sh +docker compose down -v +rm -rf keys config/adapters/experience.yaml config/adapters/network.yaml \ + config/adapters/provider.yaml +``` + +New keys mean new identities, so the provider rows must be created again and +**the old participant ids cannot be reused** — the registry's delete is soft +and keeps the unique index. + +`-v` deletes **every** volume, including `npm-letsencrypt` and `npm-data` — +your certificates and your whole routing table. To clear only catalogues, drop +`quick-start_discovery-data` alone and leave the rest. + +## Appendix N — What the collection demonstrates + +**The two `select` requests are the pair worth comparing.** Same endpoint, same +adapter, and different domain packages answer them — because each provider step +builds a binding key from the payload, serves the request if the key is its own +and passes through anything else. Nothing routes by URL, path or domain. That +is the whole dispatch mechanism, and these two requests are what show it. + +**`networkAdapterUrl` is a variable no request uses, on purpose.** `discover` +reaches the network adapter through the experience adapter and `publish` +through the provider adapter, so nothing in the collection calls it directly. +It is listed because it is the other adapter a deployment exposes publicly — +its `/publish` and `/discover` both verify signatures, so a network peer calls +it directly. Signing is not something Postman does, so those calls are not +scripted. The variable exists to give the address somewhere to live, not +because a request is missing. diff --git a/quick-start/bin/bootstrap-ubuntu.sh b/quick-start/bin/bootstrap-ubuntu.sh new file mode 100755 index 0000000..519c6bf --- /dev/null +++ b/quick-start/bin/bootstrap-ubuntu.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Everything an Ubuntu VM needs before `make up` will run. Idempotent -- safe to +# re-run, and safe to run on a box that already has some of this. +# +# curl -fsSL https://raw.githubusercontent.com/OpenAgriNet/helmcharts//quick-start/bin/bootstrap-ubuntu.sh | bash +# +# or, once the repo is cloned: +# +# bin/bootstrap-ubuntu.sh +# +# It does NOT clone the repo, write .env, or start anything. Those need +# decisions -- which branch, which credentials -- that do not belong in a +# script piped from the internet. + +set -euo pipefail + +say() { printf '\n\033[1;36m==> %s\033[0m\n' "$1"; } +info() { printf ' %s\n' "$1"; } +die() { printf '\033[1;31mbootstrap: %s\033[0m\n' "$1" >&2; exit 1; } + +[ "$(id -u)" -ne 0 ] || die "run as a normal user, not root -- this uses sudo where it needs to" +command -v apt-get >/dev/null || die "not a Debian/Ubuntu system" + +# ---------------------------------------------------------------- sizing + +# Checked rather than assumed, because the failure mode of an undersized box is +# the OOM killer taking out a Postgres mid-write, which surfaces as data +# corruption rather than as "out of memory". +say "checking this VM against what the stack needs" +mem_gb=$(( $(awk '/MemTotal/{print $2}' /proc/meminfo) / 1024 / 1024 )) +disk_gb=$(df -BG --output=avail / | tail -1 | tr -dc '0-9') +info "RAM ${mem_gb} GB (8 GB minimum, 16 GB if you run the observability profile)" +info "disk ${disk_gb} GB free (20 GB minimum -- the images alone are ~6 GB)" +[ "$mem_gb" -ge 7 ] || info "WARNING: under 8 GB. Two Postgres, two JVMs and three adapters will not fit." +[ "$disk_gb" -ge 20 ] || info "WARNING: under 20 GB free. A full disk corrupts the Docker VM rather than erroring cleanly." + +# ------------------------------------------------------------------ apt + +say "apt packages" +sudo apt-get update -qq +# python3-cryptography from apt rather than pip: Ubuntu 24.04 marks the system +# python as externally-managed (PEP 668), so `pip install cryptography` refuses +# without --break-system-packages. The apt build is the same library. +sudo apt-get install -y -qq \ + ca-certificates curl gnupg git make python3 python3-cryptography +info "git, make, python3, python3-cryptography" + +# --------------------------------------------------------------- docker + +if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then + say "docker already present" + info "$(docker --version)" + info "$(docker compose version)" +else + # Docker's own apt repo, not the `docker.io` package and not snap. The + # distro package lags, and the snap runs confined -- bind mounts out of a + # home directory, which this stack does for every adapter config, fail + # under it in ways that read as file-not-found. + say "installing docker engine from docker's apt repository" + sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg --yes + sudo chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo apt-get update -qq + sudo apt-get install -y -qq \ + docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + info "$(docker --version)" +fi + +say "docker group" +if id -nG "$USER" | tr ' ' '\n' | grep -qx docker; then + info "$USER is already in the docker group" +else + sudo usermod -aG docker "$USER" + info "$USER added to the docker group" + info "LOG OUT AND BACK IN before docker works without sudo -- group" + info "membership is read at login, so this shell still cannot use it." +fi + +say "enabling docker at boot" +sudo systemctl enable --now docker >/dev/null 2>&1 || true +info "$(systemctl is-active docker 2>/dev/null || echo unknown)" + +# ------------------------------------------------------------------ ufw + +# Only touched if it is already running. Turning a firewall on for someone is +# a good way to end a session, and on EC2 the security group is the control +# that matters anyway. +if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "^Status: active"; then + say "ufw is active -- opening what the edge needs" + sudo ufw allow 80/tcp >/dev/null + sudo ufw allow 443/tcp >/dev/null + info "80 and 443 allowed. Everything else in this stack binds 127.0.0.1" + info "and is reached over the SSH tunnel, so nothing further is needed." +else + say "ufw not active -- leaving it alone" + info "On EC2 the security group is the real control. 80 and 443 must be" + info "open to 0.0.0.0/0, not to your address: Let's Encrypt validates" + info "HTTP-01 from its own servers." +fi + +say "done" +cat <<'NEXT' + + If this added you to the docker group, log out and back in now. + + Then: + + git clone -b feat/4-docker-compose https://github.com/OpenAgriNet/helmcharts.git + cd helmcharts/quick-start + cp .env.example .env && nano .env # change every credential + make up + + `make up` includes the gateway and hyperdx. Use `make up-core` for neither. + See CERTIFICATES.md before requesting a certificate. + +NEXT diff --git a/quick-start/bin/setup.py b/quick-start/bin/setup.py new file mode 100755 index 0000000..e84938a --- /dev/null +++ b/quick-start/bin/setup.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Prepare the stack: generate the adapter keypairs, seed the registry, render +the adapter configs. + + python3 bin/setup.py + +`make up` runs this as step 2, which is where it belongs -- the adapter configs +it renders are bind-mounted files, and an adapter started before they exist +leaves a directory in their place. + +WHAT IT WRITES. Five participants and two capability bindings: + + 3 x node one per adapter -- exp, network, provider -- each with the + public halves of a keypair. The private halves stay in + keys/keys.json and never reach the registry. + 2 x upstream the two APIs this deployment calls, addressed by compose + service name. An upstream signs nothing, so it needs no role + and no keys. + 2 x binding a ProviderSchema row per capability: which upstream answers + it, the method and path, timeouts, and the mapping URL. + +This is all of it. Nothing has to be created by hand afterwards, and nothing +can be from outside the VM -- the registry has no route through the gateway and +publishes on loopback only, which is why seeding lives here rather than in a +Postman request. + +The binding keys are the load-bearing part. A provider step answers only when +the key it was configured with matches the one built from the incoming payload, +and both sides come from the same .env values in this one run -- which is what +keeps them from disagreeing. To point a capability somewhere else: edit .env, +re-run this, recreate the provider adapter. + +Safe to re-run. Keys are generated once and reused from keys/keys.json, so the +identities already in the registry stay valid; participants and bindings that +exist are left alone rather than recreated, because this registry's delete is +soft and holds the unique index -- a deleted participantId cannot be reused. + +Needs python3 and the cryptography package: + + pip install cryptography +""" +import base64, json, os, pathlib, sys, time, urllib.error, urllib.parse, urllib.request + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives import serialization as ser + +ROOT = pathlib.Path(__file__).resolve().parent.parent +KEYS = ROOT / "keys" / "keys.json" +ADAPTERS = ROOT / "config" / "adapters" + + +def load_dotenv(): + """Read .env into the environment. + + Nothing sources .env before this runs -- `make up` shells straight out to + python3 -- and a real environment variable wins so a one-off override still + works: + + REGISTRY_PORT=9081 python3 bin/setup.py + """ + path = ROOT / ".env" + if not path.exists(): + sys.exit("setup: no .env -- copy .env.example to .env first") + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + os.environ.setdefault(name.strip(), value.strip()) + + +def env(name, default=None): + v = os.environ.get(name, default) + if v is None: + sys.exit(f"setup: {name} is not set -- is it missing from .env?") + return v + + +# --------------------------------------------------------------------- keys + +def b64(raw): + return base64.b64encode(raw).decode() + + +def ed25519_pair(): + k = Ed25519PrivateKey.generate() + return (b64(k.private_bytes(ser.Encoding.Raw, ser.PrivateFormat.Raw, ser.NoEncryption())), + b64(k.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw))) + + +def x25519_pair(): + k = X25519PrivateKey.generate() + return (b64(k.private_bytes(ser.Encoding.Raw, ser.PrivateFormat.Raw, ser.NoEncryption())), + b64(k.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw))) + + +def load_or_generate_keys(): + """Keys persist across runs: the registry already holds the public halves.""" + roles = (("exp", env("EXP_SUBSCRIBER_ID")), + ("network", env("NETWORK_SUBSCRIBER_ID")), + ("provider", env("PROVIDER_SUBSCRIBER_ID"))) + + if KEYS.exists(): + print("keys: reusing keys/keys.json") + identities = json.loads(KEYS.read_text()) + # The keypair persists; the id is read from .env each run. This registry + # cannot update a record, so changing an id in .env seeds a NEW + # participant rather than editing one -- and the same keypair moving to + # the new id is what makes that a rename rather than a rekey. + for role, participant in roles: + if identities[role]["participantId"] != participant: + print(f" {role}: id is now {participant}, keeping the keypair") + identities[role]["participantId"] = participant + KEYS.write_text(json.dumps(identities, indent=2)) + return identities + + print("keys: generating") + identities = {} + for role, participant in roles: + sign_private, sign_public = ed25519_pair() + encr_private, encr_public = x25519_pair() + identities[role] = {"participantId": participant, + "signingPrivate": sign_private, "signingPublic": sign_public, + "encrPrivate": encr_private, "encrPublic": encr_public} + KEYS.parent.mkdir(exist_ok=True) + KEYS.write_text(json.dumps(identities, indent=2)) + KEYS.chmod(0o600) + return identities + + +# ----------------------------------------------------------------- registry + +def registry_url(): + return f"http://localhost:{env('REGISTRY_PORT', '8081')}" + + +def token(): + body = urllib.parse.urlencode({ + "client_id": env("KEYCLOAK_CLIENT_ID", "registry-frontend"), + "grant_type": "password", + "username": env("REGISTRY_USER", "no-user"), + "password": env("REGISTRY_PASSWORD", "no-user-password"), + }).encode() + # Keycloak sits behind PROXY_ADDRESS_FORWARDING, so it builds the token's + # issuer from these headers. Without them it answers with an empty body. + # + # keycloak:8080 is the CONTAINER-INTERNAL address, and is deliberately not + # KEYCLOAK_PORT. The registry validates the issuer against + # OAUTH2_RESOURCES_0_URI, which names that internal address -- so a token + # minted with the host port in its issuer is rejected with a 401 and an + # empty body, however the port is published. + req = urllib.request.Request( + f"http://localhost:{env('KEYCLOAK_PORT', '8080')}/auth/realms/" + f"{env('KEYCLOAK_REALM', 'sunbird-rc')}/protocol/openid-connect/token", + data=body, headers={"X-Forwarded-Host": "keycloak:8080", + "X-Forwarded-Proto": "http"}) + with urllib.request.urlopen(req, timeout=30) as r: + payload = json.load(r) + if not payload.get("access_token"): + sys.exit("setup: keycloak issued no token -- check the KEYCLOAK_* values in .env") + return payload["access_token"] + + +def post(entity, payload, bearer): + # No {"EntityName": {...}} wrapper: this registry takes the record itself, + # and a wrapper comes back as "extraneous key [...] is not permitted". + req = urllib.request.Request(f"{registry_url()}/api/v1/{entity}", method="POST", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {bearer}"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r) + except urllib.error.HTTPError as e: + # The registry answers a rejected write with a JSON envelope, but not + # always: a 401 comes back with an empty body. Decoding blindly turns + # that into a JSONDecodeError traceback that says nothing about what + # went wrong, so report the status instead. + raw = e.read() + try: + return json.loads(raw) + except ValueError: + detail = raw.decode(errors="replace").strip()[:200] or "(empty body)" + sys.exit(f"setup: the registry refused a write -- HTTP {e.code}: {detail}\n" + f" A 401 here usually means the token was minted for a different\n" + f" issuer than the registry validates against.") + + +def search(entity, filters): + req = urllib.request.Request(f"{registry_url()}/api/v1/{entity}/search", method="POST", + data=json.dumps({"filters": filters}).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r).get("data", []) + + +def wait_for_registry(): + for _ in range(60): + try: + search("Participant", {}) + return + except Exception: + time.sleep(2) + sys.exit("setup: the registry did not come up -- check `docker compose ps`\n" + " and that REGISTRY_PORT in .env matches the published port") + + +def signing_key_block(public_key): + """The published half of the signing keypair. + + Bare base64, with no encoding label in front of it: what a verifier hands + to a base64 decoder is the value as published, and a label left on fails + every verification with a decode error pointing nowhere near the registry. + + No friendly key id, because nothing could look one up: the registry assigns + an osid on write and that is what a sender names in the Authorization + header. No use either -- alg carries the purpose, ed25519 signs.""" + return [{"alg": "ed25519", "key": public_key, "status": "active", + "validFrom": "2026-01-01T00:00:00Z", "validUntil": "2030-01-01T00:00:00Z"}] + + +def node(participant_id, name, role, public_key): + """A participant that speaks Beckn. + + One level, no wrapper object: type decides which fields apply. baseUrl must + be https for a node, and the id must be hostname-shaped -- it is the + identity a signature is checked against. Neither is + resolved here: routing between the adapters is the router plugin's config, + which uses the compose service names.""" + return {"participantId": participant_id, "name": name, "type": "node", + "status": "active", "baseUrl": f"https://{participant_id}", + "role": role, "keys": signing_key_block(public_key)} + + +def upstream(participant_id, name, base_url): + """An ordinary API the provider adapter calls. + + No role and no keys: it has never heard of Beckn, so it signs nothing and + nothing verifies it. Both are permitted by the schema but neither is read -- + a signature is checked against the node identity that signed it. + + No credential either. The adapter presents one from its own config, naming + the environment variable it comes from, so nothing secret is held here.""" + return {"participantId": participant_id, "name": name, "type": "upstream", + "status": "active", "baseUrl": base_url} + + +def ensure_binding(bearer, participant_id, capability, path, mapping_url): + """Create a capability binding only when absent. + + actions is a list, not a map: the registry treats every nested object as an + entity and injects an osid into it, which a map cannot carry. It is also + what lets one action be retired without touching the others. + + mappings is one reference carrying both directions, because the response + mapping reads what the request mapping resolved.""" + binding = f"{participant_id}|{capability}" + if search("ProviderSchema", {"bindingKey": {"eq": binding}}): + print(f" {binding}: already present") + return + result = post("ProviderSchema", { + "bindingKey": binding, "participantId": participant_id, + "capabilityCode": capability, "status": "active", + "actions": [{"action": "select", "method": "GET", "path": path, + "mappings": mapping_url, + "timeoutMs": 15000, "retryMax": 2, + "status": "active"}]}, bearer) + print(f" {binding}: {result['params']['status']} " + f"{result['params'].get('errmsg', '')[:160]}") + + +def ensure_participant(bearer, participant_id, payload): + """Create only when absent. Delete here is soft and keeps the unique index, + so a recreate would fail on a duplicate key rather than replacing.""" + if search("Participant", {"participantId": {"eq": participant_id}}): + print(f" {participant_id}: already present") + return + result = post("Participant", payload, bearer) + status = result["params"]["status"] + print(f" {participant_id}: {status} {result['params'].get('errmsg', '')[:160]}") + + +def seed(identities): + wait_for_registry() + bearer = token() + + # Five participants and two capability bindings, all of it from here. + # + # The registry is not reachable from outside this stack -- no published + # port beyond loopback and no proxy host in front of it -- so there is no + # second way to create these. Everything the network needs to answer a + # request has to exist by the time this returns. + print("registry: three adapter identities") + for role, name, network_role in ( + ("exp", "OAN experience layer adapter", "consumer"), + ("network", "OAN network layer adapter", "network"), + ("provider", "OAN provider layer adapter", "provider")): + identity = identities[role] + ensure_participant(bearer, identity["participantId"], + node(identity["participantId"], name, network_role, + identity["signingPublic"])) + + # The two upstreams, addressed by compose service name: they are called from + # inside this network and nowhere else. + print("registry: two upstream providers") + weather = env("PROVIDER_PARTICIPANT_ID") + ensure_participant(bearer, weather, + upstream(weather, "IMD Mausamgram NWP (mock)", + env("MAUSAMGRAM_BASE_URL", "http://mockimd:9100"))) + mandi = env("MANDI_PARTICIPANT_ID") + ensure_participant(bearer, mandi, + upstream(mandi, "Agmarknet Vistaar (mock)", + env("MANDI_BASE_URL", "http://mockagmarknet:9101"))) + + # And what each of them answers. The binding key is participantId piped to + # capabilityCode, and it has to match what the provider adapter was + # rendered with -- both come from the same .env, which is what keeps them + # from disagreeing. + print("registry: two capability bindings") + ensure_binding(bearer, weather, env("PROVIDER_CAPABILITY"), + env("MAUSAMGRAM_PATH", "/get-daily"), env("MAPPING_URL")) + ensure_binding(bearer, mandi, env("MANDI_CAPABILITY"), + env("MANDI_PATH", "/v1/fetch-agmarknet-vistaar"), + env("MANDI_MAPPING_URL")) + + +def key_osids(identities): + """Read back each key's osid, and check the registry still holds the public + half we have the private half for. + + The Authorization header names a key by its osid rather than by the friendly + id, so the adapters have to be configured with the value the registry + assigned. + + The mismatch check matters because this registry cannot update a record and + its delete is soft: a participant seeded against an earlier keys.json keeps + that public key forever. Signing with a new private half would then produce + signatures nobody can verify -- and the failure would surface much later, as + an authentication error with no obvious cause.""" + for role, identity in identities.items(): + records = search("Participant", {"participantId": {"eq": identity["participantId"]}}) + keys = (records[0].get("keys") or []) if records else [] + if not keys: + sys.exit(f"setup: {identity['participantId']} has no published key") + + # Bare base64 now, but an older row may still carry the label, and a + # confusing mismatch error is worse than one tolerant line. + published = keys[0]["key"].removeprefix("base64:") + if published != identity["signingPublic"]: + sys.exit( + f"setup: {identity['participantId']} is registered with a different key.\n" + f" This registry cannot update a record, and its delete is soft and keeps\n" + f" the unique index, so the id cannot be reused. Either restore the\n" + f" matching keys/keys.json, or pick a new id for {role.upper()}_SUBSCRIBER_ID\n" + f" in .env and re-run.") + identity["keyOsid"] = keys[0]["osid"] + return identities + + +# ------------------------------------------------------------------ configs + +# The config filename for each role. +# +# Deliberately separate from the role key. That key is also the entry in +# keys/keys.json and the __EXP_* placeholder prefix, and changing it would make +# this script generate a FRESH keypair for a participant the registry has +# already published a public key for -- which it cannot update and whose delete +# is soft, so the id could not be reused either. The adapter would then sign +# with a key nobody can verify, and it would surface much later as an +# authentication error with no obvious cause. +# +# So the file can be spelled out in full without touching the thing that has to +# stay stable. +CONFIG_STEM = {"exp": "experience", "network": "network", "provider": "provider"} + + +def render(identities): + print("configs:") + binding = f"{env('PROVIDER_PARTICIPANT_ID')}|{env('PROVIDER_CAPABILITY')}" + mandi_binding = f"{env('MANDI_PARTICIPANT_ID')}|{env('MANDI_CAPABILITY')}" + for role in ("exp", "network", "provider"): + identity = identities[role] + stem = CONFIG_STEM[role] + template = (ADAPTERS / f"{stem}.yaml.tmpl").read_text() + prefix = role.upper() + for placeholder, value in ( + (f"__{prefix}_SUBSCRIBER_ID__", identity["participantId"]), + (f"__{prefix}_KEY_ID__", identity["keyOsid"]), + (f"__{prefix}_SIGNING_PRIVATE__", identity["signingPrivate"]), + (f"__{prefix}_SIGNING_PUBLIC__", identity["signingPublic"]), + (f"__{prefix}_ENCR_PRIVATE__", identity["encrPrivate"]), + (f"__{prefix}_ENCR_PUBLIC__", identity["encrPublic"]), + ("__PROVIDER_BINDING_KEY__", binding), + ("__MANDI_BINDING_KEY__", mandi_binding), + # Telemetry. One switch drives all three signals: with every + # one false the plugin builds no exporter and never dials, so + # a stack running without the observability profile stays + # quiet instead of logging a refused connection on a loop. + ("__OTEL_ENABLED__", env("OTEL_ENABLED", "true")), + ("__OTLP_ENDPOINT__", env("OTLP_ENDPOINT", "hyperdx:4317")), + ("__OTEL_ENVIRONMENT__", env("OTEL_ENVIRONMENT", "dev"))): + template = template.replace(placeholder, value) + if "__" in template: + sys.exit(f"setup: {stem}.yaml still has unrendered placeholders") + out = ADAPTERS / f"{stem}.yaml" + # A bare `docker compose up -d` before this script runs starts the + # adapters too, and Docker creates a DIRECTORY at a bind-mount source + # that does not exist. Writing would then fail with a bare + # IsADirectoryError that says nothing about the cause. + if out.is_dir(): + sys.exit( + f"setup: {out} is a directory, not a file.\n" + f" Docker created it, which means the adapters were started before this\n" + f" script ran. Bring them down, remove the empty directories and retry:\n" + f" docker compose down\n" + f" rmdir config/adapters/*.yaml\n" + f" make up") + out.write_text(template) + out.chmod(0o600) # holds a private key + print(f" config/adapters/{stem}.yaml") + + +if __name__ == "__main__": + load_dotenv() + identities = load_or_generate_keys() + seed(identities) + identities = key_osids(identities) + KEYS.write_text(json.dumps(identities, indent=2)) + render(identities) + print(f""" +ready. The registry holds five participants and two capability bindings, and +the adapter configs are rendered, so nothing further has to be created by hand. + + {env('PROVIDER_PARTICIPANT_ID')}|{env('PROVIDER_CAPABILITY')} + {env('MANDI_PARTICIPANT_ID')}|{env('MANDI_CAPABILITY')} + +Those are the binding keys the provider adapter answers to. They were rendered +into its config from the same .env this seeded the registry from, which is what +keeps the two from disagreeing. A payload naming anything else is answered 404 +"this module serves no capability matching the request" -- explicit, but it +names the request rather than the mismatch, so compare it against these two. + +Both providers are mocks reached by compose service name. Pointing a capability +at a real upstream is an .env edit and a re-run of this: a new participant id +and base URL under PROVIDER_* or MANDI_*, which seeds a new Participant and +ProviderSchema row and re-renders the provider config so its binding key +matches. The registry is not reachable from outside this stack, so that write +happens from here. + +Next: `make up` continues to step 3 and starts the adapters. If you ran this +on its own, the adapters need recreating to pick up the rendered configs: + + docker compose up -d --force-recreate provider-adapter network-adapter exp-adapter + +Then import postman-collection/ and run it -- six requests, nothing to fill +in.""") diff --git a/quick-start/bin/stack.sh b/quick-start/bin/stack.sh new file mode 100755 index 0000000..3a5993c --- /dev/null +++ b/quick-start/bin/stack.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# +# Bring the stack up in the order it has to come up in, and take it back down. +# +# bin/stack.sh up the whole stack, in the order it has to start +# bin/stack.sh up-core the same minus the gateway and hyperdx +# bin/stack.sh down stop everything, keep the data +# bin/stack.sh destroy stop everything, DELETE the data +# +# Everything here is also a make target: `make up`, `make down`. The Makefile is +# the front door; this file is where the reasoning lives. +# +# ------------------------------------------------------------ why a script +# +# The steps in `up` are not interchangeable and the failure mode of getting +# them wrong is not obvious. An adapter's config is a bind-mounted +# FILE that setup.py renders. Docker creates a DIRECTORY at any bind-mount +# source that does not exist yet -- so starting an adapter before step 2 both +# wedges that container on a directory it cannot parse AND leaves a directory +# sitting where step 2 needs to write a file. Recovering means `rm -rf`ing +# paths under config/adapters/ that look like they should be there. +# +# So the ordering is worth encoding once rather than remembering three times. + +set -euo pipefail + +# Every path in here is relative to the compose directory, and `docker compose` +# needs to find docker-compose.yml, so anchor to it rather than to $PWD. That +# makes `make -C quick-start up` work from anywhere. +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +# Both optional profiles, named explicitly. This matters for `down`: compose +# only acts on services whose profile is active, so a plain `docker compose +# down` leaves the gateway and hyperdx containers running and then reports +# success. Naming them on teardown is what makes "down" mean down. +PROFILES=(--profile reverse-proxy --profile observability) + +# ------------------------------------------------------------------ output + +# Steps are numbered in the output because the whole point of this script is +# that the order is load-bearing -- if it fails, you want to know at which one. +step() { printf '\n\033[1;36m==> [%s/%s] %s\033[0m\n' "$1" "$2" "$3"; } +info() { printf ' %s\n' "$1"; } +warn() { printf '\033[1;33mwarning: %s\033[0m\n' "$1" >&2; } +die() { printf '\033[1;31mstack: %s\033[0m\n' "$1" >&2; exit 1; } + +# -------------------------------------------------------------- preflight + +# Checked before anything starts, not when it is first needed. Step 1 blocks on +# Keycloak's healthcheck, which is 30 retries at 10s -- so a missing python3 or +# an unimportable `cryptography` would surface several minutes in, after a wait +# that had nothing to do with the problem. These take a millisecond each. +preflight() { + [ -f .env ] || die ".env is missing -- cp .env.example .env, then change every credential in it" + + docker compose version >/dev/null 2>&1 \ + || die "docker compose (v2) is not available -- this needs the plugin, not docker-compose" + + command -v python3 >/dev/null 2>&1 \ + || die "python3 is not installed -- bin/setup.py needs it" + + python3 -c 'import cryptography' >/dev/null 2>&1 \ + || die "the python 'cryptography' package is missing -- pip install cryptography" +} + +# ------------------------------------------------------------------- up + +# The full stack, in the order the compose file's own header documents. Five +# steps rather than three: the gateway and hyperdx are behind profiles, which +# means they are opt-in for compose, but "opt-in" and "not part of bringing the +# stack up" are different claims and only the first one is true. +# +# `up-core` below is the same thing minus steps 4 and 5, for when you want +# neither a public port nor ClickHouse's memory. +up() { + preflight + up_registry_tier 5 + up_setup 5 + up_adapters 5 + + # NPM. This is the step that makes the VM reachable from the internet -- + # 0.0.0.0:80 and :443, deliberately not scoped, because Let's Encrypt + # validates HTTP-01 from its own servers. + step 4 5 "nginx-proxy-manager -- the public edge (80 and 443, all interfaces)" + docker compose --profile reverse-proxy up -d nginx-proxy-manager + + # ClickStack. Heaviest thing here by a wide margin: ClickHouse alone wants + # 2-4 GB, which is what takes this VM from 8 GB to 16 GB. + step 5 5 "hyperdx -- ClickStack (OTLP ingest, ClickHouse, UI)" + docker compose --profile observability up -d hyperdx + + done_banner +} + +# Steps 1-3 only. Nothing publishes on a routable interface and nothing needs +# 16 GB -- this is the stack you can actually exercise, which is the reason the +# profiles exist in the first place. +up_core() { + preflight + up_registry_tier 3 + up_setup 3 + up_adapters 3 + done_banner +} + +# --- the shared steps, so `up` and `up-core` cannot drift apart ------------- +# +# Each takes the step total so the numbering reads correctly in both. + +# Naming registry and discovery also starts registry-db, keycloak and +# discovery-db: all are depends_on with condition: service_healthy, so compose +# blocks here until they pass their healthchecks rather than racing ahead. +# +# discovery belongs in this step and not in step 3. It would be dragged in +# anyway by network-adapter's depends_on, but then a discovery-db that failed +# to come up would surface as an adapter problem three steps later. +# +# No --wait. It would only add a second wait on the registry's own healthcheck, +# and setup.py already polls with an error message that says what to check. +up_registry_tier() { + step 1 "$1" "registry and discovery (also starts registry-db, keycloak, discovery-db)" + info "keycloak's healthcheck allows up to 5 minutes on a cold volume" + docker compose up -d registry discovery +} + +# Generates the adapter keypairs, registers the three adapter identities, and +# renders config/adapters/{provider,network,exp}.yaml from the .tmpl files +# beside them. Safe to re-run: keys come from keys/keys.json once it exists, +# and participants already registered are left alone. +up_setup() { + step 2 "$1" "bin/setup.py -- keys, five registry participants, adapter configs" + python3 bin/setup.py +} + +# Only now do the bind-mounted config files exist. +up_adapters() { + # The mocks are named here rather than left to provider-adapter's + # depends_on, so a failure to pull one is reported as its own step instead + # of as an adapter that will not start. + step 3 "$1" "mock upstreams and adapters (provider, network, exp)" + docker compose up -d mockimd mockagmarknet + docker compose up -d provider-adapter network-adapter exp-adapter +} + +done_banner() { + printf '\n\033[1;32m==> stack is up\033[0m\n' + docker compose "${PROFILES[@]}" ps + cat <<'NEXT' + + Everything except NPM's 80 and 443 is bound to 127.0.0.1 on this host. + From your laptop: + + ssh -L 81:127.0.0.1:81 -L 8080:127.0.0.1:8080 \ + -L 8081:127.0.0.1:8081 -L 8085:127.0.0.1:8085 -N you@the-vm + + If the gateway is running, its admin UI is on http://127.0.0.1:81 and still + has its shipped login (admin@example.com / changeme) until you change it. + That account can mint certificates and re-point every public route -- change + it before creating anything. + +NEXT +} + +# ----------------------------------------------------------------- down + +# Containers and networks go; named volumes stay. So the registry's Postgres +# data, discovery's data, and -- the one that would actually hurt -- npm-data, +# which is the ONLY copy of every proxy host and Let's Encrypt certificate, +# all survive. `up` after this is fast and lands where you left off. +down() { + step 1 1 "stopping everything, keeping the data" + docker compose "${PROFILES[@]}" down --remove-orphans + info "volumes kept. 'make destroy' is the one that deletes them." +} + +# -------------------------------------------------------------- destroy + +# The asymmetry with `down` is deliberate: this is not recoverable, and one of +# the things it deletes was never in git to begin with. +destroy() { + cat <<'WARN' +This deletes every named volume in the project: + + npm-data every NPM proxy host and Let's Encrypt certificate. NPM + keeps its routing table in a SQLite database in this + volume and nowhere else -- there is no export, and the + admin account has no reset flow. If you have not backed + it up, the click-through starts over. + registry-data the registry's Postgres: participants, keys, schemas. + discovery-data the discovery catalogue. + hyperdx-data collected telemetry. + +keys/keys.json is NOT deleted, and should not be -- it is what lets setup.py +re-register the adapters under their existing identities on the next `up`. + +WARN + if [ "${FORCE:-}" != "1" ]; then + [ -t 0 ] || die "not a terminal -- re-run as FORCE=1 make destroy if you mean it" + read -r -p "Type 'destroy' to confirm: " reply + [ "$reply" = "destroy" ] || die "aborted" + fi + + step 1 1 "removing containers, networks and volumes" + docker compose "${PROFILES[@]}" down -v --remove-orphans +} + +# ------------------------------------------------------- optional tiers + +# Separate targets rather than part of `up` because neither is needed to +# exercise the stack, and hyperdx (ClickHouse) alone wants 2-4 GB. +reverse_proxy() { + preflight + step 1 1 "nginx-proxy-manager -- publishes 80 and 443 on ALL interfaces" + docker compose --profile reverse-proxy up -d nginx-proxy-manager + cat <<'NEXT' + + The admin UI is on loopback, and ships with a live default login. Tunnel in + and change it before creating anything: + + ssh -L 81:127.0.0.1:81 -N you@the-vm then http://127.0.0.1:81 + +NEXT +} + +observability() { + preflight + step 1 1 "hyperdx -- ClickStack (OTLP ingest, ClickHouse, UI)" + docker compose --profile observability up -d hyperdx + info "UI on 127.0.0.1:8085. There is no login in front of it -- keep it on loopback." +} + +# ------------------------------------------------------------------ pull + +# `git pull`, with the one thing that otherwise stops it. +# +# config/reverse-proxy/npm-custom is bind-mounted into NPM, and NPM's s6 init chowns +# everything under /data/nginx on every start -- so those two files end up +# owned by a UID that is not you, and git cannot unlink them to update: +# +# error: unable to unlink old '.../server_proxy.conf': Permission denied +# +# The mount cannot be :ro (that stops nginx from starting at all -- see the +# comment on the mount), and git does not track ownership, so there is nothing +# to fix once and for all. Taking the files back before pulling is the whole +# workaround, and it belongs in a target rather than in someone's memory. +pull() { + step 1 2 "taking back ownership of config/reverse-proxy/npm-custom" + if [ -n "$(find config/reverse-proxy/npm-custom ! -user "$(id -un)" -print -quit 2>/dev/null)" ]; then + sudo chown -R "$(id -un):$(id -gn)" config/reverse-proxy/npm-custom + info "done -- NPM had chowned them on its last start" + else + info "already yours, nothing to do" + fi + + step 2 2 "git pull" + git -C "$(git rev-parse --show-toplevel)" pull + cat <<'NEXT' + + Then apply what came in: + + make up new services, changed images or .env + make restart changed adapter or registry config + make restart-edge changed config/reverse-proxy/npm-custom + +NEXT +} + +# --------------------------------------------------------------- restart + +# The services that hold OAN's own code and config, and nothing else. +# +# Deliberately excludes the two Postgres instances and keycloak: those are +# state, they are slow to come back, and nothing you change in this repo +# alters their behaviour -- keycloak reads its realm from a database that was +# seeded on first boot, not from a file you can edit. Restarting them to pick +# up a config change is a minute of downtime that cannot have helped. +# +# It also excludes nginx-proxy-manager, whose routing table lives in a SQLite +# database rather than in anything a restart would re-read. `restart-edge` is +# the separate target for the one case that does need it. +APP_SERVICES=(registry discovery provider-adapter network-adapter exp-adapter) + +# `restart`, not `up -d --force-recreate`. A restart keeps the container and +# therefore its address, so NPM's cached proxy_pass targets stay valid -- a +# recreate changes the address and leaves every proxy host 502ing until +# `restart-edge` runs. Same reason the compose file spells this out. +# +# What this picks up: the registry re-reads config/registry/schemas, and each +# adapter re-reads the config setup.py rendered for it. What it does not pick +# up is a changed image or a changed environment, both of which need the +# container recreated -- use `make up` for those. +restart_app() { + step 1 1 "restarting registry, discovery and the three adapters" + info "keycloak, both databases and the edge are left alone" + docker compose restart "${APP_SERVICES[@]}" + docker compose ps --format 'table {{.Service}}\t{{.Status}}' +} + +# ----------------------------------------------------------------- misc + +# NPM writes a literal proxy_pass hostname per proxy host, which nginx resolves +# at reload and then caches. RECREATE an adapter -- not merely restart it, a +# restart keeps the address -- and NPM goes on proxying to an address nothing +# answers on. This is the fix, and it is worth having as a target because the +# symptom is a bare 502 that looks like the adapter is down. +restart_edge() { + step 1 1 "restarting nginx-proxy-manager to re-resolve adapter addresses" + docker compose --profile reverse-proxy restart nginx-proxy-manager +} + +# Just step 2. Re-run it after editing a .tmpl, or to re-render configs that +# were deleted. It is idempotent, so this is always safe. +setup() { + preflight + step 1 1 "bin/setup.py" + python3 bin/setup.py +} + +usage() { + cat <<'USAGE' +bin/stack.sh + + up the whole stack: registry+discovery -> setup.py -> adapters + -> gateway (public, 80/443) -> hyperdx (wants 16 GB) + up-core steps 1-3 only. Nothing public, no ClickHouse. + down stop everything, keep the data + destroy stop everything and DELETE every volume + setup re-run bin/setup.py only + reverse-proxy start nginx-proxy-manager on its own (public, 80/443) + observability start hyperdx on its own + pull git pull, fixing the npm-custom ownership first + restart restart registry, discovery and the adapters only + restart-edge restart NPM after recreating an adapter (fixes a 502) + ps docker compose ps + logs [service] docker compose logs -f + +USAGE +} + +case "${1:-}" in + up) up ;; + up-core) up_core ;; + down) down ;; + destroy) destroy ;; + setup) setup ;; + reverse-proxy) reverse_proxy ;; + observability) observability ;; + pull) pull ;; + restart) restart_app ;; + restart-edge) restart_edge ;; + ps) docker compose "${PROFILES[@]}" ps ;; + logs) shift; docker compose "${PROFILES[@]}" logs -f "$@" ;; + ""|-h|--help) usage ;; + *) usage; die "unknown command: $1" ;; +esac diff --git a/quick-start/config/adapters/experience.yaml.tmpl b/quick-start/config/adapters/experience.yaml.tmpl new file mode 100644 index 0000000..196dfd4 --- /dev/null +++ b/quick-start/config/adapters/experience.yaml.tmpl @@ -0,0 +1,120 @@ +# exp-adapter +# +# The caller. Signs outbound requests as oan-caller and routes them by action: +# discovery to the network layer, transactions straight to the provider adapter. +# No validateSign -- the experience app calling it is inside the trust boundary, +# so there is no network signature to check on the way in. +appName: "exp-adapter" + +log: + level: debug + destinations: + - type: stdout + contextKeys: [transaction_id, message_id, subscriber_id, module_id] + +http: + port: 9202 + timeout: + read: 30 + write: 30 + idle: 30 + +pluginManager: + root: ./plugins + +# --------------------------------------------------------------------------- +# OpenTelemetry. An application-level plugin rather than a module one: it runs +# its own background lifecycle -- an exporter and a periodic flush -- instead +# of being loaded per request by a step. +# +# It ships metrics, traces and logs over OTLP/gRPC to the collector named +# below. In this stack that is ClickStack, on the observability profile, which +# `make up` starts as step 5 and `make up-core` deliberately does not. +# +# WHICH IS WHY THE ENABLE FLAGS ARE A SWITCH. With all three false the plugin +# builds no exporter at all and returns a no-op provider -- it does not dial, +# so it cannot log a connection failure every few seconds. Point a stack with +# no collector at one and that is exactly what you get, so OTEL_ENABLED=false +# is the right setting for `make up-core`. +# +# serviceVersion is deliberately absent: left empty the plugin fills in the +# adapter's own build version, which is truer than anything written here and +# does not go stale. +# +# Not set, and worth knowing they exist: +# auditFieldsConfig a YAML file of masking rules and field selection for +# audit logs -- payloads are emitted whole without it. +# networkMetricsGranularity / networkMetricsFrequency +# network-level metric windows. +# timeInterval metric export period in seconds; defaults to 5. +plugins: + otelsetup: + id: otelsetup + config: + serviceName: "oan-exp-adapter" + environment: "__OTEL_ENVIRONMENT__" + otlpEndpoint: "__OTLP_ENDPOINT__" + enableMetrics: "__OTEL_ENABLED__" + enableTracing: "__OTEL_ENABLED__" + enableLogs: "__OTEL_ENABLED__" + +modules: + - name: exp-adapter + # A subtree: every action lands here and the payload says which one it is. + path: / + handler: + type: std + role: bap + subscriberId: __EXP_SUBSCRIBER_ID__ + + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + + keyManager: + id: simplekeymanager + config: + subscriberId: __EXP_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: __EXP_KEY_ID__ + signingPrivateKey: "__EXP_SIGNING_PRIVATE__" + signingPublicKey: "__EXP_SIGNING_PUBLIC__" + encrPrivateKey: "__EXP_ENCR_PRIVATE__" + encrPublicKey: "__EXP_ENCR_PUBLIC__" + + signer: + id: signer + signValidator: + id: signvalidator + + # Base Beckn v2 schema validation, against the pinned LTS spec. The + # extended layer is off: it fetches a resource's own @context and + # validates against that, which is a network call per payload and a + # second failure mode, and nothing here needs it yet. The allowed + # domains and cache settings below only take effect if it is turned on. + schemaValidator: + id: schemav2validator + config: + type: url + location: "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" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + extendedSchema_allowedDomains: "raw.githubusercontent.com" + + router: + id: router + config: + routingConfig: /app/config/routing-experience.yaml + + steps: + - validateSchema + - addRoute + - sign diff --git a/quick-start/config/adapters/network.yaml.tmpl b/quick-start/config/adapters/network.yaml.tmpl new file mode 100644 index 0000000..ee2a04d --- /dev/null +++ b/quick-start/config/adapters/network.yaml.tmpl @@ -0,0 +1,123 @@ +# network-adapter +# +# The network layer. Verifies the caller's signature, then hands discovery to +# the discovery service and re-signs as itself on the way out. +appName: "network-adapter" + +log: + level: debug + destinations: + - type: stdout + contextKeys: [transaction_id, message_id, subscriber_id, module_id] + +http: + port: 9201 + timeout: + read: 30 + write: 30 + idle: 30 + +pluginManager: + root: ./plugins + +# --------------------------------------------------------------------------- +# OpenTelemetry. An application-level plugin rather than a module one: it runs +# its own background lifecycle -- an exporter and a periodic flush -- instead +# of being loaded per request by a step. +# +# It ships metrics, traces and logs over OTLP/gRPC to the collector named +# below. In this stack that is ClickStack, on the observability profile, which +# `make up` starts as step 5 and `make up-core` deliberately does not. +# +# WHICH IS WHY THE ENABLE FLAGS ARE A SWITCH. With all three false the plugin +# builds no exporter at all and returns a no-op provider -- it does not dial, +# so it cannot log a connection failure every few seconds. Point a stack with +# no collector at one and that is exactly what you get, so OTEL_ENABLED=false +# is the right setting for `make up-core`. +# +# serviceVersion is deliberately absent: left empty the plugin fills in the +# adapter's own build version, which is truer than anything written here and +# does not go stale. +# +# Not set, and worth knowing they exist: +# auditFieldsConfig a YAML file of masking rules and field selection for +# audit logs -- payloads are emitted whole without it. +# networkMetricsGranularity / networkMetricsFrequency +# network-level metric windows. +# timeInterval metric export period in seconds; defaults to 5. +plugins: + otelsetup: + id: otelsetup + config: + serviceName: "oan-network-adapter" + environment: "__OTEL_ENVIRONMENT__" + otlpEndpoint: "__OTLP_ENDPOINT__" + enableMetrics: "__OTEL_ENABLED__" + enableTracing: "__OTEL_ENABLED__" + enableLogs: "__OTEL_ENABLED__" + +modules: + - name: network-adapter + # A subtree: every action lands here and the payload says which one it is. + path: / + handler: + type: std + # bpp because this adapter RECEIVES rather than originates. The role + # decides which declared identity validateSign would compare a signer + # against -- but no payload here declares one, so that check is skipped + # and what is verified is the signature itself, against the key the + # registry publishes for whoever signed. + role: bpp + subscriberId: __NETWORK_SUBSCRIBER_ID__ + + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + + keyManager: + id: simplekeymanager + config: + subscriberId: __NETWORK_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: __NETWORK_KEY_ID__ + signingPrivateKey: "__NETWORK_SIGNING_PRIVATE__" + signingPublicKey: "__NETWORK_SIGNING_PUBLIC__" + encrPrivateKey: "__NETWORK_ENCR_PRIVATE__" + encrPublicKey: "__NETWORK_ENCR_PUBLIC__" + + signer: + id: signer + signValidator: + id: signvalidator + + # Base Beckn v2 schema validation, against the pinned LTS spec. The + # extended layer is off: it fetches a resource's own @context and + # validates against that, which is a network call per payload and a + # second failure mode, and nothing here needs it yet. The allowed + # domains and cache settings below only take effect if it is turned on. + schemaValidator: + id: schemav2validator + config: + type: url + location: "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" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + extendedSchema_allowedDomains: "raw.githubusercontent.com" + + router: + id: router + config: + routingConfig: /app/config/routing-network.yaml + + steps: + - validateSign + - addRoute + - sign diff --git a/quick-start/config/adapters/provider.yaml.tmpl b/quick-start/config/adapters/provider.yaml.tmpl new file mode 100644 index 0000000..ffdb10a --- /dev/null +++ b/quick-start/config/adapters/provider.yaml.tmpl @@ -0,0 +1,297 @@ +# OAN provider adapter -- dev deployment. +# +# Serves the Beckn actions at the root synchronously: verifies the sender +# against the registry, +# resolves the capability's call plan, calls the provider, and answers with the +# mapped result. No callback -- the answer is the HTTP response. +appName: "oan-provider-adapter" + +log: + level: debug + destinations: + - type: stdout + contextKeys: [transaction_id, message_id, subscriber_id, module_id] + +http: + port: 9200 + timeout: + read: 30 + write: 30 + idle: 30 + +pluginManager: + root: ./plugins + +# --------------------------------------------------------------------------- +# OpenTelemetry. An application-level plugin rather than a module one: it runs +# its own background lifecycle -- an exporter and a periodic flush -- instead +# of being loaded per request by a step. +# +# It ships metrics, traces and logs over OTLP/gRPC to the collector named +# below. In this stack that is ClickStack, on the observability profile, which +# `make up` starts as step 5 and `make up-core` deliberately does not. +# +# WHICH IS WHY THE ENABLE FLAGS ARE A SWITCH. With all three false the plugin +# builds no exporter at all and returns a no-op provider -- it does not dial, +# so it cannot log a connection failure every few seconds. Point a stack with +# no collector at one and that is exactly what you get, so OTEL_ENABLED=false +# is the right setting for `make up-core`. +# +# serviceVersion is deliberately absent: left empty the plugin fills in the +# adapter's own build version, which is truer than anything written here and +# does not go stale. +# +# Not set, and worth knowing they exist: +# auditFieldsConfig a YAML file of masking rules and field selection for +# audit logs -- payloads are emitted whole without it. +# networkMetricsGranularity / networkMetricsFrequency +# network-level metric windows. +# timeInterval metric export period in seconds; defaults to 5. +plugins: + otelsetup: + id: otelsetup + config: + serviceName: "oan-provider-adapter" + environment: "__OTEL_ENVIRONMENT__" + otlpEndpoint: "__OTLP_ENDPOINT__" + enableMetrics: "__OTEL_ENABLED__" + enableTracing: "__OTEL_ENABLED__" + enableLogs: "__OTEL_ENABLED__" + +modules: + - name: oanProvider + # A subtree, not one action. "/" with the trailing slash is a prefix + # match, so every action lands here; an exact pattern like "/select" + # would mount that one action and 404 the rest. + # + # Which action it is comes from the URL, not the payload: the mount path + # is stripped off the request path and what remains -- "select", + # "discover" -- is what the routing config matches on. + # + # The schema validator is handed that same stripped path and ignores it, + # reading context.action out of the payload instead. Nothing reconciles + # the two. A mismatch is caught anyway in practice -- POST /select + # carrying action "discover" is validated against the discover schema and + # rejected with SCH_FIELD_NOT_ALLOWED -- but it is the two body shapes + # differing that catches it, not a check that the URL and the action + # agree. + path: / + handler: + type: std + role: bpp + subscriberId: __PROVIDER_SUBSCRIBER_ID__ + + plugins: + # Serves both halves: the sender's signing key for validateSign, and the + # capability call plans the provider steps resolve against. + registry: + id: sunbirdRegistry + config: + url: http://registry:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + + # The adapter's OWN keys, used by signAck to sign what it answers with. + # Dev keys, rendered here by bin/setup.py. Production uses a key + # manager backed by a secret store, not values in a config file -- + # which is why this file is gitignored and written 0600. + keyManager: + id: simplekeymanager + config: + subscriberId: __PROVIDER_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: __PROVIDER_KEY_ID__ + signingPrivateKey: "__PROVIDER_SIGNING_PRIVATE__" + signingPublicKey: "__PROVIDER_SIGNING_PUBLIC__" + encrPrivateKey: "__PROVIDER_ENCR_PRIVATE__" + encrPublicKey: "__PROVIDER_ENCR_PUBLIC__" + + signValidator: + id: signvalidator + signer: + id: signer + + # Base Beckn v2 schema validation against the pinned LTS spec, plus the + # extended layer, which resolves each resource's @type to a capability + # schema and validates resourceAttributes against it. Base validation + # treats that object as free-form, so extended is the only layer that + # checks a capability's own attributes at all. + # + # Resolution is a FETCH of the @context each resource declares. The + # validator swaps context.jsonld for attributes.yaml to get the schema + # beside it, so a payload names the pack revision it is judged + # against and no copy of the schemas is kept here to drift. Cached for + # the TTL below, so only the first payload after a restart pays; a + # fetch that FAILS rejects the payload rather than skipping + # validation, so this container does need egress to the allowed host. + # + # Not enforced, and worth knowing before reading a pass as pack + # conformance: the validator library parses if/then/else but never + # evaluates it, so every pack rule predicated on informationMode is + # unchecked. + schemaValidator: + id: schemav2validator + config: + type: url + location: "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: "true" + # The host the packs' @context resolves to. An @context on any + # other host is refused before anything is fetched. + extendedSchema_allowedDomains: "raw.githubusercontent.com" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + + # Generic: fetches, compiles and caches whatever the registry's mapping + # URLs point at. Knows nothing about any provider. + mapper: + id: jsonmapper + config: + fetchTimeout: 10s + cacheTTL: 1m + + # One entry per provider capability. Each recognises its own binding key + # and passes through anything else. + # + # authScheme none matches what the registry publishes for this upstream, + # which is what lets its baseUrl be plaintext http. Against a real + # provider this is basic or header, and the credential is named here as + # an environment variable -- never held in this file or in the registry: + # + # authScheme: basic + # usernameEnv: MAUSAMGRAM_USER + # passwordEnv: MAUSAMGRAM_X_API_KEY + # A list: one provider can serve several capabilities, and the registry + # contract expects exactly that -- one Participant, one ProviderSchema + # row per capability. Comma-separated, because a plugin config value is + # a string; a binding key uses a pipe, so a comma is unambiguous. + providerSteps: + - id: WeatherObservation + config: + bindingKeys: "__PROVIDER_BINDING_KEY__" + authScheme: none + # A second capability in the same pipeline, from a different domain + # package. Each step recognises its own binding key and passes + # through anything else, so adding one is an entry here rather than a + # change to a routing table. + # + # Agmarknet takes its token as a QUERY parameter. The adapter holds + # the parameter's name and the name of the variable carrying the + # value, never the value -- and it redacts it from the URL it logs. + - id: MandiPrice + config: + bindingKeys: "__MANDI_BINDING_KEY__" + authScheme: query + queryName: token + queryValueEnv: MANDI_TOKEN + + # Declaring a provider step above is not enough: this list is what runs. + # Both provider steps sit in the pipeline in order, and each recognises + # its own binding key and passes through anything else -- so which one + # answers is decided by the payload, not by this order. + steps: + - validateSign # the sender's key, from the registry + - validateSchema # the pinned Beckn v2 spec + - WeatherObservation # its binding key, or pass through + - MandiPrice # its binding key, or pass through + - signAck # signs whatever the step answered with + + # The outbound leg: the provider's own catalogue system publishing to the + # network layer. POST /publish. + # + # Mounted on the EXACT path /publish while the module above takes the whole + # subtree at /. Go's mux prefers the exact pattern for /publish and falls + # back to / for everything else, so /select still reaches the module that + # answers it. The two can coexist only because the patterns differ -- give + # both the same path and registration panics at startup. + # + # THIS IS WHY routing-provider.yaml LOOKS ODD. The adapter takes the action + # from the URL, not from the payload: it strips the module's mount path off + # the request path and matches whatever is left. Under / that leaves + # "select"; under the exact /publish it leaves the empty string. So the + # routing rule keys on "" and spells its target out in full with + # excludeAction, because there is no action left to append. + # + # It has to be a second module, not another action on the one above: the + # routing step fails any action missing from its config, so routing publish + # from the module that answers select would mean listing select too -- and + # listing select would proxy it to the network layer instead of answering + # it here, which is the one thing that module does. + # + # No validateSign: the caller is this provider's own catalogue system, + # inside its trust boundary, exactly as the experience adapter takes + # unsigned calls from the app in front of it. This adapter then signs the + # forwarded request as itself, which is what the network layer verifies. + # + # No party is named in the body at all. Identity travels in the + # Authorization header's keyId, taken from keyManager below, and that is + # what the network layer verifies against the registry. + - name: oanProviderPublish + path: /publish + handler: + type: std + # bap because this module SENDS. + role: bap + subscriberId: __PROVIDER_SUBSCRIBER_ID__ + + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + + keyManager: + id: simplekeymanager + config: + subscriberId: __PROVIDER_SUBSCRIBER_ID__ + keyId: __PROVIDER_KEY_ID__ + signingPrivateKey: "__PROVIDER_SIGNING_PRIVATE__" + signingPublicKey: "__PROVIDER_SIGNING_PUBLIC__" + encrPrivateKey: "__PROVIDER_ENCR_PRIVATE__" + encrPublicKey: "__PROVIDER_ENCR_PUBLIC__" + + signer: + id: signer + + # Base Beckn v2 schema validation, against the pinned LTS spec. The + # extended layer is off: it fetches a resource's own @context and + # validates against that, which is a network call per payload and a + # second failure mode, and nothing here needs it yet. The allowed + # domains and cache settings below only take effect if it is turned on. + schemaValidator: + id: schemav2validator + config: + type: url + location: "https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml" + cacheTTL: "3600" + # ON. It could not be before: the validator stripped @context and + # @type as JSON-LD plumbing, while the packs list @type in + # allOf[].required -- so every resource was rejected for a missing + # @type the payload had sent. The validator now decides per key, + # keeping the ones a schema declares, which is what makes a + # catalogue's resource attributes checkable at all. + extendedSchema_enabled: "true" + # The host the packs' @context resolves to. An @context on any + # other host is refused before anything is fetched. + extendedSchema_allowedDomains: "raw.githubusercontent.com" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + + router: + id: router + config: + routingConfig: /app/config/routing-provider.yaml + + steps: + # Declaring the validator above is not enough: a plugin missing from this + # list never runs, which is why publish went unvalidated until now. + - validateSchema # the Beckn v2 spec, plus each resource's own @context + - addRoute # publish -> the network layer + - sign # as this provider, so the network layer can verify + + diff --git a/quick-start/config/adapters/routing-experience.yaml b/quick-start/config/adapters/routing-experience.yaml new file mode 100644 index 0000000..e09d092 --- /dev/null +++ b/quick-start/config/adapters/routing-experience.yaml @@ -0,0 +1,25 @@ +# Experience layer adapter routing. +# +# Branches by action: discovery goes to the network layer, everything +# transactional goes straight to the provider adapter. For v2.x the router +# ignores domain and matches on version and endpoint alone. +# +# targetType "url" appends the action to the path, so the base here is the +# module mount point and /discover or /select is added to it. +routingRules: + - version: "2.0.0" + targetType: "url" + target: + url: "http://network-adapter:9201" + endpoints: + - discover + + - version: "2.0.0" + targetType: "url" + target: + url: "http://provider-adapter:9200" + endpoints: + - select + - init + - confirm + - status diff --git a/quick-start/config/adapters/routing-network.yaml b/quick-start/config/adapters/routing-network.yaml new file mode 100644 index 0000000..257f7cd --- /dev/null +++ b/quick-start/config/adapters/routing-network.yaml @@ -0,0 +1,20 @@ +# Network layer adapter routing. +# +# One job: hand the catalogue actions to the discovery service, which is a +# service in this same compose and so reachable by name. +# +# The service serves /discover and /publish at its root, and so does this +# adapter now: targetType "url" appends the action to whatever base is +# given, so the base is the bare host and port. +# +# Both actions are one rule because they share a target. publish arrives from +# the provider adapter, discover from the experience adapter, and neither is +# answered here -- this adapter verifies the caller, forwards, and re-signs. +routingRules: + - version: "2.0.0" + targetType: "url" + target: + url: "http://discovery:8080" + endpoints: + - discover + - publish diff --git a/quick-start/config/adapters/routing-provider.yaml b/quick-start/config/adapters/routing-provider.yaml new file mode 100644 index 0000000..7fdae7c --- /dev/null +++ b/quick-start/config/adapters/routing-provider.yaml @@ -0,0 +1,31 @@ +# Provider adapter routing -- for the publish module only. +# +# Publishing is the one thing this adapter sends rather than answers. The +# provider's own catalogue system posts POST /publish here, and the catalogue +# has to reach the network layer, which is what fronts the discovery service. +# +# WHY THE ENDPOINT IS EMPTY, AND WHY THE TARGET IS SPELT OUT IN FULL. +# +# The adapter derives the action from the URL, not from the payload: it strips +# the module's mount path off the front of the request path and whatever is +# left is the endpoint it matches here. Every other module is mounted on a +# subtree -- "/" -- so /select leaves "select" and /discover leaves +# "discover". +# +# This module is mounted on the exact path /publish, because "/" already +# belongs to the module that answers select and two modules cannot share a +# pattern. Stripping /publish off /publish leaves the empty string, so the +# empty string is the endpoint, and there is no action left to append to a +# target. Hence excludeAction and the full URL. +# +# The alternative was mounting this on a subtree of its own and posting to +# something like /internal/publish. This keeps the URL the provider's +# catalogue system already posts to. +routingRules: + - version: "2.0.0" + targetType: "url" + target: + url: "http://network-adapter:9201/publish" + excludeAction: true + endpoints: + - "" diff --git a/quick-start/config/discovery/instance.yaml.example b/quick-start/config/discovery/instance.yaml.example new file mode 100644 index 0000000..1da4c29 --- /dev/null +++ b/quick-start/config/discovery/instance.yaml.example @@ -0,0 +1,42 @@ +# Deployment-local overrides — layer three of four, and the only optional one. +# Copy to config/instance.yaml, which is gitignored; a missing instance.yaml is +# not an error. +# +# NO SECRETS. DATABASE_URL and every other credential arrive from the process +# environment, which sits above this file precisely so a secret store beats a +# checked-out path (TRD §8). + +app: + # The network this deployment serves — mahavistar, bharatvistar, and so on. + # On publish it fills an empty publishDirectives.visibleTo (C8). It has no + # repo-wide default because there is no repo-wide answer. + network: mahavistar + +server: + port: 8080 + +database: + # 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 in Phase 1, three once semantic lands. maxConns must also stay + # under the server's own max_connections less whatever else shares it. + # minConns is a warm-start knob only — idle backends cost the server memory + # to save a connection handshake. + maxConns: 32 + minConns: 4 + +log: + level: info + +# Uncomment when an Ollama deployment exists to turn semantic search on (A5). +# embeddings: +# provider: ollama + +# Uncomment to export traces and metrics to a collector (T2). +# otel: +# exporter: otlp +# endpoint: http://localhost:4317 diff --git a/quick-start/config/mappings/agmarknet/mandi-price.select.yaml b/quick-start/config/mappings/agmarknet/mandi-price.select.yaml new file mode 100644 index 0000000..5492275 --- /dev/null +++ b/quick-start/config/mappings/agmarknet/mandi-price.select.yaml @@ -0,0 +1,206 @@ +# Agmarknet Vistaar, openagrinet:MandiPrice, select. Both directions, one file. +# +# One file per binding-action rather than one per direction, because both legs of +# an exchange are one contract: the response has to answer the request that was +# sent, and splitting them lets one change without the other. +# +# The upstream is Agmarknet's Vistaar select. It takes governed codes -- state, +# district, market, commodity -- plus a date range, and every one of them is in +# the payload, so nothing here needs resolving before the call. That is why the +# mandi plugin has no prerequisites: a MandiPrice select names the market it +# wants rather than a point to search from. +# +# NOTHING HERE IS OUTSIDE THE PACK. openagrinet:MandiPrice v0.1 carries every +# field this answer sets. Where the upstream reports something the pack has no +# home for, it is dropped rather than invented. + +# What this capability cannot serve, refused before the provider is called. +# +# The pack requires none of these: a MandiPrice select is OnDemand, and that +# branch requires only supportedCommodities and supportedPriceFields. It leaves +# market and validity optional, and defines market.district and market.state as +# "name or governed code". So a payload can be perfectly valid and still be +# unanswerable by this upstream, which wants codes and a date range. +# +# Refusing here names what is missing. Sending it anyway earns a 400 from +# Agmarknet, or worse an empty result that reads as "no prices". +required: + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $exists($ra.supportedCommodities[0].code) + ) + message: "this capability needs a commodity code in supportedCommodities[0].code" + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $exists($ra.market.state) and $exists($ra.market.district) + ) + message: "this capability needs governed state and district codes in market" + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $exists($ra.validity.startsAt) and $exists($ra.validity.endsAt) + ) + message: "this capability needs a validity window; it reports prices over a date range" + +# The upstream is a GET, so this object becomes the query string. The token is +# not here and must never be: it comes from the adapter's authScheme query, +# whose value is read from an environment variable. This file is published. +# +# marketcode is sent when the payload names one and omitted otherwise, which is +# what the upstream expects: without it the query widens from one market to the +# whole district. +request: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + + /* The upstream wants dd-MM-yyyy; the pack's validity is an ISO date. A + substring reorder rather than a date library, because these are dates + with no time and no zone, and $fromMillis would invent both. */ + $ddmmyyyy := function($iso) { + $substring($iso, 8, 2) & "-" & $substring($iso, 5, 2) & "-" & $substring($iso, 0, 4) + }; + + $base := { + "statecode": $ra.market.state, + "districtcode": $ra.market.district, + "commoditycode": $ra.supportedCommodities[0].code, + "from_date": $ddmmyyyy($ra.validity.startsAt), + "to_date": $ddmmyyyy($ra.validity.endsAt) + }; + + $exists($ra.market.marketCode) + ? $merge([$base, {"marketcode": $ra.market.marketCode}]) + : $base + ) + +# One resource per price record, in Direct mode. +# +# Direct is what the pack requires of an answer: the resource now CARRIES the +# observation rather than advertising that it could obtain one. It requires +# source, commodity, market, arrivalDate, prices and generatedAt, and all six +# are set below. +# +# The upstream's records use Title Case keys WITH SPACES -- `Modal Price`, not +# modalPrice -- so they need backticks, and its prices are STRINGS, so they need +# $number() before they satisfy the pack's numeric types. +response: | + ( + $records := $type(response) = "array" ? response + : $exists(response.data) ? response.data + : $exists(response.records) ? response.records + : []; + + $selected := beckn.message.contract.commitments[0]; + + /* The caller's own @context, echoed back rather than restated here. A + mapping that hardcodes it has to be reissued whenever the pack URL + moves, and it can disagree with what the request actually declared. + Backticks because @ is an operator in JSONata. */ + $ctx := beckn.message.contract.commitments[0].resources[0].resourceAttributes.`@context`; + $ra := $selected.resources[0].resourceAttributes; + + /* Bound once because it is used twice -- for a resource's own id and for + the offer's reference to it. Two copies of one expression is how a + dangling reference gets reintroduced. */ + /* Built from CODES, not the names the upstream reports. A market name + carries spaces and a commodity name carries brackets -- "Kasdol APMC", + "Paddy(Common)" -- and an identifier that a consumer may put in a URL or + a filter should not. The codes are already in the payload, so they cost + nothing, and they are stable where a display name is not. + + The market code is optional: without it the query widened to the whole + district, so the district code is what identifies the scope. */ + $iso := function($ddmmyyyy) { + $substring($ddmmyyyy, 6, 4) & "-" & $substring($ddmmyyyy, 3, 2) & "-" & $substring($ddmmyyyy, 0, 2) + }; + + $scope := $exists($ra.market.marketCode) ? $ra.market.marketCode : $ra.market.district; + $resourceId := function($r) { + "res:agmarknet:" & $scope & ":" & $ra.supportedCommodities[0].code + & ":" & $iso($r.`Arrival Date`) + }; + + /* dd-MM-yyyy back to ISO, so the answer speaks the pack's date format + rather than the upstream's. */ + + /* Absent rather than present-and-empty: a consumer must be able to tell + "the market reported no minimum" from "the minimum was zero". */ + $priced := function($value) { $exists($value) ? $number($value) }; + + { + "context": { + "version": beckn.context.version, + "action": "on_select", + "networkId": beckn.context.networkId, + "transactionId": beckn.context.transactionId, + "messageId": beckn.context.messageId, + "timestamp": $now() + }, + "message": { + "contract": { + "commitments": [ + { + "status": { + "descriptor": { "code": "DRAFT", "name": "Draft" } + }, + /* The offer is echoed, but its references are not: the request + named an abstract price enquiry and the answer returns the + concrete observations. Leaving resourceIds as they arrived + would point the offer at an id appearing nowhere here. */ + "offer": $merge([ + $selected.offer, + { "resourceIds": [$map($records, function($r) { $resourceId($r) })] } + ]), + /* Wrapped: JSONata collapses a one-element sequence to a bare + value, so a single-record answer would return an object where + every other count returns a list. */ + "resources": [$map($records, function($r) { + { + "id": $resourceId($r), + /* Required by Commitment.resources in the Beckn v2 spec, + which defines no quantity property and carries no Quantity + schema at all -- a defect upstream. One resource is one + market's observation for one day, so one. */ + "quantity": 1, + "resourceAttributes": { + "@context": $ctx, + "@type": "openagrinet:MandiPrice", + "informationMode": "Direct", + "subjectCategories": $ra.subjectCategories, + "source": { + "sourceId": "agmarknet", + "sourceName": "Agmarknet Vistaar" + }, + "commodity": { + "code": $ra.supportedCommodities[0].code, + "name": $r.Commodity + }, + "commodityGroup": $r.Group, + "variety": $r.Variety, + "grade": $r.Grade, + "market": { + "marketName": $r.Market, + "marketCode": $ra.market.marketCode, + "district": $r.District, + "state": $r.State + }, + "arrivalDate": $iso($r.`Arrival Date`), + "prices": { + "minimum": $priced($r.`Min Price`), + "maximum": $priced($r.`Max Price`), + "modal": $number($r.`Modal Price`), + "currency": "INR", + "unit": $r.`Price Unit` + }, + "generatedAt": $now() + } + } + })] + } + ] + } + } + } + ) diff --git a/quick-start/config/mappings/mausamgram/weather-observation.select.yaml b/quick-start/config/mappings/mausamgram/weather-observation.select.yaml new file mode 100644 index 0000000..c1d230c --- /dev/null +++ b/quick-start/config/mappings/mausamgram/weather-observation.select.yaml @@ -0,0 +1,276 @@ +# Mausamgram, openagrinet:WeatherObservation, select. Both directions, one file. +# +# One file per binding-action rather than one per direction, because both legs of +# one upstream call are one unit of configuration: they are published, reviewed +# and retired together, and a reference to one is a reference to the other. +# +# The registry entry pointing here decides which action this serves, so nothing +# in the file names it. The filename's action segment must match that entry -- +# a mismatch would apply a correct mapping to the wrong call, silently. +# +# Both halves read: +# beckn the inbound Beckn payload -- the context to echo, the offer to +# quote against +# and the response half additionally reads: +# response the provider's answer, in its own shape +# +# Nothing else is in scope. Values the provider step resolved before the call are +# not passed in: the step holds them and used them to make the call, so a mapping +# reading them back would be a second name for the same data. Where the answer +# needs them, it takes them from what the provider echoed. + +# The response half follows the openagrinet:WeatherObservation v0.1 schema pack, +# Direct mode. The pack lives in OpenAgriNet/network-specs; it is referred to +# here by name and version rather than by a path, because a path pins a branch +# and a branch moves. +# +# @context is not stated here at all. The response echoes whatever the request +# declared, so this file never has to know which pack URL is current and cannot +# contradict the caller. +# +# Direct mode requires observationType, source, location, generatedAt and +# parameters. informationMode is what selects those requirements: a catalog +# resource advertising this capability is OnDemand instead, and carries +# supportedParameters rather than values. +# +# The answer returns ONE RESOURCE PER FORECAST DAY, each with its own id derived +# from its date. That is the shape the pack describes: every WeatherObservation +# example carries a single validity and a flat parameters array, so a period is a +# resource and there is no form for several in one. +# +# The ids are therefore new -- the request named an abstract point forecast, the +# answer returns the concrete days that satisfy it. Which is why the offer's +# resourceIds are rewritten below rather than echoed: the offer arrives naming +# the id that was asked for, and leaving it would point the offer at something +# that appears nowhere in the answer. +# +# ONE FIELD HERE IS NOT IN THE PACK, deliberately. The pack sets no +# additionalProperties, so it validates; it is simply not governed. +# +# aggregation The pack's parameter entry is parameter/value/unit only. This +# provider reports a minimum AND a maximum for temperature and +# humidity, which are indistinguishable without it. +# +# Fields that are the same for every day -- the point, the source, the +# observation type -- sit once at the top. Only what varies per day repeats. + +# What this capability requires of a payload, checked before either half runs. +# A predicate that is false refuses the request with the message beside it, so +# the caller is told what is wrong with their payload rather than that an +# expression somewhere returned false. +# +# This rule used to be Go: the step read the geometry and required a Point, which +# meant a capability with a different rule needed a different build. It is here +# now, beside the extraction it guards. +# +# NOTE the consequence: nothing in the adapter enforces a geometry any more. A +# mapping that declares no preconditions accepts whatever arrives and hands it to +# the request half, which is exactly the configurability that was asked for -- +# and exactly why the responsibility sits in this file. +# +# One check, because there is one thing to say. $exists guards the type test, so +# a request carrying no location and a request carrying a Polygon both land here +# and both learn what this capability needs -- splitting them would be two +# entries repeating the same sentence. +# +# Each check is its own expression and binds $ra for itself; there is no shared +# scope with the halves below. Where several checks say genuinely different +# things, they are separate entries and the first failure is the one reported. +required: + - check: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + $exists($ra.location) and $ra.location.type = "Point" + ) + message: "this capability needs a Point location; the provider forecasts one point at a time" + +# The request half decides what the provider is asked for. Whatever it produces +# IS the request: query parameters for a method with no body, a body for one that +# takes it. +# +# This is where the extraction lives, deliberately. The step reads only the +# geometry's type -- enough to refuse a Polygon with a clear error, because a +# mapping cannot refuse -- and nothing else. So when this provider wants another +# parameter, it is an edit here and nothing else: no Go, no rebuild, live on the +# next cache expiry. +# +# A date range, for instance, is already in the payload and would be two lines: +# +# "from": $ra.validity.startsAt, +# "to": $ra.validity.endsAt +# +# $ra is bound once so the rest reads as plain field access rather than four +# repetitions of the same path. +# +# GeoJSON is [lon, lat] -- longitude first. Reading them the other way round +# gives a point in the wrong hemisphere that is still a valid request, so it +# fails as wrong data rather than as an error. +request: | + ( + $ra := beckn.message.contract.commitments[0].resources[0].resourceAttributes; + { + "lat": $ra.location.coordinates[1], + "lon": $ra.location.coordinates[0] + } + ) + +# Keyed by direction, not by the action it produces: a select is answered by an +# on_select over the same HTTP round trip, so the callback is this half rather +# than an action of its own. +response: | + ( + $lat := response.location.lat; + $lon := response.location.lon; + /* However many days the provider sent. It answers fcstday1..fcstdayN and N + is whatever the forecast ran to, so naming five would truncate a ten-day + answer and mis-handle a one-day one. + + Sorted on the numeric suffix, not the key: the keys sort lexically as + fcstday1, fcstday10, fcstday2, and a ten-day forecast delivered in that + order would be wrong in a way nothing downstream could detect. */ + $days := $each(response, function($v, $k) { + $contains($k, "fcstday") ? { + "n": $number($substringAfter($k, "fcstday")), + "day": $v + } + })^(n).day; + + $reading := function($name, $aggregation, $unit, $value) { + $exists($value) ? { + "parameter": $name, + "aggregation": $aggregation, + "unit": $unit, + "value": $value + } + }; + + /* A warning is a parameter, not a field of its own: the pack has no + advisory property but does have an Alert parameter. Unit "1" is what it + prescribes for a value that has no unit. */ + $alert := function($value) { + $exists($value) ? { + "parameter": "Alert", + "unit": "1", + "value": $value + } + }; + + $selected := beckn.message.contract.commitments[0]; + + /* The caller's own @context, echoed back rather than restated here. A + mapping that hardcodes it has to be reissued whenever the pack URL + moves, and it can disagree with what the request actually declared. + Backticks because @ is an operator in JSONata. */ + $ctx := beckn.message.contract.commitments[0].resources[0].resourceAttributes.`@context`; + + /* Bound once because it is used twice -- for a resource's own id and for the + offer's reference to it. Two copies of the same expression is how a + dangling reference gets reintroduced. */ + $resourceId := function($day) { "res:mausamgram:forecast:" & $day.date }; + + { + /* Correlation only: the ids that tie this answer to the request that + asked for it, and nothing that asserts who anybody is. + + bapId, bapUri, bppId and bppUri are deliberately absent. A mapping is + a payload transformation -- it has no business asserting network + identity, and the two Uri fields it could copy are whatever the caller + happened to send, which in a deployed stack is a container-internal + address that means nothing to anyone outside it. Echoing them would + republish another party's routing details as if they were ours. + + Identity on the wire is the adapter's own: it signs what it answers + with, using the key the registry publishes for it. */ + "context": { + "version": beckn.context.version, + "action": "on_select", + "networkId": beckn.context.networkId, + "transactionId": beckn.context.transactionId, + "messageId": beckn.context.messageId, + "timestamp": $now() + }, + "message": { + "contract": { + "commitments": [ + { + /* DRAFT, not QUOTED. The Beckn v2 status enum is DRAFT, ACTIVE + and CLOSED, and a quote is still a draft: nothing is committed + until init and confirm. QUOTED read better and validated + nowhere -- base schema validation refuses it. */ + "status": { + "descriptor": { "code": "DRAFT", "name": "Draft" } + }, + /* The offer is echoed, but its references are not: the request + named the abstract point forecast, and the answer returns the + concrete days. Leaving resourceIds as they arrived would point + the offer at an id that appears nowhere in the answer. + + $merge keeps everything else the request offered -- the id, the + descriptor, the provider -- and replaces one key. */ + "offer": $merge([ + $selected.offer, + { "resourceIds": [$map($days, function($day) { $resourceId($day) })] } + ]), + /* One resource per forecast day, which is what the pack describes: + every WeatherObservation example carries a single validity and a + flat parameters array, so a period is a resource and there is no + form for several in one. + + Wrapped for the same reason as the resourceIds above: JSONata + collapses a one-element sequence to a bare value, so a one-day + forecast would answer with an object where every other N answers + with a list. */ + "resources": [$map($days, function($day) { + { + "id": $resourceId($day), + /* Required by Commitment.resources in the spec, which defines + no quantity property and no Quantity schema anywhere -- a + defect upstream. One resource is one day's observation, so + one. Omitting it makes every answer fail validation for a + consumer who validates. */ + "quantity": 1, + "resourceAttributes": { + "@context": $ctx, + "@type": "openagrinet:WeatherObservation", + "informationMode": "Direct", + "observationType": "Forecast", + "subjectCategories": ["Weather"], + "source": { + "sourceId": "mausamgram", + "sourceName": "IMD Mausamgram NWP" + }, + /* Emitted only when the provider echoed both coordinates. + JSONata drops undefined values inside an array, so a + provider that answered without its location echo would + otherwise produce "coordinates": [] -- an invalid Point, + signed and delivered. Absent is honest; empty is a lie + in the shape of an answer. */ + "location": $exists($lat) and $exists($lon) ? { + "type": "Point", + "coordinates": [$lon, $lat] + }, + "generatedAt": $now(), + /* This resource reports one day, so its validity opens and + closes on it. */ + "validity": { + "startsAt": $day.date, + "endsAt": $day.date + }, + "parameters": [ + $reading("Rainfall", "Total", "mm", $day.rain), + $reading("Temperature", "Minimum", "Cel", $day.tmin), + $reading("Temperature", "Maximum", "Cel", $day.tmax), + $reading("Humidity", "Minimum", "%", $day.rhmin), + $reading("Humidity", "Maximum", "%", $day.rhmax), + $reading("WindSpeed", "Average", "m/s", $day.wspd), + $alert($day.weather_warning ? $day.weather_warning : $day.cloud_message) + ] + } + } + })] + } + ] + } + } + } + ) diff --git a/quick-start/config/registry/imports/realm-export.json b/quick-start/config/registry/imports/realm-export.json new file mode 100644 index 0000000..0f5efa1 --- /dev/null +++ b/quick-start/config/registry/imports/realm-export.json @@ -0,0 +1,2321 @@ +{ + "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": {} + }, + { + "name": "registryOperator", + "description": "Registry Operator - manages SchemaRegistry, Participant and ProviderSchema records", + "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", + "registryOperator" + ] + } + ], + "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": [] + } +} diff --git a/quick-start/config/registry/schemas/Participant.json b/quick-start/config/registry/schemas/Participant.json new file mode 100644 index 0000000..c8cff80 --- /dev/null +++ b/quick-start/config/registry/schemas/Participant.json @@ -0,0 +1,184 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Participant", + "type": "object", + "required": [ + "Participant" + ], + "properties": { + "Participant": { + "$ref": "#/definitions/Participant" + } + }, + "definitions": { + "Participant": { + "description": "Someone the network deals with. `type` says which kind, and decides which of the remaining fields apply: a node speaks Beckn and is addressed by its participantId; an upstream is an ordinary API our adapter calls. Permits additional properties because the registry re-validates the MERGED document on a PUT, and that document carries the osid, osUpdatedAt and osOwner the registry injected itself -- which a strict schema rejects as extraneous. Do not set this back to false without removing the update requests from the Postman collection.", + "type": "object", + "additionalProperties": true, + "required": [ + "participantId", + "name", + "type", + "status", + "baseUrl" + ], + "properties": { + "participantId": { + "$ref": "#/definitions/ParticipantId" + }, + "name": { + "description": "Human label.", + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "\\S" + }, + "type": { + "$ref": "#/definitions/ParticipantType" + }, + "status": { + "$ref": "#/definitions/Status" + }, + "baseUrl": { + "description": "The base something is appended to: a Beckn action for a node, a binding's path for an upstream. https for a node, since that is its wire identity; an upstream may be plaintext, because nothing in the registry is sent to it.", + "type": "string", + "maxLength": 2000, + "pattern": "^https?://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" + }, + "role": { + "description": "What this party does on the network. consumer asks. provider answers. network exposes publish and discover, answering discover from published catalogs. Required on a node. Permitted on an upstream, where nothing reads it.", + "type": "string", + "enum": [ + "consumer", + "provider", + "network" + ] + }, + "keys": { + "description": "Plural for rotation: an overlap where old and new are both valid. The sender names which it used by osid in the Authorization header. Required on a node, since a node's signatures are verified against them. Permitted on an upstream, which does not sign anything this stack verifies.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/PublicKey" + } + } + }, + "allOf": [ + { + "description": "A node speaks Beckn: it needs a role and keys, and its id is its wire identity so it must be a hostname.", + "if": { + "properties": { + "type": { + "const": "node" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "role", + "keys" + ], + "properties": { + "participantId": { + "maxLength": 253, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" + }, + "baseUrl": { + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" + } + } + } + } + ] + }, + "PublicKey": { + "description": "A public key, held as material: it is public, so there is nothing to protect. Identified by the osid the registry assigns on write -- which is what a sender names in the Authorization header, and so the only id a verifier can look up. alg carries the purpose: ed25519 signs, x25519 encrypts.", + "type": "object", + "additionalProperties": false, + "required": [ + "alg", + "key", + "validFrom", + "status" + ], + "properties": { + "alg": { + "description": "The purpose, and the only thing that carries it now: ed25519 signs, x25519 encrypts. Both curves are 32-byte keys.", + "type": "string", + "enum": [ + "ed25519", + "x25519" + ] + }, + "key": { + "description": "base64 of the 32 raw bytes: 44 chars, one trailing '='. Bare -- no encoding label in front of it. A truncated or wrong-curve key fails at write time.", + "type": "string", + "pattern": "^[A-Za-z0-9+/]{43}=$" + }, + "validFrom": { + "type": "string", + "format": "date-time" + }, + "validUntil": { + "description": "Absent means open-ended. Overlap with the next key's validFrom is the rotation window.", + "type": "string", + "format": "date-time" + }, + "status": { + "description": "revoked withdraws the key early. Effective only once the reader refreshes.", + "type": "string", + "enum": [ + "active", + "revoked" + ] + } + } + }, + "ParticipantId": { + "description": "Stable id, and the only id. For a node this is its network identity -- what field 1 of the Authorization header names, and what a signature is verified against; for an upstream it is the Beckn offer.provider.id.", + "type": "string", + "maxLength": 253, + "pattern": "^[a-z0-9][a-z0-9._:-]{2,252}$" + }, + "Status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "ParticipantType": { + "description": "node speaks Beckn. upstream is an API we call over ordinary HTTP and does not.", + "type": "string", + "enum": [ + "node", + "upstream" + ] + } + }, + "_osConfig": { + "uniqueIndexFields": [ + "participantId" + ], + "indexFields": [ + "status", + "type", + "baseUrl" + ], + "privateFields": [], + "roles": [ + "registryOperator" + ], + "systemFields": [ + "osCreatedAt", + "osUpdatedAt", + "osCreatedBy", + "osUpdatedBy" + ] + } +} diff --git a/quick-start/config/registry/schemas/ProviderSchema.json b/quick-start/config/registry/schemas/ProviderSchema.json new file mode 100644 index 0000000..5744c68 --- /dev/null +++ b/quick-start/config/registry/schemas/ProviderSchema.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProviderSchema", + "type": "object", + "required": ["ProviderSchema"], + "properties": { "ProviderSchema": { "$ref": "#/definitions/ProviderSchema" } }, + + "definitions": { + + "ProviderSchema": { + "description": "One row is one provider and one capability. What varies per Beckn action — the URL, the method, the mapping, the timeout — varies inside actions[]. Permits additional properties because the registry re-validates the MERGED document on a PUT, and that document carries the osid, osUpdatedAt and osOwner the registry injected itself -- which a strict schema rejects as extraneous. Do not set this back to false without removing the update requests from the Postman collection.", + "type": "object", + "additionalProperties": true, + "required": ["bindingKey", "participantId", "capabilityCode", "status", "actions"], + "properties": { + "bindingKey": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._:-]{2,63}\\|openagrinet:[A-Z][A-Za-z0-9]*$" + }, + "participantId": { "$ref": "#/definitions/ParticipantId" }, + "capabilityCode": { "$ref": "#/definitions/CapabilityCode" }, + "status": { "$ref": "#/definitions/Status" }, + + "actions": { + "description": "At least one. uniqueItems compares whole objects, so it cannot pin one entry per action — verify/records.py does.", + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { "$ref": "#/definitions/ActionBinding" } + } + } + }, + + "ActionBinding": { + "description": "How to call this provider for one Beckn action: where, how, and with which mapping. Anything the upstream needs that the Beckn body cannot express is the adapter plugin's work, not a field here. status is per action, so one can be retired without touching the others. Permits additional properties because the registry re-validates the MERGED document on a PUT, and that document carries the osid, osUpdatedAt and osOwner the registry injected itself -- which a strict schema rejects as extraneous. Do not set this back to false without removing the update requests from the Postman collection.", + "type": "object", + "additionalProperties": true, + "required": ["action", "method", "path", "mappings", "status"], + "properties": { + "action": { "$ref": "#/definitions/Action" }, + "method": { "type": "string", "enum": ["GET", "POST"] }, + "path": { "$ref": "#/definitions/Path" }, + "mappings": { "$ref": "#/definitions/MappingPath" }, + + "timeoutMs": { "type": "integer", "minimum": 1000, "maximum": 120000, "default": 15000 }, + "retryMax": { "type": "integer", "minimum": 0, "maximum": 5, "default": 0 }, + + "status": { "$ref": "#/definitions/Status" } + } + }, + + "Action": { + "description": "beckn v2.0.0 request actions. An on_* callback is not one: it is the response half of the action that made the call.", + "type": "string", + "enum": ["discover", "select", "init", "confirm", "status", + "track", "cancel", "update", "rate", "support"] + }, + + "MappingPath": { + "description": "Fully-qualified URL of one published mapping file, holding request: and response: as YAML block scalars. The action segment must equal the action it sits under — verify/records.py.", + "type": "string", + "maxLength": 2000, + "pattern": "^https?://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/[A-Za-z0-9._~%-]*\\.(discover|select|init|confirm|status|track|cancel|update|rate|support)\\.ya?ml$" + }, + + "ParticipantId": { "type": "string", "maxLength": 253, "pattern": "^[a-z0-9][a-z0-9._:-]{2,252}$" }, + "CapabilityCode": { "type": "string", + "pattern": "^openagrinet:[A-Z][A-Za-z0-9]*$" }, + "Status": { "type": "string", "enum": ["active", "inactive"] }, + "Path": { "type": "string", "maxLength": 512, + "description": "Appended to that upstream's baseUrl. Single slashes: an empty segment is never deliberate, and many servers answer //a differently from /a. A trailing slash is allowed, because /api/ and /api are a distinction some APIs make.", + "pattern": "^(?!.*//)/[A-Za-z0-9._~%/-]*$" } + }, + + "_osConfig": { + "uniqueIndexFields": ["bindingKey"], + "indexFields": ["participantId", "capabilityCode", "status"], + "roles": ["registryOperator"], + "systemFields": ["osCreatedAt", "osUpdatedAt", "osCreatedBy", "osUpdatedBy"] + } +} diff --git a/quick-start/config/registry/schemas/SchemaRegistry.json b/quick-start/config/registry/schemas/SchemaRegistry.json new file mode 100644 index 0000000..c398d63 --- /dev/null +++ b/quick-start/config/registry/schemas/SchemaRegistry.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SchemaRegistry", + "type": "object", + "required": ["SchemaRegistry"], + "properties": { "SchemaRegistry": { "$ref": "#/definitions/SchemaRegistry" } }, + + "definitions": { + + "SchemaRegistry": { + "type": "object", + "additionalProperties": false, + "required": ["capabilityCode", "name", "version", "schemaUrl", "status"], + "properties": { + "capabilityCode": { "$ref": "#/definitions/CapabilityCode" }, + "name": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "\\S" }, + "version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "schemaUrl": { "type": "string", "maxLength": 2000, + "pattern": "^https://raw\\.githubusercontent\\.com/OpenAgriNet/network-specs/[A-Za-z0-9_-]+/schema/[A-Za-z0-9]+/v[0-9]+\\.[0-9]+/[A-Za-z0-9_-]+\\.yaml$" }, + "status": { "$ref": "#/definitions/Status" } + } + }, + + "CapabilityCode": { "type": "string", + "pattern": "^openagrinet:[A-Z][A-Za-z0-9]*$" }, + "Status": { "type": "string", "enum": ["active", "inactive"] } + }, + + "_osConfig": { + "uniqueIndexFields": ["capabilityCode"], + "indexFields": ["status"], + "roles": ["registryOperator"], + "systemFields": ["osCreatedAt", "osUpdatedAt", "osCreatedBy", "osUpdatedBy"] + } +} diff --git a/quick-start/config/reverse-proxy/npm-advanced/exp.conf b/quick-start/config/reverse-proxy/npm-advanced/exp.conf new file mode 100644 index 0000000..bfc5f59 --- /dev/null +++ b/quick-start/config/reverse-proxy/npm-advanced/exp.conf @@ -0,0 +1,20 @@ +# NOT loaded automatically. This is a paste job. +# +# NPM UI -> Hosts -> Proxy Hosts -> the experience-layer host -> Advanced -> +# paste this into "Custom Nginx Configuration" -> Save. +# +# It lives in a file, in this repo, because NPM's Advanced field is a textarea +# in a SQLite row: nothing diffs it, nothing reviews it, and a restore from the +# npm-data volume is the only thing that remembers it. Keeping the source here +# means the rule can be read and reviewed even though the running copy cannot. +# +# It applies to the experience host ONLY, which is the reason it is not in +# npm-custom/server_proxy.conf: /exp is app-facing and unsigned, while the +# network and provider hosts carry signed peer traffic that a 10 r/s ceiling +# would throttle for no security gain. +# +# The zone itself is declared in npm-custom/http_top.conf, which IS loaded +# automatically. If nginx rejects this paste with "unknown limit_req zone", +# that mount is missing rather than this snippet being wrong. + +limit_req zone=exp burst=20 nodelay; diff --git a/quick-start/config/reverse-proxy/npm-custom/http_top.conf b/quick-start/config/reverse-proxy/npm-custom/http_top.conf new file mode 100644 index 0000000..4ab629a --- /dev/null +++ b/quick-start/config/reverse-proxy/npm-custom/http_top.conf @@ -0,0 +1,22 @@ +# Included by NPM at the TOP of its http block, automatically, from +# /data/nginx/custom/http_top.conf. No UI step -- this file being mounted is +# the whole configuration. +# +# http_top rather than http.conf because a limit_req_zone has to be declared +# before any server block that references it, and NPM's generated proxy hosts +# are server blocks. + +# The rate-limit bucket for the experience layer. Declaring the zone costs +# nothing until a server block opts in with `limit_req` -- which is a per-host +# decision and therefore lives in that host's Advanced tab, not here. See +# config/reverse-proxy/npm-advanced/exp.conf. +# +# 10 r/s per address with a burst of 20 absorbs a Postman collection run while +# still bounding what is, at bottom, an open relay into the network: /exp/ +# takes UNSIGNED requests, so anyone who reaches it can originate one as this +# deployment's consumer. +limit_req_zone $binary_remote_addr zone=exp:10m rate=10r/s; + +# 503 says "the server is unwell"; 429 says "you are going too fast", which is +# what actually happened and the only one of the two a client can act on. +limit_req_status 429; diff --git a/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf b/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf new file mode 100644 index 0000000..85d0a65 --- /dev/null +++ b/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf @@ -0,0 +1,30 @@ +# Included by NPM inside EVERY proxy host's server block, automatically, from +# /data/nginx/custom/server_proxy.conf. No UI step. +# +# Because it applies to every host, only rules that are correct everywhere +# belong here. There is exactly one, and it matters more than the rest of this +# stack's edge configuration combined. + +# The provider adapter mounts TWO modules. / is its Beckn surface and +# verifies the sender's signature against the registry. But oanProviderPublish +# is mounted at `/` with NO validateSign at all, because its intended caller is +# the provider's own catalogue system, inside the trust boundary -- the same +# reason the experience adapter takes unsigned calls. +# +# So a proxy host pointed at provider-adapter:9200 exposes, at /publish, +# an unauthenticated "write anything into the catalogue" endpoint. NPM's UI +# gives no way to route a host while withholding one path, and this is not a +# thing to leave to remembering an Advanced-tab paste on one host out of three. +# +# An exact-match location beats NPM's generated `location /`, so this wins +# without conflicting with it. On the exp and network hosts it denies a path +# their adapters do not serve, which costs nothing. +# +# To open it for a real catalogue system outside this VM, do NOT edit this to +# `allow`. Give that caller a tunnel, or put it in the VPC and reach +# provider-adapter directly -- an endpoint with no credential to check does not +# belong on the public edge, and an address allowlist in front of it is a +# statement about the network, which is where it should be made. +# location = /publish { +# deny all; +# } diff --git a/quick-start/docker-compose.yml b/quick-start/docker-compose.yml new file mode 100644 index 0000000..2b73bbc --- /dev/null +++ b/quick-start/docker-compose.yml @@ -0,0 +1,635 @@ +# The whole OAN stack in one file: registry, discovery, the three adapters, the +# telemetry stack and the public edge. +# +# Read it in tiers -- the banner comments below are the structure: +# +# registry registry-db, keycloak, registry +# discovery discovery-db, discovery +# adapters provider, network, exp +# observability hyperdx profile: observability +# edge nginx-proxy-manager profile: gateway +# +# The last two are behind profiles because neither is needed to exercise the +# stack, and HyperDX is the heaviest thing here. +# +# ---------------------------------------------------------------- bringing up +# +# 1. cp .env.example .env change every credential -- they are +# shipped defaults +# 2. docker compose up -d registry discovery +# everything EXCEPT the adapters +# 3. python3 bin/setup.py keys, the three adapter entries, and +# the adapter configs they mount +# 4. docker compose up -d now the adapters +# 5. docker compose --profile reverse-proxy --profile observability up -d +# the edge and the telemetry stack +# +# Naming registry and discovery in step 2 is not tidiness. An adapter config +# is a bind-mounted FILE, and Docker creates a DIRECTORY at any bind-mount +# source that does not exist yet -- so starting an adapter before step 3 both +# wedges that container on a directory it cannot parse and leaves a directory +# where step 3 needs to write a file. +# +# Nothing is built here. Every image is pulled from the tags named in .env. +# +# ------------------------------------------------------------- what is public +# +# Exactly one container publishes on a routable interface: Nginx Proxy +# Manager, on 80 and 443. Everything else -- both Postgres instances, +# Keycloak, the registry, discovery, the HyperDX UI, and NPM's own admin UI on +# 81 -- publishes on 127.0.0.1 and is reachable only through an SSH tunnel: +# +# ssh -L 81:127.0.0.1:81 -L 8080:127.0.0.1:8080 \ +# -L 8081:127.0.0.1:8081 -L 8085:127.0.0.1:8085 -N you@the-vm +# +# The loopback binds are written literally rather than taken from a BIND_ADDR +# variable, which is what they used to be. One variable that moves the whole +# stack onto a public interface is a footgun once an edge exists to do that +# job deliberately for the one tier that should be reachable: behind those +# ports are a Keycloak whose admin password ships as a default and a registry +# whose write token anyone who has read .env.example can mint. +# +# ------------------------------------------------------------------- networks +# +# Two, and with a UI-configured proxy at the edge the split stops being +# decoration and becomes the actual control. NPM's "Forward Hostname" is a +# free-text field, so anyone with the admin password can point a public route +# at `registry` or `keycloak`. NPM sits only on oan-edge, where neither name +# resolves and neither address is routable -- the three adapters are the only +# containers it shares a network with. The adapters straddle both networks; +# everything else is internal-only. +# +# Neither is `internal: true`: the adapters and discovery fetch the Beckn spec +# from raw.githubusercontent.com at boot, the provider adapter calls an +# upstream API that lives outside this VM, and NPM has to reach Let's Encrypt. +# Cutting egress would break all three. + +networks: + oan-internal: + name: oan-internal + oan-edge: + name: oan-edge + +volumes: + registry-data: + discovery-data: + hyperdx-data: + npm-data: + npm-letsencrypt: + +x-adapter: &adapter + # Pulled, never built. Set ADAPTER_IMAGE in .env to the tag published for + # this environment -- `docker compose up -d` is the whole deployment step. + image: ${ADAPTER_IMAGE} + pull_policy: missing + restart: unless-stopped + # The adapters are the only services on oan-edge, and therefore the only + # ones NPM can reach. They still need oan-internal to read the registry and + # to call discovery, so they sit on both. + networks: [oan-internal, oan-edge] + +x-adapter-env: &adapter-env + CONFIG_FILE: /app/config/adapter.yaml + # OpenTelemetry. These ARE read: the otelsetup plugin builds gRPC exporters + # and the SDK applies its OTEL_EXPORTER_OTLP_* environment config before any + # option the plugin passes. + # + # OTEL_EXPORTER_OTLP_INSECURE IS LOAD-BEARING, and the reason is worth + # knowing because the symptom names TLS and the cause does not. + # + # The SDK derives transport security from the endpoint's SCHEME: + # + # switch u.Scheme { case "http", "unix": WithInsecure(); default: WithSecure() } + # + # "default" includes an EMPTY scheme. OTLP_ENDPOINT is a bare host:port, + # because that is what the plugin's own otlpEndpoint config field takes -- + # so without the scheme prefix below, or this flag, the exporter dials TLS + # at hyperdx's plaintext 4317 and logs, once per export interval: + # + # failed to upload metrics: ... authentication handshake failed: + # tls: first record does not look like a TLS handshake + # + # The flag is set explicitly rather than left to the scheme, so a future + # edit to the URL cannot silently turn TLS back on. + # + # This does not weaken anything that was encrypted: the collector is on + # oan-internal, reachable from nowhere else, and serves plaintext OTLP. + # + # There is also a bug underneath, in the plugin rather than here: it passes + # insecure credentials via WithDialOption, and NewGRPCConfig appends its own + # default TLS credentials AFTER user options, so gRPC takes the TLS ones and + # the plugin's setting is silently discarded. The env var is what actually + # controls this until that is fixed upstream. + # + # OTEL_EXPORTER_OTLP_PROTOCOL is deliberately absent. The plugin constructs + # gRPC exporters in code, so a protocol preference here is ignored and only + # suggests the transport is configurable from this file. + OTEL_EXPORTER_OTLP_ENDPOINT: http://${OTLP_ENDPOINT:-hyperdx:4317} + OTEL_EXPORTER_OTLP_INSECURE: "true" + +services: + # ========================================================================== + # registry -- who is on the network, their public keys, and which upstream + # API answers which capability. + # ========================================================================== + registry-db: + image: postgres:14 + container_name: oan-registry-db + restart: unless-stopped + networks: [oan-internal] + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - registry-data:/var/lib/postgresql/data + # No published port at all, not even on loopback: psql runs through + # `docker compose exec registry-db`, so publishing one would only widen + # the surface without adding a way in. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 5s + retries: 20 + + keycloak: + image: ghcr.io/sunbird-rc/sunbird-rc-keycloak:latest + container_name: oan-keycloak + restart: unless-stopped + networks: [oan-internal] + volumes: + - ./config/registry/imports:/opt/jboss/keycloak/imports + environment: + - DB_VENDOR=postgres + - DB_ADDR=registry-db + - DB_PORT=5432 + - DB_DATABASE=${POSTGRES_DB} + - DB_USER=${POSTGRES_USER} + - DB_PASSWORD=${POSTGRES_PASSWORD} + - KEYCLOAK_USER=${KEYCLOAK_ADMIN_USER} + - KEYCLOAK_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} + - KEYCLOAK_IMPORT=/opt/jboss/keycloak/imports/realm-export.json + - PROXY_ADDRESS_FORWARDING=true + ports: + # Loopback only. This is an admin console with an imported realm, and + # the tunnel is how you reach it. + - "127.0.0.1:${KEYCLOAK_PORT}:8080" + - "127.0.0.1:${KEYCLOAK_ADMIN_PORT}:9990" + depends_on: + registry-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9990/ || exit 1"] + interval: 10s + timeout: 10s + retries: 30 + + registry: + image: ghcr.io/sunbird-rc/sunbird-rc-core:${REGISTRY_VERSION} + container_name: oan-registry + restart: unless-stopped + # oan-internal ONLY, and that is the decision, not an omission. NPM sits + # on oan-edge alone, so it cannot resolve the name `registry` -- a proxy + # host pointed here fails to start rather than quietly working. Attaching + # oan-edge is the one edit that would make the registry publishable, so it + # is the one edit to refuse. + # + # Why not route it. POST /api/v1/Participant/search takes NO token, which + # makes it tempting: it is the one call a network peer needs. But + # SunbirdRC uses POST for both reads and writes -- /Participant/search + # reads, /Participant creates -- so no method rule separates them, and a + # route publishes the write surface the moment anything can mint a token. + # Today nothing can, for one reason only: Keycloak publishes on + # 127.0.0.1. That is a decision forty lines up, and a route here would + # silently depend on it. + # + # What this costs: nothing outside the VM can write a registry row, which + # is why bin/setup.py seeds all five participants and both bindings from + # inside. It is also why the Postman collection has no registry request -- + # there is no way for one to work. + # + # Reaching it for debugging is an SSH tunnel to ${REGISTRY_PORT}. + networks: [oan-internal] + volumes: + # Schemas are read at startup, so a change here needs this service + # restarted before the registry will honour it. + - ./config/registry/schemas:/home/sunbirdrc/config/public/_schemas + environment: + - connectionInfo_uri=jdbc:postgresql://registry-db:5432/${POSTGRES_DB} + - connectionInfo_username=${POSTGRES_USER} + - connectionInfo_password=${POSTGRES_PASSWORD} + - search_providerName=dev.sunbirdrc.registry.service.NativeSearchService + - authentication_enabled=true + - sunbird_sso_realm=${KEYCLOAK_REALM} + - sunbird_sso_url=http://keycloak:8080/auth + # Without this the service resolves its issuer to localhost -- which + # inside the container is itself -- and the security filter chain fails + # to build, so the whole registry never starts. + - OAUTH2_RESOURCES_0_URI=http://keycloak:8080/auth/realms/${KEYCLOAK_REALM} + - OAUTH2_RESOURCES_0_PROPERTIES_ROLES_PATH=realm_access.roles + - identity_provider=dev.sunbirdrc.auth.keycloak.KeycloakProviderImpl + - sunbird_sso_admin_client_id=${KEYCLOAK_ADMIN_CLIENT_ID} + - sunbird_sso_client_id=${KEYCLOAK_CLIENT_ID} + - sunbird_sso_admin_client_secret=${KEYCLOAK_SECRET} + - sunbird_keycloak_user_set_password=true + - sunbird_keycloak_user_password=${REGISTRY_DEFAULT_USER_PASSWORD} + - 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 + - manager_type=DefinitionsManager + - expand_reference=false + - swagger_title=OAN Registry + - logging.level.root=INFO + ports: + # Loopback only, and bin/setup.py talks to exactly this: it derives + # http://localhost:${REGISTRY_PORT} and runs on the VM. + - "127.0.0.1:${REGISTRY_PORT}:8081" + depends_on: + registry-db: + condition: service_healthy + keycloak: + condition: service_healthy + healthcheck: + # This image ships no curl, wget, nc or bash, so the check reads the + # kernel's own table instead: 1F91 is 8081 in hex, and the service binds + # v6, hence tcp6. It proves the port is accepting connections, which is + # what everything downstream waits for. + test: ["CMD-SHELL", "grep -q ':1F91' /proc/net/tcp6"] + interval: 5s + timeout: 5s + retries: 60 + start_period: 40s + + # ========================================================================== + # discovery -- catalogue search, and its own Postgres. + # ========================================================================== + discovery-db: + # pgvector rather than plain postgres: the discovery service's HNSW index + # options arrived in 0.8. + image: pgvector/pgvector:0.8.0-pg16 + container_name: oan-discovery-db + restart: unless-stopped + networks: [oan-internal] + environment: + # Not read from .env, unlike the registry's pair, and so not covered by + # the credential rotation .env.example asks for. That is tolerable only + # because this database publishes no port and sits on an internal + # network; if it ever needs to be reachable, these move to .env first. + POSTGRES_USER: discovery + POSTGRES_PASSWORD: discovery + POSTGRES_DB: discovery + volumes: + - discovery-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U discovery -d discovery"] + interval: 5s + timeout: 5s + retries: 20 + + discovery: + image: ${DISCOVERY_IMAGE} + pull_policy: missing + container_name: oan-discovery + restart: unless-stopped + networks: [oan-internal] + environment: + DATABASE_URL: postgres://discovery:discovery@discovery-db:5432/discovery?sslmode=disable + DATABASE_AUTO_MIGRATE: "true" + APP_NETWORK_ID: ${APP_NETWORK_ID} + SERVER_PORT: 8080 + VALIDATION_SPEC_URL: ${BECKN_SPEC_URL} + + # Telemetry. The endpoint is the SDK's own variable, which is what this + # service reads (src/platform/config/config.go), so it needs no + # translation -- but the exporter stays `none` by default on purpose: + # in the current build OTEL_EXPORTER is parsed into config and nothing + # consumes it, and the only OpenTelemetry packages in go.mod are + # indirect. Setting it to otlp today emits nothing rather than failing; + # flip OTEL_EXPORTER=otlp in .env once the exporter is wired and the + # traces land in HyperDX with no other change here. + OTEL_EXPORTER: ${OTEL_EXPORTER:-none} + # Same scheme requirement as the adapters -- see the note on the + # x-adapter anchor. Inert while OTEL_EXPORTER is none, but wrong + # in a way that would only surface when someone turns it on. + OTEL_EXPORTER_OTLP_ENDPOINT: http://${OTLP_ENDPOINT:-hyperdx:4317} + OTEL_EXPORTER_OTLP_INSECURE: "true" + OTEL_SERVICE_NAME: oan-discovery + # config/common.yaml is baked into the image and is the reviewed default. + # To override a setting, copy config/discovery/instance.yaml.example to + # config/discovery/instance.yaml and uncomment the mount below -- compose + # creates a DIRECTORY if you mount a file that does not exist, which the + # service then fails to parse, so the file has to be there first. + # volumes: + # - ./config/discovery/instance.yaml:/app/config/instance.yaml:ro + ports: + # Loopback only. Discovery is reached by the network adapter over + # oan-internal; this publish exists for curl through the tunnel. + - "127.0.0.1:${DISCOVERY_PORT}:8080" + depends_on: + discovery-db: + condition: service_healthy + + # ========================================================================== + # adapters -- experience, network and provider. Same image, three configs. + # + # Their configs are bind-mounted FILES that do not exist until bin/setup.py + # renders them from the .tmpl beside them, which is what the step ordering + # above is about. + # ========================================================================== + + # ========================================================================== + # mock upstreams -- stand-ins for the real provider APIs, so the stack can be + # exercised without their credentials. + # + # Pulled like everything else. The sources are in mock-server/ to be built and + # published once, not built here -- see mock-server/README.md. + # + # oan-internal only: an upstream is called BY the provider adapter and is + # never reached from outside, so NPM has no business seeing it. The published + # ports are for looking at the mock directly while debugging. + # + # Both reproduce the awkward parts of the services they stand in for on + # purpose. A tidy mock would let a mapping pass here and fail in the real + # deployment. + # ========================================================================== + mockimd: + image: ${MOCKIMD_IMAGE} + pull_policy: missing + container_name: oan-mockimd + restart: unless-stopped + networks: [oan-internal] + command: + - "-addr=:9100" + # How many forecast days it answers with. The mapping reads however many + # arrive, so this is the knob for checking that it does. + - "-days=${MOCKIMD_DAYS:-3}" + ports: + - "127.0.0.1:${MOCKIMD_PORT}:9100" + + mockagmarknet: + image: ${MOCKAGMARKNET_IMAGE} + pull_policy: missing + container_name: oan-mockagmarknet + restart: unless-stopped + networks: [oan-internal] + command: + - "-addr=:9101" + # It answers 401 without this token, which is what proves the adapter + # sent one rather than the call happening to work anyway. + - "-token=${MANDI_TOKEN}" + - "-days=${MOCKAGMARKNET_DAYS:-2}" + ports: + - "127.0.0.1:${MOCKAGMARKNET_PORT}:9101" + + # Verifies the caller, calls the upstream provider, answers synchronously. + # It has no provider address of its own: it reads the ProviderSchema row, + # so repointing it at a different upstream is a registry write, not a + # change here or a restart. + provider-adapter: + <<: *adapter + container_name: oan-provider-adapter + depends_on: + registry: + condition: service_healthy + mockimd: + condition: service_started + mockagmarknet: + condition: service_started + environment: + <<: *adapter-env + OTEL_SERVICE_NAME: oan-provider-adapter + # The mandi upstream's credential. Named here and read at call time, so + # it is never in a config file or in the registry. + MANDI_TOKEN: ${MANDI_TOKEN} + volumes: + - ./config/adapters/provider.yaml:/app/config/adapter.yaml:ro + - ./config/adapters/routing-provider.yaml:/app/config/routing-provider.yaml:ro + ports: + - "127.0.0.1:${PROVIDER_ADAPTER_PORT}:9200" + + # Verifies the caller, hands discovery on, re-signs as itself. + network-adapter: + <<: *adapter + container_name: oan-network-adapter + depends_on: + registry: + condition: service_healthy + discovery: + condition: service_started + environment: + <<: *adapter-env + OTEL_SERVICE_NAME: oan-network-adapter + volumes: + - ./config/adapters/network.yaml:/app/config/adapter.yaml:ro + - ./config/adapters/routing-network.yaml:/app/config/routing-network.yaml:ro + ports: + - "127.0.0.1:${NETWORK_ADAPTER_PORT}:9201" + + # The caller, and the only one that takes unsigned requests: the experience + # app is inside the trust boundary, so there is no network signature to + # check. This is what makes the stack testable with a plain curl -- and it + # is also why the edge rate-limits this host and nothing else. + exp-adapter: + <<: *adapter + container_name: oan-exp-adapter + depends_on: + network-adapter: + condition: service_started + provider-adapter: + condition: service_started + environment: + <<: *adapter-env + OTEL_SERVICE_NAME: oan-exp-adapter + volumes: + - ./config/adapters/experience.yaml:/app/config/adapter.yaml:ro + - ./config/adapters/routing-experience.yaml:/app/config/routing-experience.yaml:ro + ports: + - "127.0.0.1:${EXP_ADAPTER_PORT}:9202" + + # ========================================================================== + # observability -- HyperDX / ClickStack: OTLP ingest, ClickHouse, and the UI + # over it. + # + # docker compose --profile observability up -d + # + # Sizing: ClickHouse alone wants 2-4 GB. With this profile on, the VM needs + # 16 GB (t3.xlarge or m6i.xlarge); without it, 8 GB is workable. + # ========================================================================== + hyperdx: + # clickstack-local, not clickstack-all-in-one: local runs single-user with + # no team to create and no ingestion API key to mint, which is what makes + # `up -d` the whole setup step. It is also why this must stay on loopback + # -- there is no login in front of it. + image: clickhouse/clickstack-local:latest + container_name: oan-hyperdx + restart: unless-stopped + profiles: ["observability"] + networks: [oan-internal] + env_file: + # Optional. Present so an existing .env.docker keeps working; compose + # errors on a missing env_file unless it is declared this way. + - path: .env.docker + required: false + environment: + # Self-instrumentation off: the telemetry stack reporting on itself is + # noise in the same tables the stack under test writes to, and at debug + # level it is most of the volume. + OTEL_SDK_DISABLED: "true" + HYPERDX_LOG_LEVEL: error + HYPERDX_USAGE_STATS_ENABLED: "false" + HYPERDX_USAGE_STATS_COLLECTION_ENABLED: "false" + OTEL_LOG_LEVEL: none + OTEL_TRACES_EXPORTER: none + OTEL_METRICS_EXPORTER: none + OTEL_LOGS_EXPORTER: none + USAGE_STATS_ENABLED: "false" + ports: + # 8085, not the 8080 this image serves the UI on and not the 8081 it is + # usually published as: 8081 is REGISTRY_PORT here, and two services + # cannot claim one host port. + - "127.0.0.1:${HYPERDX_PORT:-8085}:8080" + # OTLP. Published on loopback only so a reverse tunnel can carry + # telemetry from a provider API running on someone's laptop; the + # in-stack senders reach hyperdx:4317/4318 over oan-internal and do not + # need these at all. + - "127.0.0.1:${OTLP_GRPC_PORT:-4317}:4317" + - "127.0.0.1:${OTLP_HTTP_PORT:-4318}:4318" + volumes: + - hyperdx-data:/var/lib/clickhouse + healthcheck: + # wget, not curl: this image has busybox. And a GET, not `--spider`, + # which sends HEAD -- Next.js answers HEAD / with a 404 while serving + # GET / perfectly, so the probe failed against a UI that was working. + # + # 127.0.0.1 rather than localhost, because busybox resolves localhost to + # ::1 first and these listeners are v4. + # + # Two checks, because this container is two things. 8080 is the Next.js + # UI -- what 8085 publishes and what you actually open. 13133 is the + # collector's health_check extension, and the collector is what receives + # telemetry on 4317/4318: it can die while the UI keeps serving, which + # loses data silently and is the failure worth catching. + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/ && wget -q -O /dev/null http://127.0.0.1:13133/"] + interval: 10s + timeout: 5s + # ClickHouse creates its system tables on a cold volume, which takes + # minutes rather than the 80s the previous 5/30s pair allowed. Docker + # does not re-evaluate once a container has failed past `retries`, so a + # window that is merely tight leaves a permanent wrong label. + retries: 10 + start_period: 180s + + # ========================================================================== + # edge -- Nginx Proxy Manager. + # + # docker compose --profile reverse-proxy up -d + # + # NPM rather than a hand-written nginx.conf because it owns the part that is + # genuinely tedious to do by hand -- ACME. It requests, installs and renews + # Let's Encrypt certificates from a UI rather than from a certbot invocation + # someone has to remember. In exchange, the routing table stops being a file + # in this repo and becomes rows in NPM's SQLite database under the npm-data + # volume: reviewable only by opening the UI, and restorable only from that + # volume. See README for the proxy hosts to create. + # ========================================================================== + nginx-proxy-manager: + image: jc21/nginx-proxy-manager:latest + container_name: oan-npm + restart: unless-stopped + profiles: ["reverse-proxy"] + + # oan-edge ONLY, and this is the whole security argument for putting a + # UI-configured proxy in front of this stack. + # + # NPM's "Forward Hostname" is a free-text field. Anyone with the admin + # password can type `registry`, `keycloak` or `discovery-db` into it. On + # this network none of those names resolve and none of those addresses are + # routable from here -- the three adapters are the only containers NPM + # shares a network with. So the blast radius of a wrong click, or of the + # admin UI being reached by someone who should not have it, is bounded to + # the tier that is meant to be public anyway. + # + # Putting NPM on oan-internal to "make things easier" would remove that + # bound entirely and put the registry's write API one form field away from + # the internet. Do not. + networks: [oan-edge] + + ports: + # Public. Both of these must be reachable from anywhere, not just from + # your address: Let's Encrypt validates HTTP-01 by fetching + # http:///.well-known/acme-challenge/... from its own servers, and + # those come from addresses you do not get to enumerate. An SG rule + # scoped to your IP will make certificate issuance fail with a challenge + # timeout. Use DNS-01 instead if 80 must stay closed -- see README. + - "0.0.0.0:${GATEWAY_HTTP_PORT:-80}:80" + - "0.0.0.0:${GATEWAY_HTTPS_PORT:-443}:443" + + # The admin UI, on loopback -- deliberately not the "publish it and + # restrict it in the firewall" arrangement. Two reasons. It ships with + # a known default login (admin@example.com / changeme) that is live from + # first boot until someone changes it, and it is the one surface here + # that can mint certificates and re-point every public route. A security + # group is a second system to keep in step with that; a loopback bind is + # not. Reach it over the tunnel: + # + # ssh -L 81:127.0.0.1:81 -N you@the-vm then http://127.0.0.1:81 + - "127.0.0.1:${NPM_ADMIN_PORT:-81}:81" + + volumes: + - npm-data:/data + - npm-letsencrypt:/etc/letsencrypt + + # Version-controlled nginx that NPM includes on its own. /data is a + # named volume and this bind mounts over one directory inside it, which + # works because the more specific mount wins. + # + # Only what is genuinely context-wide belongs here -- see the README for + # which of these NPM loads automatically and which is a paste job. + # + # NOT :ro, however much it wants to be. NPM's s6 init runs 50-ipv6.sh + # over everything under /data/nginx, which writes a .tmp beside each file + # and then chowns it. On a read-only mount both fail, the `prepare` + # service exits 1, s6 aborts the rest of the chain, and nginx never + # starts -- the container still reports Up, because s6 itself is alive, + # so the symptom is an empty reply on 81 and a healthcheck that never + # passes rather than anything that says "permissions". + # + # What NPM actually rewrites is `listen` directives, and neither file + # here has one, so the pass is a no-op on content. It does take + # ownership of the files, which is cosmetic and not tracked by git. + - ./config/reverse-proxy/npm-custom:/data/nginx/custom + + depends_on: + # Ordering only; the adapter image has no healthcheck. + # + # And ordering is not the whole problem. NPM writes a literal + # proxy_pass hostname per proxy host, which nginx resolves at reload and + # then caches -- so if you RECREATE an adapter (not merely restart it; + # a restart keeps the address) NPM keeps proxying to an address nothing + # answers on. The fix is one command, and it is worth knowing before you + # spend an afternoon on a 502: + # + # docker compose restart nginx-proxy-manager + exp-adapter: + condition: service_started + network-adapter: + condition: service_started + provider-adapter: + condition: service_started + + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:81/api/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s diff --git a/quick-start/mock-server/README.md b/quick-start/mock-server/README.md new file mode 100644 index 0000000..6b662ee --- /dev/null +++ b/quick-start/mock-server/README.md @@ -0,0 +1,43 @@ +# Mock providers + +Two stand-in upstreams, so the stack can be exercised end to end without +credentials for the real services. + +| mock | stands in for | port | auth | +|---|---|---|---| +| `mockimd` | IMD Mausamgram NWP | 9100 | basic | +| `mockagmarknet` | Agmarknet Vistaar | 9101 | token as a query parameter | + +## Why the sources are here + +The compose file **pulls** every image and builds nothing, so these are not +built by `make up`. They are here to be built and published once, and then +pulled like everything else: + + docker build -t ghcr.io//oan-mockimd:latest mock-server/mockimd + docker build -t ghcr.io//oan-mockagmarknet:latest mock-server/mockagmarknet + docker push ghcr.io//oan-mockimd:latest + docker push ghcr.io//oan-mockagmarknet:latest + +Then set `MOCKIMD_IMAGE` and `MOCKAGMARKNET_IMAGE` in `.env`. + +## What they deliberately get wrong + +A mock that answered tidily would let a mapping pass here and fail against the +real service, so both reproduce the awkward parts on purpose. + +`mockimd` answers `fcstday1..N` with the count coming from `-days`, so a mapping +that hardcodes five days is caught. Forecasts derive from the requested point, +so a wrong lat/lon shows up as wrong numbers rather than passing silently. + +`mockagmarknet` answers a **bare JSON array** whose records use **Title Case +keys containing spaces** — `Modal Price`, `Arrival Date` — with **prices as +strings** and dates as `dd-MM-yyyy`. It requires the token as a query +parameter and answers 401 without it, which is what proves the adapter sent +one. Its last record reports no minimum or maximum, as the real data +sometimes does, so a mapping is forced to distinguish "not reported" from +"zero". Prices derive from the requested market and commodity codes, so a +wrong code is visible in the answer. + +Neither reproduces the real services' error bodies or credentials. What the +real ones do on no-data, rate limits or auth failure is still unobserved. diff --git a/quick-start/mock-server/mockagmarknet/Dockerfile b/quick-start/mock-server/mockagmarknet/Dockerfile new file mode 100644 index 0000000..42a8747 --- /dev/null +++ b/quick-start/mock-server/mockagmarknet/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26.1-bookworm AS builder +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN CGO_ENABLED=0 go build -o mockagmarknet . + +FROM cgr.dev/chainguard/wolfi-base:latest +WORKDIR /app +COPY --from=builder /src/mockagmarknet . +EXPOSE 9101 +ENTRYPOINT ["./mockagmarknet"] diff --git a/quick-start/mock-server/mockagmarknet/go.mod b/quick-start/mock-server/mockagmarknet/go.mod new file mode 100644 index 0000000..2abe3ef --- /dev/null +++ b/quick-start/mock-server/mockagmarknet/go.mod @@ -0,0 +1,3 @@ +module mockagmarknet + +go 1.22.2 diff --git a/quick-start/mock-server/mockagmarknet/main.go b/quick-start/mock-server/mockagmarknet/main.go new file mode 100644 index 0000000..98ecc42 --- /dev/null +++ b/quick-start/mock-server/mockagmarknet/main.go @@ -0,0 +1,127 @@ +// Command mockagmarknet stands in for Agmarknet's Vistaar API during local +// end-to-end runs. +// +// It answers the shape the real service does: a bare JSON array of records with +// Title Case keys containing spaces and prices as strings. Both of those are +// awkward, and reproducing them is the point -- a mock that returned tidy +// camelCase numbers would let a mapping pass here and fail against the real +// thing. +// +// It requires the token as a query parameter, which is how that API +// authenticates, so the adapter's query auth path is exercised rather than +// skipped. Prices are derived from the requested market and commodity codes, so +// a wrong code shows up as wrong numbers instead of passing silently. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "hash/fnv" + "log" + "net/http" + "os" + "time" +) + +// record is one market's report for one day, keyed exactly as Agmarknet keys it. +type record struct { + Grade string `json:"Grade"` + Group string `json:"Group"` + State string `json:"State"` + Market string `json:"Market"` + Variety string `json:"Variety"` + District string `json:"District"` + Commodity string `json:"Commodity"` + MaxPrice string `json:"Max Price,omitempty"` + MinPrice string `json:"Min Price,omitempty"` + PriceUnit string `json:"Price Unit"` + ModalPrice string `json:"Modal Price"` + ArrivalDate string `json:"Arrival Date"` +} + +func main() { + addr := flag.String("addr", ":9101", "address to listen on") + token := flag.String("token", "local-mandi-token", "token the query must carry") + days := flag.Int("days", 2, "how many daily records to answer with") + flag.Parse() + + http.HandleFunc("/v1/fetch-agmarknet-vistaar", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + log.Printf("%s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + + if query.Get("token") != *token { + // The real API answers 401 for a bad token. Worth reproducing: it is + // what proves the adapter sent one at all. + http.Error(w, `{"message":"invalid token"}`, http.StatusUnauthorized) + return + } + for _, required := range []string{"statecode", "districtcode", "commoditycode", "from_date", "to_date"} { + if query.Get(required) == "" { + http.Error(w, fmt.Sprintf(`{"message":"missing %s"}`, required), http.StatusBadRequest) + return + } + } + + from, err := time.Parse("02-01-2006", query.Get("from_date")) + if err != nil { + // dd-MM-yyyy, not ISO. A mapping that forgets to convert lands here. + http.Error(w, `{"message":"from_date must be dd-MM-yyyy"}`, http.StatusBadRequest) + return + } + + commodity := query.Get("commoditycode") + market := query.Get("marketcode") + if market == "" { + // Without a market code the real API widens to the district, so the + // answer names the district rather than one market. + market = "district-" + query.Get("districtcode") + } + base := 1500 + int(hash(market+commodity)%800) + + records := make([]record, 0, *days) + for day := 0; day < *days; day++ { + date := from.AddDate(0, 0, day) + modal := base + day*25 + rec := record{ + Grade: "Non-FAQ", + Group: "Cereals", + State: "Chattisgarh", + Market: "Mock APMC " + market, + Variety: "D.B.", + District: "Balodabazar", + Commodity: "Commodity " + commodity, + PriceUnit: "Rs./Qtl", + ModalPrice: fmt.Sprintf("%d", modal), + ArrivalDate: date.Format("02-01-2006"), + } + // The last record reports no minimum or maximum, which happens in + // the real data. It must arrive as absent, not zero. + if day < *days-1 { + rec.MinPrice = fmt.Sprintf("%d", modal-100) + rec.MaxPrice = fmt.Sprintf("%d", modal+100) + } + records = append(records, rec) + } + + w.Header().Set("Content-Type", "application/json") + // A bare array, which is one of the three shapes the real API uses. + if err := json.NewEncoder(w).Encode(records); err != nil { + log.Printf("could not write the answer: %v", err) + } + }) + + log.Printf("mockagmarknet listening on %s, %d records per answer", *addr, *days) + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Printf("mockagmarknet stopped: %v", err) + os.Exit(1) + } +} + +// hash makes the prices depend on what was asked for, so a wrong code is +// visible in the answer rather than silently tolerated. +func hash(s string) uint32 { + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return h.Sum32() +} diff --git a/quick-start/mock-server/mockimd/Dockerfile b/quick-start/mock-server/mockimd/Dockerfile new file mode 100644 index 0000000..9f73c1d --- /dev/null +++ b/quick-start/mock-server/mockimd/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26.1-bookworm AS builder +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN CGO_ENABLED=0 go build -o mockimd . + +FROM cgr.dev/chainguard/wolfi-base:latest +WORKDIR /app +COPY --from=builder /src/mockimd . +EXPOSE 9100 +ENTRYPOINT ["./mockimd"] diff --git a/quick-start/mock-server/mockimd/go.mod b/quick-start/mock-server/mockimd/go.mod new file mode 100644 index 0000000..850bbd2 --- /dev/null +++ b/quick-start/mock-server/mockimd/go.mod @@ -0,0 +1,3 @@ +module mockimd + +go 1.22.2 diff --git a/quick-start/mock-server/mockimd/main.go b/quick-start/mock-server/mockimd/main.go new file mode 100644 index 0000000..0b61d5f --- /dev/null +++ b/quick-start/mock-server/mockimd/main.go @@ -0,0 +1,147 @@ +// Command mockimd stands in for IMD's Mausamgram NWP API during local +// end-to-end runs. +// +// It answers the shape the real service does -- fcstday1..N carrying date, +// rain, tmin, tmax, rhmin, rhmax, wspd and a warning -- and requires the same +// basic auth, so the adapter's credential path is exercised rather than +// skipped. Forecasts are derived from the requested point so a wrong lat/lon +// shows up as wrong numbers instead of passing silently. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "math" + "net/http" + "os" + "strconv" + "time" +) + +type forecast struct { + Date string `json:"date"` + Rain float64 `json:"rain"` + TMin float64 `json:"tmin"` + TMax float64 `json:"tmax"` + RHMin int `json:"rhmin"` + RHMax int `json:"rhmax"` + WSpd float64 `json:"wspd"` + Wind []string `json:"wind,omitempty"` + WeatherWarning string `json:"weather_warning,omitempty"` + CloudMessage string `json:"cloud_message,omitempty"` +} + +func main() { + addr := flag.String("addr", ":9100", "listen address") + user := flag.String("user", "", "basic auth username; empty with -pass means no auth") + pass := flag.String("pass", "", "basic auth password; empty with -user means no auth") + days := flag.Int("days", 3, "forecast days to return (1-5)") + flag.Parse() + + // No credential configured means none demanded. That is how this runs in the + // local stack: the registry publishes auth.scheme "none" for this upstream, + // which is what lets its baseUrl be plaintext http, and a mock that still + // demanded a password would contradict the record the adapter reads. + requireAuth := *user != "" || *pass != "" + + http.HandleFunc("/get-daily", func(w http.ResponseWriter, r *http.Request) { + if requireAuth { + gotUser, gotPass, ok := r.BasicAuth() + if !ok || gotUser != *user || gotPass != *pass { + log.Printf("401 %s %s -- basic auth missing or wrong", r.Method, r.URL.RequestURI()) + w.Header().Set("WWW-Authenticate", `Basic realm="mausamgram"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + lat, lon, err := point(r) + if err != nil { + log.Printf("400 %s -- %v", r.URL.RequestURI(), err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // The whole query, not just the two fields this mock parses: what the + // adapter sent is decided by a mapping file, so a log that prints only + // the fields already known here cannot show a mapping change at all. + log.Printf("200 %s?%s (lat=%v lon=%v)", r.URL.Path, r.URL.RawQuery, lat, lon) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body(lat, lon, *days)) + }) + + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "ok") + }) + + auth := "no auth" + if requireAuth { + auth = fmt.Sprintf("basic auth %s/%s", *user, *pass) + } + log.Printf("mock IMD listening on http://%s (%s, %d day forecast)", *addr, auth, *days) + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Println(err) + os.Exit(1) + } +} + +// point reads the coordinates the adapter sent, which is what proves the +// request mapping produced them. +func point(r *http.Request) (float64, float64, error) { + latRaw, lonRaw := r.URL.Query().Get("lat"), r.URL.Query().Get("lon") + if latRaw == "" || lonRaw == "" { + return 0, 0, fmt.Errorf("lat and lon are required, got %q", r.URL.RawQuery) + } + lat, err := strconv.ParseFloat(latRaw, 64) + if err != nil { + return 0, 0, fmt.Errorf("lat %q is not a number", latRaw) + } + lon, err := strconv.ParseFloat(lonRaw, 64) + if err != nil { + return 0, 0, fmt.Errorf("lon %q is not a number", lonRaw) + } + return lat, lon, nil +} + +// body derives a forecast from the point, so a wrong coordinate produces wrong +// numbers rather than passing unnoticed. The last day is deliberately partial: +// a provider that reports some readings and not others is the ordinary case, +// and the mapping has to omit what was not measured. +func body(lat, lon float64, days int) map[string]any { + if days < 1 { + days = 1 + } + if days > 5 { + days = 5 + } + + out := map[string]any{"location": map[string]float64{"lat": lat, "lon": lon}} + base := math.Abs(lat) + math.Abs(lon) + + for day := 1; day <= days; day++ { + date := time.Now().AddDate(0, 0, day-1).Format("2006-01-02") + f := forecast{ + Date: date, + TMin: round(20 + math.Mod(base, 5) + float64(day)*0.4), + TMax: round(30 + math.Mod(base, 4) + float64(day)*0.3), + } + if day < days { + f.Rain = round(math.Mod(base*float64(day), 20)) + f.RHMin = 50 + day + f.RHMax = 88 + day + f.WSpd = round(3 + math.Mod(base, 3)) + f.Wind = []string{"NW", "North Westerly"} + if f.Rain > 10 { + f.WeatherWarning = "Heavy rainfall warning" + } else { + f.CloudMessage = "Partly cloudy" + } + } + out[fmt.Sprintf("fcstday%d", day)] = f + } + return out +} + +func round(v float64) float64 { return math.Round(v*10) / 10 } 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"