022 fixed; 023 filed — a password is not a connection
022 turned out to have a silent half worth recording: the provider refuses loudly and names the modules, which reads as a decision, while the consuming node does not refuse at all. Three modules wanting one database produce one need, so two of them get no credential file and each starts and fails to authenticate with nothing saying why. 023 is what remained after fixing it. A consumer now gets its own password, in whatever shape its configuration wants, and still cannot connect: the user name is invented by the provisioner and recorded nowhere in the mesh, and the host and port sit in a JSON binding that an application reading KEY=value cannot use. The asymmetry is backwards and the coverage document now says so. The secret is the hard case, because the mesh must not be able to read it, and the secret is the part that arrives. The host and port are ordinary facts the mesh holds in the clear, and they are the ones stuck. Keycloak, Gitea, Mailu and MinIO all parse and resolve and none of them can start. This is what stands between the module set and a running one.
This commit is contained in:
@@ -104,6 +104,28 @@ what kind it is. Small, self-contained, and only interesting once something pres
|
|||||||
Modules published to a registry and consumed as libraries. This is a *build* output the mesh does
|
Modules published to a registry and consumed as libraries. This is a *build* output the mesh does
|
||||||
not deliver to a node, so it may not belong here at all.
|
not deliver to a node, so it may not belong here at all.
|
||||||
|
|
||||||
|
## What checking the coverage found
|
||||||
|
|
||||||
|
Two faults, both surfaced by asking what a *real* node looks like rather than what a test does.
|
||||||
|
Neither is about the vocabulary; both are about the machinery under it.
|
||||||
|
|
||||||
|
**A credential belonged to a machine, not to a module**
|
||||||
|
([`022`](../../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md),
|
||||||
|
fixed). A node running three services against one database could not be planned at all — and on
|
||||||
|
the consumer's side did not refuse, it just gave two of the three no credential. Every scenario
|
||||||
|
written to date had one consumer per node, which is the natural shape of a small test and not the
|
||||||
|
shape of a machine.
|
||||||
|
|
||||||
|
**A consumer still cannot build a connection string**
|
||||||
|
([`023`](../../04-ISSUES/023-a-consumer-cannot-build-a-connection-string/00-report.md), open). It
|
||||||
|
is given its own password in whatever shape its configuration needs, and the host, port and user
|
||||||
|
name are still out of reach: the user name is invented by the provisioner and recorded nowhere,
|
||||||
|
and the bound values sit in a JSON document that an application reading `KEY=value` cannot use.
|
||||||
|
|
||||||
|
The asymmetry is worth stating, because it is backwards. **The secret is the hard case** — the
|
||||||
|
mesh must not be able to read it — and the secret is the part that now arrives. The host and port
|
||||||
|
are ordinary facts held in the clear, and they are the ones stuck.
|
||||||
|
|
||||||
## What the survey found that is not about coverage
|
## What the survey found that is not about coverage
|
||||||
|
|
||||||
**Declaration and reality had drifted in the system being replaced.** Several live provisions are
|
**Declaration and reality had drifted in the system being replaced.** Several live provisions are
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
status: located
|
status: fixed
|
||||||
opened: 2026-09-01
|
opened: 2026-09-01
|
||||||
located-in: [mesh-control]
|
located-in: [mesh-control]
|
||||||
fixed-by:
|
fixed-by: mesh-control 0af3ea1
|
||||||
amended-design:
|
amended-design:
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,6 +17,20 @@ anchor has 3 modules asking for "postgres-database" and they would share one
|
|||||||
credential: gitea, keycloak, umami
|
credential: gitea, keycloak, umami
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**And that is only the loud half.** The consuming node does not refuse at all. Three modules
|
||||||
|
wanting one database produce **one** need:
|
||||||
|
|
||||||
|
```
|
||||||
|
modules=3 needs=1
|
||||||
|
name=postgres-database from=anchor for=gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
So the first module gets a credential, the other two get no file at all, and each starts and fails
|
||||||
|
to authenticate with nothing anywhere saying why — the shape of
|
||||||
|
[`021`](../021-a-consumer-on-the-providers-machine-is-given-no-credential/00-report.md), on a
|
||||||
|
different axis. The refusal that reads like a decision is on the provider; the silence is on the
|
||||||
|
consumer.
|
||||||
|
|
||||||
The refusal is correct about what it says. They *would* share one credential, and sharing one is
|
The refusal is correct about what it says. They *would* share one credential, and sharing one is
|
||||||
worse than refusing — a login that opens three databases is not three credentials. But the
|
worse than refusing — a login that opens three databases is not three credentials. But the
|
||||||
arrangement being refused is the ordinary one. **The node this mesh exists to take over runs
|
arrangement being refused is the ordinary one. **The node this mesh exists to take over runs
|
||||||
@@ -79,3 +93,21 @@ said so, because from the provisioner's side it created exactly what it was aske
|
|||||||
This crosses the control plane, the grant file naming, and every provisioner that names something
|
This crosses the control plane, the grant file naming, and every provisioner that names something
|
||||||
after `Consumer`. It is not a local fix, and it is the last thing between the current state and a
|
after `Consumer`. It is not a local fix, and it is the last thing between the current state and a
|
||||||
node that looks like a real one.
|
node that looks like a real one.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
Needs fan out per consuming module in one place, after the resolution walk. The credential's key
|
||||||
|
gains the consuming module, the grant file is named after both halves of the consumer, needs are
|
||||||
|
matched by provision *and* module, and the provisioners name the role and the access key after the
|
||||||
|
module rather than the machine. The refusal is gone because there is nothing left to refuse.
|
||||||
|
|
||||||
|
Existing credentials are discarded rather than backfilled: they cannot say which module they were
|
||||||
|
for, and one is remade and delivered to both ends on the next push, so it costs one rotation.
|
||||||
|
|
||||||
|
A guard was added for PostgreSQL's 63-byte identifier limit, which truncates with a notice rather
|
||||||
|
than an error — two consumers whose role names agree that far would otherwise become one login,
|
||||||
|
which is this same fault at a length nobody would think to test.
|
||||||
|
|
||||||
|
What it did **not** fix is [`023`](../023-a-consumer-cannot-build-a-connection-string/00-report.md):
|
||||||
|
a consumer now receives its own password and still cannot build a connection string, because the
|
||||||
|
user name is the provisioner's invention and the bound values cannot reach a configuration file.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
status: located
|
||||||
|
opened: 2026-09-01
|
||||||
|
located-in: [mesh-control]
|
||||||
|
fixed-by:
|
||||||
|
amended-design:
|
||||||
|
---
|
||||||
|
|
||||||
|
# 023 — A consumer is given every part of a connection except the two it cannot invent
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
A module that requires a database is now given its password in whatever shape its configuration
|
||||||
|
needs ([`022`](../022-one-credential-per-node-per-provision-not-per-module/00-report.md) and the
|
||||||
|
sealed-placeholder work). It still cannot connect, because a password is not a connection.
|
||||||
|
|
||||||
|
What it is given is a **binding**, as JSON:
|
||||||
|
|
||||||
|
```
|
||||||
|
provision postgres-database
|
||||||
|
from the node providing it
|
||||||
|
at that node's address on the private network
|
||||||
|
serves what the provider said a consumer must know — the port
|
||||||
|
```
|
||||||
|
|
||||||
|
What it needs, to write `KC_DB_URL` or `GITEA__database__USER`, is the host, the port, the
|
||||||
|
database name and **the user name**. Two of those are missing, for two different reasons.
|
||||||
|
|
||||||
|
## The user name is nobody's to say
|
||||||
|
|
||||||
|
The provisioner invents it — `mesh_<node>_<module>` — and nothing else in the mesh knows that
|
||||||
|
string. The control plane does not record it, the binding does not carry it, and the consumer
|
||||||
|
cannot derive it without hard-coding another module's naming convention.
|
||||||
|
|
||||||
|
So the one identifier a consumer must present in order to authenticate is the one thing no part
|
||||||
|
of the mesh will tell it. It works today only because nothing has yet had to write a connection
|
||||||
|
string; every proof so far stopped at "the credential arrived".
|
||||||
|
|
||||||
|
## The values cannot reach the file that needs them
|
||||||
|
|
||||||
|
The binding is a JSON document. The consumers are containers reading `KEY=value`, or a program
|
||||||
|
reading a YAML file, or one reading an attribute inside a different JSON document. A sealed secret
|
||||||
|
can now be placed inside any of those — the module writes the file with a hole in it and the host
|
||||||
|
fills the hole on the machine. **The bound values have no such route**, so the half of the
|
||||||
|
connection that is not secret is the half that cannot be delivered.
|
||||||
|
|
||||||
|
This asymmetry is backwards. The secret is the hard case, because the mesh must not be able to
|
||||||
|
read it. The host and port are ordinary facts the mesh knows in the clear, and they are the ones
|
||||||
|
stuck in a document nothing can read.
|
||||||
|
|
||||||
|
## Why it was not noticed
|
||||||
|
|
||||||
|
Every provider so far has been reached by a **provisioner**, a program written for the job, which
|
||||||
|
reads the JSON because it was built to. The first consumers to need a plain configuration file
|
||||||
|
were the first real applications. The binding was designed for the program and then handed to the
|
||||||
|
application.
|
||||||
|
|
||||||
|
There is also a stale comment saying a binding *"carries no credential: the mesh has no way to
|
||||||
|
issue one yet"*. That stopped being true when [`021`](../021-a-consumer-on-the-providers-machine-is-given-no-credential/00-report.md)
|
||||||
|
was fixed.
|
||||||
|
|
||||||
|
## What a fix has to settle
|
||||||
|
|
||||||
|
- **Who names the role.** Either the mesh records what the provisioner will create, or the
|
||||||
|
consumer contributes the name it wants and the provisioner uses it. The second is more in
|
||||||
|
keeping with the rest — a consumer already contributes the database name it wants — and it
|
||||||
|
removes an invented convention rather than documenting one.
|
||||||
|
- **How a bound value reaches a file.** The symmetric answer to the sealed placeholder, and
|
||||||
|
simpler: these values are not secret, so the control plane can put them in before sending and
|
||||||
|
the host learns nothing new.
|
||||||
|
- **That it stays name-agnostic.** The control plane must not learn what a `postgres-database`
|
||||||
|
is. What the keys mean is agreed by the requirement's name
|
||||||
|
([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)), so
|
||||||
|
whatever is added has to work for a bucket and a mail relay without being told about either.
|
||||||
|
|
||||||
|
## Blocked on this
|
||||||
|
|
||||||
|
Keycloak, Gitea, Mailu and MinIO all have manifests that parse and resolve, and none of them can
|
||||||
|
start. This is what stands between the module set and a running one.
|
||||||
Reference in New Issue
Block a user