Skip to content

Latest commit

 

History

History
543 lines (475 loc) · 30.7 KB

configuration-contract.md

File metadata and controls

543 lines (475 loc) · 30.7 KB

Producer Configuration Contract

Codefly defines the configuration contract. Infrastructure producers, including infra-base, emit it. The CLI does not translate a producer's resource inventory, database engine conventions, proxy mode, or credential naming scheme.

environments.CoordinateContract schema codefly/coordinate/v1 wraps the existing environments.Environment model under environment. JSON keys inside that object are exactly the workspace YAML keys. The CLI owns this deployment document; it projects runtime context into Core. Core does not parse the coordinate contract. coordinate is an optional opaque provenance label; the document is named for its subject, and "cell" is not a word the model has.

There is one spelling. A document naming itself codefly/cell/v1 or codefly/cell/v2 is refused: under a strict parser the alternative to an error is a silent guess, and a permanent fallback is how a retired word outlives every decision to retire it.

Examples accepted by the CLI are in ../pkg/environments/testdata/coordinates/managed-identity.json and ../pkg/environments/testdata/coordinates/password-auth.json. They are contract fixtures, not claims that a particular infrastructure producer already emits this format.

Producers must supply:

  • The selected environment name and namespace. Import refuses a different target rather than reusing credentials or delivery paths resolved for another target.

  • Each managed service under its explicit service key, with its endpoint and port. Multiple services are supported without assigning the first one to store. The key is <module>/<service>, or a bare <service> where only one module of the workspace declares that name. Composed modules routinely ship a service of the same name — a redis, say — and a bare key covers every one of them, so all of them would render with this entry's address and secrets while only one was meant; a bare key matching several is refused at workspace load, naming the candidates. A qualified key matching no service is refused too: it is a typo in the module or the service name, and the service it should have replaced would otherwise deploy as its module declares it with nothing reporting the entry went unused. A bare key matching nothing stays inert, so a contract may declare a fleet's managed services for a workspace composing only some of them.

  • Explicit secret references, including the target Secret name, remote key, optional property and secret store. No declaration means no reference is added; it does not assert that the service is passwordless.

  • Any workload identity principal and opaque annotation/label attachments. Identities and secret references can coexist. The CLI does not infer an auth mode. There are two homes, for two questions. A managed service's identity is reachable only through a managed service the workload consumes; what a workload authenticates as regardless of that goes under service-identity, keyed the way service-config and service-secrets are — an environment-wide default plus the services that differ. A per-service entry replaces the default outright: an override is total, and must restate the annotations and labels it still needs. A principal whose platform attachment is missing authenticates as nothing, so an override that carries only a principal is a declaration that cannot work where the identity webhook keys off a label. Unknown keys in either block are refused rather than dropped: a mistyped services would otherwise leave a valid default standing and silently collapse every override onto it.

  • Any labels the cell needs on a service's Service under service-mesh, keyed <module>/<service>: today the waypoint attachment that puts the cell's protected waypoint in front of a presenter. The values are the cell's convention, so the render carries them verbatim and derives none of them:

    service-mesh:
      services:
        shop/gateway:
          labels:
            istio.io/use-waypoint: <namespace>-waypoint
            istio.io/use-waypoint-namespace: istio-waypoint

    The render stamps them as metadata.labels on that service's Service, as a patch in the unit's environment overlay, and on no other Service: a Service is the finest attachment there is, so every port of an attached Service goes behind the waypoint's L7 hop. An entry is refused, naming the service, unless the service declares a presenter endpoint under cell:; a service the workspace does not have, an unknown key, a codefly.dev/ label and a label the rendered Service already carries are refused too.

  • Where every workload exports operational telemetry, under telemetry: the OTLP intake the cell's infrastructure provides, resolved by the producer like every other address, as an https:// (or, declared insecure, http://) URL:

    telemetry:
      otlp-endpoint: https://otel.internal.ops.example:443

    The CLI delivers it to every service it runs or renders as OTEL_EXPORTER_OTLP_ENDPOINT, with OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES (codefly run, docs/commands.md: "Where a service's telemetry goes"). No module declares a configuration group for it.

  • Any application secret mappings through the existing service-secrets model.

  • Any resolved non-secret values through service-config, keyed by consuming service and then by the exact key the service reads. The producer resolves the value; The CLI carries it verbatim and derives none of it. A declared service with nothing to inject, or a value that resolved to empty, is refused at load.

Values and secret references are two blocks, not one dictionary of either-or entries: a resolved value belongs in service-config, a reference in service-secrets. One service declaring the same key in both is refused rather than resolved — both render an entry of that name into one container, where one silently overwrites the other. The refusal compares explicit remote-keys; a defaults template covers whichever of a service's own keys are declared secret, which the environment block alone cannot see.

  • Resolved delivery repository, branch and path when declaring a GitOps target. The CLI neither appends the namespace nor chooses a branch.

Local configuration can declare configuration-profile (or the explicit chain configuration-profiles, e.g. [staging, local]: each configuration location reads the first profile it holds, never merging profiles — Core's docs/workspace-composition.md) and secrets without a cluster or registry. Import is configuration admission, not deployment approval: CLI still validates the selected operation, service graph and deployment target. Local secret resolution and deployed secret projection remain separate consumers of the existing configuration model; secret values do not belong in descriptors.

Unknown fields and required capabilities are rejected. A producer declaring an identity on a managed service uses requires_capabilities: ["managed-service-identity"]; one declaring a consuming service's own identity uses ["service-identity"]. Proxy containers, image choices and loopback routing are not part of this contract.

The projected identity token, and the path that names it

A workload a host admits proves who it is with a ServiceAccount token the render projects for the host's audience. Two things reach the container, and both are the render's:

  • the codefly-workload-identity volume, mounted read-only at /var/run/secrets/codefly, with the token at token inside it; and
  • CODEFLY__WORKLOAD_TOKEN_FILE, carrying that file's full path.

The mount alone is not the contract. The SDK's ProjectedTokenFile is a path value its caller supplies and reads no environment itself, and the consumer that presents the credential reads only this variable and refuses when it is absent — it infers no default path. A workload that mounts the token and is told nothing therefore acquires no publication lease, and its readiness refuses, with a correctly rendered volume and ServiceAccount. A native run is handed the same variable name, from the same constant, pointing at the path it writes the projection to; deployed and native differ only in the path.

The value is derived from the mount and the filename rather than written twice, so the projection and the path it advertises cannot drift. Only the container that mounts the token is told about it — never a sidecar beside it.

The variable's name contains TOKEN, so it is classified as a credential key, and a literal value under such a key is normally refused: that is how a secret would otherwise become a plaintext manifest. A mount path is not secret material, so the render admits exactly one pair — this variable name carrying exactly the path it mounts — and refuses everything adjacent: a different value under the same name, the same path under another credential-named key, a real token literal, and the variable on any container that does not mount the token at that path. There is no general exemption for *_FILE names and the classifier itself is unchanged.

Public module artifacts

A workspace configuration value may be bound to a file in a composed module instead of being written out as a literal, so a public artifact a module already ships — an installation document, for example — reaches services as configuration without being copied into the composition and kept in step by hand.

The binding is declared in the selected profile, in configurations/<profile>/module-artifacts.codefly.json, and names only a destination and a source:

{
  "schemaVersion": 1,
  "bindings": [
    {"configuration": "docs", "key": "README",
     "source": {"module": "wiki", "path": "docs/install.md"}}
  ]
}

Acquired once per ordinary load, and nothing is frozen across loads. Every ordinary configuration load — a run's delivery, a codefly doctor check — acquires the declared file afresh and carries its exact bytes, with the SHA256 and byte count core computes from those bytes, on the value that load delivers. A standalone check therefore describes what it observed and proves nothing about a later delivery; an already running service keeps the configuration it was delivered until its ordinary lifecycle replaces it. A run whose planning read saw one version and whose delivery load saw another delivers the second, and never presents the first as its evidence.

What the CLI refuses. The declared path is opened under the selected module's directory with confined, non-blocking, regular-file access, so an absolute path, a .. element, a symlink leaving the module, a FIFO, a directory and a missing file all refuse; so does a file that changed while it was being read. A composed module this machine has not materialized refuses rather than being fetched behind the read, and a path the CLI materialized needs a resolution receipt that answers the request the workspace makes now. An explicit path:, an overlay path or a worktree legitimately has no receipt: its bytes are local development evidence, not release authenticity. Core then bounds the bytes at the inline carrier threshold (32 KiB, inclusive), requires valid UTF-8 with no NUL, and refuses text that would need interpolation.

No value can quietly take a bound destination. A literal, secret, template or structured document offered for the same destination is a conflict rather than a silent winner, so moving an existing literal onto a binding is an explicit source edit: remove the literal and add the binding. At delivery the same rule holds against additional offers — a configuration the agent returns from Init, a dependency's configuration at Start, a --set or derived runtime override — including one that offers the identical bytes. Equality buys no replacement right, and an empty override map does not skip the check. Removing a binding restores ordinary ownership on the next load; there is no generated state to clean up.

An explicit binding is the author's assertion that these bytes are public. Confinement and a digest do not establish publicness, and no scanner or filename rule can: a binding declaring private or mixed-secret content as public exposes both the content and a testable digest. That judgement belongs to whoever reviews the composition.

Such a value is delivered inline to the process. It cannot be exported through --output-env when its bytes contain a line break, which that raw format cannot represent (see docs/commands.md).

Module-owned keys

A few keys on an environment belong to a module agent, not to the CLI. The CLI admits them, preserves them on load and save, and hands them verbatim to the module bundle generator in the workspace it stages for it; it never interprets them, never validates their values (the agent does, and refuses an unsupported one by name), and never sends them to a service agent: Runtime() projects none of them.

Block Key Meaning (the module agent's)
environment key-service Which key service holds this environment's keys, in the key-service configuration group's KEY_SERVICE_BACKEND vocabulary. A module reads it to decide whether it composes a secrets store here at all.
managed-services.<svc> auth-mode password (a connection secret through secret-references) or external-identity (the workload authenticates as its own identity).
managed-services.<svc> egress-namespace For an in-cluster managed dependency, the namespace the caller's egress policy selects on.
managed-services.<svc> instance-connection-name The project:region:instance coordinate a Cloud SQL instance is addressed by, which is not a DNS name.
environments:
  - name: staging
    key-service: kms
    managed-services:
      shop/store:
        kind: cloud-sql-postgres
        external-name: 127.0.0.1
        port: 5432
        auth-mode: external-identity
        instance-connection-name: project:us-east1:store

The list is closed (environments.CarriedEnvironmentKeys, environments.CarriedManagedServiceKeys): every other key in either block is still refused by name at workspace load, so key-servce fails exactly as a mistyped CLI field does, and a module-owned key written on the wrong block is refused too. Admitting a new one is one entry in that list. A key written with no value is carried as null, which a module agent may read as unstated; refusing it is the agent's, as for any other value. Because the keys load, a declaration on one deployed environment no longer fails the whole workspace — including the local environment a codefly run selects.

A module-owned key only reaches a module that renders its own bundle (a module whose module.codefly.yaml names a module agent). It does not change the service set the CLI itself renders for a module composed by version; that is exclude-services, below.

Excluding a service from one environment

exclude-services withdraws named services of composed modules from one environment. The composition decides it; the module's own services: list is untouched, so every other environment — the local run included — still composes the service.

environments:
  - name: staging
    key-service: kms          # the module's own word for why (module-owned key)
    exclude-services:
      - service: saas/vault
        withdrawn-from: [saas/accounts]

Nothing may still reach what is withdrawn: a service that declares a dependency on it is refused unless withdrawn-from names it, and so are a solution consuming one of its endpoints, an environment declaration naming it and a configuration reference to one of its endpoints. Those refusals are judged for the environment the operation selected, and the message names that environment: an operation is not refused because a different environment's exclude-services, service-scoped names or managed-services are inconsistent with the workspace graph. A bare service-scoped key the producer has re-keyed is retired explicitly, by an operator directive on codefly environment import — never inferred from a matching name or equal values; see the command reference. A malformed exclusion is a separate matter — the check validates every environment's declarations for shape before comparing the selected one against the graph, so one of those refuses whatever was selected. It is the validation that refuses, not the load: such a workspace still loads, and a clean environment still selects. That distinction is load-bearing, because the graph a declaration is held to is the workspace's current composition — an environment imported before a module overlay added a service carries no withdrawn-from for it, and holding every environment to that graph refuses operations on environments whose own declarations are complete. A service the module's interface: exports, or names its service-entry, is withdrawn only with the whole module: an environment that names every service of a module leaves the module out, and its exports leave with it. Withdrawing some of its services is refused, because the module the environment still composes would promise an endpoint that is not there.

environments:
  - name: staging             # the host is another module's service here
    exclude-services:
      - service: authority/local
      - service: authority/hop
        withdrawn-from: [documents/documents]
      - service: authority/edge
      - service: authority/store

A product whose hosted environment is imported can keep this decision in an authored delivery.environments.yaml and pass it alongside the producer's contract:

environments:
  blue:
    exclude-services:
      - service: saas/vault
        withdrawn-from: [saas/accounts]
  green:
    exclude-services:
      - service: saas/vault
        withdrawn-from: [saas/accounts]
codefly environment import blue --coordinate-contract blue.json --product-intent delivery.environments.yaml

This optional input accepts only environments.<name>.exclude-services and, optionally, environments.<name>.service-secrets with generate and may-be-empty:

environments:
  blue:
    exclude-services: []
    service-secrets:
      generate:
        - {scope: service, configuration: clickhouse, keys: [CLICKHOUSE_PASSWORD], services: [tracing/evidence]}
      may-be-empty: [CODEFLY__SERVICE_SECRET_CONFIGURATION__SAAS__ACCOUNTS__SENTRY__DSN]

Which keys are minted at random and which hold no value are decisions about the product's own configuration, which no cell producer can know; where the cell's store files each value is a cell fact. So the import merges the two: secret-store, defaults and services (remote keys, per-service stores, templates, refresh intervals) come from the contract, generate and may-be-empty from the intent, and a contract declaring either of those is refused. Once the intent is passed it is their only source: re-importing an intent that drops an entry withdraws it from the workspace. Each generator must name a secret configuration a composed service (or, for scope: workspace, the workspace) declares in the environment's profile, with keys that configuration declares, and each may-be-empty key a stored secret key of a declared configuration; anything else is refused by name. An intent declaring service-secrets for an environment with no secret store in the contract or the workspace is refused.

It is not the historical full delivery.intent.yaml launch format: profiles, host and ingress are not interpreted here. The coordinate producer continues to own placement, runtime bindings, the secret store and key-service. Unknown fields, placement overrides, a missing target environment, malformed exclusions, an invalid withdrawal and exclusions declared by both inputs are refused before workspace or profile files are written. Only the selected environment is imported. Use exclude-services: [] to clear an earlier exclusion; a missing or null list is refused. Omitting --product-intent preserves the existing importer behavior, including already imported exclusions omitted from the producer's contract.

In that environment an excluded service is not in the dependency graph: it is not built, published, rendered or started, has no unit in the render inventory, no tree under deployments/modules/<module>/services/, no workload in the cell file and no mesh principal; build module and deploy module skip it there. A tree of the excluded service's own module rendered before the exclusion is refused by name — by the cell file and by publish — until that module is rendered again. (Another module's tree rendered before the exclusion is not re-checked: render the consumers again after excluding a service they reached.)

Nothing may still consume what is withdrawn, so the render can never produce a graph with a hole in it. Each is refused before anything renders, builds or runs — every environment by the render's workspace validation, the selected environment by a run, build or deploy, and a configuration reference by the plan — naming the service and what holds on to it:

  • A declared dependency. A service whose service-dependencies name the excluded one is refused unless withdrawn-from names it. Naming it withdraws that edge with the service: the consumer is handed no address for it, and no mesh edge or cell consumer is derived from it. Every consumer must be named, and every name must be a consumer, so a stale entry is refused too. Withdraw an edge only from a consumer that does not use it in that environment (in the example, accounts on the cloud key service opens no Vault connection).
  • A configuration reference. A workspace configuration a rendered service resolves — one it declares or one the composition root provides — that carries ${endpoint:<module>/<service>/…} for an excluded service is refused at plan time, and by the cell file; drop the reference from that configuration in this environment, or keep the service.
  • A declaration of the environment. ingress, host.gateway, host.delivery, cell, egress, service-mesh, service-identity, service-config, service-secrets (including a generator's services) and managed-services naming the service are refused — a service is either rendered, managed or excluded, never two of them.
  • A module interface export, a service entry, a solution's consumption. A module whose interface exports one of the excluded service's endpoints, a module naming it its service-entry, and a solution whose manifest api.consumes one of its endpoints are refused: each promises the composition what this environment does not have.

Entries are <module>/<service>, never bare names; an unknown key in an entry is refused. A codefly run, build or deploy of the excluded service itself in that environment is refused: there is nothing to run there.

Configuration and secret injection

Injecting a workload's configuration and secrets needs four declarations and no others: the target (name, namespace, cluster.context), resolved values under service-config, secret references under service-secrets, and a workload identity under service-identity. ../pkg/environments/testdata/coordinates/config-injection.json is that whole shape.

The fourth is why service-identity exists rather than being read off a managed service. A descriptor for this flow declares no managed-services, so an identity carried there would be unreachable: the consumer renders no ServiceAccount, the pod keeps the namespace default, and the ExternalSecret that did render cannot authenticate to the store. Nothing fails at import or at render, and the first signal is in-cluster. Environment.WorkloadIdentity(service) resolves the per-service entry or the default; composing that with a managed service's own identity belongs to the consumer, which knows which services consume what — an Environment does not.

A producer emitting for this path populates nothing else. managed-services, registry, ingress, resource-quota and dns serve other flows and are correctly absent here; a producer's own inventory — database engines, store isolation tiers, cloud and location, operator cluster access — has no home in this contract by design, and a non-secret value does not become one by being routed through a secret store to find somewhere to live.

Migration

Retired codefly/cell/v1 is rejected, not converted. Its database-to-store translation, password_auth inference, manufactured secret-store/<namespace>/store handoff and implicit delivery paths are removed. The producer must emit the resolved Codefly environment directly, and the CLI import fixtures must move with it. No compatibility shim reads infra-base's private format. Producers and CLI must qualify against the CLI-owned coordinate contract before release.

Workload identity projection validates a staged manifest tree and atomically exchanges it with the destination on Linux and macOS. Cancellation before the exchange leaves the destination unchanged; after publication the complete tree is committed. Filesystems without atomic directory exchange fail without a per-file publication fallback. Existing file and directory permissions survive.

Ownership

The strict parser and deployment declarations live in pkg/environments. Core owns generic runtime configuration and secrets, not this document. Kubernetes identity attachments, ingress, quotas and cloud deployment policy belong here. The spelling codefly/coordinate/v1 and existing producer fields are unchanged.

service-config.values entries are injected under their exact environment names into containers declaring that service through Core's CODEFLY__SERVICE runtime variable, directly or through a referenced, namespace-matched ConfigMap. Container names are agent-owned and are not service selectors. Only the selected overlay's resource graph supplies these declarations. Explicit service-secrets keys render both a container secretKeyRef and an ExternalSecret reference. Neither secrets nor configuration are implicitly copied to sidecars. Rendering refuses a declaration that cannot bind to a service container, and a literal configuration value cannot replace a rendered secret reference.

A per-service secret mapping may specify refresh-interval and a template with engine-version: v2, merge-policy: Merge and data expressions for its explicit remote keys. These are External Secrets declarations, evaluated only by ESO. The CLI never resolves or evaluates secret expressions. Other template engines, replacement policies and undeclared output keys fail admission.

A producer may also declare one of its secret values as a template over its own secret configuration values (core's ConfigurationValue.template). The CLI translates it into the consumer's ExternalSecret target.template over the producer's primitives, read from the producer's remote keys through whichever surface the producer itself resolves through, and never reads the assembled value from the store. The translation reproduces core's EvaluateConfigurationValueTemplate byte for byte. A template of literals only is refused: under a credential-named key it would be a value in the tree. So is a template referencing a key the producer's own deployment does not read as a secret, and a producer whose keys resolve through a different store than the consumer. The template is emitted with mergePolicy: Replace and an entry for every key the consumer references, so the primitives it reads are fetched but never emitted into the consumer's Secret. Rendered manifests admit template delimiters, and credential-named keys, only at an ExternalSecret's spec.target.template.data.<key>, and only for a value that carries a template action.

What is credential-named is core's decision, narrowed in exactly one place: a public OAuth client identifier. A client id identifies the client to an authorization server and is published in redirects, in discovery documents and in browser requests; the matching secret is the client secret. Core already calls a bare CLIENT_ID public; what it still classified was the spelling an operator writes — OAUTH_CLIENT_ID, AUTH_CLIENT_ID — and only through the AUTH word in front of it, so the render refused a public value and the only way to deliver it was a secretKeyRef into a store entry somebody had to fill. A key is read as a public client identifier only when all three hold: it ends in the client-identifier spelling (client_id, client-id, clientId), core classifies it today (this narrows core's reading and never widens it), and removing the AUTH marker makes core stop classifying it — so the AUTH word is the only credential signal in the name. CLIENT_SECRET, OAUTH_ACCESS_TOKEN, AUTH_PASSWORD, OAUTH_CLIENT_KEY, CLIENT_ID_SECRET and OAUTH_TOKEN_CLIENT_ID all keep their refusal, and a secret configuration carrier (CODEFLY__WORKSPACE_SECRET_CONFIGURATION__…) is sensitive whatever its key: the value was authored as a secret, and that declaration outranks any reading of its name. Both sides are pinned by TestTheRenderGuardAcceptsAPublicClientIdAndRefusesACredential and TestRenderAcceptsAPlainClientIdAndRefusesAPlainClientSecret (pkg/gitops).

This narrows the render's own tree guard. Promotion to a secretKeyRef (pkg/orchestration) is unchanged and still routes such a key to the secret store, because core's restricted-deployment guard (validateRestrictedDeploymentRequest) refuses the inline value by the same classification; narrowing that half needs the change in core.

Validation rebuilds the selected overlay and checks the complete CLI-owned ExternalSecret delivery specification, including its store, target, keys and properties. Overlay patches may not redirect or replace that specification. The target Secret and every consuming workload must share a namespace. Workload identity is checked at pod scope against the ServiceAccount's namespace and name, independently of container configuration bindings.

Qualification limits

The actual infra-base fixture and its immutable source provenance are under pkg/environments/testdata/coordinates. The Lodestar output selects Accounts' writer identity, public identity keys its source reads, exact remote secret references, the existing registration-digest expression, cluster and GitOps target. The integration test imports this emitted JSON and verifies effective Kustomize output without managed services. Database convention values remain informational for consumers that have not adopted those keys. Azure runtime database bindings are not inferred from the GCP declaration.

Core transports structured runtime data through its scoped, versioned JSON carrier, and SDK-Go exposes raw and typed document accessors. This is separate from service-config.values, whose explicitly declared environment values remain strings. Render qualification does not establish live cloud identity, secret-store access or database connectivity.