Skip to content

Commit 2a83a53

Browse files
romamoclaude
andcommitted
Components declare non-secret environment as receipted state
Feature flags, Redis database ids, and other plain configuration move out of encrypted fragments into a declared spec.environment block on the Component: string-only values validated against control characters, typed into inventory, serialized (they are config, not secrets), and merged into the rendered environment file alongside secret fragments. A key may live in exactly one place - a declared key that also appears in a secret source fails rendering with secrets_key_conflict naming the key and file, so neither channel can silently shadow the other. The example component declares a flag and its Redis URL; the secrets guide documents the split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HX4L4HUFnJ5bq7SFQwHzDU
1 parent acdf127 commit 2a83a53

6 files changed

Lines changed: 99 additions & 7 deletions

File tree

‎docs/secrets-guide.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ sops secrets/production/crm/backend.env
5757
# opens your editor; write KEY=VALUE lines; sops encrypts on save
5858
```
5959

60+
## Non-secret configuration: declare it in state
61+
62+
Feature flags, Redis database ids, and other plain configuration do not
63+
belong in encrypted fragments — they belong in the component's declared
64+
`environment` block, where changes are diffable, reviewable, and audited
65+
like any other state:
66+
67+
```yaml
68+
# state/production/components/crm-backend.yaml
69+
spec:
70+
environment:
71+
FEATURE_SIGNUPS: "true"
72+
REDIS_URL: redis://127.0.0.1:6379/0
73+
```
74+
75+
Rendering merges declared entries with the secret fragments into one
76+
environment file. A key may live in **exactly one place**: if a declared
77+
key also appears in a secret source, rendering fails fast with a
78+
`secrets_key_conflict` naming the key and the offending file — no silent
79+
shadowing in either direction.
80+
6081
## Declaring and rendering
6182

6283
Projects declare platform-scope references; components declare project and

‎sdk/src/cloudfall/inventory.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,10 +494,11 @@ class ComponentInventory:
494494
service: ComponentService
495495
health_check: ComponentHealthCheck
496496
secret_refs: tuple[SecretReference, ...] = ()
497+
environment: tuple[tuple[str, str], ...] = ()
497498

498499
def as_dict(self) -> dict[str, object]:
499500
"""Serialize the component for inventory consumers."""
500-
return {
501+
result: dict[str, object] = {
501502
"id": self.resource_id.value,
502503
"project": self.project_id.value,
503504
"servers": [server.value for server in self.server_ids],
@@ -508,6 +509,9 @@ def as_dict(self) -> dict[str, object]:
508509
"service": self.service.as_dict(),
509510
"healthCheck": self.health_check.as_dict(),
510511
}
512+
if self.environment:
513+
result["environment"] = dict(self.environment)
514+
return result
511515

512516

513517
@dataclass(frozen=True, slots=True)
@@ -1453,9 +1457,22 @@ def _component_inventory(document: ResourceDocument) -> ComponentInventory:
14531457
),
14541458
health_check=_component_health_check(health),
14551459
secret_refs=_secret_references(spec.get("secretRefs")),
1460+
environment=_declared_environment(spec.get("environment")),
14561461
)
14571462

14581463

1464+
def _declared_environment(value: object) -> tuple[tuple[str, str], ...]:
1465+
if value is None:
1466+
return ()
1467+
if not isinstance(value, dict) or not all(
1468+
isinstance(key, str) and isinstance(entry, str)
1469+
for key, entry in value.items()
1470+
):
1471+
message = "validated component environment is not a string mapping"
1472+
raise TypeError(message)
1473+
return tuple(value.items())
1474+
1475+
14591476
def _secret_references(value: object) -> tuple[SecretReference, ...]:
14601477
if value is None:
14611478
return ()

‎sdk/src/cloudfall/secrets.py‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
_ERROR_SOURCE_INVALID = "secrets_source_invalid"
3434
_ERROR_COMPONENT_UNKNOWN = "secrets_component_unknown"
3535
_ERROR_DECRYPT_FAILED = "secrets_decrypt_failed"
36+
_ERROR_KEY_CONFLICT = "secrets_key_conflict"
3637
_KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
3738
_FILE_MODE = 0o600
3839

@@ -111,11 +112,19 @@ def render_environment(
111112
if project.resource_id == component.project_id
112113
)
113114
references = (*project.secret_refs, *component.secret_refs)
114-
merged: dict[str, str] = {
115-
key: value
116-
for reference in references
117-
for key, value in provider.fetch(reference)
118-
}
115+
declared = dict(component.environment)
116+
merged: dict[str, str] = dict(declared)
117+
for reference in references:
118+
for key, value in provider.fetch(reference):
119+
if key in declared:
120+
message = (
121+
f"{key} is declared in the component's non-secret "
122+
"environment and also provided by the secret source "
123+
f"{provider.source_path(reference)}; a key must live "
124+
"in exactly one place"
125+
)
126+
raise SecretsError(_ERROR_KEY_CONFLICT, message)
127+
merged[key] = value
119128
content = "".join(f"{key}={value}\n" for key, value in merged.items())
120129
output_path.parent.mkdir(parents=True, exist_ok=True)
121130
descriptor = os.open(
@@ -130,6 +139,7 @@ def render_environment(
130139
"environmentFile": str(output_path),
131140
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
132141
"keys": sorted(merged),
142+
"declaredKeys": sorted(declared),
133143
"references": [reference.as_dict() for reference in references],
134144
}
135145

‎sdk/tests/test_secrets.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,19 @@ def test_render_environment_merges_refs_in_override_order(
6868
assert "LOG_LEVEL=info" in content
6969
assert "LOG_LEVEL=warning" not in content
7070
assert "STRIPE_KEY=sk_test_123" in content
71+
assert "FEATURE_SIGNUPS=true" in content
72+
assert "REDIS_URL=redis://127.0.0.1:6379/0" in content
7173
mode = stat.S_IMODE(output.stat().st_mode)
7274
assert mode == 0o600
7375
assert result["status"] == "ok"
74-
assert result["keys"] == ["LOG_LEVEL", "SENTRY_DSN", "STRIPE_KEY"]
76+
assert result["keys"] == [
77+
"FEATURE_SIGNUPS",
78+
"LOG_LEVEL",
79+
"REDIS_URL",
80+
"SENTRY_DSN",
81+
"STRIPE_KEY",
82+
]
83+
assert result["declaredKeys"] == ["FEATURE_SIGNUPS", "REDIS_URL"]
7584
serialized = str(result)
7685
assert "sk_test_123" not in serialized
7786
assert "https://shared.example/1" not in serialized
@@ -124,3 +133,23 @@ def test_unknown_component_is_refused(tmp_path: Path) -> None:
124133
)
125134

126135
assert caught.value.code == "secrets_component_unknown"
136+
137+
138+
def test_declared_and_secret_key_conflicts_fail_fast(tmp_path: Path) -> None:
139+
provider = _provider(tmp_path)
140+
backend = tmp_path / "secrets" / "production" / "crm" / "backend.env"
141+
backend.write_text(
142+
"STRIPE_KEY=sk_test_123\nFEATURE_SIGNUPS=false\n", encoding="utf-8"
143+
)
144+
145+
with pytest.raises(SecretsError) as caught:
146+
render_environment(
147+
_context(tmp_path),
148+
ResourceId.from_boundary("crm-backend"),
149+
provider,
150+
tmp_path / "out.env",
151+
)
152+
153+
assert caught.value.code == "secrets_key_conflict"
154+
assert "FEATURE_SIGNUPS" in caught.value.message
155+
assert "exactly one place" in caught.value.message

‎state/examples/components/crm-backend.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ spec:
2929
- 127.0.0.1
3030
- --port
3131
- "8100"
32+
environment:
33+
FEATURE_SIGNUPS: "true"
34+
REDIS_URL: redis://127.0.0.1:6379/0
3235
healthCheck:
3336
type: http
3437
scheme: http

‎state/schemas/v1/component.schema.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,18 @@
193193
}
194194
]
195195
},
196+
"environment": {
197+
"type": "object",
198+
"minProperties": 1,
199+
"patternProperties": {
200+
"^[A-Za-z_][A-Za-z0-9_]*$": {
201+
"type": "string",
202+
"maxLength": 1000,
203+
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
204+
}
205+
},
206+
"additionalProperties": false
207+
},
196208
"secretRefs": {
197209
"type": "array",
198210
"items": {

0 commit comments

Comments
 (0)