Skip to content

Latest commit

 

History

History
219 lines (188 loc) · 11.5 KB

module-manifest.md

File metadata and controls

219 lines (188 loc) · 11.5 KB

The module manifest

A module (or a composed solution) is present on a host because a signed module manifest declared it — never because its process announced itself. One manifest per module instance says where it runs, which workloads run as it, where its endpoints are, and what authority it asks for. solutionhost/ owns the type, its strict reader, its rules, its canonical encoding, the DSSE signature over it, and the decisions a host makes when one arrives. Core does not render one (the CLI does), sign one (the delivery pipeline holds the key), or apply one (the host does).

A mint answers four questions from two inputs plus the organisation's installation: who is calling (the workload credential, attested by core workloadidentity), which module it is (the attested namespace and account match exactly one workload of one manifest), may it act for this organisation (an active installation), and with which scopes (what the manifest requests, intersected with the administrator's approval). There is no build approval at runtime: no image, release or artifact digest is compared when a credential is minted. "Only approved images run" is deploy-time admission, outside core.

The document

Schema codefly/module-manifest/v1 (solutionhost.SchemaModuleManifestV1), conventionally module-manifest.codefly.yaml (solutionhost.FileName):

schema: codefly/module-manifest/v1
binding: acme.local.wiki          # instance id (workspace.env.instance), stable
alias: wiki                       # route alias: one lowercase path segment
kind: module                      # module | solution
generation: 7                     # monotonic per binding, set by the publisher
removed: false                    # true = withdrawal: identity fields only
host: {coordinate: obin/local/dev, domain: obin}
release: {publisher: obin, name: wiki, version: 0.1.0}
group: {id: knowledge, title: Knowledge, order: 1}   # solutions only, optional
frontend:                         # optional; payload is generated by the renderer
  title: Wiki
  order: 10
  exposed_module: ./Page
  host_contract: 1
  payload: {artifact: wiki-frontend, digest: "sha256:…", media_type: application/gzip}
workloads:                        # every workload that runs as the module's principal
  - service: backend              # also the key an endpoint routes to
    container: backend            # the one container that authenticates
    spiffe_id: spiffe://cluster.local/ns/platform-obin-wiki/sa/backend
endpoints:                        # what a host or gateway may route to, with the address
  - {name: rest, service: backend, api: rest, address: "http://localhost:5123"}
authority:                        # what the module asks for; absent asks for nothing
  principal: wiki
  bindings:
    - id: model
      audience: modelservice
      operations: {invoke: [modelservice.profiles:invoke], headless: [modelservice.profiles:invoke]}
  queues: [wiki.index]
  namespaces: [wiki]
  resource_kinds: [wiki.pages]

solutionhost.Parse decodes strictly — an unknown field, a repeated key (aliases resolved), a merge key, an explicit null, a fractional number or a second document is refused rather than repaired — and validates. Rules (solutionhost.ModuleManifest.Validate):

  • the binding is an instance id (never base, the collision checker's exempt owner); the alias is one lowercase path segment; the kind is module or solution; the generation is at least 1; the host coordinate and domain are names; the release publisher and name are single segments and the version is exact semver;
  • a live manifest declares at least one workload, each a (service, container) pair and nothing of where it runs; services are unique. A cell spells the rest by rule — the namespace named after the module, the ServiceAccount named after the service — and the rule reads both ways in workloadidentity (Workload.KubernetesNamespace, Workload.KubernetesAccount, Workload.SPIFFEID, FromKubernetes), so no typed SPIFFE ID is declared and a mint finds a workload by that rule, by service (solutionhost.ModuleManifest.WorkloadNamed);
  • each endpoint names a declared workload's service and carries one address;
  • the frontend and the group are held to their block rules (bounded texts, decimal numbers, surfaces, the payload's digest and media type; a group only on a solution); solutionhost.ParseFrontend reads a component's standalone frontend file through the same rules, and refuses a payload in it;
  • the authority principal is an opaque id; binding ids are unique; each binding requests at least one of invoke, lookup, headless, each with at least one <resource kind>:<action> scope; queues, namespaces and resource kinds are unique single-line names. The principal equalling the module name is the CLI's rule: core holds only that it is well formed;
  • a withdrawal (removed: true) carries its identity and nothing else: no workloads, endpoints, authority, frontend or group.

solutionhost.Marshal is the one writer: it validates, encodes, and reads the bytes back, returning them only when they are the same manifest.

Documents the manifest replaced

The presence (codefly/solution-host-binding/*), authority (codefly/solution-authority/*) and endpoint-delivery (codefly/endpoint-delivery/*) documents are gone, with no compatibility reading. A document declaring any version of them is refused with solutionhost.ErrSchema and the remedy: re-render it with a current Codefly CLI. Signing refuses them the same way, so neither end can produce or accept one.

Canonical bytes and the signature

solutionhost.ModuleManifest.CanonicalBytes is JSON with object keys in name order at every depth and every set sorted (workloads by service, endpoints by service and name, bindings by id, scopes, queues, namespaces, resource kinds, frontend surfaces), so the bytes move only when the manifest does. solutionhost.ModuleManifest.Digest is their SHA-256. They are what a signature covers.

The signature is DSSE with exactly one signature, payload type application/vnd.codefly.module-manifest+json, verified offline against pinned public keys (Ed25519 or ECDSA P-256) using only the Go standard library. Each pin names the ownership domains its key may deliver under; the manifest's host.domain asserts its domain, and the pin is what bounds it.

A producer:

payload, err := solutionhost.SignedPayload(manifest)
envelope, err := solutionhost.SignEnvelope(payload, signer) // a Signer the pipeline holds
carrier, err := solutionhost.Carrier(payload, envelope)     // refuses a non-canonical payload
encoded, err := solutionhost.MarshalSigned(carrier)         // JSON, under solutionhost.SignedFileName

A host:

keys, err := solutionhost.NewPinnedKeySet(solutionhost.PinnedKey{
    KeyID: "release", PublicKeyPEM: pem, Kind: solutionhost.KeyKindRelease, OwnershipDomains: []string{"obin"},
})
verifier, err := solutionhost.NewVerifier(keys)
carrier, err := solutionhost.ParseSigned(body)
delivered, err := solutionhost.VerifyDelivered(ctx, carrier, verifier)
manifest, err := delivered.Manifest() // re-derived from the attested bytes, fresh each call

solutionhost.VerifyDelivered returns nothing unless the signature verifies under the pin its key id selects, the payload is the canonical encoding of a valid manifest (solutionhost.ErrNotCanonical otherwise, even under a genuine signature), and the signer is pinned for the manifest's domain (solutionhost.ErrWrongDomain). A solutionhost.Delivered stores the attested bytes, not a parsed value, so nothing a caller does to the manifest it was handed changes what the host stores (solutionhost.Delivered.Payload). solutionhost.ManifestFromVerified is the document half alone, for bytes a host already verified and stored.

Admission: one binding, monotonic generations

decision, err := solutionhost.Admit(coordinate, previous, candidate) // previous: nil for a new binding

solutionhost.Admit takes the host's coordinate, the manifest it applied for the candidate's binding (or nil) and the candidate, and answers:

Case Answer
no previous manifest DecisionAccept
higher generation DecisionAccept (a withdrawal at a higher generation too)
same generation, same canonical bytes DecisionReplay
lower generation ErrStaleGeneration
same generation, different content ErrRewrittenGeneration
candidate names another coordinate than the host, or than the binding's ErrWrongHost
candidate names another domain than the binding was applied under (a withdrawal included) ErrWrongDomain
previous is a withdrawal and the candidate a later generation ErrWithdrawn
previous is invalid or for another binding ErrAppliedUnusable (the host's own state)

A withdrawal is terminal: everything keyed on a binding — installations, grants — would otherwise re-attach to a new instance that merely reused the id. A replacement instance gets a new binding. Removal is a manifest at the next generation with removed: true; absence is never removal, so an unreadable inbox can never read as "withdraw everything".

Route aliases

err := solutionhost.CheckAlias(reserved, applied, candidate)

solutionhost.CheckAlias holds a candidate's alias to the host's routes: not inside a reserved namespace (codefly reserves codefly, codefly/…, codefly.…), and not claimed by another live binding on the same coordinate. The candidate's own applied manifest is the one it replaces; a withdrawal claims no alias. Only the candidate is judged against the reserved namespaces, so a newly reserved namespace never blocks an unrelated delivery because an incumbent sits in it. A collision answers composition.ErrCollision, like every route collision in core.

What the manifest does not carry

No credential, no observation, no health, and no build approval: no image, release, artifact or started-artifact digest, no approved builds, no build size, no module pins, no envelope revision. The administrator's approval — the grant a module's request is intersected with — is the host's record, not part of this document; see the host's documentation. What a module asks for is declared in its module contract, which the CLI resolves into authority; see module-contract.md.

Installation declarations in the signed carrier

authority.installation_role and authority.edge_dependencies carry the module contract's validated installation declarations verbatim. The selector is empty when undeclared, literal none for no standing machine grant, or a role name. Dependencies reuse modulecontract.EdgeDependency with module and presence fields; they grant no authority. Core's module-contract parser validates these declarations; this carrier adds no selection, defaulting or second declaration validator.

Canonical encoding retains dependency declaration order and includes both fields in the signed payload and digest. It does not sort, trim or infer them. Absent fields add no keys; nil and empty dependency lists encode identically, so older manifest bytes and signatures remain unchanged. CLI owns populating these fields from the resolved contract and pairing the Authority document; Authority and the host own installation semantics and catalogue resolution. Each dependency's module is the exact signed module principal. The existing host catalogue resolves it to the target and binding; the principal is never treated as a target UUID, nor replaced by a route alias, binding ID or release.