Skip to content

feat: add the quick-start docker compose deployment - #5

Merged
manjudr merged 84 commits into
developmentfrom
feat/4-docker-compose
Sep 8, 2026
Merged

feat: add the quick-start docker compose deployment#5
manjudr merged 84 commits into
developmentfrom
feat/4-docker-compose

Conversation

@ameersohel45

@ameersohel45 ameersohel45 commented Sep 7, 2026

Copy link
Copy Markdown

What

The stack — quick-start/docker-compose.yml

Read in tiers, which the banner comments mark: registry (Postgres, Keycloak, the registry itself), discovery (pgvector plus the service), the three adapters, then two profiles — observability for ClickStack and reverse-proxy for Nginx Proxy Manager.

Two deliberate constraints. The registry is not on the edge network, so nothing outside the stack can reach it; that is also why bin/setup.py has to create all five participants and both capability bindings, since there is no second way in. And every published port except the edge's 80 and 443 is bound to 127.0.0.1, written literally rather than taken from a variable, because one variable that moves every port to the public interface at once is a footgun.

Startup order — quick-start/bin/stack.sh

Every make target is one line of delegation to this script, which is where the reasoning lives. The order is not interchangeable: an adapter's config is a bind-mounted file that setup.py renders, and Docker creates a directory at any bind-mount source that does not exist — so starting an adapter before step 2 both wedges that container and leaves a directory where step 2 needs to write a file.

Seeding — quick-start/bin/setup.py

Generates the three adapter keypairs, registers five participants, writes both capability bindings, and renders the adapter configs from the .tmpl files beside them. Idempotent: keys come from keys/keys.json once it exists, and participants already registered are left alone. The same .env feeds both the registry rows and the rendered configs, which is what keeps the two from disagreeing — a mismatch is the most common failure and the README explains which 404 means which side is wrong.

Schema validation

The provider adapter validates the Beckn envelope against the pinned v2 LTS spec, and resourceAttributes against the published capability packs. Schemas are fetched from each resource's own @context and cached, so no copy lives here to drift and the revision comes from the payload. Recorded in the README because a green result is otherwise misleading: the validator library parses if/then/else and never evaluates it, so pack rules predicated on informationMode are unenforced.

The collection — postman-collection/

One folder per capability, publish then discover then select, 32 assertions. Deliberately carries no registry requests: the registry has no route through the edge, so nothing in a shared collection could reach it. An environment file ships beside it with loopback defaults, so importing both and changing the base URL is the whole setup.

Why

The stack needed to be deployable by someone who had not built it, on a fresh VM, without reconstructing the startup order or the seeding from source. Everything here is pulled rather than built, so docker compose up -d is the deployment step and the images are the only moving part.

Testing

Brought up from empty on a VM and exercised end to end with the collection — publish, discover and select for both capabilities. The two mocks reproduce the awkward parts of the real APIs on purpose, including Title Case keys with spaces, string prices, dd-MM-yyyy dates and a query token the mandi mock rejects when absent, which is what proves the adapter sent one.

Also verified the things that fail silently: the /publish deny returns 403 through the edge, which is the only evidence the NPM custom config is still mounted; and losing that mount would quietly open an unauthenticated catalogue write.

Notes for review

  • The adapter configs and ADAPTER_IMAGE roll together. The configs name plugins by id, and an id is the basename of a .so inside the image, so an image built before the capability plugins were renamed makes every adapter exit with unrecognized step: WeatherObservation. The matching adapter change is OpenAgriNet/network-adapter PR #17.
  • ADAPTER_IMAGE follows latest, which is less dynamic than it reads. pull_policy: missing is set on every image and nothing in stack.sh pulls, so a tag already on disk is never re-fetched: make up will not pick up a newer latest, and a stack can sit on a stale image indefinitely. The upgrade section gives the explicit docker compose pull. It also means "the old image" has no name once the tag has moved, so a rollback is by digest — worth pinning before a change you might need to undo.
  • The image tags point at personal namespaces, as they did before this branch. An org package published by CI is the real fix — OpenAgriNet/network-adapter issue feat: add the quick-start docker compose deployment #5.
  • NPM keeps its routing table, its account and its certificates in the npm-data and npm-letsencrypt volumes, and those are the only copy. make destroy deletes them along with everything else. Worth knowing before reaching for it to clear catalogues — dropping discovery-data alone does that.
  • Keycloak shares the registry's database (DB_ADDR=registry-db), so the two cannot be wiped independently. Giving it its own database is a reasonable follow-up and is not done here.
  • config/registry/schemas/ is read only at registry startup, so a change there needs the registry service restarted. The README says so in the place you would otherwise get it wrong.

ameersohel45 and others added 30 commits September 2, 2026 12:36
…/network-adapter#4]

One compose file brings up the registry, the discovery service and the three
adapters, with every config under config/. Intended for trying the network
end to end on one machine, not for production: the adapter keys live in a
config file and nothing runs TLS.

Two things are deliberately absent, because they are the user's own:

  the provider API      run it locally and expose it with ngrok. No config
                        here names it -- the provider adapter reads its base
                        URL from the registry at request time, so moving to
                        a new tunnel is a registry edit and nothing more.
  its registry rows     the Participant and ProviderSchema are created by
                        hand, since the base URL is that tunnel.

bin/setup.py does only what cannot be done by hand: it generates the three
adapter keypairs, registers the three adapter identities, and renders the
adapter configs with the key osid the registry assigned. Those three rows
have to exist before any adapter can sign or verify anything, and the osid
is only knowable after the write.

The images build from remote git contexts, so no sibling checkout is needed.
ADAPTER_SRC points at a feature branch because the three OAN plugins are not
on the adapter's default branch yet.

config/mappings/ carries the published mapping to read and to fork, and is
not served from here. The registry holds a full URL and the adapter fetches
it verbatim, so a mapping has to be published before it can be tested --
serving the local copy would prove the file works and prove nothing about the
file anyone else reads.

No Makefile: the four commands are in the README.
…ork-adapter#4]

The folder read as a laptop sandbox. It is a shared dev environment on a VM,
and the difference matters in three places.

Ports now bind to 127.0.0.1 rather than every interface, overridable with
BIND_ADDR. Behind them sit a Keycloak whose admin password ships as "admin"
and a registry whose write token any reader of .env.example can mint, so the
old default published the whole network's identity records, writable, to
whatever the VM was reachable from. The README documents the ssh -L tunnel
and says what has to be true before BIND_ADDR is widened.

.env.example no longer claims nothing in it is secret. Every credential in it
is a shipped default, which is the same as public, and it says so.

The three adapter identities are now *.oan.dev rather than *.oan.local, and
the discovery network id oan-dev rather than local-network. The registry is
append-only and an id can never be reused, so a misleading name is permanent.

Also drops the four identity fields from the response context in the shipped
mapping, matching the adapter's own copy: bapId, bapUri, bppId and bppUri. A
mapping transforms a payload and has no business asserting network identity,
and the two Uri fields it copied were whatever the caller sent -- which in a
deployed stack is a container-internal address that means nothing outside it.
Identity on an answer is the signature the adapter puts on it.
…iNet/network-adapter#4]

Registry schema, four changes:

  role      consumer, provider and network replace BAP, BPP and NETWORK. A
            role now says what a party does rather than which Beckn acronym
            it is. Nothing in the adapter compared the old values, so this
            costs a re-seed and no code.
  keyId     gone. Nothing could look one up: the registry assigns an osid on
            write, and that osid is what a sender names in the Authorization
            header, so the friendly id was decoration that read like an
            identifier.
  use       gone. alg already carried the purpose -- ed25519 signs, x25519
            encrypts -- and the adapter treats a missing use as "may sign".
            The two conditionals that derived alg from use went with it: with
            use absent each `if` matched vacuously, so both `then` branches
            applied and alg had to be two curves at once, which refused every
            key. Found by seeding against the new schema.
  key       bare base64, no "base64:" label. What a verifier hands to a
            decoder is now the value as published.

Auth is removed entirely, along with the paramName, secret and materialRef
definitions it used and the privateFields entry that redacted it. The adapter
never read it -- it presents credentials from its own config, naming
environment variables -- so it described a mechanism that did not exist. Its
plaintext-http guard goes too, having guarded a field nothing consumed; if
that rule is wanted it belongs where the credential actually lives.

Compose no longer builds anything. ADAPTER_IMAGE and DISCOVERY_IMAGE name
tags to pull and have no working default, so an unset value fails on pull
naming the tag it tried rather than quietly running something else.

Verified against a registry running this schema: the three adapter rows seed
with the new roles and bare keys, and role "BAP", a prefixed key, a key
carrying keyId or use, and an upstream carrying auth are each refused.
…er [OpenAgriNet/network-adapter#4]

publish now enters at the provider adapter and reaches the discovery service
by way of the network layer, which is what fronts discovery for discover
already. select is untouched and still answered at the provider adapter
without going near it.

It needed more than one routing file, and the reason is worth recording.

The routing step fails any request whose action is absent from its config, so
it cannot be added to the provider adapter's existing /beckn/ module: that
module would then have to list select as well, and listing select would proxy
it to the network layer instead of answering it locally, which is the only
thing that module does. So publishing gets its own module and its own routing
config, mounted at /catalog/ and reached as POST /catalog/publish.

The catalogue body carries no bapId or bppId, and needs none. The provider
adapter signs the forwarded request as itself and the identity travels in the
Authorization header's keyId, taken from its keyManager config; the network
layer verifies that signature, and its identity check skips a body that
declares no caller rather than demanding one.

Verified against the local stack: POST /catalog/publish answers ACCEPTED with
the catalogue's stats, and select and discover both still work -- select
answered locally with its per-day resources, discover returning catalogs.
…nAgriNet/network-adapter#4]

The mount is /oan/ rather than /beckn/, on all three adapters, and the
/catalog/ module added for publishing is gone. One subtree per adapter, and
the payload's action says which action it is.

Dropping that module moved where publishing enters. It cannot enter at the
provider adapter any more: the routing step fails an action missing from its
config, so routing publish from the module that answers select would mean
listing select too, and listing select would proxy it away instead of
answering it there. A second mount was what bought both, and a second mount
is what has been removed.

So publish is a routing entry on the experience adapter instead, which
already exists to take a call and forward it. Two entry points now:

  POST :9202/oan/publish   through the experience adapter, which takes
                           unsigned calls from inside the trust boundary and
                           signs on the way out -- so a plain curl works
  POST :9201/oan/publish   straight at the network adapter, for a publisher
                           that signs for itself. Unsigned it answers 401
                           AUT_SIGNATURE_MISSING.

Either way the network adapter verifies the signature and forwards to the
discovery service, and the catalogue body still needs no bapId or bppId --
identity travels in the Authorization header's keyId.

Config only; no adapter code was touched.

Verified on the local stack, all on /oan: select answers on_select with its
per-day resources, discover returns catalogs, publish answers ACCEPTED, and
an unsigned publish direct to the network adapter is refused 401.
…AgriNet/network-adapter#4]

The provider's own catalogue system posts POST /publish to the provider
adapter. That adapter signs the request as itself and forwards it to the
network layer, which verifies the signature and hands it to the discovery
service. The caller signs nothing.

It is a second module on that adapter, and it has to be: the routing step
fails any action missing from its config, so routing publish from the module
that answers select would mean listing select too -- and listing select would
proxy it to the network layer instead of answering it there.

The new module sits at the root rather than under /oan/, and that split is
the point. /oan/ is the adapter's Beckn surface -- the path the registry
publishes as its baseUrl -- so a peer calling <baseUrl>/select has to reach
the module that answers select. Publishing is not part of that surface; it
arrives from inside the provider's own deployment. Go's mux takes the longest
matching pattern, so /oan/... still reaches the capability module and only
/publish falls through to the root one. No prefix is invented for either.

publish is no longer a routing entry on the experience adapter: it enters at
the provider now, so exp is back to discover and the transactional actions.

Config only; no adapter code was touched.

Verified on the local stack: POST /publish answers ACCEPTED with the
catalogue's stats, select still answers on_select with its per-day resources,
discover still returns catalogs, and an unsigned POST /oan/select at the
provider adapter is refused 401 -- which is what proves the two mounts are
resolving to the right modules rather than the root one swallowing both.
OpenAgriNet/network-adapter#4]

bapId, bppId, bapUri and bppUri are gone from every payload -- the example
select and discover bodies, the catalogue body, and the prose that described
them as what goes on the wire. Identity travels in the Authorization header's
keyId, which names the signer and the key the registry published for it, and
a body that declares no caller skips the declared-identity comparison rather
than failing it. The two *Uri fields were container-internal addresses that
meant nothing outside the compose network in any case.

Catalogue-level bppId and bppUri go too. The discovery service never names
them -- they ride through in the stored document -- so publishing without
them is accepted and discover still returns the catalogue.

ADAPTER_IMAGE is ghcr.io/nisargabd/oan-adapter:latest. Verified pullable
anonymously, and verified to carry the three plugins these configs name:
oanregistry.so, jsonmapper.so and weather.so, alongside router, signer,
signvalidator and simplekeymanager. DISCOVERY_IMAGE is still unset -- no tag
has been shared for it yet.

Verified on the local stack with no party named anywhere: publish answers
ACCEPTED, select answers on_select with its per-day resources and a context
carrying only correlation ids, and discover returns the catalogue.
…/network-adapter#4]

Every adapter config declares schemav2validator, pointed at the LTS spec
tag, with the extended layer off: it fetches each resource's own @context and
validates against that, which is a network call per payload and a second
failure mode, and nothing here needs it. The extendedSchema_* settings are
present but inert until it is switched on.

The validateSchema step is wired only where every action the module handles
is defined in that spec:

  exp /oan/            discover, select, init, confirm, status -- validated
  provider /oan/       select -- validated
  network /oan/        carries publish -- declared, not wired
  provider /           carries publish -- declared, not wired

publish is absent from the spec, and the validator refuses an action it
cannot find rather than passing it through, so wiring the step on either of
those modules would reject every catalogue with "unsupported action:
publish". Validating it needs an auxiliary spec that defines the action --
auxiliaryTypes and auxiliaryLocations, which are additive and must not
overlap the primary spec.

Turning validation on surfaced a defect in the spec itself, and the example
payloads now carry a quantity because of it: Commitment.resources requires
["id", "quantity"] while Resource defines no quantity property and the spec
has no Quantity schema anywhere. additionalProperties is unset, so any value
satisfies it. Without one every select is refused
SCH_REQUIRED_FIELD_MISSING. That is upstream, not a choice made here, and the
README says so where someone writing a payload will read it.

Verified on the local stack: select, discover and publish all answer 200 with
validation on; a select missing quantity is refused 400
SCH_REQUIRED_FIELD_MISSING naming the JSON path, an unknown action is refused
400 "unsupported action", and extendedSchema_enabled reads "false" inside the
running container.
…AgriNet/network-adapter#4]

DISCOVERY_IMAGE is ghcr.io/nisargabd/discovery-service:${TAG:-latest}, with
TAG pinning a known build and unset meaning latest. Compose interpolates a
.env value, so both resolve: verified :latest with TAG unset and :v0.3.1 with
TAG set.

With both images published the stack was runnable for the first time, and
running it end to end found two things.

THE DOCUMENTED BRING-UP WAS BROKEN. Step one was a bare `docker compose up
-d`, which starts the adapters too -- and an adapter config is a bind-mounted
FILE that step two has not written yet, so Docker created a DIRECTORY at each
of those paths. The adapters then died on "adapter.yaml: is a directory", the
later `up` did not recreate them so they stayed dead, and the directories sat
where setup.py needed to write files. Step one now names registry and
discovery explicitly, the compose header and README say why, and setup.py
refuses with an explanation and the commands to recover if it finds a
directory where a config belongs.

THE PARTICIPANT SCHEMA HAD BEEN REVERTED. Porting a description reword in
20ccc9d copied oan-local's copy over this one, which still carries the old
contract, undoing 37f02c6: the role enum was back to BAP/BPP/NETWORK, keyId
and use were required again, keys wanted the base64: label and auth was back.
Re-applied, with the reworded descriptions kept. oan-local is deliberately
still on the old contract, so it is not a source to copy this file from.

Verified from an empty volume, following the README exactly, on the published
images: the three adapter rows seed, all eight services come up, the two
provider rows register, and then select answers on_select with three per-day
resources and no dangling offer references, publish answers ACCEPTED, and
discover returns the catalogue.
…Net/network-adapter#4]

The README, .env.example and setup.py all said a mismatch comes back as a
bare {"status":"ACK"} with no on_select, looking as though nothing happened.
That is wrong, and it sent a reader looking for a silent failure that does not
occur. Both cases were run against the stack to see what they really answer:

  the payload names a provider the adapter is not configured for
    404 NET_ENTITY_NOT_FOUND, "this module serves no capability matching the
    request". The step passes through and nothing behind it answers, so the
    refusal is explicit and names the cause.

  the adapter is configured for the key but the registry has no matching
  ProviderSchema row
    502 with an EMPTY body. This is the one worth documenting: nothing in the
    response says why, and "no call plan for <key>" appears only in the
    provider adapter's log.

Both are now described with the command that surfaces the second, and the
consequence of a single configured key is stated where it will be read: while
the adapter carries one, onboarding a second provider is an edit to .env, a
re-run of setup.py and a restart -- a registry entry alone is not enough.

Whether the adapter should instead match on capability, and serve any provider
the registry lists for it, is a separate question and deliberately still open.
…iNet/network-adapter#4]

The troubleshooting entry it names was renamed in the previous commit, so the pointer described a symptom that no longer appears anywhere.
Establishes the repo-level scaffolding the charts depend on: naming and
structure conventions, a lint script that rebuilds file:// dependencies
before linting, and a CI workflow that runs it. .gitignore excludes Helm
dependency artifacts, which are regenerated rather than committed.
oan-common holds the shared helpers every OAN chart renders through -
names, labels, image references, resources, and security contexts - so
the service charts stay declarative. Resources are deliberately required
rather than defaulted: an unset value fails the render instead of
shipping an unbounded pod.

oan-template is the copy-from starting point for a new service chart.
postgresql-cnpg renders one CloudNativePG Cluster per release, with
optional ScheduledBackup and Barman Cloud ObjectStore. Neither chart
renders a password: every credential is a reference to a Secret, and the
render fails when one is unset rather than defaulting.

Applications connect as the owner of their own database, never as
postgres. Extensions that require superuser are created once through
bootstrap.postInitApplicationSQL, which the operator runs during
bootstrap, so no long-lived role holds the privilege.

postgresql-migration runs Flyway as a Job across the databases the
cluster chart created. Both target directories carry no SQL yet -
Sunbird RC and Keycloak each manage their own schema - so the Job
currently skips them and the targets exist as a versioned home.
Imports the sunbird-rc realm from a ConfigMap on first start, with the
realm content checksummed into the pod annotations so a realm change
rolls the pod. An init container waits for the database, standing in for
compose's depends_on: condition: service_healthy.

Keycloak connects as the owner of its own keycloak database rather than
sharing the registry's database as postgres, which is how the compose
stack runs it.

The realm JSON is Sunbird RC's export and still carries its upstream
defaults: a placeholder admin-api client secret, an enabled placeholder
user with a known password, and a wildcard redirect URI on the public
frontend client. All three need hardening before this reaches any
environment that is not local. See the chart README for the client
secret step; the other two are tracked as follow-up.
registry runs Sunbird RC with its Participant schema mounted from a
ConfigMap, and connects as the owner of the registry database rather
than as postgres.

discovery renders the Beckn discovery service, taking its whole DSN from
the CNPG-generated Secret so no password is assembled or escaped in the
chart. It enables readOnlyRootFilesystem, which the other service charts
cannot yet. The Beckn spec is fetched by URL with a cache fallback, and
the render fails when neither a URL nor an existing ConfigMap is set,
since the service refuses to boot without the document.

Both charts fail the render on an unset credential rather than
defaulting one.
Records the chart inventory and the order releases have to go out in,
since the database cluster has to exist before the migration Job and the
services that connect to it.
…n upstream [OpenAgriNet/network-adapter#4]

MAPPING_URL pointed at a personal fork. It now points at this repo's own copy
of the file, the one already sitting in config/mappings/, served over the raw
CDN -- so the file a reviewer reads and the file the adapter fetches are one
file and cannot drift. Verified fetchable anonymously, and it is in fact the
current mapping: the fork still serves the older one that echoed bapId and
bppId back in the response context.

The URL carries a branch, which is noted in both .env.example and the README:
repoint it at the default branch on merge, or pin a tag, so a deployment is
not following a moving file.

The Participant schema no longer forbids role and keys on a type "upstream".
Nothing reads them there -- a signature is verified against the node identity
that signed it, never against an upstream -- but forbidding them refused a
record a deployment might legitimately want to keep, and the rule read as
though an upstream were prevented from ever signing.

The conditional is deleted rather than emptied: an if/then with an empty then
reads like a rule and enforces nothing. The node conditional is untouched and
still requires role and keys, which is the half that matters.

Verified against a stack on the published images: an upstream with role and
keys is now accepted, one without still is, and a node without them is still
refused. The full Postman flow passes 20 assertions with the adapter fetching
the mapping from this repo -- confirmed in its log alongside the signature it
verified, exp.oan.dev's, resolved from the registry.
…enAgriNet/network-adapter#4]

The mapping this deployment serves produced an answer the pinned LTS spec
refuses, which matters more here than anywhere: MAPPING_URL points at this
copy, so this is the file the adapter fetches.

  status.descriptor.code was QUOTED; the spec's enum is DRAFT, ACTIVE and
  CLOSED. DRAFT is also the honest value -- a quote is a draft commitment,
  since nothing is committed until init and confirm.

  each resource lacked quantity, which Commitment.resources requires while
  the spec defines no quantity property and no Quantity schema. The defect is
  upstream; the consequence was ours, since an answer without it fails
  validation for any consumer who validates.

Carried over verbatim from the adapter's reference copy, where the same fix
is committed with its test.
…ter#4]

Anyone cloning this branch got the stack but not a way to exercise it beyond
copying curl out of the README. The collection under postman-collection/ runs
the whole thing: a write token, the provider's two registry rows, both
registry searches, publish, discover and select.

Every value is prefilled in the collection's own variables rather than in a
separate environment file, so the file is useful to whoever receives it with
no second import. That includes providerId and capability, set to the values
.env.example uses -- which matters, because those two form the binding key the
provider adapter answers to, and a row naming anything else is refused 404
with the response naming the module rather than the cause. Typing the id by
hand is how that happens. One variable is left to set: upstreamBaseUrl, which
the deployment cannot know.

The requests carry assertions, so a run reports whether the stack is healthy
rather than merely returning 200s: that the three adapter identities exist
with signing keys, that the provider is registered as an upstream with no role
or keys, that publish is ACCEPTED, that select answers a resource per forecast
day whose status is in the spec's enum and whose every resource carries a
quantity, and that no party is named in the answer.

Every payload in it validates against the pinned Beckn v2 LTS spec and, for
resourceAttributes, against openagrinet:WeatherObservation v0.1. It carries no
credential: the token variable ships empty and is filled by request 1.
Splits the stack across two networks. Nginx Proxy Manager sits only on
oan-edge and is the one container publishing on a routable interface;
everything else binds 127.0.0.1 and is reached over an SSH tunnel. NPM's
Forward Hostname is a free-text field, so the network split is what bounds
the blast radius of a wrong entry -- names on oan-internal do not resolve
from there.

Adds HyperDX behind an observability profile, and the version-controlled
nginx that NPM includes on its own: a rate-limit zone for the unsigned
experience layer, and a global deny on /publish, which the provider adapter
mounts with no signature validation.

The npm-custom mount is deliberately not :ro. NPM's s6 init rewrites and
chowns everything under /data/nginx, so a read-only mount makes the prepare
service exit 1 and nginx never starts -- while the container still reports
Up, because s6 itself is alive.

Also ignores .env.* so a `cp .env .env.bak` before an edit cannot be
committed; the existing pattern was an exact match on .env only.
`make up` runs the five steps in the order they have to run in. The ordering
is load-bearing rather than tidy: an adapter's config is a bind-mounted FILE
that setup.py renders, and Docker creates a DIRECTORY at any bind-mount
source that does not exist yet -- so starting an adapter first both wedges
that container and leaves a directory where setup.py needs to write a file.

Preflight checks .env, compose v2, python3 and the cryptography import
before anything starts, because keycloak's healthcheck allows five minutes
and a missing import would otherwise surface after that wait.

down keeps volumes; destroy deletes them and requires the word typed, since
npm-data is the only copy of every proxy host and certificate. up-core skips
the gateway and hyperdx for a box that has neither a public port nor 16 GB.
Installs docker from docker's own apt repository rather than the docker.io
package or the snap -- the snap runs confined, and bind-mounting a file out
of a home directory, which every adapter config does, fails under it in ways
that read as file-not-found.

Takes cryptography from apt rather than pip: Ubuntu 24.04 marks the system
python externally-managed, so pip refuses without --break-system-packages,
and setup.py needs that import.

Checks RAM and disk up front. An undersized box fails by having the OOM
killer take out a Postgres mid-write, which surfaces as corruption rather
than as an out-of-memory error.
Two mechanisms, split by whether the name is reachable from the internet.
Let's Encrypt cannot certify a loopback name -- 127.0.0.1.sslip.io resolves
correctly, to 127.0.0.1, which is exactly why their validators cannot reach
it -- so local work needs mkcert and a private CA.

Includes the failure table, because every symptom in this area points away
from the cause: a CA present in the keychain but carrying no trust setting
verifies fine under `curl --cacert` and fails in every browser, and a host
with no certificate attached fails as an SNI alert rather than as anything
mentioning certificates.
…etwork-adapter#4]

If the provider answered without its location echo, JSONata dropped the
undefined values inside the array and this emitted "coordinates": [] -- an
invalid GeoJSON Point, which the adapter then signed and delivered. Nothing
downstream could detect it: the payload was well formed and spec-shaped,
just describing a point that does not exist.

Absent is honest; empty is a lie in the shape of an answer.

This deployment serves its own copy of the mapping, deliberately, so the file
a reviewer reads is the file the adapter fetches -- which also means it does
not pick the fix up from anywhere else. Brought level with the adapter's
reference copy, where it was fixed in review of network-adapter PR #2.

Mapping only. The other findings from that review are in Go and reach this
deployment when the adapter image is next published.
busybox resolves localhost to ::1 first and this image binds v4 only, so the
check failed with "Connection refused" against [::1]:8080 while the UI on
8085 served 200 throughout. A container reporting unhealthy while working
sends you to ClickHouse, which is the one place the problem was not.

Also widens the window. ClickHouse creates its system tables on a cold
volume, which takes minutes rather than the 80 seconds the previous
retries/start_period pair allowed, and Docker does not re-evaluate once a
container has failed past retries -- so a window that is merely tight leaves
a label that is permanently wrong.
…er#4]

A select had nothing to answer it out of the box. Whoever brought the stack
up had to run a provider API themselves and expose it through a tunnel, which
made the first end-to-end run depend on the one piece that was not here.

Both mocks are in the compose file now, on oan-internal and published on
loopback only for looking at directly while debugging. The provider adapter
reaches them by service name, so the registry rows point at
http://mockimd:9100 and http://mockagmarknet:9101 rather than at anything
that has to exist outside.

Sources are in mocks/, byte-identical to the ones the oan-local stack has
been tested against, and they are pulled as published images like everything
else here -- nothing in this stack builds. mocks/README.md carries the build
commands and, more usefully, what each one deliberately gets wrong:
mockagmarknet answers 401 without its token, sends prices as strings with
Title-Case spaced keys, and leaves the minimum and maximum off its last
record. Those are the cases the mappings exist to handle, so a mock that
answered cleanly would test nothing.

MANDI_TOKEN reaches the provider adapter as an environment variable and the
mock as a flag, from the same .env value. The adapter config names the
variable, never the value.
…network-adapter#4]

setup.py registered the three adapter identities and left the provider's two
rows to be created by hand, on the reasoning that the upstream's base URL
belonged to whoever ran it. That no longer holds twice over: the upstreams
are in this stack now, and the registry is not reachable from outside it, so
"by hand" had no way to happen.

It seeds all of it -- five participants and two capability bindings. Three
node identities with the keypairs it generates, two upstreams addressed by
compose service name, and a ProviderSchema row per capability carrying the
method, path, timeouts and mapping URL. Both new helpers search before they
write, so a re-run converges rather than failing, and keys/keys.json is
reused rather than regenerated.

The binding keys are the part worth being careful about. A provider step
answers only when the key it was configured with matches the one built from
the incoming payload, and the two are now rendered and seeded from the same
.env values in the same run -- which is what keeps them from disagreeing.
Changing a provider is an .env edit and a re-run.

Adds the mandi capability to the provider template alongside weather, lifted
from the config the oan-local stack has been tested against. Both ids are in
providerSteps and both are in steps:, because a step that is declared but
missing from steps: never runs and the request falls through to a 404 that
does not say why.

Also drops BIND_ADDR from .env.example. Nothing read it -- the loopback binds
are written literally in the compose file, deliberately, since one variable
that moves every port onto a public interface at once is a footgun.
…adapter#4]

The registry was on oan-edge so NPM could resolve it, with a documented
route, an allowlist to paste into the host's Advanced tab and an Access List
to attach. The decision is the other way now: it is on oan-internal only, so
a proxy host pointed at it fails to resolve rather than quietly working.

The argument against the route is that it cannot be reduced to reads.
SunbirdRC uses POST for both -- /Participant/search reads, /Participant
creates -- so no method rule separates them and a host forwards the whole
API. What kept writes out was never the route: it was that nothing outside
the VM can mint a Keycloak token, because Keycloak publishes on 127.0.0.1.
That is a decision made elsewhere in this file, and a registry route would
have depended on it silently -- publish Keycloak later for an unrelated
reason and the write surface opens with it, with nothing in the route
changing to say so.

So the allowlist goes with it. It was a real defence, but a defence for a
host that should not exist is worse than no host: it makes the route look
considered.

Reaching the registry is now an SSH tunnel, and everything that has to write
to it -- which is all of setup.py -- runs on the VM.
…OpenAgriNet/network-adapter#4]

The README described a flow that no longer exists: compose up, then create
the provider's rows by hand, with an ngrok URL for an API you brought
yourself. It is `cp .env.example .env && make up` now, and nothing has to be
created afterwards.

"Register the provider" becomes "What is in the registry, and why you did not
create it" -- what the five rows are, what a binding key is for, and how to
repoint a capability at a real API. Routing the registry is replaced by why
it is the one service not to route, with discovery as the worked example
instead. The troubleshooting section leads with the two binding-key failures
and which 404 means which side is wrong.

The Postman collection loses all five registry requests. Not a trim: the
registry has no route, so none of them could work from outside the VM, and
setup.py does what they did. Six requests remain -- publish, discover and
select for each capability -- 32 assertions, and nothing left to fill in,
since the two provider ids are prefilled with what the stack deploys. The
mandi select's networkId was still oan-local's value; it is a variable now.

Two corrections while checking claims rather than copying them:

- The old text said publish is not validated because the spec does not
  define it. The spec does define /catalog/publish, the validator indexes it
  under the action these payloads send, and both publish bodies validate
  against it with no errors. It is off because validateSchema is absent from
  those two modules' steps: -- a choice pending a test, not a limitation.
- A binding key with no matching ProviderSchema row was documented as a 502
  with an empty body. It has been a 404 naming the binding since the adapter
  fix; the 502 text predated it.
OpenAgriNet/network-adapter#4]

The previous commit said the migration was in README under "Renaming this
directory". It was not. It is now.

Compose derives its project name from the directory holding the compose file,
so renaming to quick-start renames all five volumes with it and Docker moves
no data. A plain `make up` after the pull starts on empty ones: empty registry,
empty catalogue, and an NPM with no proxy hosts or certificates.

npm-letsencrypt is the one that hurts. Re-issuing runs into Let's Encrypt's
five-per-week duplicate limit, so losing it can mean days without certificates.
The section gives the copy-across, tested here on scratch volumes including a
nested path before writing it down, and says to verify the registry and the
proxy hosts before deleting the old copies.

Also notes Keycloak's realm lives in registry-data, so it travels with that one
volume and equally does not survive skipping it.

Two stale references fixed while here: the collection is a sibling now rather
than a subdirectory, and it was still described as six requests and 32
assertions -- it is nineteen and 50.
…penAgriNet/network-adapter#4]

registry/ is local scratch -- a standalone registry someone ran by hand -- and
its .env holds live credentials. It was never part of this repo. It got in
because the rename commit used `git add -A` instead of naming paths, which swept
up an untracked directory. The Permission denied warning on registry/db-data/ in
that commit's output was the tell.

Removed from tracking and added to a root .gitignore so the same command cannot
do it again.

REMOVING IT HERE DOES NOT UNDO IT. The file is still in 3d87de3, which is
pushed, so POSTGRES_PASSWORD, KEYCLOAK_SECRET and REGISTRY_DEFAULT_USER_PASSWORD
should be treated as disclosed and rotated. Two of the fields
(KEYCLOAK_ADMIN_USER, KEYCLOAK_ADMIN_PASSWORD) match defaults already published
in quick-start/.env.example, so those are no change.

Purging it from history needs a rewrite and a force-push on a shared branch,
which is not mine to do unilaterally -- rotation is the reliable fix regardless.
…AgriNet/network-adapter#4]

Back to what this collection is for: the flows a consumer drives. Two folders,
Weather and Mandi, six requests, 32 assertions.

The registry requests never belonged in a shared collection. The registry has no
route through the gateway and publishes on loopback only, so they only ever
worked from the VM or through a tunnel -- and bin/setup.py already seeds every
row from the same .env the adapter configs are rendered from, which is what
keeps the two from disagreeing. Anyone who does need to poke a row has the curl
in quick-start's README.

Removing them orphaned 29 variables -- the Keycloak and registry URLs and
credentials, the node ids and keys, the binding keys, paths and mapping URLs.
All dropped, from the collection and the environment file, so neither ships
fields nothing reads. Six remain: the three adapter URLs, the two provider ids
and networkId.

networkAdapterUrl is kept although no request uses it. It is the other adapter a
deployment exposes, and both its /publish and /discover verify signatures, so a
network peer calls it directly -- Postman does not sign, so that call is not
scripted, but the address is worth having in one place.

The signing-key section moved to quick-start's README rather than being deleted
with the requests it described. It is about what a registry row may contain --
that an upstream may hold keys, and why that lets a provider publish straight to
the network adapter -- which outlives any particular Postman request. Reframed
so it reads as a property of the row, not of a request that no longer exists.

Verified against a live stack via an environment override: 6 requests, 32
assertions, no failures.
…nt [OpenAgriNet/network-adapter#4]

Discover asked only for text. It now sends all three parts of an Intent the
Beckn spec defines -- textSearch, a jsonpath filter and an S_DWITHIN spatial
constraint -- and the catalogues carry the geometry that makes the spatial part
mean something.

WHERE THE GEOMETRY GOES, because I had this wrong first. coverageAreas items are
a oneOf -- an administrative area reference OR a GeoJSON geometry -- and
coverageAreas is inherited from the AgricultureResource base pack, which neither
WeatherObservation nor MandiPrice forbids in OnDemand mode. So a catalogue can
carry the drawn area beside the code reference, exactly as the field's own
description invites, with no provider-level field added.

I had first concluded the geometry had to live at provider.availableAt[].geo,
having read only the AdministrativeAreaReference branch of that oneOf and seen
additionalProperties: false. coverageAreas is also the better target: it is
per-resource, so two capabilities from one provider can cover different areas,
where availableAt cannot distinguish them.

resourceAttributes.location stays out of it -- the OnDemand branch explicitly
forbids it, along with every other reading-bearing field. A catalogue advertises
capability; a Direct select response carries the reading and its point.

distanceMeters is 100000, not the 250000 the service's own docs example uses.
The service caps it at 200000 and answers SCH_INVALID_FORMAT above that, which
the test run found.

VERIFIED THAT BOTH FILTERS ACTUALLY BITE, since one silently ignored looks
identical to one that works: as written, 1 catalogue; probe point moved to
London, 0; @type changed to a value we never publish, 0. Full run 51 assertions,
no failures.

Also reverts @context to the identifier the packs declare in their own x-jsonld
block. Pointing it at the raw CDN was only ever to make extended validation
fetch something, that stays off, and in JSON-LD @context is a name rather than a
fetch target -- so the raw URL put a branch name on the wire for no gain.
…work-adapter#4]

Both are Context fields, and both were absent, which made discover quieter than
it looked.

networkId absent does not mean "this network" -- the service reads its absence
differently on each path:

  publish   absent means APP_NETWORK_ID, used to fill an empty visibleTo
  discover  absent means NO network predicate: every network's catalogs match

So discover was searching across every network and getting the right answer by
having only one. It now sends {{networkId}}, matching what publish stores.

schemaContext goes in context, not message.intent. The reference implementation
put it in intent, which Intent's additionalProperties:false forbids outright.
Each entry is a context URI with an optional #fragment naming the type, and the
predicate is exact string equality against what publish indexed off each
resource:

  SchemaContext = resourceAttributes.@context
  SchemaType    = resourceAttributes.@type

So publish needs no schemaContext of its own -- which is also why reverting
@context to the packs' declared identifier mattered: the filter has to spell the
same string the resource carries, and a raw-CDN URL on one side would have
drifted from the other.

VERIFIED EACH PREDICATE ACTUALLY BITES, because a filter that matches
everything is indistinguishable from one that works:

  as written                       1 catalogue
  networkId -> another network     0
  #fragment -> a type we never publish   0
  context URI -> example.com       0

Full run 51 assertions, no failures.
…network-adapter#4]

One directive per catalogue, in message.publishDirectives -- a sibling of
catalogs, and permitted by the spec, which matters now that publish is
schema-validated.

  catalogId     matches the catalogue's own id; that is how a directive binds
  catalogType   REGULAR. The spec requires this field, and MASTER means
                canonical definitions reusable across the network -- not this
  updateMode    MERGE, stated rather than defaulted. FULL DELETES the resources
                a payload omits, so a republish would silently wipe anything
                unlisted. Worth spelling out so nobody flips it casually
  visibleTo     ["{{networkId}}"], the same variable discover filters on

visibleTo is the substantive one. Without it the network came from the
receiving server's APP_NETWORK_ID, so the same publish landed in a different
network on a differently-configured deployment, with no error and a discover
that then found nothing. The payload is authoritative now, and both sides read
one variable.

Verified it binds rather than being decorative: published with visibleTo set to
another network, and a discover scoped to local-network then returned 0
catalogues; republished with the correct value and it returned 1 again. Full run
51 assertions, no failures.

resourceDirectives is deliberately absent -- Phase 1 refuses any carrying
extends with SCH_TYPE_NOT_SUPPORTED, and there is nothing here to inherit.
…nAgriNet/network-adapter#4]

  1. Weather  ->  1. WeatherObservation
  2. Mandi    ->  2. MandiPrice

The folders now read as the capability codes they exercise -- the second half of
every binding key, the @type on every resource, and the schema pack each one
validates against. "Weather" and "Mandi" were shorthand that matched nothing
else in the system.

README updated, including the --folder example. Verified both folders still run
standalone under the new names: WeatherObservation 13 assertions, MandiPrice 19,
and the full run 51, none failing.
…riNet/network-adapter#4]

Base validation covers the Beckn envelope and treats resourceAttributes as a
free-form object, so nothing checked a capability's own attributes. The
provider adapter now runs extended validation on both its modules: it resolves
each resource's @type to a capability schema and validates the object against
it.

This could not be switched on before. The validator stripped @context and
@type as JSON-LD plumbing while the packs list @type in allOf[].required, so
every resource was rejected for a missing @type the payload had sent -- the
comment on the publish module said as much. The adapter now decides per key,
keeping the ones a schema declares.

The schemas are fetched, not committed. bin/fetch-schemas.sh downloads the
eight published packs plus the five Beckn core schemas they $ref into
config/schemas, which is gitignored and mounted read-only. A copy in git would
drift from what the network publishes, which is the same reason the mappings
are fetched. SCHEMA_PACKS_URL picks the revision, so that stays a deployment
decision rather than something frozen into adapter config.

The fetch runs in stack.sh step 2, with setup.py, not step 3. It has to: the
provider adapter preloads these at startup and REFUSES TO START without them,
and the path is a bind mount, so an adapter started first would find a
directory Docker had invented and fail on it being empty. Failing closed is
the behaviour to want here -- the alternative is accepting unvalidated
payloads because a mount was forgotten.

Resolution is a memory lookup, so no payload costs a network call and the
container needs no egress to validate. The allowlist is narrowed to the host
the packs' own @context names, so a local miss fails loudly rather than
quietly fetching from elsewhere.

Also renames the two capability steps to WeatherObservation and MandiPrice,
matching the plugin ids on network-adapter feat/8-capability-schema-conformance
-- the .so basename is the id this config refers to, so the two move together
or the adapter fails with "unrecognized step".

README: the schema validation section said extended was off and that publish
was unvalidated. Both are now wrong. It also now records what extended
validation does NOT check -- the library parses if/then/else and never
evaluates it, so the packs' informationMode rules are unenforced and a pass
here is not full pack conformance.
…adapter#4]

validity.startsAt and endsAt resolve to ClosedTimePeriod -> TimePeriod, where
both ends are format: date-time. The request sent bare dates, which base
validation never looked at and extended validation refuses.

Now full RFC 3339 timestamps in IST, the zone the market data is actually in,
with the window closing at the end of the second day rather than at its start.

Safe for the mapping: its request half reads the date with
$substring($iso, 8, 2), which yields 20-08-2025 from a bare date and from a
timestamp alike. Verified against the running stack -- the select passes
extended validation and the collection is 51 of 51.

Note arrivalDate wants the opposite: it is format: date, so the response's
bare date there is correct and stays.
…nAgriNet/network-adapter#4]

Drops bin/fetch-schemas.sh, the config/schemas mount, the two SCHEMA_* .env
variables and the fetch step in stack.sh. All of it existed for one reason:
the @context the payloads declared -- schemas.openagrinet.global -- does not
resolve, and a failed schema fetch rejects the payload, so extended validation
could only work off files placed on disk.

The published packs do serve context.jsonld. Pointing @context at them makes
the fetch the validator already knows how to do work, and the whole local
mechanism unnecessary:

    @context .../network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/context.jsonld
    fetched  .../network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/attributes.yaml

Better than the mount in two ways. A payload names the revision it wants to be
judged against, so nothing in this repo can go stale against the published
packs. And the deployment loses a step that could be forgotten -- there is no
directory to populate and no startup that fails because it was not.

The allowlist becomes raw.githubusercontent.com, the host @context actually
resolves to, and is now load-bearing: an @context on any other host is refused
before a fetch is attempted. The stack already reaches that host for the
mappings, so no new egress.

Costs, both in the README: the provider adapter needs that egress, and the
first payload after a restart pays for the fetch -- about 2s, then cached for
24h.

Verified in oan-local, same configs: publish and select pass, the first
payload logs "fetching from network" and later ones "LRU cache hit", a foreign
@context is refused with SCH_INVALID_JSONLD_CONTEXT, and the collection is 51
of 51.
…ter image [OpenAgriNet/network-adapter#4]

Two gaps this closes, both found by upgrading a live VM.

There was no upgrade path in the README. It covered a first run and starting
over, with nothing in between, so a sync was reconstructed by hand each time.
The new section splits it by what actually changed -- config only, config plus
a new image, or payload shapes -- because those need different work and doing
them in the wrong order is the likely way to break a working VM. It also gives
the check order (steps initialized, then errors, then the collection) and the
rollback, which is both halves or neither.

ADAPTER_IMAGE pointed at a build that predates the plugin rename. The configs
in this repo now name WeatherObservation and MandiPrice, and a plugin id is
the basename of a .so inside the image, so the shipped default would have made
every adapter exit with "unrecognized step: WeatherObservation" -- a failure
that reads like a config typo and is not. It is pinned to a tag built from the
adapter branch these configs expect, with the coupling written down next to it
and a troubleshooting entry naming the error.

The tag is in a personal namespace, as the others already are. An org package
published by CI is the real fix; that is issue #5.
…er#4]

ADAPTER_IMAGE was pinned to a SHA tag; it follows latest now.

That needs two things said, because latest here behaves less dynamically than
it reads. pull_policy is missing on every image and nothing in stack.sh pulls,
so a tag already on disk is never re-fetched -- `make up` does NOT pick up a
newer latest, and a stack can sit on a stale image indefinitely. Both the
image comment and the upgrade section now give the explicit pull, and say why
the pull without the recreate and the recreate without the pull each do
nothing.

The rollback note is corrected too. Rolling the config back is exact, but "the
old image" has no name once the tag has moved, so recovery is by digest if the
layers are even still on the box. Recorded rather than argued: pinning is
still the thing to do before a change you might need to undo.

The coupling warning is unchanged and matters more, not less -- an id is the
basename of a .so inside the image, so latest moving under a deployment can
produce "unrecognized step" with nothing in this repo having changed.
…nAgriNet/network-adapter#4]

The jsonpath filter compared resourceAttributes."@type" against the
capability, which the request already narrows by: context.schemaContext
carries the same @context and @type and is matched by exact equality before
the filter runs. So the filter demonstrated the mechanism and narrowed
nothing that was not already narrowed.

subjectCategories is a different dimension -- Weather for one capability,
Market for the other -- so the filter now does work the other predicates do
not.

Written as subjectCategories[*] == "..." because the field is an array. Lax
mode would auto-unwrap it and the comparison is existential either way, but
the explicit form says what is meant and behaves the same in strict mode.
Equality keeps it indexable, which is what the service requires of a filter
arriving without a text search or spatial constraint to narrow the corpus
first -- ours carry both, but the expression should not depend on that.

Verified against the running stack rather than by reading: "Weather" returns
the one catalogue, a category nothing publishes returns none, and the other
capability's category on this request returns none -- so the predicate
discriminates on the value rather than passing everything. Collection is 51 of
51.
…penAgriNet/network-adapter#4]

The two path-shaped fields in a discover were spelled differently for no
reason: filters.expression in dot notation and spatial.targets in
bracket-quoted form. The bracket form was copied from the discovery service's
own documentation, where it is the CANONICAL spelling -- the one the service
stores -- not a required input.

Both go through the same Canonicalise(), which reads .name and ['name'] alike
and rewrites either into the stored form before the comparison. So the two
spellings were never doing different things, and one request carrying both
just invited the reader to look for a distinction that is not there.

The distinction that IS real, and is worth keeping straight: filters.expression
is EXECUTED, handed to PostgreSQL as @filter::jsonpath, which is why it can
carry a predicate. spatial.targets is COMPARED, as the right-hand side of
target_path = ANY($1) against the path the publish walker recorded, which is
why it can only name a location.

That also means a typo in targets fails as an empty result rather than an
error -- verified, a pointer at serviceArea instead of coverageAreas returns
zero rows quietly.

Checked both spellings against the running stack before changing anything:
bracket and dot targets return the identical one catalogue and one resource, a
wrong pointer returns none, and after the change the spatial constraint still
narrows -- the same request with a point in London returns none. Collection is
51 of 51.

@manjudr manjudr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please - update the readme file; it's verbose at the moment.

  • renamed from exp to experience
  • only keep local env file in api collection
  • rename postgame collection to api-collection
  • remove OAN keyword from file names

ameersohel45 and others added 11 commits September 7, 2026 16:45
…twork-adapter#4]

OAN-dev-flow.postman_collection.json -> api-collection.json
OAN-dev.postman_environment.json     -> local_postman_environment.json

The old names carried both the tool and the environment in them, which dated
badly on two counts: the collection is not dev-specific -- pointing it at
another deployment is a URL change, which is what the environment file is for
-- and Postman's own suffix convention says nothing a reader needs.

Kept the .json on the environment file. Postman filters the import dialog by
extension, so a file without it cannot be selected.

Two things that named the old state and no longer did:

- The collection's own info.name still read "registry, publish, discover,
  select". The registry requests were dropped several commits ago, so it
  advertised a folder that is not there.
- quick-start/README.md promised nineteen requests and fifty assertions "the
  registry writes and reads that set the stack up, then publish, discover and
  select". It is six requests and thirty-two assertions, with no registry
  requests at all -- setup.py seeds it, because the registry has no route
  through the edge. Corrected, and it now says why they are absent rather than
  leaving their absence to look like an omission.

Content is otherwise untouched: diffed the renamed file against the old one and
only info.name differs. oan-local's collection, which targets the stack running
here, is 51 of 51.
…network-adapter#4]

config/adapters/exp.yaml.tmpl -> experience.yaml.tmpl, and the rendered
experience.yaml with it.

The rename is only the filename, and that is the point. setup.py used one
string for three different things -- the template's name, the key in
keys/keys.json, and the __EXP_* placeholder prefix -- so renaming the role
would have renamed the keys.json entry too, and setup.py would then have
generated a FRESH keypair for a participant the registry has already published
a public key for. This registry cannot update a record and its delete is soft,
so the id could not be reused either: the adapter would sign with a key nobody
can verify, and it would surface later as an authentication error with no
obvious cause. setup.py documents that trap a few lines above, which is what
made it worth honouring rather than discovering.

So CONFIG_STEM maps the role to its filename and the role key stays "exp".
Placeholders stay __EXP_*, keys/keys.json keeps its entry, and EXP_SUBSCRIBER_ID
is untouched -- a deployed VM re-renders onto the new name and keeps its
identity.

Carried the new name into the compose mount, .gitignore, and the two places the
README named the file. The compose SERVICE is still exp-adapter and the routing
file is still routing-exp.yaml; renaming either is a separate change, the
service because it is what NPM's proxy hosts resolve.
…ehind it [OpenAgriNet/network-adapter#4]

It was 1218 lines with no entry point. The 299-line section on the public edge
came BEFORE "Before you start", so the first thing a reader met was
certificates and proxy hosts, and the commands that actually bring the stack up
were three hundred lines down. About forty-five minutes of reading to find a
ten-minute task.

Now: a body of 375 lines, eight minutes, that is only the path through. Two
parts, because they are two audiences -- Part 1 runs it on a laptop, Part 2 is
the differences on a VM and nothing else, so neither reader wades through the
other's material.

Part 1 is eight numbered steps: prerequisites, configure, shared services,
seed, then one step per layer, then verify. Each layer step has the same four
headings -- what it does, the config it needs, bring it up, check it worked --
so the third one is skimmable once the first has been read.

The steps run provider, network, experience, which is the reverse of the
request flow and deliberately so: exp-adapter depends on the other two, so
that is the order Compose starts them and the order the commands work in. The
request flow is in the diagram at the top, where it belongs as orientation
rather than as instructions.

Everything else is Appendix A to O, moved VERBATIM. No reasoning was
condensed away -- the edge, the registry contents, the troubleshooting, the
request-flow detail, the startup-order argument are all intact, just no longer
between a reader and their first command. The long-form "Before you start" and
"Test it end to end" are kept as N and O rather than dropped, since the body
supersedes their steps but not their explanation.

Two errors of mine found while writing it, both by checking rather than
assuming:

- APP_NETWORK_ID was listed as the network adapter's config. It is the
  discovery service's -- the network template never references it. Moved to
  the step where it is actually read.
- The provider step listed participant ids without their capabilities. It is
  the PAIR that makes a binding key, which is what makes a mismatch a 404
  rather than a config error, so both halves are named now.

Verified: no dangling appendix references, code fences balanced, both tables
render, no reference left pointing at an old section title.
id: oanregistry -> id: sunbirdRegistry, in all three adapter templates. The
adapter branch renamed the package to say what the registry is rather than
whose it is, and the .so basename is the plugin id this config refers to, so
the two move together or the adapter exits at startup with

  failed to load Registry plugin (sunbirdRegistry): plugin sunbirdRegistry not found

A matching image is published: ghcr.io/ameersohel45/oan-adapter at 642743e,
which latest now points at too, so a deployment following latest picks it up on
the next explicit pull. Note that pull has to be explicit -- pull_policy is
missing and nothing in stack.sh pulls, as .env.example says.

Verified end to end rather than by inspection, and it caught a real mistake:
rebuilding only ONE of the three adapter images left the other two carrying the
old .so, and both died with exactly the error above while the provider came up
fine. All three rebuilt, all three started, 51 of 51 on the collection. Worth
recording because a partial rebuild fails in a way that looks like a config
typo in one adapter rather than a stale image in two.

Left alone, as deployment identity rather than code: container_name
oan-provider-adapter, OTEL_SERVICE_NAME, and appName/serviceName in the
provider template. Renaming those changes what `docker ps` shows and what
telemetry reports, which is a separate decision from naming a Go package.
…ME [OpenAgriNet/network-adapter#4]

routing-exp.yaml -> routing-experience.yaml, finishing what the
experience.yaml.tmpl rename started. Renamed on BOTH sides of the mount, so the
container path matches the file rather than preserving the old spelling
invisibly, and the template's routingConfig follows it.

postman-collection/README.md is removed. Nothing linked to it, and the
quick-start README's Step 8 already covered importing the two files, the
counts, and why there are no registry requests.

Four things in it were not covered anywhere else, so they moved rather than
went away:

- Point it at another deployment by editing the ENVIRONMENT file, not the
  collection: Postman resolves an environment variable ahead of a collection
  variable of the same name, so the loopback defaults survive for the next
  person. Step 8.
- The newman one-liner for a single folder. Step 8.
- What the two select requests demonstrate -- same endpoint, same adapter,
  different domain packages answering, because dispatch is a binding key built
  from the payload and not a route. Appendix O.
- Why networkAdapterUrl is a variable no request uses. It is the other adapter
  a deployment exposes publicly, and its endpoints verify signatures, which
  Postman does not do -- so the variable exists to give the address a home,
  not because a request is missing. Without this written down it reads like an
  oversight and invites someone to "fix" it. Appendix O.
…et/network-adapter#4]

The appendices were the original sections moved verbatim, which fixed the
ordering problem and left the length one: 1224 lines of reference behind a
375-line walkthrough. Now 561.

The cuts are prose, not facts. Every command, table, gotcha and reason is
still there -- the certificate traps, the /publish deny and why it is a
mounted file rather than a click, the Keycloak X-Forwarded-* trap, the
append-only registry, the bind-mount-directory failure, the if/then/else the
validator never evaluates, the npm-letsencrypt duplicate-certificate limit.
What went is the paragraph of build-up in front of each one.

Two sections went entirely. "Before you start -- the long form" duplicated
Steps 1 and 2 with nothing added. "Testing it end to end -- the long form"
duplicated Step 8 except for two explanations, which are now Appendix N on
their own: what the two select requests demonstrate, and why
networkAdapterUrl is a variable no request uses.

THE DIAGRAM WAS WRONG, not just verbose. It drew a box around the experience
adapter and put the network and provider adapters INSIDE it, with an arrow
leaving the closed box afterwards -- so it read as one service containing two
others. Redrawn as what it is: the consumer reaches the experience adapter,
which fans out to the network adapter for discover and the provider adapter
for select, and the provider calls the mocks. publish running the other way
and the shared registry reads are now stated beside it rather than crammed
into the drawing.

Also dropped a line from Appendix K listing bin/fetch-schemas.sh, which was
removed several commits ago. Listing a file that is not there is worse than
not listing it.

Verified: 4 tables well-formed, code fences balanced, every appendix reference
in the body resolves, and no reference left pointing at a dropped section.
…/network-adapter#4]

Step 1 was prerequisites and Step 2 was `cp .env.example .env`, so the
walkthrough never said where the repo comes from, and every command silently
assumed a working directory the reader was never put in. There is a Step 2 now
that clones and cds into quick-start/, and it says so explicitly.

Part 2 had the same bug, worse: Step V1 ran bin/bootstrap-ubuntu.sh from a repo
that had not been cloned. The script installs git ITSELF, and documents a
curl | bash form for exactly this case -- so V1 now fetches it over curl, is
placed before the clone rather than after, and says to log out and back in for
the docker group.

The per-layer steps were also dishonest. Steps 5, 6 and 7 each ran a
`docker compose up -d <adapter>`, then Step 7 offered `make up-core` as an
alternative that does all of it -- so the earlier commands were work the reader
did not need. Bring-up is now one command in Step 4, and Steps 5-7 are what
each layer is, the .env keys it uses, and the log line that confirms it.

And the over-explaining is gone. Each step had two to four paragraphs of
reasoning; the reasoning is what the appendices are for, so the steps carry a
command, a line of what it does, and a pointer. Part 1: 190 lines to 117.
Part 2: 88 to 62. Body 388 to 279, about six minutes.

Dropped an ASCII annotation in the provider step that tried to bracket six
config keys into pairs with box-drawing characters and did not line up, and
said "those same four values" above a list of six.

Verified rather than assumed: the bootstrap URL returns 200 for 5345 bytes,
the branch the clone names exists on the remote, every appendix reference
resolves, and the fences balance.
Models the network-adapter service from docker-deployment/docker-compose.yml
as a chart on oan-common. The adapter is stateless: its identity is a keypair
in a Secret and everything else comes from the registry.

Config is rendered by an init container from a ConfigMap of placeholders plus
the identity Secret into an emptyDir, so the keypair is never written to a
ConfigMap and never appears in a process environment.

Renders fail outright on the five values whose absence would otherwise produce
a pod that starts, reports Ready and does not work. HPA, PodDisruptionBudget,
Ingress and the upstream readiness gates are all optional and off by default;
ci/otel-ingress-values.yaml renders that other branch so lint covers it.
The chart shipped with an empty tag, which renders an image reference with no
tag at all. latest keeps the draft chart installable while the discovery
service has no released version to pin to.
Renames network-adapter to adapter-service and makes the role a value. The
provider, network and experience adapters are the same image and the same
config format, so what differed between them was configuration, not three
charts: role now selects the handler role, the step list and the routing
target.

experience is the role that justifies the abstraction. It sits inside the
trust boundary and accepts unsigned requests, so it runs as bap with no
validateSign step, while the other two receive from the network and must
verify first. Its example sets both explicitly rather than relying on the
default, so the missing step reads as deliberate.

discovery.url becomes routing.rules, a list, because provider fans out to
several upstreams and a single target could not express that. Every rule is
checked at render time for a target url, a missing trailing slash and a
non-empty endpoint list.

Each example sets fullnameOverride to <role>-adapter. Without it fullname
resolves to "<release>-adapter-service", so a release named network-adapter
produced a Service called network-adapter-adapter-service and the experience
adapter's route to network-adapter:9201 resolved to nothing. That only worked
before because the chart itself was named network-adapter.

ci/otel-ingress-values.yaml now renders the experience role, so CI covers the
role branches that ci/lint-values.yaml (network) does not reach.

BREAKING CHANGE: chart renamed network-adapter -> adapter-service; role is
required with no default; discovery.url replaced by routing.rules; config
placeholders renamed __NETWORK_* -> __ADAPTER_*; routing file is now
routing-<role>.yaml.
@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. The one nginx rule this file exists for is commented out, leaving the unauthenticated /publish endpoint exposed (the file's own header comment calls this rule "not optional hardening," and README.md documents a verification step expecting curl .../publish to return 403 — as shipped it won't).

# statement about the network, which is where it should be made.
# location = /publish {
# deny all;
# }

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…library

feat: chart repo scaffold, common library and the first service charts
@manjudr
manjudr merged commit 500212d into development Sep 8, 2026
1 check passed
@manjudr
manjudr deleted the feat/4-docker-compose branch September 8, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants