diff --git a/api-collection/OpenAgriNet.network-adapter.local-environment.json b/api-collection/OpenAgriNet.network-adapter.local-environment.json new file mode 100644 index 00000000..273aa5c7 --- /dev/null +++ b/api-collection/OpenAgriNet.network-adapter.local-environment.json @@ -0,0 +1,162 @@ +{ + "id": "oan-local-environment-single", + "name": "OpenAgriNet local (single mode)", + "values": [ + { + "key": "consumer-adapter-host", + "value": "http://127.0.0.1:9201", + "enabled": true, + "type": "default", + "description": "Where discover and select enter. Single mode: the one adapter, so all three adapter hosts are the same URL. NETWORK_ADAPTER_PORT in .env." + }, + { + "key": "provider-adapter-host", + "value": "http://127.0.0.1:9201", + "enabled": true, + "type": "default", + "description": "Where publish enters. Single mode: the same adapter as consumer-adapter-host. NETWORK_ADAPTER_PORT in .env." + }, + { + "key": "network-adapter-host", + "value": "http://127.0.0.1:9201", + "enabled": true, + "type": "default", + "description": "Used only by the health check. Single mode: the same adapter; its network tier sits under /_internal/ and is not called from Postman. NETWORK_ADAPTER_PORT in .env." + }, + { + "key": "discovery-host", + "value": "http://127.0.0.1:8090", + "enabled": true, + "type": "default", + "description": "The catalogue index. Reached through an adapter, not directly, so only its health check is here. DISCOVERY_PORT in .env." + }, + { + "key": "registry-host", + "value": "http://127.0.0.1:8081", + "enabled": true, + "type": "default", + "description": "Participants and capability bindings, seeded by bin/setup.py. HOST ONLY: the API base is /api/v1, so a request spells it out. REGISTRY_PORT in .env." + }, + { + "key": "keycloak-host", + "value": "http://127.0.0.1:8080", + "enabled": true, + "type": "default", + "description": "Issues the registry write token, and authenticates nothing else -- it is Sunbird RC's, not the network's. KEYCLOAK_PORT in .env." + }, + { + "key": "keycloak-realm", + "value": "sunbird-rc", + "enabled": true, + "type": "default", + "description": "KEYCLOAK_REALM in .env." + }, + { + "key": "keycloak-client-id", + "value": "registry-frontend", + "enabled": true, + "type": "default", + "description": "The client that logs a user in. admin-api is the one that mints tokens and is not used from here. KEYCLOAK_CLIENT_ID in .env." + }, + { + "key": "registry-user", + "value": "no-user", + "enabled": true, + "type": "default", + "description": "REGISTRY_ADMIN_USER in .env. A shipped default, so it is public until changed." + }, + { + "key": "registry-password", + "value": "no-user-password", + "enabled": true, + "type": "default", + "description": "REGISTRY_ADMIN_PASSWORD in .env. A shipped default, so it is public until changed." + }, + { + "key": "consumer-participant-id", + "value": "consumer.oan.dev", + "enabled": true, + "type": "default", + "description": "The consumer adapter's network identity, and what its signatures verify against. CONSUMER_SUBSCRIBER_ID in .env." + }, + { + "key": "consumer-base-url", + "value": "https://consumer.oan.dev", + "enabled": true, + "type": "default", + "description": "The address PEERS are told to POST to, published in the registry row. Nothing in this stack resolves it, so a wrong value costs nothing locally and everything on a shared network -- and the registry cannot correct it. CONSUMER_BASE_URL in .env." + }, + { + "key": "consumer-participant-key", + "value": "", + "enabled": true, + "type": "default", + "description": "EMPTY ON PURPOSE. The public half lives in quick-start/keys/keys.json, which bin/setup.py generates; paste it in before registering. setup.py has already registered this participant, so it is normally not needed." + }, + { + "key": "mausamgram-participant-id", + "value": "mausamgram", + "enabled": true, + "type": "default", + "description": "IMD Mausamgram, the weather upstream. Half of its binding key. MAUSAMGRAM_PARTICIPANT_ID in .env." + }, + { + "key": "mausamgram-capability", + "value": "openagrinet:WeatherObservation", + "enabled": true, + "type": "default", + "description": "MAUSAMGRAM_CAPABILITY in .env." + }, + { + "key": "mausamgram-binding-key", + "value": "mausamgram|openagrinet:WeatherObservation", + "enabled": true, + "type": "default", + "description": "|. Used to look up the binding's osid. The provider adapter builds this from the incoming payload, so it must match the registry row exactly -- a mismatch is a 404." + }, + { + "key": "mausamgram-base-url", + "value": "https://mausamgram.imd.gov.in", + "enabled": true, + "type": "default", + "description": "MAUSAMGRAM_BASE_URL in .env." + }, + { + "key": "mausamgram-path", + "value": "/nwpapi/get-daily", + "enabled": true, + "type": "default", + "description": "MAUSAMGRAM_PATH in .env." + }, + { + "key": "mausamgram-mapping-url", + "value": "https://raw.githubusercontent.com/OpenAgriNet/helmcharts/0.0.1/quick-start/config/mappings/mausamgram/weather-observation.select.yaml", + "enabled": true, + "type": "default", + "description": "What the binding row publishes and the provider adapter fetches. A TAG ref, so what a running stack reads is fixed at 0.0.1 and a later push does not move it. MAUSAMGRAM_MAPPING_URL in .env." + }, + { + "key": "capability-code", + "value": "openagrinet:WeatherObservation", + "enabled": true, + "type": "default", + "description": "WHICH CAPABILITY THE 4. Capability Schemas FOLDER OPERATES ON -- the one variable to change to work on another, because the schemaUrl and the display name are derived from it rather than written down. The four this deployment serves are openagrinet:WeatherObservation, openagrinet:MandiPrice, openagrinet:KnowledgeAdvisory and openagrinet:AgricultureFacility; network-specs carries four more packs no capability uses yet." + }, + { + "key": "schema-pack-ref", + "value": "main", + "enabled": true, + "type": "default", + "description": "The ref in OpenAgriNet/network-specs that a derived schemaUrl points at. SCHEMA_PACK_REF in .env, which is what bin/setup.py seeds the rows from -- keep the two the same or a create here disagrees with a seeded row. MAIN IS A 404 FOR THESE PATHS TODAY; only schema-packs-v0.1 serves them. The registry checks the URL's shape and never fetches it, so a row stores either way, and main is the ref that becomes correct once the packs merge." + }, + { + "key": "schema-pack-version", + "value": "v0.1", + "enabled": true, + "type": "default", + "description": "The pack version, and the row's version field. All eight packs are v0.1 today. Per capability in setup.py rather than one setting for all four, because a capability can move to v0.2 while the others stay -- so if you point capability-code at one that has, change this too." + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_using": "hand-written, tracked in this repo" +} diff --git a/charts/adapter-service/CHANGELOG.md b/charts/adapter-service/CHANGELOG.md index b8e36b07..5093db13 100644 --- a/charts/adapter-service/CHANGELOG.md +++ b/charts/adapter-service/CHANGELOG.md @@ -5,6 +5,83 @@ 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). +## [0.6.0] - 2026-10-09 + +### Added +- **Two deployment modes, with single as the default.** + `networkAdapterDeploymentMode: single | multi` (default `single`), read by + role `network` only. In single, role `network` is the Network Adapter Layer: the consumer, network and provider tiers as four + modules of one process (`/` for the consumer, `/publish`, + `/_internal/network/`, `/_internal/provider/`), one release called `network-adapter`. In multi, + `provider`, `network` and `consumer` run one release per tier, unchanged. +- `config/network-adapter-config.yaml`: the layer's adapter config, mounted + like any role's file (an outbound connection pool and log level info on + every module). Each module is its tier's module, so a change to + a tier file that should apply to both deployment modes is made in both. Its routing + is generated at render time from `config/routing-*.yaml` + (`routing-network-adapter-{consumer,provider}.yaml`), with tier hosts rewritten + to `http://localhost:/_internal/`, so tier hops stay inside the pod. The render fails if `http.port` differs from `service.targetPort`. +- Three identities: each module signs as its own tier from + `networkAdapterLayer.keys.{consumer,network,provider}.existingSecret` + (`networkAdapterLayer.extraSubstitutions` for extra placeholders). A literal + private key in the config, including a `config:` override, fails the render. +- Flat paths: `/discover`, `/publish`, `/` and `/health`, the + same URLs as the multi deployment mode. The network and provider tiers are mounted + under `/_internal/`, which only the process itself calls. +- Ingress for the layer may expose only single-segment `/`, as literal + paths with `pathType` Prefix or Exact and no `use-regex` / `rewrite-target` + annotation; `/`, `/publish` and `/_internal*` are refused. `routing.config` fails the render for this + role. +- `examples/network-adapter.yaml` and `ci/network-adapter-values.yaml` + (3 replicas, PDB, spread, graceful shutdown). +- `topologySpreadConstraints`, + `terminationGracePeriodSeconds`, `preStopSleepSeconds`, `goMemLimit`; all off + by default. + +### Changed +- **Breaking:** `role: network` now defaults to the single deployment mode. + An existing network-tier release must set `networkAdapterDeploymentMode: + multi` (as `examples/network.yaml` does), or the upgrade turns it into the + Network Adapter Layer, which needs the three `networkAdapterLayer.keys` + Secrets. +- Docs, `values.yaml` and the example headers say single is the default + deployment mode and multi is optional. +- Naming: in single mode the release, Service, app name and namespace are + `network-adapter`, the same as the multi network tier's (telemetry service + `oan-network-adapter`, set in `config/network-adapter-config.yaml`). + Switching modes upgrades that release in place. + +### Fixed +- Liveness and readiness probes now render. Values used `probes.liveness` / + `probes.readiness`, which `common.probes` never read, so no adapter pod had a + readiness probe. Upgrading rolls every adapter pod once. +- The startup probe bounds first boot at 180s (`periodSeconds: 5` x + `failureThreshold: 36`) and holds off liveness until `/health` answers. + Pods that had no probe before now get a liveness probe, and the adapter + fetches `beckn.yaml` at boot, so without this a slow or proxied egress + would get a cold start killed after about 70s. +- NOTES.txt in single mode no longer prints `registry.url` and + `otel.*`: that role mounts `config/network-adapter-config.yaml` as is, so those + values never reached the pod and NOTES reported the opposite of what it ran. + The example and CI values no longer set them. +- The Ingress annotations merge tolerates `ingress.annotations: null`. + `ci/network-adapter-values.yaml` turns the Ingress on, so CI renders the path guard. + +### Removed +- Unused helpers `adapter-service.name`, `.appName`, `.otelServiceName`, + `.registryUrl` and `.otlpEndpoint` (nothing included them). +- **Breaking:** the `probes.liveness` and `probes.readiness` values keys, renamed to + `livenessProbe` / `readinessProbe`, plus a new `startupProbe`, on by + default. The liveness and readiness timings are unchanged. An environment + that overrides `probes.*` must rename those keys; the old keys never took + effect and are now silently ignored, which is why this is a minor bump + rather than a patch. + +### Unchanged +- Roles `provider`, `network`, `consumer`: same config, routing and manifests + apart from the probes (the chart version label and the config checksum + annotation also change with the version). + ## [0.5.2] - 2026-09-29 ### Changed diff --git a/charts/adapter-service/Chart.yaml b/charts/adapter-service/Chart.yaml index e3c0eb0d..b1d7bbb0 100644 --- a/charts/adapter-service/Chart.yaml +++ b/charts/adapter-service/Chart.yaml @@ -1,13 +1,17 @@ apiVersion: v2 name: adapter-service description: >- - One chart for the OAN Beckn adapters. provider, network and consumer 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 + One chart for the OAN Beckn adapters, in two deployment modes. single (the + default): the Network Adapter Layer (role network, networkAdapterDeploymentMode single) runs the + consumer, network and provider tiers as four modules of one process; + tier-to-tier calls stay inside the pod and each module keeps its own tier + identity. multi (optional): provider, network and consumer, one release per + tier. Same image and config format; role selects the handler role, the step + list and the routing target. Stateless: an adapter's identity is a keypair held in a Secret, and everything else it reads from the registry. type: application -version: 0.5.2 +version: 0.6.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" diff --git a/charts/adapter-service/README.md b/charts/adapter-service/README.md index 7bd5cc81..ac000a2a 100644 --- a/charts/adapter-service/README.md +++ b/charts/adapter-service/README.md @@ -1,8 +1,12 @@ # adapter-service -One chart for all three Beckn adapters. `provider`, `network` and `consumer` -are the same image and the same config format, so `role` selects the handler -role, the step list and where requests are routed. +One chart for the Beckn adapters, in two deployment modes. **single (the default):** +the Network Adapter Layer: role `network` runs all three tiers in one adapter. +**multi (optional):** roles `provider`, `network` and `consumer`, one release +per tier. `networkAdapterDeploymentMode: single | multi` (default `single`) +picks the mode; only role `network` reads it. All roles are the same image and the same +config format, so `role` selects the handler role, the step list +and where requests are routed. ``` consumer ──unsigned──▶ network ──▶ discovery @@ -12,11 +16,33 @@ role, the step list and where requests are routed. └──▶ mandi / agmarknet upstreams ``` +``` + role network, single mode (one adapter, one pod): + / ──lo──▶ /_internal/network/ ──▶ discovery + └──────lo────▶ /_internal/provider/ ──▶ upstream APIs + /publish ──lo──▶ /_internal/network/publish +``` + +The paths are flat and the same in both deployment modes: `/discover`, `/publish`, +`/` and `/health`. The network and provider tiers sit under +`/_internal/`, which only this process calls (over loopback). The ingress may +expose only single-segment `/` paths: `/publish` stays in-cluster for +the catalogue publisher, and `/_internal*` is never routed. + +Why flat: OAN is single-sided today -- our app calls out, and our own +discovery and upstreams answer. If OAN later accepts inbound calls from +external Beckn peers, a peer's `select` and our app's `select` are both +`POST /select`, and path alone cannot tell them apart. The distinguisher exists +(peer calls carry an `Authorization` signature, app calls do not), but ONIX +dispatches on path, so that would mean routing on signature presence at the +edge or giving inbound its own hostname. Neither changes these public paths. + | Role | Handler | Steps | Routes to | |---|---|---|---| | `provider` | `bpp` | `validateSign → addRoute → sign` | upstream APIs (several) | -| `network` | `bpp` | `validateSign → addRoute → sign` | discovery | +| `network` (multi) | `bpp` | `validateSign → addRoute → sign` | discovery | | `consumer` | `bap` | `addRoute → sign` | network | +| `network` (single, default) | `bap` + `bpp` (four modules) | each module runs its own tier's steps | discovery and the upstream APIs; tier hops stay inside the pod | `consumer` is the only one that accepts **unsigned** requests: the consumer app is inside the trust boundary, so there is no network signature to check. @@ -28,26 +54,46 @@ 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. +Pick **one** deployment mode per environment (→ [Choosing a deployment mode](#choosing-a-deployment-mode-single-default-or-multi-optional)). +Install the Secrets first: the render fails without them. ```sh helm dependency build charts/adapter-service +``` + +**single (default): one release, the Network Adapter Layer** -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 consumer-adapter charts/adapter-service -n oan \ - -f charts/adapter-service/examples/consumer.yaml +```sh +helm upgrade --install network-adapter charts/adapter-service \ + -n network-adapter --create-namespace \ + -f charts/adapter-service/examples/network-adapter.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 — `consumer` -points at the network adapter, `network` points at discovery. +It needs `consumer-adapter-keys`, `network-adapter-keys`, +`provider-adapter-keys` and `provider-upstream` in `network-adapter` +(openbao-secrets mirrors them there). -The Secret comes first — the render fails without it. +**multi (optional): one release per tier, each in its own namespace** (see +[Multi mode](#multi-mode)). **Keep +the release and namespace names**: they make the Service DNS name the other +adapters address (the consumer routes to +`network-adapter.network-adapter.svc` and `provider-adapter.provider-adapter.svc`). + +```sh +helm upgrade --install provider-adapter charts/adapter-service \ + -n provider-adapter --create-namespace -f charts/adapter-service/examples/provider.yaml +helm upgrade --install network-adapter charts/adapter-service \ + -n network-adapter --create-namespace -f charts/adapter-service/examples/network.yaml +helm upgrade --install consumer-adapter charts/adapter-service \ + -n consumer-adapter --create-namespace -f charts/adapter-service/examples/consumer.yaml +``` + +Each example sets `fullnameOverride` to `-adapter` (`network-adapter` for the layer). Without it the Service +would be named `-adapter-service`, and nothing routing by name would +resolve. In multi, rename a release and you must update whatever routes to it — +`consumer` points at the network adapter, `network` points at discovery. In +single nothing routes to another release: the tiers call each other inside the +pod. ## What you must decide @@ -58,7 +104,7 @@ 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 | +| `keys.existingSecret.name` | Without an identity the pod starts, serves `/health`, reports Ready, and fails every signature it makes — in a peer's logs. Role `network` in single mode uses `networkAdapterLayer.keys.{consumer,network,provider}.existingSecret` instead (three identities) | | `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 | @@ -121,12 +167,73 @@ 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. +## Choosing a deployment mode: single (default) or multi (optional) + +| Deployment mode | Releases → namespaces | Values | App calls | Catalogue publishes to | +|---|---|---|---|---| +| **single (default)**: Network Adapter Layer | `network-adapter` → `network-adapter` | `examples/network-adapter.yaml` | `network-adapter.network-adapter.svc:9201/` | `network-adapter.network-adapter.svc:9201/publish` | +| multi (optional) | `consumer-adapter`, `network-adapter`, `provider-adapter` → one namespace each, same name | `examples/{consumer,network,provider}.yaml` | `consumer-adapter.consumer-adapter.svc:9202/` | `provider-adapter.provider-adapter.svc:9200/publish` | + +**When to use which** + +| Use **single** (default) when | Use **multi** when | +|---|---| +| One team runs all three tiers in one cluster | The tiers are run by different parties or in different trust zones | +| You want fewer hops and fewer objects (one Deployment, Service, PDB, and an opt-in HPA) | A tier needs its own scaling or resource profile (e.g. a provider with heavy upstream calls) | +| | You need to roll out or roll back one tier without the others | + +Both sign as the same three identities from the same registry rows, so the +choice changes nothing in the registry. + +Role `network` in single mode runs all three tiers in one adapter, as four +modules of one process. Its config is `config/network-adapter-config.yaml` +(each module is its tier's module from `config/-config.yaml`, so a change +to a tier file that should apply to both deployment modes goes in both files). Its routing +is **generated** at render time from `config/routing-*.yaml`, so routes stay in +one place. Tier hops go to the pod's own listener, `localhost:` (any image). It signs as +the same three identities, from the same three Secrets, so the registry is the +same in both deployment modes. An environment's `config:` override for a multi role is NOT +seen by the Network Adapter Layer; give its release its own `config:` if +needed (it replaces `network-adapter-config.yaml` whole). + +Pick the deployment mode with ONE value, `networkAdapterDeploymentMode: single | +multi` (**default `single`**), on the `network-adapter` release. `single`: that +release is the Network Adapter Layer and nothing else is installed. `multi`: it +is the network tier, and `consumer-adapter` and `provider-adapter` are +installed beside it. `provider` and `consumer` ignore the value. Any other +value fails the render. + +Both modes release `network-adapter` into namespace `network-adapter`, so +switching modes is `helm upgrade` of that release with the other example, +plus installing or removing the other two releases. + +### Multi mode + +1. Set `networkAdapterDeploymentMode: multi` on the network release + (`examples/network.yaml` does). **An existing `role: network` release + without it becomes single on upgrade.** +2. Install the three releases as above. +3. In openbao-secrets, remove `network-adapter` from the namespaces of + `consumer-adapter-keys`, `provider-adapter-keys` and `provider-upstream`, so + the network tier holds only its own key. Then delete any copies already in + the namespace: + + ```sh + kubectl -n network-adapter delete secret consumer-adapter-keys \ + provider-adapter-keys provider-upstream --ignore-not-found + ``` +4. Point the app at `consumer-adapter.consumer-adapter.svc:9202/` and + the catalogue publisher at `provider-adapter.provider-adapter.svc:9200/publish`. + +To return to single, reverse the steps: put the namespaces back, upgrade +`network-adapter` with `examples/network-adapter.yaml`, uninstall the other two. + ## Values See `values.yaml` — every field is commented with what it does and what breaks -without it. `examples/{provider,network,consumer}.yaml` are working dev deployments; -`ci/` holds the two files lint renders, one minimal and one with every switch -turned on. +without it. `examples/{provider,network,consumer}.yaml` are working dev deployments for the +multi deployment mode, and `examples/network-adapter.yaml` for the single deployment mode (the Network Adapter Layer, default); +`ci/` holds the files lint renders, including `ci/network-adapter-values.yaml`. ## Related diff --git a/charts/adapter-service/ci/lint-values.yaml b/charts/adapter-service/ci/lint-values.yaml index d01cdfad..e4a060ee 100644 --- a/charts/adapter-service/ci/lint-values.yaml +++ b/charts/adapter-service/ci/lint-values.yaml @@ -4,6 +4,7 @@ # 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 +networkAdapterDeploymentMode: multi fullnameOverride: network-adapter image: diff --git a/charts/adapter-service/ci/network-adapter-values.yaml b/charts/adapter-service/ci/network-adapter-values.yaml new file mode 100644 index 00000000..4efe2e98 --- /dev/null +++ b/charts/adapter-service/ci/network-adapter-values.yaml @@ -0,0 +1,76 @@ +# charts/adapter-service/ci/network-adapter-values.yaml +# CI only (scripts/lint-charts.sh lints and renders every ci/*-values.yaml). +# Not for installs: use examples/network-adapter.yaml. +# +# Renders the single deployment mode, role network (the Network Adapter +# Layer): config/network-adapter-config.yaml, the generated routing, +# the three tier identities, and the PDB / spread / graceful-shutdown +# settings, so those templates are linted too. Pinned image tag so the render +# is reproducible. +# +# The Ingress is on so the path guard in templates/ingress.yaml runs in CI: the +# allowed path shape (one-segment /, Exact and Prefix) must render. +# ingress.annotations is null on purpose, to cover the `| default dict` on the +# annotations merge. +role: network +networkAdapterDeploymentMode: single +fullnameOverride: network-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "v1.9.0" + +# registry.url, otel.*, appName, logLevel, http.timeout.*, routing.* and +# handler.* are not used by this role: the adapter config is mounted from +# config/network-adapter-config.yaml as is (registry URL, OTLP endpoint and +# log level are set there), so they are left out here. Edit that file instead. + +service: + port: 9201 + targetPort: 9201 + +networkAdapterLayer: + keys: + consumer: + existingSecret: consumer-adapter-keys + network: + existingSecret: network-adapter-keys + provider: + existingSecret: provider-adapter-keys + extraSubstitutions: {} + +# Scale defaults for the Network Adapter Layer: roughly the three tiers' +# requests in one pod, at least one replica per node. +resources: + requests: + cpu: 300m + memory: 384Mi + limits: + cpu: 1500m + memory: 1Gi +goMemLimit: 900MiB +replicaCount: 3 +podDisruptionBudget: + enabled: true + minAvailable: 1 +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway +terminationGracePeriodSeconds: 40 +preStopSleepSeconds: 10 # needs Kubernetes 1.30+; 0 on older clusters + +ingress: + enabled: true + className: nginx + annotations: null + hosts: + - host: network-adapter.ci.example.com + paths: + - path: /discover + pathType: Exact + - path: /select + pathType: Prefix + - path: /status + pathType: Exact diff --git a/charts/adapter-service/config/network-adapter-config.yaml b/charts/adapter-service/config/network-adapter-config.yaml new file mode 100644 index 00000000..f0817118 --- /dev/null +++ b/charts/adapter-service/config/network-adapter-config.yaml @@ -0,0 +1,381 @@ +# OAN Network Adapter Layer (role network, single deployment mode). The consumer, network +# and provider tiers as four modules of ONE adapter process: +# +# / the consumer (bap); the app posts here, unsigned +# /publish the catalogue system publishes here +# /_internal/network/ verifies and forwards to discovery +# /_internal/provider/ answers from the upstream APIs (bpp) +# +# The paths are flat: /discover, /publish, / and /health. The tiers are +# not part of them. /_internal/ is only ever called by this process's own +# modules (http://localhost:9201/_internal/...); the chart's ingress never +# routes it, and never routes /publish either. +# +# Each module is its tier's module from consumer-config.yaml, network-config.yaml +# and provider-config.yaml. A change to one of those files that should also +# apply here must be made in this file too. +# +# Differences from the three tier files: +# - one listener (9201) and one appName; tier hops call this same listener (see the routing-network-adapter-*.yaml files, which the chart +# generates from routing-consumer.yaml and routing-provider.yaml); +# - an outbound connection pool (httpClientConfig) on every module; +# - log level info. +# +# This file is mounted verbatim. Each module signs as its OWN tier: the +# __CONSUMER_*__, __NETWORK_*__ and __PROVIDER_*__ placeholders are substituted +# by the init container from that tier's Secret +# (networkAdapterLayer.keys..existingSecret) at pod start, so no key is in this +# file, in the ConfigMap, or in git. +appName: "network-adapter" + +log: + level: info + destinations: + - type: stdout + contextKeys: [transaction_id, message_id, subscriber_id, module_id] + +http: + # Also the port the generated routing uses: keep it equal to the + # chart's service.targetPort. + port: 9201 + timeout: + read: 30 + write: 30 + idle: 30 + +pluginManager: + root: ./plugins + +plugins: + # Ships metrics, traces and logs over OTLP/gRPC. On because clickstack is + # deployed alongside. Turn all three off for a cluster without a collector: + # the exporter logs a connection failure every few seconds when nothing + # answers. + otelsetup: + id: otelsetup + config: + serviceName: "oan-network-adapter" + environment: "dev" + otlpEndpoint: "clickstack-otel-collector.observability.svc.cluster.local:4317" + enableMetrics: "true" + enableTracing: "true" + enableLogs: "true" + +modules: + # ------------------------------------------------------------------ consumer + # From consumer-config.yaml. Accepts unsigned requests from the app, signs as + # the consumer, and routes discover to /_internal/network and transactions to + # /_internal/provider. + # + # Mounted at / on purpose: it is the one catch-all mount, and that is + # load-bearing, not accidental. /publish is an exact pattern, so it wins over + # this subtree, and /_internal// are longer subtrees, so they win too. + # Everything else lands here, and the routing config is in effect an action + # allow-list: addRoute runs before sign, so an action with no routing rule is + # an error, never a forwarded request. + - name: consumer + path: / + handler: + type: std + role: bap + subscriberId: "__CONSUMER_SUBSCRIBER_ID__" + # Outbound pool. Go's default keeps 2 idle connections per host, so under + # load most calls would open a new connection. responseHeaderTimeout + # bounds the wait for an answer's headers, which on the consumer -> + # provider hop includes the upstream API call. + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry.registry.svc.cluster.local:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + cacheTTL: 0 + keyManager: + id: simplekeymanager + config: + subscriberId: "__CONSUMER_SUBSCRIBER_ID__" + keyId: "__CONSUMER_KEY_ID__" + signingPrivateKey: "__CONSUMER_SIGNING_PRIVATE__" + signingPublicKey: "__CONSUMER_SIGNING_PUBLIC__" + encrPrivateKey: "__CONSUMER_ENCR_PRIVATE__" + encrPublicKey: "__CONSUMER_ENCR_PUBLIC__" + 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" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global,openagrinet.github.io" + signValidator: + id: signvalidator + signer: + id: signer + router: + id: router + config: + routingConfig: /app/config/routing-network-adapter-consumer.yaml + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bap + steps: + - validateSchema + - addRoute + - sign + + # ------------------------------------------------------------------- network + # From network-config.yaml. Verifies the caller's signature and forwards + # discover and publish to discovery. Internal: never expose it on an Ingress. + - name: network + path: /_internal/network/ + handler: + type: std + role: bpp + subscriberId: "__NETWORK_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry.registry.svc.cluster.local:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + cacheTTL: 0 + keyManager: + id: simplekeymanager + config: + subscriberId: "__NETWORK_SUBSCRIBER_ID__" + keyId: "__NETWORK_KEY_ID__" + signingPrivateKey: "__NETWORK_SIGNING_PRIVATE__" + signingPublicKey: "__NETWORK_SIGNING_PUBLIC__" + encrPrivateKey: "__NETWORK_ENCR_PRIVATE__" + encrPublicKey: "__NETWORK_ENCR_PUBLIC__" + # The extended layer is off: it fetches a resource's own @context per + # payload, and nothing here needs it. The settings below only apply 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,schema.beckn.io,schema.nfh.global,openagrinet.github.io" + signValidator: + id: signvalidator + signer: + id: signer + router: + id: router + config: + # The multi-adapter network routing, unchanged: discover and + # publish to discovery. + routingConfig: /app/config/routing-network.yaml + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bpp + steps: + - validateSign + - addRoute + + # ------------------------------------------------------------------ provider + # From provider-config.yaml, module 1. Verifies the caller and answers from + # the upstream APIs itself, so it has no router. Upstream credentials come + # from the environment (envFromSecrets: provider-upstream). + - name: provider + path: /_internal/provider/ + handler: + type: std + role: bpp + subscriberId: "__PROVIDER_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry.registry.svc.cluster.local:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + cacheTTL: 0 + 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__" + 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" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global,openagrinet.github.io" + signValidator: + id: signvalidator + signer: + id: signer + mapper: + id: jsonmapper + config: + fetchTimeout: 10s + maxMappingBytes: 262144 + cacheTTL: 1m + negativeTTL: 1m + maxCacheEntries: 200 + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bpp + providerSteps: + - id: WeatherObservation + config: + bindingKeys: "mausamgram|openagrinet:WeatherObservation" + mausamgram: + authScheme: header + headerName: Authorization + headerValueEnv: MAUSAMGRAM_AUTH + + - id: MandiPrice + config: + bindingKeys: "agmarknet|openagrinet:MandiPrice" + agmarknet: + authScheme: none + + - id: KnowledgeAdvisory + config: + bindingKeys: "bharat-vistaar|openagrinet:KnowledgeAdvisory" + bharat-vistaar: + authScheme: oauth2 + # Substituted from the provider Secret (vistaarTokenUrl) via + # networkAdapterLayer.extraSubstitutions. + tokenUrl: "__VISTAAR_TOKEN_URL__" + clientIdEnv: VISTAAR_CLIENT_ID + clientSecretEnv: VISTAAR_CLIENT_SECRET + maxResponseBytes: 1048576 + + - id: AgricultureFacility + config: + bindingKeys: "pocra|openagrinet:AgricultureFacility" + pocra: + authScheme: none + searchConcurrency: 1 + steps: + - validateSign + - validateSchema + - WeatherObservation + - MandiPrice + - KnowledgeAdvisory + - AgricultureFacility + + # ---------------------------------------------------------- provider-publish + # From provider-config.yaml, module 2. The catalogue system publishes here; + # signed as the provider and routed to /_internal/network/publish. No + # validateSign: never expose it on an Ingress. At top-level /publish, the + # same path as the multi provider adapter's publish module, so both + # deployment modes present the same URL. + - name: provider-publish + path: /publish + handler: + type: std + role: bap + subscriberId: "__PROVIDER_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://registry.registry.svc.cluster.local:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + cacheTTL: 0 + 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__" + 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" + extendedSchema_cacheTTL: "86400" + extendedSchema_maxCacheSize: "100" + extendedSchema_downloadTimeout: "30" + extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global,openagrinet.github.io" + signer: + id: signer + router: + id: router + config: + routingConfig: /app/config/routing-network-adapter-provider.yaml + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bap + steps: + - validateSchema + - addRoute + - sign diff --git a/charts/adapter-service/examples/network-adapter.yaml b/charts/adapter-service/examples/network-adapter.yaml new file mode 100644 index 00000000..63d9416b --- /dev/null +++ b/charts/adapter-service/examples/network-adapter.yaml @@ -0,0 +1,111 @@ +# =========================================================================== +# role network, single deployment mode -- the Network Adapter Layer, the DEFAULT +# deployment mode. The optional alternative is multi: examples/{consumer,network,provider}.yaml. +# +# The consumer, network and provider tiers as four modules of ONE adapter +# process, on one Service: +# +# / the consumer (bap); the app posts here, unsigned +# /publish the catalogue system publishes here (internal) +# /_internal/network/ internal: verifies and forwards to discovery +# /_internal/provider/ internal: answers from the upstream APIs (bpp) +# +# The alternative to installing the three multi-adapter releases +# (examples/consumer.yaml, network.yaml, provider.yaml). Run one mode or the +# other in an environment, not both. +# +# helm install network-adapter . -n network-adapter --create-namespace \ +# -f examples/network-adapter.yaml +# +# The config is config/network-adapter-config.yaml; the routing is +# generated from config/routing-*.yaml. Tier hops stay inside the pod, so any +# adapter image works. +# =========================================================================== +role: network +networkAdapterDeploymentMode: single + +# The Service name and DNS name callers use: +# network-adapter.network-adapter.svc.cluster.local:9201 +fullnameOverride: network-adapter + +image: + registry: ghcr.io + repository: openagrinet/network-adapter + tag: "latest" + +# registry.url, otel.*, appName, logLevel, http.timeout.*, routing.* and +# handler.* are not used by this role: the adapter config is mounted from +# config/network-adapter-config.yaml as is (registry URL, OTLP endpoint and +# log level are set there), so they are left out here. Edit that file instead. + +# The same port the quick-start publishes. targetPort is also the port the +# generated routing uses, and must equal http.port in +# config/network-adapter-config.yaml (the render checks). +service: + port: 9201 + targetPort: 9201 + +networkAdapterLayer: + # The three tier Secrets the multi-adapter releases use. openbao-secrets + # reflects them into this namespace. + keys: + consumer: + existingSecret: consumer-adapter-keys + network: + existingSecret: network-adapter-keys + provider: + existingSecret: provider-adapter-keys + # Set per environment: the config's __VISTAAR_TOKEN_URL__ placeholder needs + # an entry here (e.g. __VISTAAR_TOKEN_URL__: provider/vistaarTokenUrl), or + # the init container stops on the leftover placeholder. + extraSubstitutions: {} + +# The provider tier reads its upstream credentials (MAUSAMGRAM_AUTH, +# AGMARKNET_*, VISTAAR_CLIENT_*) from the environment. +envFromSecrets: + - provider-upstream + +resources: + requests: + cpu: 300m + memory: 384Mi + limits: + cpu: 1500m + memory: 1Gi +goMemLimit: 900MiB + +replicaCount: 3 + +podDisruptionBudget: + enabled: true + minAvailable: 1 + +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + +terminationGracePeriodSeconds: 40 +preStopSleepSeconds: 10 # needs Kubernetes 1.30+; 0 on older clusters + +# Off here. When on, only single-segment / paths (the consumer) are +# accepted: /, /publish and /_internal* fail the render. +ingress: + enabled: false + className: "" + hosts: + - host: network-adapter.oan.example.com + paths: + - path: /discover + pathType: Exact + - path: /select + pathType: Exact + - path: /init + pathType: Exact + - path: /confirm + pathType: Exact + - path: /status + pathType: Exact + +waitFor: + enabled: false diff --git a/charts/adapter-service/examples/network.yaml b/charts/adapter-service/examples/network.yaml index 3b7eb061..5d4c15f9 100644 --- a/charts/adapter-service/examples/network.yaml +++ b/charts/adapter-service/examples/network.yaml @@ -1,15 +1,17 @@ # =========================================================================== -# network role -- the network layer of the Beckn network. +# network role, multi deployment mode -- the network tier alone. # # 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 +# helm install network-adapter . -n network-adapter -f examples/network.yaml # # Keep the release name `network-adapter`: consumer routes to this Service by name. # =========================================================================== role: network +# Required for the network tier: the default, single, runs all three tiers. +networkAdapterDeploymentMode: multi # The Service name, and therefore the in-cluster DNS name other adapters use. # Set explicitly because fullname is otherwise "-" -- release diff --git a/charts/adapter-service/templates/NOTES.txt b/charts/adapter-service/templates/NOTES.txt index 9a0a7db3..151c8bbe 100644 --- a/charts/adapter-service/templates/NOTES.txt +++ b/charts/adapter-service/templates/NOTES.txt @@ -1,16 +1,34 @@ +{{- $nal := include "adapter-service.isNetworkAdapterLayer" . -}} {{ .Chart.Name }} {{ .Chart.Version }} — {{ include "adapter-service.fullname" . }} - +{{ if $nal }} + mode single (the default): the Network Adapter Layer + role network, all three tiers in one adapter (/ consumer bap, /publish bap, /_internal/network/ bpp, /_internal/provider/ bpp) + hops between tiers inside the pod (http://localhost:{{ .Values.service.targetPort }}/_internal/) +{{- else }} + mode multi: one release per tier role {{ include "adapter-service.role" . }} ({{ include "adapter-service.handlerRole" . }}) steps {{ include "adapter-service.steps" . | fromYamlArray | join " -> " }} +{{- end }} image {{ include "adapter-service.image" . }} service {{ include "adapter-service.fullname" . }}:{{ .Values.service.port }} +{{- if $nal }} + registry set in config/network-adapter-config.yaml (registry.url is not used by this role) + routes generated from config/routing-{consumer,network,provider}.yaml + telemetry set in config/network-adapter-config.yaml (otel.* is not used by this role) +{{- else }} registry {{ .Values.registry.url }} routes from config/routing-{{ include "adapter-service.role" . }}.yaml telemetry {{ if .Values.otel.enabled }}{{ .Values.otel.endpoint }}{{ else }}off{{ end }} +{{- end }} -This Service is reachable in-cluster as {{ include "adapter-service.fullname" . }}:{{ .Values.service.port }}. +This Service is reachable in-cluster as {{ include "adapter-service.fullname" . }}.{{ include "common.namespace" . }}.svc:{{ .Values.service.port }}. +{{- if $nal }} +The app and the catalogue publisher call it there; its tiers call each other +inside the pod, so no other adapter routes to it. +{{- else }} 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. +{{- end }} Check it is serving: @@ -22,6 +40,21 @@ chart's appVersion. TWO THINGS THIS CHART CANNOT CHECK FOR YOU +{{ if $nal -}} +1. The Participant rows. This adapter signs as three identities -- consumer, + network and provider -- from Secrets {{ include "adapter-service.networkAdapterLayer.tierSecret" (dict "ctx" . "tier" "consumer") }}, + {{ include "adapter-service.networkAdapterLayer.tierSecret" (dict "ctx" . "tier" "network") }} and {{ include "adapter-service.networkAdapterLayer.tierSecret" (dict "ctx" . "tier" "provider") }}: the same rows the + multi-adapter releases use. Each must carry its tier's public key. + +2. Key rotation. Changing any of those Secrets restarts nothing: + + kubectl -n {{ include "common.namespace" . }} rollout restart deploy/{{ include "adapter-service.fullname" . }} + +Callers address the tiers by path on this one Service: + app (discover, select, ...) {{ include "adapter-service.fullname" . }}:{{ .Values.service.port }}/ + catalogue publisher {{ include "adapter-service.fullname" . }}:{{ .Values.service.port }}/publish + /_internal/ is the network and provider tiers: never route to it from outside. +{{- else -}} 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 @@ -33,8 +66,9 @@ TWO THINGS THIS CHART CANNOT CHECK FOR YOU checksum. Changing that Secret does not restart anything: kubectl -n {{ include "common.namespace" . }} rollout restart deploy/{{ include "adapter-service.fullname" . }} +{{- end }} -{{- if not .Values.otel.enabled }} +{{- if and (not .Values.otel.enabled) (not $nal) }} 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. @@ -42,7 +76,14 @@ on and nothing listening the exporter logs a failure every interval. {{- if .Values.ingress.enabled }} Ingress is ON. -{{- if eq (include "adapter-service.role" .) "consumer" }} +{{- if $nal }} +This is the single deployment mode. / (the consumer) accepts UNSIGNED requests: the consumer tier is +inside the trust boundary and has no validateSign step, so an Ingress on it exposes an +unauthenticated entry point to the network. Rate-limit this host. +Rule: only single-segment / paths may be exposed (the render refuses other paths), +never /_internal* or /publish: /_internal/ holds the network and provider tiers, and /publish +has no validateSign and signs as the provider. +{{- else if eq (include "adapter-service.role" .) "consumer" }} This is the consumer 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 diff --git a/charts/adapter-service/templates/_helpers.tpl b/charts/adapter-service/templates/_helpers.tpl index ccbf19ad..2f494c0f 100644 --- a/charts/adapter-service/templates/_helpers.tpl +++ b/charts/adapter-service/templates/_helpers.tpl @@ -5,8 +5,10 @@ # Purpose: chart-local helpers delegating to common, plus the role, # identity, upstream and config-rendering wiring an adapter needs. # -# One chart, three roles. provider, network and consumer are the same image and the -# same config format; the role decides the handler role, the step list and +# One chart, three roles: provider, network and consumer. Role network with +# networkAdapterDeploymentMode single (the default) runs all three tiers in one +# adapter; in multi it is the network tier and the three roles run one release +# each. Same image and 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. @@ -23,7 +25,7 @@ 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, consumer. 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) -}} +{{- fail (printf "%s: role is required -- one of provider, network, consumer (role network runs all three tiers when networkAdapterDeploymentMode is single). 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" "consumer")) -}} {{- fail (printf "%s: role must be one of provider, network, consumer (got %q)." .Chart.Name $role) -}} @@ -31,22 +33,6 @@ and then mis-handles every request, at the far end, in a peer's logs. {{- $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. consumer is the caller; network and provider receive. Overridable, because the role is a shorthand for a default @@ -87,11 +73,6 @@ self-describing when you exec into a pod. routing-{{ include "adapter-service.role" . }}.yaml {{- end }} - -{{- define "adapter-service.name" -}} -{{- include "common.name" . -}} -{{- end }} - {{- define "adapter-service.fullname" -}} {{- include "common.fullname" . -}} {{- end }} @@ -129,34 +110,6 @@ why. {{- $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. diff --git a/charts/adapter-service/templates/_network-adapter.tpl b/charts/adapter-service/templates/_network-adapter.tpl new file mode 100644 index 00000000..39b6b358 --- /dev/null +++ b/charts/adapter-service/templates/_network-adapter.tpl @@ -0,0 +1,103 @@ +{{/* +# ============================================================================ +# role network, networkAdapterDeploymentMode single -- the Network Adapter Layer. +# +# The consumer, network and provider tiers as four modules of ONE adapter +# process. The adapter config is config/network-adapter-config.yaml +# (configmap.yaml mounts it like any role's file). The routing is GENERATED at +# render time from config/routing-.yaml, the same files the three +# multi-adapter roles mount, with tier hosts pointed at this pod's own listener. +# ============================================================================ +*/}} + +{{/* +networkAdapterDeploymentMode: single (default) | multi. Only role network +reads it; provider and consumer ignore it. +*/}} +{{- define "adapter-service.deploymentMode" -}} +{{- $mode := .Values.networkAdapterDeploymentMode | default "single" -}} +{{- if not (has $mode (list "single" "multi")) -}} +{{- fail (printf "%s: networkAdapterDeploymentMode must be single or multi (got %q)." .Chart.Name $mode) -}} +{{- end -}} +{{- $mode -}} +{{- end }} + +{{/* +"true" when this release runs the Network Adapter Layer (role network, mode +single), else empty. +*/}} +{{- define "adapter-service.isNetworkAdapterLayer" -}} +{{- if and (eq (include "adapter-service.role" .) "network") (eq (include "adapter-service.deploymentMode" .) "single") -}}true{{- end -}} +{{- end }} + +{{/* +Fails unless every private-key VALUE in a single-mode config is one of the +tier placeholders. Parsed, not grepped: it reads each module's keyManager. +Usage: include "adapter-service.networkAdapterLayer.checkKeys" (dict "ctx" $ "cfg" $parsedConfig) +*/}} +{{- define "adapter-service.networkAdapterLayer.checkKeys" -}} +{{- range $m := .cfg.modules -}} +{{- $km := dig "handler" "plugins" "keyManager" "config" dict $m -}} +{{- range $field := list "signingPrivateKey" "encrPrivateKey" -}} +{{- $v := index $km $field | default "" | toString -}} +{{- if and $v (not (regexMatch "^__(CONSUMER|NETWORK|PROVIDER)_(SIGNING|ENCR)_PRIVATE__$" $v)) -}} +{{- fail (printf "%s: single-mode module %s sets %s to a value that is not a tier placeholder. A private key written into config reaches git, the ConfigMap and `helm get values`; use ___%s__ and the init container fills it from that tier's Secret." $.ctx.Chart.Name $m.name $field (ternary "SIGNING_PRIVATE" "ENCR_PRIVATE" (eq $field "signingPrivateKey"))) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* +Routing for the Network Adapter Layer, as YAML: {: }. + +routing-network.yaml is the multi-adapter file, unchanged (discover and publish +to discovery). The other two are generated from routing-consumer.yaml and +routing-provider.yaml by pointing every tier address at THIS adapter's own +listener: http://-adapter[....][:port] becomes +http://localhost:/_internal/, and any path after it is kept +(.../publish becomes /_internal/network/publish). Only the network and provider +tiers are ever targets, so only they are mounted under /_internal/. A target that is not a tier adapter is +kept as it is, and a rule with no target is left alone (the same nil guard as +configmap.yaml). So the two modes are one routing source, and splitting the +modules across pods again changes only the hosts. +*/}} +{{- define "adapter-service.networkAdapterLayer.routing" -}} +{{- $ctx := . -}} +{{- $ownListener := printf "http://localhost:%v/_internal/${1}${4}" .Values.service.targetPort -}} +{{- /* Whole URL, anchored both ends: only a tier host (optionally with a + namespace suffix and port) followed by nothing or a path is rewritten. */ -}} +{{- $tierHost := "^https?://(consumer|network|provider)-adapter(\\.[a-z0-9.-]+)?(:[0-9]+)?(/.*)?$" -}} +{{- $out := dict -}} +{{- range $pair := list (list "routing-consumer.yaml" "routing-network-adapter-consumer.yaml") (list "routing-network.yaml" "routing-network.yaml") (list "routing-provider.yaml" "routing-network-adapter-provider.yaml") -}} +{{- $src := index $pair 0 -}} +{{- $doc := $ctx.Files.Get (printf "config/%s" $src) | fromYaml -}} +{{- if not $doc.routingRules -}} +{{- fail (printf "%s: config/%s parsed to no routingRules; the single-mode routing is generated from it." $ctx.Chart.Name $src) -}} +{{- end -}} +{{- range $i, $rule := $doc.routingRules -}} +{{- if $rule.target -}} +{{- $url := $rule.target.url | default "" -}} +{{- $url = regexReplaceAll $tierHost $url $ownListener -}} +{{- if hasSuffix "/" $url -}} +{{- fail (printf "%s: single-mode routing from config/%s routingRules[%d] ends in a slash (%q). The router appends the action, so that produces //discover." $ctx.Chart.Name $src $i $url) -}} +{{- end -}} +{{- $_ := set $rule.target "url" $url -}} +{{- end -}} +{{- end -}} +{{- $_ := set $out (index $pair 1) $doc -}} +{{- end -}} +{{- toYaml $out -}} +{{- end }} + +{{/* +Secret holding one tier's keypair. Fails naming the tier: a missing identity +would otherwise start, report Ready, and fail every signature that tier makes. +Usage: include "adapter-service.networkAdapterLayer.tierSecret" (dict "ctx" $ "tier" "network") +*/}} +{{- define "adapter-service.networkAdapterLayer.tierSecret" -}} +{{- $name := (index .ctx.Values.networkAdapterLayer.keys .tier).existingSecret | default "" -}} +{{- if not $name -}} +{{- fail (printf "%s: networkAdapterLayer.keys.%s.existingSecret is required -- the %s tier signs as its own identity, from its own Secret (the six keys under keys.existingSecret)." .ctx.Chart.Name .tier .tier) -}} +{{- end -}} +{{- $name -}} +{{- end }} diff --git a/charts/adapter-service/templates/configmap.yaml b/charts/adapter-service/templates/configmap.yaml index f5c47701..aa7fa109 100644 --- a/charts/adapter-service/templates/configmap.yaml +++ b/charts/adapter-service/templates/configmap.yaml @@ -9,6 +9,10 @@ into an emptyDir at start-up. Everything that differs between the provider, network and consumer 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. + +role network in single mode reads config/network-adapter-config.yaml; its routing +files are generated from the tier routing files (_network-adapter.tpl), and it +renders more than one routing file. */}} {{/* RAW CONFIG OVERRIDE. @@ -24,6 +28,25 @@ at pod start, which is why no rendered config ever holds a key. A raw config with a literal private key in it would put that key in git, in a ConfigMap, and in `helm get values` -- so the render refuses it. */}} +{{- $networkAdapterLayer := include "adapter-service.isNetworkAdapterLayer" . }} +{{- $nlCfg := "" }} +{{- if $networkAdapterLayer }} +{{- /* single mode: the config is config/network-adapter-config.yaml, + unless `config` replaces it whole. Either way every private key must be a + tier placeholder; checked on the parsed config. */}} +{{- $nlCfg = .Values.config | default (.Files.Get "config/network-adapter-config.yaml") }} +{{- if not $nlCfg }} +{{- fail (printf "%s: role network in single mode reads config/network-adapter-config.yaml, which is missing from the chart." .Chart.Name) }} +{{- end }} +{{- $nlParsed := $nlCfg | fromYaml }} +{{- if or (not $nlParsed) (hasKey $nlParsed "Error") }} +{{- fail (printf "%s: the single-mode adapter config (`config` override or config/network-adapter-config.yaml) does not parse as YAML, so its private keys cannot be checked." .Chart.Name) }} +{{- end }} +{{- include "adapter-service.networkAdapterLayer.checkKeys" (dict "ctx" . "cfg" $nlParsed) }} +{{- if ne (toString (dig "http" "port" "" $nlParsed)) (toString .Values.service.targetPort) }} +{{- fail (printf "%s: the single-mode config listens on http.port %v but service.targetPort is %v. The generated routing sends tier hops to localhost:, so the two must match." .Chart.Name (dig "http" "port" "unset" $nlParsed) .Values.service.targetPort) }} +{{- end }} +{{- else }} {{- $cfgFile := printf "config/%s-config.yaml" (include "adapter-service.role" .) }} {{- $cfg := .Values.config | default (.Files.Get $cfgFile) }} {{- if $cfg }} @@ -33,6 +56,7 @@ in `helm get values` -- so the render refuses it. {{- end }} {{- end }} {{- end }} +{{- end }} apiVersion: v1 kind: ConfigMap metadata: @@ -44,6 +68,18 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} data: + {{- if $networkAdapterLayer }} + adapter.yaml: | + {{- $nlCfg | nindent 4 }} + {{- if .Values.routing.config }} + {{- fail (printf "%s: routing.config is not used in the single deployment mode. Its routing files are generated from config/routing-{consumer,network,provider}.yaml. To change a route edit those files; to replace the whole adapter config set `config`." .Chart.Name) }} + {{- end }} + {{- range $file, $doc := include "adapter-service.networkAdapterLayer.routing" . | fromYaml }} + {{ $file }}: | + # GENERATED by adapter-service from config/routing-*.yaml. Do not edit; edit those files. + {{- toYaml $doc | nindent 4 }} + {{- end }} + {{- else }} adapter.yaml: | {{- /* The config comes from a FILE, not from this template. @@ -99,3 +135,4 @@ data: {{- end }} {{- end }} {{- $routes | nindent 4 }} + {{- end }} diff --git a/charts/adapter-service/templates/deployment.yaml b/charts/adapter-service/templates/deployment.yaml index e6c623c2..9726b5af 100644 --- a/charts/adapter-service/templates/deployment.yaml +++ b/charts/adapter-service/templates/deployment.yaml @@ -44,6 +44,22 @@ spec: securityContext: {{- . | nindent 8 }} {{- end }} + {{- with .Values.terminationGracePeriodSeconds }} + terminationGracePeriodSeconds: {{ . }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- range . }} + - {{- toYaml (omit . "labelSelector") | nindent 10 }} + labelSelector: + {{- if .labelSelector }} + {{- toYaml .labelSelector | nindent 12 }} + {{- else }} + matchLabels: + {{- include "adapter-service.selectorLabels" $ | nindent 14 }} + {{- end }} + {{- end }} + {{- end }} initContainers: {{- include "common.waitFor" (dict "ctx" . "tcp" list "http" list) | nindent 8 }} {{/* @@ -70,13 +86,23 @@ spec: - -c - | set -eu + {{- if include "adapter-service.isNetworkAdapterLayer" . }} + # single mode: adapter.yaml plus one routing file per routed module. + cp /config-template/*.yaml /rendered/ + {{- else }} cp /config-template/adapter.yaml /rendered/adapter.yaml cp /config-template/{{ include "adapter-service.routingFile" . }} /rendered/{{ include "adapter-service.routingFile" . }} + {{- end }} subst() { # $1 placeholder, $2 file holding the value if [ ! -s "$2" ]; then + {{- if include "adapter-service.isNetworkAdapterLayer" . }} + # three tier Secrets share key names: name the tier too (e.g. network/keyId) + echo "render-config: ${2#/keys/} is missing or empty in its tier's keys Secret" >&2 + {{- else }} echo "render-config: $(basename "$2") is missing or empty in the keys Secret" >&2 + {{- end }} exit 1 fi # A value the registry has not assigned yet. keyId is the osid it @@ -85,7 +111,11 @@ spec: # -- the sentinel contains a colon -- or an adapter signing with an # identity nothing can verify. if head -c 9 "$2" | grep -q "<&2 + {{- else }} echo "render-config: $(basename "$2") is still unresolved." >&2 + {{- end }} echo " The registry assigns it when registry-seed writes this" >&2 echo " adapter's public key. Run that, then:" >&2 echo " ./scripts/resolve-key-ids.sh -e " >&2 @@ -93,6 +123,24 @@ spec: fi sed -i "s|$1|$(cat "$2")|g" /rendered/adapter.yaml } + {{- if include "adapter-service.isNetworkAdapterLayer" . }} + + {{- /* Three identities: each tier's module signs as that tier, from + that tier's Secret mounted at /keys/. */}} + {{- $k := .Values.keys.existingSecret }} + {{- range $tier := list "consumer" "network" "provider" }} + {{- $T := upper $tier }} + subst __{{ $T }}_SUBSCRIBER_ID__ /keys/{{ $tier }}/{{ $k.subscriberIdKey }} + subst __{{ $T }}_KEY_ID__ /keys/{{ $tier }}/{{ $k.keyIdKey }} + subst __{{ $T }}_SIGNING_PRIVATE__ /keys/{{ $tier }}/{{ $k.signingPrivateKey }} + subst __{{ $T }}_SIGNING_PUBLIC__ /keys/{{ $tier }}/{{ $k.signingPublicKey }} + subst __{{ $T }}_ENCR_PRIVATE__ /keys/{{ $tier }}/{{ $k.encrPrivateKey }} + subst __{{ $T }}_ENCR_PUBLIC__ /keys/{{ $tier }}/{{ $k.encrPublicKey }} + {{- end }} + {{- range $ph, $path := .Values.networkAdapterLayer.extraSubstitutions }} + subst {{ $ph }} /keys/{{ $path }} + {{- end }} + {{- else }} subst __ADAPTER_SUBSCRIBER_ID__ /keys/{{ .Values.keys.existingSecret.subscriberIdKey }} subst __ADAPTER_KEY_ID__ /keys/{{ .Values.keys.existingSecret.keyIdKey }} @@ -107,6 +155,7 @@ spec: {{- range $ph, $key := .Values.keys.existingSecret.extraSubstitutions }} subst {{ $ph }} /keys/{{ $key }} {{- end }} + {{- end }} # A placeholder that survived means a key was substituted with # nothing. Catch it here rather than at the far end, in a peer's @@ -116,14 +165,22 @@ spec: grep -oE '__[A-Z][A-Z0-9_]*__' /rendered/adapter.yaml | sort -u >&2 exit 1 fi - echo "render-config: config rendered for the {{ include "adapter-service.role" . }} role" + echo "render-config: config rendered for the {{ include "adapter-service.role" . }} role{{ if include "adapter-service.isNetworkAdapterLayer" . }} (single mode: all three tiers){{ end }}" volumeMounts: - name: config-template mountPath: /config-template readOnly: true + {{- if include "adapter-service.isNetworkAdapterLayer" . }} + {{- range $tier := list "consumer" "network" "provider" }} + - name: keys-{{ $tier }} + mountPath: /keys/{{ $tier }} + readOnly: true + {{- end }} + {{- else }} - name: keys mountPath: /keys readOnly: true + {{- end }} - name: config mountPath: /rendered resources: @@ -135,6 +192,12 @@ spec: - name: {{ .Chart.Name }} image: {{ include "adapter-service.image" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.preStopSleepSeconds }} + lifecycle: + preStop: + sleep: + seconds: {{ .Values.preStopSleepSeconds }} + {{- end }} {{- with (include "common.securityContext" .) }} securityContext: {{- . | nindent 12 }} @@ -150,6 +213,10 @@ spec: */}} - name: CONFIG_FILE value: {{ include "adapter-service.configDir" . }}/adapter.yaml + {{- with .Values.goMemLimit }} + - name: GOMEMLIMIT + value: {{ . | quote }} + {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} @@ -176,10 +243,19 @@ spec: - name: config-template configMap: name: {{ include "adapter-service.fullname" . }}-config + {{- if include "adapter-service.isNetworkAdapterLayer" . }} + {{- range $tier := list "consumer" "network" "provider" }} + - name: keys-{{ $tier }} + secret: + secretName: {{ include "adapter-service.networkAdapterLayer.tierSecret" (dict "ctx" $ "tier" $tier) }} + defaultMode: 0400 + {{- end }} + {{- else }} - name: keys secret: secretName: {{ include "adapter-service.keysSecretName" . }} defaultMode: 0400 + {{- end }} {{/* The rendered config. emptyDir rather than anything durable: it is derived from a ConfigMap and a Secret and is rebuilt on every start, diff --git a/charts/adapter-service/templates/ingress.yaml b/charts/adapter-service/templates/ingress.yaml index 529e1f6a..98bacb0d 100644 --- a/charts/adapter-service/templates/ingress.yaml +++ b/charts/adapter-service/templates/ingress.yaml @@ -1,9 +1,42 @@ {{- if .Values.ingress.enabled }} +{{- $ann := merge (dict) (.Values.ingress.annotations | default dict) (.Values.commonAnnotations | default dict) }} +{{- if include "adapter-service.isNetworkAdapterLayer" . }} +{{- /* single mode: the public surface is flat -- /discover, + / and /health, all served by the consumer module at /. Two + things must never be public: /_internal/ (the network and provider + tiers, called only by this process) and /publish (no validateSign, + signs as the provider). So a path is accepted only if it is one literal + segment and is neither of those; "/" is refused because a Prefix "/" + covers both. + Prefix and Exact only: ImplementationSpecific is a plain string prefix + on ingress-nginx, and regex or rewrite annotations turn paths into + patterns, so either could reach /_internal or /publish. */}} +{{- if eq (index $ann "nginx.ingress.kubernetes.io/use-regex" | default "" | toString | trim | lower) "true" }} +{{- fail (printf "%s: ingress annotation nginx.ingress.kubernetes.io/use-regex is not allowed in the single deployment mode. A regex path can match /_internal or /publish." .Chart.Name) }} +{{- end }} +{{- if hasKey $ann "nginx.ingress.kubernetes.io/rewrite-target" }} +{{- fail (printf "%s: ingress annotation nginx.ingress.kubernetes.io/rewrite-target is not allowed in the single deployment mode. It turns paths into regexes and can rewrite a request onto /_internal or /publish." .Chart.Name) }} +{{- end }} +{{- range .Values.ingress.hosts }} +{{- range .paths }} +{{- $p := .path | default "" }} +{{- $pt := .pathType | default "Prefix" }} +{{- if not (has $pt (list "Prefix" "Exact")) }} +{{- fail (printf "%s: ingress path %q has pathType %s, which is not allowed in the single deployment mode. Use Prefix or Exact." $.Chart.Name $p $pt) }} +{{- end }} +{{- if or (not (regexMatch "^/[A-Za-z0-9_.-]+$" $p)) (hasPrefix "/_internal" $p) (eq $p "/publish") }} +{{- fail (printf "%s: ingress path %q is not allowed in the single deployment mode. Allowed: one literal segment, / (e.g. /discover, /select). /, /publish and /_internal* are never public." $.Chart.Name $p) }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} {{/* 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 +The provider and network roles take SIGNED requests -- validateSign is their +first step -- so publishing them is not the same risk as publishing an adapter +that does not. The consumer role, and the single-mode consumer module +at /, take UNSIGNED requests (NOTES says so when ingress is on). Either way 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. @@ -14,7 +47,7 @@ metadata: name: {{ include "adapter-service.fullname" . }} labels: {{- include "adapter-service.labels" . | nindent 4 }} - {{- with (merge (dict) .Values.ingress.annotations (.Values.commonAnnotations | default dict)) }} + {{- with $ann }} annotations: {{- toYaml . | nindent 4 }} {{- end }} diff --git a/charts/adapter-service/values.yaml b/charts/adapter-service/values.yaml index d00b624a..bc5f3521 100644 --- a/charts/adapter-service/values.yaml +++ b/charts/adapter-service/values.yaml @@ -1,19 +1,33 @@ # =========================================================================== # adapter-service # -# One chart for all three Beckn adapters. provider, network and consumer are the -# same image and the same config format; `role` below decides the handler -# role, the step list and where requests are routed. +# One chart for the Beckn adapters, in two deployment modes. Same image and 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: +# single (the DEFAULT): ONE release, the Network Adapter Layer, serving all +# three tiers as modules of one process (role network, +# networkAdapterDeploymentMode single): # -# helm install provider-adapter . -f examples/provider.yaml -# helm install network-adapter . -f examples/network.yaml -# helm install consumer-adapter . -f examples/consumer.yaml +# helm install network-adapter . -n network-adapter --create-namespace \ +# -f examples/network-adapter.yaml +# +# multi (OPTIONAL): one release per tier, with networkAdapterDeploymentMode +# multi on the network release. 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 consumer-adapter . -f examples/consumer.yaml +# +# Run one deployment mode per environment. Both release `network-adapter` into +# namespace `network-adapter`; switching modes upgrades that release in place. # # 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 -- consumer routes to the network adapter, provider is routed to by network. +# other adapters address. In multi, rename a release and you must update +# whatever routes to it -- consumer routes to the network adapter, provider is +# routed to by network. The single deployment mode has no such addresses: its tiers +# call each other inside the pod. # # Two things every role needs that this chart cannot give it: # @@ -33,6 +47,12 @@ # --------------------------------------------------------------------------- role: "" +# single | multi. Read by role network only: single (the default) runs all +# three tiers in one adapter (networkAdapterLayer below); multi is the network +# tier alone. A multi network release MUST set multi, or an upgrade turns it +# into single. provider and consumer ignore it. +networkAdapterDeploymentMode: single + # Overrides for what the role implies. Leave empty to take the role's default. appName: "" @@ -261,8 +281,8 @@ otel: # 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. + # Not read by any template: the role's config file sets it + # (plugins.otelsetup.config.serviceName). serviceName: "" logLevel: info @@ -327,21 +347,37 @@ resources: # 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 +# The startup probe holds liveness off until /health first answers. The +# adapter fetches beckn.yaml from raw.githubusercontent.com at boot, so on +# slow or proxied egress first boot can take well past liveness's ~70s budget +# (10s + 3 x 20s). This bounds it at 180s without loosening steady-state +# liveness. +startupProbe: + enabled: true + httpGet: path: /health - initialDelaySeconds: 10 - periodSeconds: 20 - timeoutSeconds: 3 - failureThreshold: 3 - readiness: - enabled: true + port: http + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 36 +livenessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 +readinessProbe: + enabled: true + httpGet: path: /health - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 3 + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 autoscaling: enabled: false @@ -376,6 +412,20 @@ securityContext: drop: - ALL +# Spread replicas so one node loss is not an outage. labelSelector defaults to +# this release's selector labels when omitted. +topologySpreadConstraints: [] + +# Graceful shutdown. preStop sleeps so the endpoint leaves the Service before +# the process stops accepting. preStop.sleep needs Kubernetes 1.30+; keep 0 on +# older clusters. Empty grace period = the Kubernetes default (30s). +terminationGracePeriodSeconds: "" +preStopSleepSeconds: 0 + +# Soft memory limit for the Go runtime (e.g. 900MiB under a 1Gi limit), so GC +# works harder before the container is OOM-killed. Empty = unset. +goMemLimit: "" + nodeSelector: {} tolerations: [] affinity: {} @@ -389,3 +439,41 @@ extraVolumes: [] extraVolumeMounts: [] extraEnv: [] envFromSecrets: [] + +# --------------------------------------------------------------------------- +# role network, networkAdapterDeploymentMode single -- the Network Adapter Layer. +# +# The consumer, network and provider tiers as four modules of ONE process +# (/ for the consumer, /publish, /_internal/network/, /_internal/provider/). Its adapter config is +# config/network-adapter-config.yaml, mounted verbatim like any role's +# file: log level, timeouts, telemetry, extended schema and the outbound +# connection pool are set there. The routing is generated at render time from +# the tier routing files, with tier-to-tier hops pointed at this pod's own +# listener (http://localhost:/_internal/): any adapter image +# works. +# +# Ignored by provider, consumer and a multi network release. +# +# NOT USED in single mode: `routing.config` (setting it fails the render, because +# the routing is generated) and `handler.*` (role, steps and path are in the +# config file). `config` replaces config/network-adapter-config.yaml +# WHOLE: the private-key guard still runs on it, and it must point its routers +# at /app/config/routing-network-adapter-consumer.yaml, routing-network.yaml and +# routing-network-adapter-provider.yaml. +# --------------------------------------------------------------------------- +networkAdapterLayer: + # Three identities, one per tier: the same Secrets (six keys each, named as + # under keys.existingSecret) and the same registry rows the multi-adapter + # releases use, so either mode runs against the same registry. They must + # exist in THIS release's namespace (openbao-secrets reflects them there). + keys: + consumer: + existingSecret: "" + network: + existingSecret: "" + provider: + existingSecret: "" + + # Extra placeholders, as "/". + # __VISTAAR_TOKEN_URL__: provider/vistaarTokenUrl + extraSubstitutions: {} diff --git a/charts/openbao-secrets/CHANGELOG.md b/charts/openbao-secrets/CHANGELOG.md index 9e3e163e..7e2160b0 100644 --- a/charts/openbao-secrets/CHANGELOG.md +++ b/charts/openbao-secrets/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to the `openbao-secrets` 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.1] - 2026-10-09 + +### Added +- `network-adapter` as a target namespace of `consumer-adapter-keys`, + `provider-adapter-keys` and `provider-upstream`: in the single deployment + mode (the default) `adapter-service` role `network` runs all three tiers + there. In multi mode, remove `network-adapter` from those three entries. + ## [0.1.0] - 2026-09-27 ### Added diff --git a/charts/openbao-secrets/Chart.yaml b/charts/openbao-secrets/Chart.yaml index 7a430ef1..b6971ecd 100644 --- a/charts/openbao-secrets/Chart.yaml +++ b/charts/openbao-secrets/Chart.yaml @@ -6,7 +6,7 @@ description: >- This is what turns an OpenBao entry into something a chart can reference by name. type: application -version: 0.1.0 +version: 0.1.1 appVersion: "0.10.7" maintainers: - name: OpenAgriNet Engineering Team diff --git a/charts/openbao-secrets/values.yaml b/charts/openbao-secrets/values.yaml index 75fc2c67..24ee4686 100644 --- a/charts/openbao-secrets/values.yaml +++ b/charts/openbao-secrets/values.yaml @@ -57,14 +57,22 @@ secrets: # -- adapter identities. keyId inside these is filled in by # resolve-key-ids.sh AFTER the registry has been seeded, so these entries # change once per environment after the first bring-up. + # + # network-adapter runs the Network Adapter Layer in the single deployment + # mode (the default), which runs all three tiers, so it reads all three + # identities. In multi mode drop network-adapter from the consumer and + # provider entries, so the network tier never holds their keys + # (charts/adapter-service/README.md, "Multi mode"). - name: consumer-adapter-keys - namespaces: [consumer-adapter] + namespaces: [consumer-adapter, network-adapter] - name: network-adapter-keys namespaces: [network-adapter] - name: provider-adapter-keys - namespaces: [provider-adapter] + namespaces: [provider-adapter, network-adapter] # The provider adapter's upstream credentials, named as environment variables - # by its config rather than substituted into it. + # by its config rather than substituted into it. The Network Adapter Layer + # (namespace network-adapter, single mode) runs the provider tier too, so it reads + # them as well. - name: provider-upstream - namespaces: [provider-adapter] + namespaces: [provider-adapter, network-adapter] diff --git a/docs/adr/0000-template.md b/docs/adr/0000-template.md new file mode 100644 index 00000000..8751ab3f --- /dev/null +++ b/docs/adr/0000-template.md @@ -0,0 +1,24 @@ +# ADR-0000 — Title + +**Status:** Proposed | Accepted | Superseded by ADR-NNNN +**Date:** YYYY-MM-DD +**Issues:** owner/repo#N + +## Context + +What forced a decision. The constraint, not the preamble. + +## Decision + +What was chosen, stated so that a reader can tell whether a given change +complies with it. + +## Alternatives considered + +What was rejected, and the specific property that disqualified it. "It was +worse" is not a reason; "it exposes an unsigned endpoint" is. + +## Consequences + +What this costs, including the cases it makes harder. An ADR that lists only +benefits is advocacy, not a record. diff --git a/docs/adr/0001-single-network-adapter-deployment-mode.md b/docs/adr/0001-single-network-adapter-deployment-mode.md new file mode 100644 index 00000000..02afdca8 --- /dev/null +++ b/docs/adr/0001-single-network-adapter-deployment-mode.md @@ -0,0 +1,79 @@ +# ADR-0001 — One Beckn OAN network adapter process for all three tiers, by default + +**Status:** Proposed +**Date:** 2026-10-15 +**Issues:** OpenAgriNet/helmcharts#55, OpenAgriNet/helmcharts#66, OpenAgriNet/network-adapter#55 (parent OpenAgriNet/engineering-tracker#171) + +## Context + +The Beckn OAN network adapter runs in three tiers: consumer (`bap`, 9202), network (`bpp`, 9201) and provider (`bpp`, 9200). Each was its own container in `quick-start/` and its own Helm release in `charts/adapter-service`, installed once per role. The release name is the Service name the other tiers address. + +So every request crossed two containers or pods, with a network hop and a DNS lookup: +- consumer to network for `discover`; +- consumer to provider for `select`; +- provider to network for `publish`. + +Every environment also ran three of every object: Deployment, Service, ConfigMap, and the PDB or HPA where enabled. + +OAN is single-sided today: our app calls out, and our own discovery and upstream APIs answer. The separate scaling and ownership the split allows is not needed. + +Constraints: +- **The registry cannot update a row.** The identities and participant ids already written must keep working. +- **The published Beckn OAN network adapter image must work unchanged.** + +## Decision + +Two deployment modes from the same chart, compose file, keys and registry rows. **`single` is the default; `multi` (the three adapters, unchanged) is optional.** + +In `single`, one Beckn OAN network adapter process listens on **9201** (the multi network tier's port) and mounts four modules. Each module is its tier's module from the tier configs, with the same steps and its own identity and Secret: + +| Module | Path | Steps | Routes to | +|---|---|---|---| +| consumer | `/` (app calls `/`) | validateSchema, addRoute, sign | `localhost:9201/_internal/network` (discover), `localhost:9201/_internal/provider` (select, init, confirm, status) | +| network | `/_internal/network/` | validateSign, addRoute | discovery | +| provider | `/_internal/provider/` | validateSign, validateSchema, capability steps | answers itself | +| provider-publish | `/publish` | validateSchema, addRoute, sign | `localhost:9201/_internal/network/publish` | + +A change complies with this decision if: +- **Paths:** the paths callers see stay `/discover`, `/publish`, `/` and `/health`, the same as multi. Tier-to-tier paths stay under `/_internal/`, called only by the process itself. +- **Routing:** each routing module keeps its own routing file (`routing-network-adapter-consumer.yaml`, `routing-network-adapter-provider.yaml`; network reuses `routing-network.yaml`). A module's routing file is its list of allowed actions: `addRoute` runs before `sign`, so an action with no rule errors instead of being forwarded. +- **Helm:** no new role. Role `network` with `networkAdapterDeploymentMode: single` (the default) is the single adapter; with `multi` it is the network tier. `provider` and `consumer` ignore the value. Both modes release `network-adapter` into namespace `network-adapter`. Routing is generated at render time from the existing `config/routing-*.yaml`, with hosts rewritten to `localhost`. The render fails on: + - a literal private key; + - `http.port` ≠ `service.targetPort`; + - an unset tier Secret name; + - an Ingress path other than single-segment `/`. +- **Secrets:** `openbao-secrets` reflects the consumer and provider key Secrets and `provider-upstream` into `network-adapter` for single mode. A multi environment removes those targets and deletes the copies, so the network tier holds only its own key. +- **Choosing the mode:** the chart value `networkAdapterDeploymentMode: single | multi` on the `network-adapter` release; multi also installs `consumer-adapter` and `provider-adapter`. In quick-start, `NETWORK_ADAPTER_DEPLOYMENT_MODE` picks `network-adapter`'s config, and profile `multi` adds the other two adapters, via `make up` and `make up-multi`. + +## Alternatives considered + +- **Keep three adapters only.** It keeps three of every object and a cross-pod hop on every request, for a separation a single-sided deployment does not use. +- **In-process transport (`inproc://`) between modules.** It needs an adapter image that includes the transport, so it fails the "image unchanged" constraint. The gain does not pay for that dependency: the `localhost` hop was already sub-millisecond on Docker, the prototype saved about 0.1–0.3 ms per hop, and an end-to-end benchmark showed no measurable gain. +- **Tier prefixes in the public paths** (`/network/`, `/provider/`, `/provider/publish`). This was the first version. It put the tiers back into the URLs callers use, gave single and multi different URLs, and needed a per-path allow-list at the edge instead of one `/_internal` deny. Replaced after review. +- **One Helm role `network` with a mode switch.** With `single` as the default, every existing `role: network` release would become a three-identity adapter on upgrade and fail without the consumer and provider Secrets. It also makes every existing network-tier code path depend on a new setting. +- **One shared routing file for the consumer and publish modules.** The publish module's rule is for the empty action `""`. Shared, it would also apply to the consumer, so `POST /` on the public consumer would publish into the catalogue. +- **Port 9200.** That is the multi provider's port. 9201 is the multi network tier's port and already the chart's default `service.port`. + +## Consequences + +**Gains:** +- One Deployment, Service, ConfigMap and PDB per environment. +- No hop between pods. +- No registry, identity or image change. +- Reversible: install the multi releases next to the single one, repoint the app, remove the single release. + +**Costs:** +- **Scaling is per adapter, not per tier.** An environment that needs a tier scaled on its own uses `multi`. +- **One crash takes all three tiers down.** Replicas (3 in the example), the PDB and the spread constraint limit the blast radius. +- **Tier configs exist twice.** Each module in `network-adapter-config.yaml` mirrors its tier file, so a change meant for both modes is made in both. +- **The catalogue publisher's port differs by mode:** `:9201/publish` in single, `:9200/publish` in multi. + +**Security:** +- `/publish` has no `validateSign`, and `/_internal/` is internal. Their protection is path-level: + - **Helm:** the Ingress guard; + - **quick-start:** the `server_proxy.conf` denies. +- Inside the cluster, anything that reaches port 9201 can call `/publish`. A NetworkPolicy cannot isolate it, because it has no notion of paths. Port-level isolation would need a second listener, which is not built. + +**Outside this repository:** `OpenAgriNet/network-adapter`'s own `install/` and `config/` still describe the per-role layout. Retiring them is the remaining part of network-adapter#55. + +**Verification (2026-10-22, flat paths, port 9201):** quick-start single (11/11 assertions) and multi (10/10), and k3d single (12/12), all passing: publish, discover and select 200, tier hops on `localhost:9201/_internal/`, and on k3d an unsigned call to `/_internal/network/discover` refused with 401. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..e4ffadcc --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,12 @@ +# Architecture Decision Records + +One file per decision, numbered, never edited after acceptance. A decision +that turns out wrong is **superseded** by a new record, not rewritten. The +point of the log is to answer *why does this look the way it does*, and a +rewritten record cannot answer it. + +Start from [`0000-template.md`](0000-template.md). + +| # | Decision | Issues | +|---|---|---| +| [0001](0001-single-network-adapter-deployment-mode.md) | One Beckn OAN network adapter process for all three tiers, by default (`single`); three adapters stay optional (`multi`) | helmcharts#55, #66 | diff --git a/quick-start/.env.example b/quick-start/.env.example index 3b6d9a10..7adfacdd 100644 --- a/quick-start/.env.example +++ b/quick-start/.env.example @@ -145,6 +145,14 @@ PROVIDER_SUBSCRIBER_ID=provider.oan.dev # The address PEERS are told to POST to, published in each registry row. Nothing # inside this stack resolves it -- routing here is by compose service name -- so # a wrong value costs nothing locally and everything on a shared network. +# +# Single mode (network-adapter) has one public host, so only CONSUMER_BASE_URL +# names a real address there. The network and provider modules sit under +# /_internal/ and the edge denies them; their rows are read for key lookup +# only, and no peer resolves their baseUrl while OAN is single-sided. They are +# still kept per tier, the same as multi mode, on purpose: the registry cannot +# update a row, so a later split to multi mode (or a separate inbound host) +# would otherwise need new participant ids. CONSUMER_BASE_URL=https://consumer.oan.dev NETWORK_BASE_URL=https://network.oan.dev PROVIDER_BASE_URL=https://provider.oan.dev @@ -211,3 +219,9 @@ POCRA_CAPABILITY=openagrinet:AgricultureFacility POCRA_BASE_URL=https://middleware-bap-client.mahapocra.gov.in POCRA_PATH=/search POCRA_MAPPING_URL=https://raw.githubusercontent.com/OpenAgriNet/helmcharts/0.0.1/quick-start/config/mappings/pocra/agriculture-facility.select.yaml + +# Adapter deployment mode, read by bin/stack.sh at startup: "single" (network-adapter +# runs all three tiers, config network-adapter.yaml) or "multi" (network-adapter +# runs config network.yaml, the network tier alone, and provider- and +# consumer-adapter start beside it). MODE=multi on the command line overrides it. +NETWORK_ADAPTER_DEPLOYMENT_MODE=single diff --git a/quick-start/.gitignore b/quick-start/.gitignore index ac5fe84d..26c000c3 100644 --- a/quick-start/.gitignore +++ b/quick-start/.gitignore @@ -16,6 +16,7 @@ config/adapters/experience.yaml config/adapters/exp.yaml config/adapters/network.yaml config/adapters/provider.yaml +config/adapters/network-adapter.yaml # A local override for the discovery service, if you make one. config/discovery/instance.yaml diff --git a/quick-start/Makefile b/quick-start/Makefile index 1aae6475..1d3f3b9a 100644 --- a/quick-start/Makefile +++ b/quick-start/Makefile @@ -3,8 +3,10 @@ # 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 the whole stack (Network Adapter), in order # make up-core the same minus the reverse proxy and hyperdx +# make up-multi the whole stack with three adapters instead of one +# make up-core-multi up-core with three adapters instead of one # make down stop everything, keep the data # make help the rest # @@ -13,7 +15,7 @@ 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 +.PHONY: help up up-core up-multi up-core-multi 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. @@ -25,6 +27,8 @@ help: up: ; @$(STACK) up up-core: ; @$(STACK) up-core down: ; @$(STACK) down +up-multi: ; @$(STACK) up-multi +up-core-multi: ; @$(STACK) up-core-multi destroy: ; @$(STACK) destroy # --------------------------------------------- the optional tiers, on their own diff --git a/quick-start/README.md b/quick-start/README.md index 1165f014..920bdfe1 100644 --- a/quick-start/README.md +++ b/quick-start/README.md @@ -1,7 +1,12 @@ # 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. +The whole OAN stack in Docker Compose: a registry, a discovery service, the +network adapter, and two mock upstreams standing in for real provider APIs. + +The adapter runs in one of two modes, from the same compose file, keys and +registry. **Single is the default**: one container, `network-adapter`, runs the +consumer, network and provider tiers on port 9201. **Multi is optional**: the +three tiers as three containers. → [Two modes](#two-modes). Nothing is built here — the images are pulled. Locally that is about ten minutes, most of it waiting for Keycloak. Discovery is the one exception, and @@ -30,8 +35,11 @@ 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 consumer -adapter depends on the other two, so Compose brings them up first. The steps +The picture shows the three tiers. In single mode (the default) they are +modules of one container, and the arrows between them are calls inside it. + +That is the request order. **Startup order is the reverse**: in multi mode the +consumer 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? @@ -43,6 +51,7 @@ below follow startup order, so they work top to bottom. | Observability | not started | optional, wants 2–4 GB | | Credentials | shipped defaults are ok | **must all be changed** | | Command | `make up-core` | `make up` | +| Multi mode instead | `make up-core-multi` | `make up-multi` | **Local** is Part 1. **VM** is Part 1, then Part 2 for the differences. @@ -53,14 +62,14 @@ below follow startup order, so they work top to bottom. ## Step 1 — Prerequisites - `git` -- Docker with **Compose v2** — `docker compose version` must work, not `docker-compose` +- Docker with **Compose v2.20 or later** — `docker compose version` must work, not `docker-compose`; the compose file uses `depends_on: required: false`, which older v2 releases reject - `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 +git clone -b development https://github.com/OpenAgriNet/helmcharts.git cd helmcharts/quick-start ``` @@ -76,14 +85,14 @@ cp .env.example .env Locally, nothing in it needs changing. It publishes these on `localhost`: ``` -8081 registry 9200 provider adapter 9100 mock-imd -8080 keycloak 9201 network adapter 9101 mock-agmarknet -9990 keycloak admin 9202 consumer adapter +8081 registry 9201 network adapter (single; network tier in multi) 9100 mock-imd +8080 keycloak 9200 provider adapter (multi only) 9101 mock-agmarknet +9990 keycloak admin 9202 consumer adapter (multi only) 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 +needs. `NETWORK_ADAPTER_PORT` is the network adapter's port in both modes. Adapters reach each other by Compose service name, so it only changes what you type into Postman. ## Step 4 — Start it @@ -93,11 +102,13 @@ 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 +`bin/setup.py`, then the adapter (`network-adapter` on 9201; `make up-core-multi` +starts the three adapters instead). Allow up to five minutes the first time — Keycloak on a cold volume. `setup.py` generates a keypair per adapter, registers seven participants and four -capability bindings, and renders the three adapter configs. Nothing needs +capability bindings, and renders the adapter configs (the three tier configs and +the single adapter's `network-adapter.yaml`). Nothing needs creating by hand. → Appendix C for why the order matters, Appendix D for what it wrote. @@ -107,6 +118,10 @@ make ps ## Step 5 — Provider layer +> Steps 5–7 describe the three tiers. In single mode they run in one container, +> so check them with `docker logs network-adapter` instead of the +> `docker compose logs -adapter` commands below. + Answers `select`, and the only layer that calls an upstream. Serves all four capabilities from one adapter. @@ -154,19 +169,21 @@ straight to the provider layer, per `config/adapters/routing-consumer.yaml`. docker compose logs consumer-adapter | grep 'Server listening' ``` -`setup.py` renders all three configs from these keys. **Do not edit the +`setup.py` renders all the adapter 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 `../api-collection/` into Postman — -`OpenAgriNet.api-collection.json` and `OpenAgriNet.local-environment.json`. -Select the environment, or every `{{...-host}}` resolves to nothing. Or: +`OpenAgriNet.api-collection.json` and the environment for your mode: +`OpenAgriNet.network-adapter.local-environment.json` (single, every adapter +host on 9201) or `OpenAgriNet.local-environment.json` (multi). Select the +environment, or every `{{...-host}}` resolves to nothing. Or: ```sh newman run ../api-collection/OpenAgriNet.api-collection.json \ - -e ../api-collection/OpenAgriNet.local-environment.json + -e ../api-collection/OpenAgriNet.network-adapter.local-environment.json # multi: OpenAgriNet.local-environment.json ``` **18 requests, 32 assertions**, one folder per capability plus a health folder. @@ -187,6 +204,56 @@ OAuth2 client credentials that only the adapter reads, at call time. To point it at another deployment, edit the **environment** file, not the collection. → Appendix N. +## Two modes + +| | single (default) | multi (optional) | +|---|---|---| +| What runs | one container, `network-adapter` | `consumer-adapter`, `network-adapter`, `provider-adapter` | +| `NETWORK_ADAPTER_DEPLOYMENT_MODE` | `single` | `multi` | +| `network-adapter` config | `config/adapters/network-adapter.yaml` | `config/adapters/network.yaml` | +| Start | `make up-core` / `make up` | `make up-core-multi` / `make up-multi` | +| Ports | `127.0.0.1:9201` | 9200 provider, 9201 network, 9202 consumer | +| App calls | `:9201/discover`, `:9201/select` | `:9202/discover`, `:9202/select` | +| Catalogue publishes to | `:9201/publish` | `:9200/publish` | + +Single is the everyday choice: one container, the same requests. Use multi to +reproduce a deployment where the tiers run separately. + +**Switching** is running the other target: it recreates `network-adapter` with +the other config, and starts (multi) or removes (single) `provider-adapter` and +`consumer-adapter`. `MODE=multi` also works with `make up` and `make up-core`; +`make restart`, `make down`, `make ps` and `make logs` follow whichever mode is +running. The mode is read once, at startup, by `bin/stack.sh`, which passes +compose the config to mount (`NETWORK_ADAPTER_CONFIG`). Plain compose mounts +`network-adapter.yaml` unless told otherwise: +`NETWORK_ADAPTER_CONFIG=network docker compose --profile multi up -d provider-adapter network-adapter consumer-adapter`. + +**Upgrading a stack.** The compose service `network-adapter-layer` and +`COMPOSE_PROFILES=network` are gone: add `NETWORK_ADAPTER_DEPLOYMENT_MODE=single` +to `.env` (optional; single is the default). `make up` starts single mode +and removes the 9200/9202 adapters, so repoint NPM proxy hosts at +`network-adapter:9201`, or use `make up-multi`. + +**Multi mode.** `make up-multi`, or `NETWORK_ADAPTER_DEPLOYMENT_MODE=multi` in +`.env` and `make up`. Only `network.yaml` is mounted into `network-adapter`, so +the network tier never holds the consumer or provider keys. The app calls +`consumer-adapter:9202`, the catalogue publishes to `provider-adapter:9200`. + +**How single mode is wired.** `config/adapters/network-adapter.yaml.tmpl`, +rendered by `setup.py` to `network-adapter.yaml`, holds four modules, each its tier's module from the +tier templates, each with its own tier identity: + +| Module | Path | Routes to | +|---|---|---| +| `consumer` | `/` | discover to `localhost:9201/_internal/network`, select/init/confirm/status to `localhost:9201/_internal/provider` (`routing-network-adapter-consumer.yaml`) | +| `network` | `/_internal/network/` | discovery (`routing-network.yaml`, reused) | +| `provider` | `/_internal/provider/` | answers itself | +| `provider-publish` | `/publish` | `localhost:9201/_internal/network/publish` (`routing-network-adapter-provider.yaml`) | + +`/_internal/` is called only by the adapter itself; `server_proxy.conf` denies +it, and `/publish`, on every proxy host. Signing is off in every quick-start +template, in both modes, so keep the adapter ports on `127.0.0.1`. + --- # Part 2 — Run it on a VM @@ -198,7 +265,7 @@ Four differences. **V1 comes before Part 1 Step 2**, because it installs `git`. 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 +curl -fsSL https://raw.githubusercontent.com/OpenAgriNet/helmcharts/development/quick-start/bin/bootstrap-ubuntu.sh | bash ``` Installs `git`, `make`, `python3-cryptography` and Docker from Docker's own apt @@ -230,7 +297,10 @@ 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.** +the VM reachable from the internet.** `make up-multi` runs the three adapters +instead; the mode decides what the proxy hosts in Appendix B forward to. +Switching mode on a running VM restarts nginx-proxy-manager, so the public +hosts drop connections for a few seconds. ## Step V4 — Decide what is exposed @@ -249,7 +319,7 @@ ssh -L 9202:127.0.0.1:9202 -L 9200:127.0.0.1:9200 \ ``` The collection's defaults then work unchanged, since they already point at -loopback. +loopback. Single mode needs only 9201 for the adapter; 9200 and 9202 are for multi. ## Step V6 — Observability (optional) @@ -312,7 +382,8 @@ Reference, read on demand. Nothing here is a step. ## Appendix A — What is here, and what is not Running: **registry** (SunbirdRC + Postgres + Keycloak), **discovery** -(catalogue search + Postgres), **three adapters** (same image, three configs), +(catalogue search + Postgres), **the adapters** (one Network Adapter by +default, or three tier adapters with `make up-multi`; same image), **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 @@ -361,6 +432,16 @@ 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. +**Single mode (`make up`, the default).** The table above is for +`make up-multi`. With the single adapter there is no `consumer-adapter`, +`network-adapter` or `provider-adapter` to forward to: point the consumer host +at `network-adapter`, port 9201, with the same Advanced paste. The +app's paths (`/discover`, `/select`, …) do not change, and neither does +`/publish`: both modes present the same URLs. `server_proxy.conf` denies +`/publish` and `/_internal` on every host: `/_internal/` holds the network and +provider tiers, `/publish` publishes as the provider without checking the +caller, and signing is off in quick-start. + **A fourth host for HyperDX**, if the `observability` profile is up — this is what replaces `ssh -L 8085` for reading telemetry: @@ -439,21 +520,24 @@ 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 +### `POST /publish` and `/_internal` return 403 on every host Not optional hardening. The provider adapter's module at `/` verifies the -sender's signature; `oanProviderPublish`, on the exact path `/publish`, has +sender's signature; the publish module (`oanProviderPublish` in multi mode, +`provider-publish` in single mode), 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. +`provider-adapter:9200` (multi) or `network-adapter:9201` (single) therefore +exposes `/publish` to anyone. In single mode the network and provider +tiers also sit under `/_internal/`, which only the adapter itself calls. 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. +something to remember on one host. 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 +and let it reach the adapter directly (9200 in multi, 9201 in single). Do not turn that `deny` into an `allow`. ### Which nginx config loads itself, and which is a paste job @@ -461,7 +545,7 @@ an `allow`. | File | How it applies | |---|---| | `npm-custom/http_top.conf` | **Automatic**, top of the `http` block. The `consumer` rate-limit zone and `limit_req_status 429`. | -| `npm-custom/server_proxy.conf` | **Automatic**, every server block. The `/publish` deny. | +| `npm-custom/server_proxy.conf` | **Automatic**, every server block. The `/publish` and `/_internal` denies. | | `npm-advanced/consumer.conf` | **Manual** — paste into the consumer host's Advanced tab. `limit_req` for that host only; a 10 r/s ceiling on signed peer traffic would throttle for no gain. | | `npm-advanced/hyperdx.conf` | **Manual** — paste into the HyperDX host's Advanced tab. 300s timeouts for ClickHouse scans, buffering off for live tail. Carries no credential; the Access List does. | @@ -513,8 +597,8 @@ loopback. A registry route would depend on that silently. ``` 1. registry and discovery (also sunbird-registry-postgres, sunbird-registry-keycloak, discovery-postgres) -2. bin/setup.py keys, seven participants, four bindings, three configs -3. mock upstreams, then the three adapters +2. bin/setup.py keys, seven participants, four bindings, four configs +3. the adapters network-adapter (default), or the three with MODE=multi 4. nginx-proxy-manager the public edge — 80 and 443, all interfaces 5. hyperdx ClickStack ``` @@ -583,7 +667,7 @@ unedited. Those four 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 +into `provider.yaml` and `network-adapter.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, @@ -730,7 +814,7 @@ an id is a `.so` basename. If `ADAPTER_IMAGE` names a **new tag**, set it before nothing in `stack.sh` pulls, so the stack quietly comes back on the old image: ```sh -docker compose pull provider-adapter network-adapter consumer-adapter +docker compose --profile multi pull provider-adapter network-adapter consumer-adapter ``` Build from the adapter repo at the commit the config expects, and check the @@ -751,6 +835,10 @@ publish reseeds first; `updateMode: MERGE` updates in place. **Then check, cheapest first:** ```sh +# single mode (the default) +docker logs network-adapter 2>&1 | grep 'Processor steps initialized' +docker logs network-adapter 2>&1 | grep -iE '"level":"(error|fatal)"' +# multi mode docker compose logs provider-adapter | grep 'Processor steps initialized' docker compose logs provider-adapter | grep -iE '"level":"(error|fatal)"' make ps @@ -1003,7 +1091,7 @@ 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 + server_proxy.conf and /_internal denies every proxy host gets npm-advanced/ NOT loaded. Paste into a host's Advanced tab; kept consumer.conf here because a textarea in a database is not hyperdx.conf reviewable. consumer = rate limit; hyperdx = ClickHouse @@ -1019,6 +1107,9 @@ config/ routing-consumer.yaml which action goes where: consumer sends discover routing-network.yaml to the network layer and select to the provider; routing-provider.yaml provider sends publish to the network layer + network-adapter.yaml.tmpl single mode: all three tiers, one file + routing-network-adapter-consumer.yaml single mode: consumer hops to + routing-network-adapter-provider.yaml localhost:9201/_internal/... registry/ schemas/ Participant, ProviderSchema, SchemaRegistry. Read at startup -- a change needs a restart @@ -1060,7 +1151,7 @@ that volume — and equally does not survive if you skip it. ```sh docker compose down -v rm -rf keys config/adapters/consumer.yaml config/adapters/network.yaml \ - config/adapters/provider.yaml + config/adapters/provider.yaml config/adapters/network-adapter.yaml ``` New keys mean new identities, so the provider rows must be created again and diff --git a/quick-start/bin/bootstrap-ubuntu.sh b/quick-start/bin/bootstrap-ubuntu.sh index 519c6bf0..87140659 100755 --- a/quick-start/bin/bootstrap-ubuntu.sh +++ b/quick-start/bin/bootstrap-ubuntu.sh @@ -110,7 +110,7 @@ cat <<'NEXT' Then: - git clone -b feat/4-docker-compose https://github.com/OpenAgriNet/helmcharts.git + git clone -b development https://github.com/OpenAgriNet/helmcharts.git cd helmcharts/quick-start cp .env.example .env && nano .env # change every credential make up diff --git a/quick-start/bin/setup.py b/quick-start/bin/setup.py index 541ef06e..c7f3f7ad 100755 --- a/quick-start/bin/setup.py +++ b/quick-start/bin/setup.py @@ -565,17 +565,23 @@ def render(identities): vistaar_binding = (f"{env('VISTAAR_PARTICIPANT_ID')}" f"|{env('VISTAAR_CAPABILITY')}") pocra_binding = f"{env('POCRA_PARTICIPANT_ID')}|{env('POCRA_CAPABILITY')}" - for role in ROLES: - identity = identities[role] - template = (ADAPTERS / f"{role}.yaml.tmpl").read_text() - prefix = role.upper() + # The three tier configs, then the Network Adapter Layer's + # (network-adapter.yaml.tmpl): its modules carry all three tiers' + # identities, so it gets every tier's keys. + for name in (*ROLES, "network-adapter"): + template = (ADAPTERS / f"{name}.yaml.tmpl").read_text() + for role in (ROLES if name == "network-adapter" else (name,)): + identity = identities[role] + 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"])): + template = template.replace(placeholder, value) 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"]), ("__MAUSAMGRAM_BINDING_KEY__", mausamgram_binding), ("__AGMARKNET_BINDING_KEY__", agmarknet_binding), ("__VISTAAR_BINDING_KEY__", vistaar_binding), @@ -601,8 +607,8 @@ def render(identities): ("__OTEL_ENVIRONMENT__", env("OTEL_ENVIRONMENT", "dev"))): template = template.replace(placeholder, value) if "__" in template: - sys.exit(f"setup: {role}.yaml still has unrendered placeholders") - out = ADAPTERS / f"{role}.yaml" + sys.exit(f"setup: {name}.yaml still has unrendered placeholders") + out = ADAPTERS / f"{name}.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 @@ -617,7 +623,7 @@ def render(identities): f" make up") out.write_text(template) out.chmod(0o600) # holds a private key - print(f" config/adapters/{role}.yaml") + print(f" config/adapters/{name}.yaml") if __name__ == "__main__": @@ -659,9 +665,7 @@ def render(identities): 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 consumer-adapter +on its own, run `make up` (or `make up-multi`) to pick up the rendered configs. Then import ../api-collection/ -- both files, the collection and the environment -- and run it. Eighteen requests across the four capabilities, diff --git a/quick-start/bin/stack.sh b/quick-start/bin/stack.sh index 1e7ac6e1..4c020653 100755 --- a/quick-start/bin/stack.sh +++ b/quick-start/bin/stack.sh @@ -4,6 +4,8 @@ # # 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 up-multi as up, with the three tier adapters +# bin/stack.sh up-core-multi as up-core, with the three tier adapters # bin/stack.sh down stop everything, keep the data # bin/stack.sh destroy stop everything, DELETE the data # @@ -30,11 +32,71 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -# Both optional profiles, named explicitly. This matters for `down`: compose +# Every optional profile, 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) +# down` leaves the gateway, hyperdx and the adapters (both deployment modes are +# profiled) running and then reports success. Naming them on teardown is what +# makes "down" mean down. +PROFILES=(--profile reverse-proxy --profile observability --profile multi) + +# Adapter deployment mode: "single" (default) runs network-adapter as the +# Network Adapter Layer, one process for all three tiers; "multi" runs +# network-adapter as the network tier plus provider- and consumer-adapter +# (profile multi). The mode decides, at startup, which config network-adapter +# mounts (NETWORK_ADAPTER_CONFIG below). +# +# MODE is the mode `up` starts: the shell's MODE, else .env, else single. +# Every other compose call exports the RUNNING mode, so a command that touches +# network-adapter only through depends_on (reverse-proxy, observability) never +# recreates it with the other config. +# Validated only by the commands that use it (check_mode), so an unrelated +# MODE in the shell never blocks `down`, `destroy` or `help`. + +# A key's value in .env: last occurrence, quotes, inline comment and CR +# stripped, empty when absent (never an error under set -e). +dotenv_get() { + local v + v=$(grep -E "^$1=" .env 2>/dev/null | tail -1 | cut -d= -f2-) || true + v=${v%%#*} + printf '%s' "$v" | tr -d '"'"'"' \t\r' +} + +# The mode network-adapter is running in, from the config it mounts +# (network.yaml is the network tier alone): single, multi, or empty when it is +# not running. +running_mode() { + [ "$(docker inspect -f '{{.State.Running}}' network-adapter 2>/dev/null)" = true ] || return 0 + case "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/config/adapter.yaml"}}{{.Source}}{{end}}{{end}}' network-adapter 2>/dev/null)" in + */network.yaml) echo multi ;; + *) echo single ;; + esac +} + +MODE="${MODE:-$(dotenv_get NETWORK_ADAPTER_DEPLOYMENT_MODE)}" +MODE="${MODE:-single}" +RUNNING_MODE="$(running_mode)" + +# The config network-adapter mounts for a mode; compose reads it as +# NETWORK_ADAPTER_CONFIG. +network_config_for() { [ "$1" = multi ] && echo network || echo network-adapter; } +export NETWORK_ADAPTER_CONFIG="$(network_config_for "${RUNNING_MODE:-$MODE}")" +check_mode() { + case "$MODE" in single|multi) ;; *) die "MODE must be single or multi, not '$MODE'" ;; esac +} + +adapters_for() { # $1 mode + case "$1" in + single) echo network-adapter ;; + multi) echo provider-adapter network-adapter consumer-adapter ;; + esac +} +# The rendered config files (config/adapters/.yaml) a deployment mode mounts. +configs_for() { # $1 mode + case "$1" in + single) echo network-adapter ;; + multi) echo provider network consumer ;; + esac +} # ------------------------------------------------------------------ output @@ -74,6 +136,7 @@ preflight() { # `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() { + check_mode preflight up_registry_tier 5 up_setup 5 @@ -97,6 +160,7 @@ up() { # 16 GB -- this is the stack you can actually exercise, which is the reason the # profiles exist in the first place. up_core() { + check_mode preflight up_registry_tier 3 up_setup 3 @@ -125,9 +189,10 @@ up_registry_tier() { } # Generates the adapter keypairs, registers the three adapter identities, and -# renders config/adapters/{provider,network,consumer}.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. +# renders every adapter config (provider, network, consumer and the +# Network Adapter Layer's network-adapter.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, seven registry participants, adapter configs" python3 bin/setup.py @@ -142,8 +207,34 @@ up_adapters() { # debugging a mapping without the real credentials -- but that also needs a # new participant id and base URL in .env plus a setup.py re-run, because # the registry cannot repoint an existing row. - step 3 "$1" "adapters (provider, network, consumer)" - docker compose up -d provider-adapter network-adapter consumer-adapter + check_mode + local f + # `up` starts the requested mode, not the running one. + NETWORK_ADAPTER_CONFIG="$(network_config_for "$MODE")" + step 3 "$1" "adapters, mode $MODE ($(adapters_for "$MODE"))" + # A stack from before the merge has container network-adapter under the + # old service name network-adapter-layer, which compose no longer knows + # and would not remove: the name would clash. + if [ "$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' network-adapter 2>/dev/null)" = network-adapter-layer ]; then + docker rm -f network-adapter >/dev/null + fi + # Single runs network-adapter alone: remove the multi-only adapters. + if [ "$MODE" = single ]; then + docker compose --profile multi rm -sf provider-adapter consumer-adapter >/dev/null 2>&1 || true + fi + # Never let compose create a directory where a bind-mounted config belongs. + for f in $(configs_for "$MODE"); do + [ -f "config/adapters/$f.yaml" ] \ + || die "config/adapters/$f.yaml was not rendered (a directory there? rm -r it, then re-run)" + done + # shellcheck disable=SC2046 + docker compose --profile multi up -d --force-recreate $(adapters_for "$MODE") + # NPM resolves adapter names when nginx loads and caches the addresses, so + # a recreated or switched adapter 502s through it until it restarts. + if [ "$(docker inspect -f '{{.State.Running}}' nginx-proxy-manager 2>/dev/null)" = true ]; then + info "restarting nginx-proxy-manager so it re-resolves the adapter addresses" + docker compose --profile reverse-proxy restart nginx-proxy-manager >/dev/null + fi } done_banner() { @@ -282,7 +373,6 @@ NEXT # 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 consumer-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 @@ -294,10 +384,13 @@ APP_SERVICES=(registry discovery provider-adapter network-adapter consumer-adapt # 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" + [ -n "$RUNNING_MODE" ] && MODE=$RUNNING_MODE + check_mode + step 1 1 "restarting registry, discovery and the $MODE-mode adapters" info "keycloak, both databases and the edge are left alone" - docker compose restart "${APP_SERVICES[@]}" - docker compose ps --format 'table {{.Service}}\t{{.Status}}' + # shellcheck disable=SC2046 + docker compose --profile multi restart sunbird-registry-service discovery-service $(adapters_for "$MODE") + docker compose --profile multi ps --format 'table {{.Service}}\t{{.Status}}' } # ----------------------------------------------------------------- misc @@ -324,9 +417,14 @@ usage() { cat <<'USAGE' bin/stack.sh - up the whole stack: registry+discovery -> setup.py -> adapters + up the whole stack, Network Adapter Layer (default): + registry+discovery -> setup.py -> network-adapter (Network Adapter Layer) -> gateway (public, 80/443) -> hyperdx (wants 16 GB) up-core steps 1-3 only. Nothing public, no ClickHouse. + up-multi as up, with the three adapters (provider, network, consumer) + up-core-multi as up-core, with the three adapters + (MODE=multi also works with up and up-core; restart + follows the running mode) down stop everything, keep the data destroy stop everything and DELETE every volume setup re-run bin/setup.py only @@ -344,6 +442,8 @@ USAGE case "${1:-}" in up) up ;; up-core) up_core ;; + up-multi) MODE=multi; up ;; + up-core-multi) MODE=multi; up_core ;; down) down ;; destroy) destroy ;; setup) setup ;; diff --git a/quick-start/config/adapters/network-adapter.yaml.tmpl b/quick-start/config/adapters/network-adapter.yaml.tmpl new file mode 100644 index 00000000..7cb75653 --- /dev/null +++ b/quick-start/config/adapters/network-adapter.yaml.tmpl @@ -0,0 +1,368 @@ +# Network Adapter: the consumer, network and provider tiers as +# four modules of ONE adapter process (docker-compose.yml service network-adapter, single mode). +# +# / the consumer (bap); the app posts here, unsigned +# /publish the catalogue publishes here +# /_internal/network/ verifies and forwards to discovery +# /_internal/provider/ answers from the upstream APIs (bpp) +# +# The public paths are the same as the multi adapters'. /_internal/ is only +# called by this process's own modules over loopback. +# +# Each module is its tier's module from consumer.yaml.tmpl, network.yaml.tmpl +# and provider.yaml.tmpl. A change to one of those templates that should also +# apply here must be made in this file too. The same layout as the Helm chart's +# charts/adapter-service/config/network-adapter-config.yaml. +# +# Differences from the three tier templates: +# - one listener (9201) and one appName; tier hops call this same listener +# (routing-network-adapter-*.yaml beside this file); +# - an outbound connection pool (httpClientConfig) on every module; +# - log level info. +# +# SIGNING IS OFF, as in the three quick-start tier templates: no module runs +# sign or validateSign, so /_internal/ and /publish accept unsigned requests +# from anyone who can reach :9201. Keep it on 127.0.0.1 (as the compose file +# does); server_proxy.conf denies /_internal and /publish at the edge. The +# Helm chart's network-adapter-config.yaml has signing on. +# +# bin/setup.py fills the placeholders -- each module's own tier identity +# (the CONSUMER_, NETWORK_ and PROVIDER_ ones) and the shared ones -- and +# writes config/adapters/network-adapter.yaml (mode 600, gitignored: it +# holds private keys). +appName: network-adapter + +log: + level: info + 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 + +plugins: + otelsetup: + id: otelsetup + config: + serviceName: network-adapter + environment: "__OTEL_ENVIRONMENT__" + otlpEndpoint: "__OTLP_ENDPOINT__" + enableMetrics: "__OTEL_ENABLED__" + enableTracing: "__OTEL_ENABLED__" + enableLogs: "__OTEL_ENABLED__" + +modules: + # ------------------------------------------------------------------ consumer + # From consumer.yaml.tmpl. Mounted at /, so the app calls /discover, /select, + # ... as on the multi-adapter consumer-adapter. Accepts unsigned requests + # under the consumer identity (signing is off here), routes discover to /_internal/network and + # transactions to /_internal/provider (routing-network-adapter-consumer.yaml). + - name: consumer + path: / + handler: + type: std + role: bap + subscriberId: "__CONSUMER_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://sunbird-registry-service:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + cacheTTL: 0 + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + keyManager: + id: simplekeymanager + config: + subscriberId: "__CONSUMER_SUBSCRIBER_ID__" + keyId: "__CONSUMER_KEY_ID__" + signingPrivateKey: "__CONSUMER_SIGNING_PRIVATE__" + signingPublicKey: "__CONSUMER_SIGNING_PUBLIC__" + encrPrivateKey: "__CONSUMER_ENCR_PRIVATE__" + encrPublicKey: "__CONSUMER_ENCR_PUBLIC__" + signer: + id: signer + signValidator: + id: signvalidator + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bap + 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,schema.beckn.io,schema.nfh.global,openagrinet.github.io + router: + id: router + config: + routingConfig: /app/config/routing-network-adapter-consumer.yaml + steps: + - validateSchema + - addRoute + + # ------------------------------------------------------------------- network + # From network.yaml.tmpl. Forwards discover and + # publish to discovery (routing-network.yaml, the multi-adapter file). + - name: network + path: /_internal/network/ + handler: + type: std + role: bpp + subscriberId: "__NETWORK_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://sunbird-registry-service:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + cacheTTL: 0 + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + keyManager: + id: simplekeymanager + config: + subscriberId: "__NETWORK_SUBSCRIBER_ID__" + 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 + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bpp + # The extended layer is off: it fetches a resource's own @context per + # payload, and nothing here needs it. The settings below only apply 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,schema.beckn.io,schema.nfh.global,openagrinet.github.io" + router: + id: router + config: + routingConfig: /app/config/routing-network.yaml + steps: + - addRoute + + # ------------------------------------------------------------------ provider + # From provider.yaml.tmpl, module 1. Answers from the + # upstream APIs itself: no router. + - name: provider + path: /_internal/provider/ + handler: + type: std + role: bpp + subscriberId: "__PROVIDER_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://sunbird-registry-service:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + cacheTTL: 0 + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + 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__" + signValidator: + id: signvalidator + signer: + id: signer + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bpp + 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" + extendedSchema_allowedDomains: raw.githubusercontent.com,schema.beckn.io,schema.nfh.global,openagrinet.github.io + extendedSchema_cacheTTL: '86400' + extendedSchema_maxCacheSize: '100' + extendedSchema_downloadTimeout: '30' + mapper: + id: jsonmapper + config: + fetchTimeout: 10s + cacheTTL: 1m + negativeTTL: 1m + maxMappingBytes: 262144 + maxCacheEntries: 200 + providerSteps: + - id: WeatherObservation + config: + bindingKeys: "__MAUSAMGRAM_BINDING_KEY__" + __MAUSAMGRAM_PARTICIPANT_ID__: + authScheme: header + headerName: Authorization + headerValueEnv: MAUSAMGRAM_AUTH + - id: MandiPrice + config: + bindingKeys: "__AGMARKNET_BINDING_KEY__" + __AGMARKNET_PARTICIPANT_ID__: + authScheme: tokenQuery + tokenUrl: "__AGMARKNET_TOKEN_URL__" + tokenUserField: access_name + tokenUserEnv: AGMARKNET_ACCESS_NAME + tokenSecretField: password + tokenSecretEnv: AGMARKNET_PASSWORD + tokenResponseField: token + queryName: token + tokenTtl: 10m + - id: KnowledgeAdvisory + config: + bindingKeys: "__VISTAAR_BINDING_KEY__" + __VISTAAR_PARTICIPANT_ID__: + authScheme: oauth2 + tokenUrl: "__VISTAAR_TOKEN_URL__" + clientIdEnv: VISTAAR_CLIENT_ID + clientSecretEnv: VISTAAR_CLIENT_SECRET + maxResponseBytes: 1048576 + - id: AgricultureFacility + config: + bindingKeys: "__POCRA_BINDING_KEY__" + __POCRA_PARTICIPANT_ID__: + authScheme: none + searchConcurrency: 1 + steps: + - validateSchema + - WeatherObservation + - MandiPrice + - KnowledgeAdvisory + - AgricultureFacility + + # ---------------------------------------------------------- provider-publish + # From provider.yaml.tmpl, module 2. The catalogue publishes here, under the + # provider identity (signing is off here); routed to /_internal/network/publish + # (routing-network-adapter-provider.yaml). Top-level /publish, the same path + # as the multi provider adapter's. + - name: provider-publish + path: /publish + handler: + type: std + role: bap + subscriberId: "__PROVIDER_SUBSCRIBER_ID__" + httpClientConfig: + maxIdleConns: 512 + maxIdleConnsPerHost: 128 + idleConnTimeout: 90s + responseHeaderTimeout: 30s + plugins: + registry: + id: sunbirdRegistry + config: + url: http://sunbird-registry-service:8081/api/v1 + entity: Participant + providerEntity: ProviderSchema + cacheTTL: 0 + timeout: 2 + retry_max: 1 + retry_wait_min: 100ms + retry_wait_max: 500ms + maxResponseBytes: 1048576 + 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 + middleware: + - id: reqpreprocessor + config: + contextKeys: transaction_id,message_id + role: bap + 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" + extendedSchema_allowedDomains: raw.githubusercontent.com,schema.beckn.io,schema.nfh.global,openagrinet.github.io + extendedSchema_cacheTTL: '86400' + extendedSchema_maxCacheSize: '100' + extendedSchema_downloadTimeout: '30' + router: + id: router + config: + routingConfig: /app/config/routing-network-adapter-provider.yaml + steps: + - validateSchema + - addRoute diff --git a/quick-start/config/adapters/routing-network-adapter-consumer.yaml b/quick-start/config/adapters/routing-network-adapter-consumer.yaml new file mode 100644 index 00000000..9f181810 --- /dev/null +++ b/quick-start/config/adapters/routing-network-adapter-consumer.yaml @@ -0,0 +1,34 @@ +# What the consumer layer does with each action, in the Network Adapter Layer +# (consumer, network and provider as modules of one adapter). +# +# Both targets are THIS SAME adapter on another path. In the multi-adapter +# stack they are two other containers; here the hop is a call from one module +# to another inside the same container. It stays a real HTTP hop rather than an in-process call +# because that keeps the two shapes the same config: split these across +# containers and only the host in these URLs changes. +# +# 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. localhost:9201 is +# the container's own listener and must match `http.port` in network-adapter.yaml.tmpl. +routingRules: + # Discovery is answered from this network's own discovery service, which the + # network module fronts. + - version: "2.0.0" + targetType: "url" + target: + url: "http://localhost:9201/_internal/network" + endpoints: + - discover + + # Everything transactional goes to the provider layer, and this layer decides + # nothing about where it ends up. context.bppUri travels onward in the body + # untouched. + - version: "2.0.0" + targetType: "url" + target: + url: "http://localhost:9201/_internal/provider" + endpoints: + - select + - init + - confirm + - status diff --git a/quick-start/config/adapters/routing-network-adapter-provider.yaml b/quick-start/config/adapters/routing-network-adapter-provider.yaml new file mode 100644 index 00000000..00c210a0 --- /dev/null +++ b/quick-start/config/adapters/routing-network-adapter-provider.yaml @@ -0,0 +1,18 @@ +# This network's catalogue system publishing inward, in the Network Adapter +# Layer. +# +# It goes to the NETWORK module rather than straight to discovery, so a publish +# takes the same path a publish from anywhere else takes -- one place that +# decides what reaches discovery, rather than two. +# +# The publish module is mounted at the exact path /publish, so the +# endpoint left after stripping it is "" -- which is what this rule keys on, +# with excludeAction so nothing is appended to the target. +routingRules: + - version: "2.0.0" + targetType: "url" + target: + url: "http://localhost:9201/_internal/network/publish" + excludeAction: true + endpoints: + - "" diff --git a/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf b/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf index 35f956d6..6c740255 100644 --- a/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf +++ b/quick-start/config/reverse-proxy/npm-custom/server_proxy.conf @@ -2,29 +2,38 @@ # /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 +# belong here. There are two, and they matter 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 +# verifies the sender's signature against the registry. But the publish module +# is mounted at `/publish` with NO validateSign at all, because its intended caller is # the provider's own catalogue system, inside the trust boundary -- the same # reason the consumer 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. +# thing to leave to remembering an Advanced-tab paste on one host. # # An exact-match location beats NPM's generated `location /`, so this wins -# without conflicting with it. On the consumer and network hosts it denies a path -# their adapters do not serve, which costs nothing. +# without conflicting with it. In multi mode, on the consumer and network hosts +# it denies a path their adapters do not serve, which costs nothing. In single +# mode the one adapter (network-adapter:9201) serves /publish itself +# (provider-publish module, no validateSign), so the deny matters on its host. # # 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; -# } +location = /publish { + deny all; +} + +# The Network Adapter Layer (single mode) mounts its network and provider tiers +# under /_internal/. Only the process itself calls them, over loopback, so the +# whole prefix is denied. ^~ stops regex locations from overriding it. +location ^~ /_internal { + deny all; +} diff --git a/quick-start/docker-compose.yml b/quick-start/docker-compose.yml index 9568abc3..9f41c9fa 100644 --- a/quick-start/docker-compose.yml +++ b/quick-start/docker-compose.yml @@ -11,7 +11,8 @@ # registry sunbird-registry-postgres, sunbird-registry-keycloak, # sunbird-registry-service # discovery discovery-postgres, discovery-service -# adapters provider, network, consumer +# adapters network-adapter (all three tiers by default) + +# provider, consumer (profile multi) # observability hyperdx profile: observability # edge nginx-proxy-manager profile: reverse-proxy # @@ -24,9 +25,11 @@ # shipped defaults # 2. docker compose up -d sunbird-registry-service discovery-service # 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 +# 3. python3 bin/setup.py keys, the three tier identities, and +# every adapter config they mount +# 4. docker compose up -d network-adapter the Network Adapter +# (multi: NETWORK_ADAPTER_CONFIG=network docker compose --profile multi +# up -d provider-adapter network-adapter consumer-adapter) # 5. docker compose --profile reverse-proxy --profile observability up -d # the edge and the telemetry stack # @@ -533,7 +536,22 @@ services: condition: service_healthy # ========================================================================== - # adapters -- consumer, network and provider. Same image, three configs. + # adapters -- TWO deployment modes, chosen at startup by + # NETWORK_ADAPTER_DEPLOYMENT_MODE in .env (or MODE): bin/stack.sh turns it + # into NETWORK_ADAPTER_CONFIG, the config network-adapter mounts. Same image. + # + # single (the default; `make up`) + # network-adapter mounts config/adapters/network-adapter.yaml: the + # consumer, network and provider tiers as four modules of ONE process on + # 127.0.0.1:9201. Tier-to-tier hops stay inside the container. + # + # multi (`make up-multi`) + # network-adapter mounts config/adapters/network.yaml (the network tier + # alone), and provider-adapter and consumer-adapter (profile multi) run + # beside it. Only the network tier's config is mounted, so the network + # container never holds the consumer or provider keys. + # + # Both use the same keys and registry rows. # # 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 @@ -592,6 +610,7 @@ services: # change here or a restart. provider-adapter: <<: *adapter + profiles: ["multi"] container_name: provider-adapter # Registry only. The mocks are deliberately NOT dependencies: every # capability points at a real external upstream, and depends_on would start @@ -635,6 +654,19 @@ services: - "127.0.0.1:${PROVIDER_ADAPTER_PORT}:9200" # Verifies the caller, hands discovery on, re-signs as itself. + # + # In single mode (the default) it is the Network Adapter Layer instead: the + # consumer, network and provider tiers as modules of one process. + # + # / the consumer, the caller's entry (bap), takes unsigned requests + # /publish sends the provider's catalogue to /_internal/network/publish + # /_internal/network/* hands discover and publish on to discovery + # /_internal/provider/* calls the upstream, answers synchronously + # + # Signing is off, as in every quick-start template, so /_internal/ and + # /publish accept unsigned requests: keep :9201 on 127.0.0.1. Every routing + # file and the upstream credentials are mounted in both modes; the multi + # config does not read them. network-adapter: <<: *adapter container_name: network-adapter @@ -646,9 +678,19 @@ services: environment: <<: *adapter-env OTEL_SERVICE_NAME: network-adapter + # Upstream credentials for the single-mode provider module, read at call + # time -- the same variables and reasons as provider-adapter. + MAUSAMGRAM_AUTH: ${MAUSAMGRAM_AUTH} + AGMARKNET_ACCESS_NAME: ${AGMARKNET_ACCESS_NAME} + AGMARKNET_PASSWORD: ${AGMARKNET_PASSWORD} + MOCKAGMARKNET_TOKEN: ${MOCKAGMARKNET_TOKEN:-local-mandi-token} + VISTAAR_CLIENT_ID: ${VISTAAR_CLIENT_ID} + VISTAAR_CLIENT_SECRET: ${VISTAAR_CLIENT_SECRET} volumes: - - ./config/adapters/network.yaml:/app/config/adapter.yaml:ro + - ./config/adapters/${NETWORK_ADAPTER_CONFIG:-network-adapter}.yaml:/app/config/adapter.yaml:ro - ./config/adapters/routing-network.yaml:/app/config/routing-network.yaml:ro + - ./config/adapters/routing-network-adapter-consumer.yaml:/app/config/routing-network-adapter-consumer.yaml:ro + - ./config/adapters/routing-network-adapter-provider.yaml:/app/config/routing-network-adapter-provider.yaml:ro ports: - "127.0.0.1:${NETWORK_ADAPTER_PORT}:9201" @@ -658,6 +700,7 @@ services: # is also why the edge rate-limits this host and nothing else. consumer-adapter: <<: *adapter + profiles: ["multi"] container_name: consumer-adapter depends_on: network-adapter: @@ -938,12 +981,18 @@ services: # whenever NPM does. That means the restart above is not optional after # `make observability` -- NPM was started before the name existed, so the # HyperDX host 502s until it re-resolves. + # + # required: false -- consumer- and provider-adapter exist only in multi + # mode (profile "multi"); NPM starts with whichever is running. consumer-adapter: condition: service_started + required: false network-adapter: condition: service_started + required: false provider-adapter: condition: service_started + required: false healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:81/api/ || exit 1"]