Skip to content

feat(environment): product intent declares service-secrets generate and may-be-empty - #1149

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
feat/product-intent-service-secrets
Oct 24, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
feat/product-intent-service-secrets

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

Gap

An environment's service-secrets block had no supported producer for an imported hosted environment. codefly environment import <env> --coordinate-contract … --product-intent … accepted only environments.<env>.exclude-services from the intent, and the coordinate contract (infra-base obinctl coordinate-contract) carries no generate / may-be-empty. platform-obin's blue environment got its generators from a pre-release build that read the legacy delivery.intent.yaml; nobody could add a generated key such as {scope: service, configuration: clickhouse, keys: [CLICKHOUSE_PASSWORD], services: [tracing/evidence]} without hand-editing imported data. Without that, codefly deploy secrets --rotate tracing/evidence/clickhouse:CLICKHOUSE_PASSWORD is refused ("service-secrets.generate does not declare it"), so a leaked password cannot be rotated.

Design

The product intent may declare, per environment:

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

Field ownership. I went through every service-secrets field:

  • generate: product-owned. Which of the product's own configuration keys are minted at random is a decision about the product's configuration, and no cell producer can know it.
  • may-be-empty: product-owned. Which of the product's keys hold no value in an environment is the same kind of decision, matched on the product's stored key.
  • secret-store: contract-owned. It names the store the cell runs.
  • defaults and services.<svc>.{remote-keys, secret-store, template, refresh-interval}: contract-owned. They say where and how the cell's store files each value.

environments.ProductSecretPolicy (new, pkg/environments/product_secrets.go) holds the two product fields and ProductSecretPolicyKeys names them.

Precedence

  • Contract-owned fields come from the contract, merged exactly as before.
  • Product-owned fields come from the intent. When --product-intent is passed, a contract that also declares service-secrets.generate or may-be-empty is refused ("declared by both … select one owner"), the same rule as exclude-services. Neither owner silently wins.
  • Withdrawal. With the flag, the intent is the only source of those two fields. An entry the intent no longer carries is withdrawn on re-import, including when the intent declares no service-secrets at all. This generalises fix(environment): re-import withdraws a registry mirror the contract no longer carries #1140's withdrawContractOwnedAbsences into withdrawOwnedAbsences(…, owned, parentOptional). It is the same mechanism with a second owned set, not a second mechanism. Contract-owned keys still need a declared parent.
  • No flag means no change. Without --product-intent, nothing about service-secrets changes, so a producer-only refresh keeps the product's policy.
  • No store, no policy. An intent that declares a policy for an environment with no secret store in either the contract or the workspace is refused with that reason.

Validation

  • Strict decoding is kept. KnownFields(true), and EnvironmentSecretGenerator's own unknown-key refusal. An unknown policy field (for example secret-store in the intent) is refused.
  • Structural checks reuse the workspace rules. They run through the existing EnvironmentSecretGenerator.validate, for every environment in the intent.
  • Entries are checked against declared configurations. environments.ValidateSecretPolicy loads the environment's configurations with core's ConfigurationInformationLocalReader.Load(env.Runtime()), the same read pkg/gitops' configured-endpoint check uses. The checks are:
    • A generator's service must be composed.
    • That service (or the workspace, for scope: workspace) must declare a secret configuration of that name, holding those keys.
    • A generator with no services needs at least one composed service declaring it.
    • A may-be-empty key must be a stored secret key of a declared configuration.
  • Refusals name the item. Each one names the service, configuration or key, and nothing is written.

Reused: EnvironmentSecretGenerator.validate, core's configuration reader and key derivation (NameToKey, ServiceSecretConfigurationKeyFromUnique, WorkspaceSecretConfigurationPrefix), the existing withdrawal helper, and the exclusion import path. Not built: no new intent format, and no parallel configuration scan.

Classification: fix, at the importer that owns turning authored product decisions into the environment block.

Tests

cmd/environment/product_intent_test.go:

  • TestProductIntentImportsAndWithdrawsSecretPolicy checks four things:
    • The import adds generate and may-be-empty, and the contract's secret-store and services.remote-keys stay untouched.
    • A producer-only refresh keeps the policy.
    • Dropping may-be-empty withdraws it and keeps generate.
    • An intent with no service-secrets withdraws both, and the contract fields remain.
  • TestProductIntentSecretPolicyRefusalsDoNotWrite covers each of these cases, refused by name with the workspace byte-identical: unknown service, undeclared configuration, undeclared key, a workspace group that does not exist, undeclared may-be-empty key, unknown generator field, unknown policy field, invalid scope, dual ownership, no secret store.
  • TestProductIntentGeneratorWithoutServicesNeedsADeclaringService covers a generator with no services.
  • Existing behaviour is unchanged. The exclude-services tests (TestProductIntentImportPreservesPlacementAndSupportsClearing, …RefusalsDoNotWrite, …RefusesDualOwnership, …FlagDryRun) and the fix(environment): re-import withdraws a registry mirror the contract no longer carries #1140 withdrawal tests pass unchanged.
  • The tests catch regressions. With the withdrawal call and then the validation call removed, the new tests fail; restored, they pass.

Ran: go test ./cmd/environment/... ./pkg/environments/... ./pkg/deploysecrets/... ./cmd, make lint (0 issues).

Docs

  • The --help long text and flag text, regenerated into docs/cli-reference.md.
  • The docs/commands.md environment import section.
  • The product-intent section of docs/configuration-contract.md, with the example and the ownership argument.

Not verified

  • Not run against platform-obin. I did not run it against platform-obin's real delivery.environments.yaml or the blue contract, and I did not run deploy secrets --rotate end to end after an import.
  • Configuration-source edge case. The configuration check reads the profile as it is on disk before materializeConfigurationProfile writes it. On the first import of an environment with a new configuration-source, workspace groups that only the not-yet-materialised profile would carry are not visible to the check. Service configurations are unaffected.
  • Document groups are refused. A structured .secret.yaml document group has no enumerable keys, so a generator naming keys in one is refused. I did not confirm the render's key naming for document groups.

🤖 Generated with Claude Code

…nd may-be-empty

An imported hosted environment had no supported producer for its secret
policy: --product-intent accepted only exclude-services and the coordinate
contract carries no service-secrets, so a generated key (and with it
`deploy secrets --rotate`) could only be added by hand-editing imported data.

The product intent may now declare, per environment,
service-secrets.generate and service-secrets.may-be-empty: which of the
product's own keys are minted and which hold no value are product
decisions; secret-store, defaults and per-service mappings name the cell's
store and stay the contract's. With --product-intent the intent is the
only source of those two fields: a contract declaring one is refused, and
an entry the intent drops is withdrawn on re-import (as #1140 does for
registry mirrors). Each entry is checked against the secret configurations
the environment's profile declares; an unknown service, configuration or
key is refused by name. Decoding stays strict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit a36ceed Oct 24, 2026
14 checks passed
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.

1 participant