Commit f36a674
authored
Add cross-resource references to Function, Alias, FunctionUrlConfig and LayerVersion (#232)
Adds cross-resource references to 4 fields across 4 resources in `lambda-controller`.
These come from a fleet-wide audit for CRD spec fields that hold another AWS resource's identifier but have no `references` block, each of which forces a user to hardcode an ARN, ID, or name they cannot know until the other resource exists.
### Fields Added
| # | Field (`generator.yaml` path) | Target | `path` |
| --- | --- | --- | --- |
| 1 | `Function.Layers` | lambda `LayerVersion` (same service) | `Status.ACKResourceMetadata.ARN` |
| 2 | `Alias.FunctionVersion` | lambda `Version` (same service) | `Status.Version` |
| 3 | `FunctionUrlConfig.Qualifier` | lambda `Alias` (same service) | `Spec.Name` |
| 4 | `LayerVersion.Content.S3Bucket` | s3 `Bucket` | `Spec.Name` |
Notes on individual fields:
- **(1) `Function.Layers`** requires the versioned layer ARN ("Specify each layer by its ARN, including the version"). `LayerVersion` sets `ACKResourceMetadata.ARN` from `LayerVersionArn`, which carries the trailing version; `Status.LayerARN` is the *unversioned* ARN and would be the wrong choice. The field already carried `set: ignore` on Create and ReadOne, with `LayerStatuses` as the read-only echo, so the new `layerRefs` companion is not clobbered by the response.
- **(2) `Alias.FunctionVersion`** holds the bare numeric version (`pattern: ^(\$LATEST(\.PUBLISHED)?|[0-9]+)$`), not an ARN, so it targets `Status.Version` on `Version`. Being API-required, the field correctly leaves the CRD `required` list now that either form is acceptable. `$LATEST` remains expressible through the concrete field.
- **(3) `FunctionUrlConfig.Qualifier`** is documented as "The alias name" and its pattern (`^((?!^\d+$)^[0-9a-zA-Z-_]+$)$`) rejects an all-digits value, ruling out a version number and leaving an alias name as the only legal content — hence `Spec.Name` on `Alias`.
- **(4) `LayerVersion.Content.S3Bucket`** mirrors `Function.Code.S3Bucket`, already wired to s3 `Bucket` at `Spec.Name`. **This one needed more than a `references` block:** `PublishLayerVersion` returns a `LayerVersionContentOutput` that shares no members with the `LayerVersionContentInput` held in the spec, so the generated write-back replaced `Spec.Content` with an *empty struct* and would have dropped the new `s3BucketRef` on the first reconcile. `set: ignore` is therefore added for Create and Update (ReadOne was already ignored). `Content` is user-supplied with no server default, so no `late_initialize` is needed. Side benefit: a `LayerVersion` created with a concrete `content.s3Bucket` now keeps it in `.spec` instead of having it wiped to `{}` after create.
No new module dependency: `s3-controller` is already required by the existing `Function.Code.S3Bucket` reference, so `go.mod` is unchanged and `ATTRIBUTION.md` does not need regenerating.
### Verification
Deployed this branch to `ack-dev-auto` (`us-west-2`) with a 60s resync period (`ack-workspace deploy lambda --resync-period 60`), alongside `s3-controller` for the cross-service target, and tested each field. PASS requires all four conditions, held delta-free across at least 3 reconciles: `ACK.ReferencesResolved=True`, `ACK.ResourceSynced=True`, concrete field absent from `.spec`, `*Ref` present in `.spec`.
| # | Resource.field | RefsResolved | Synced | Concrete absent | `*Ref` present | Deltas / 4 resyncs | Result |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | `Function.layerRefs` | ✅ | ✅ | ✅ | ✅ | 0 | **PASS** |
| 2 | `Alias.functionVersionRef` | ✅ | ✅ | ✅ | ✅ | 0 | **PASS** |
| 3 | `FunctionUrlConfig.qualifierRef` | ✅ | ✅ | ✅ | ✅ | 0 | **PASS** |
| 4 | `LayerVersion.content.s3BucketRef` | ✅ | ✅ | ✅ | ✅ | 0 | **PASS** |
**Summary: 4/4 PASS** — resync period 60s, delta-free across 4 reconciles.
Each reference was additionally confirmed against AWS rather than only against the CR, since three of these paths are echoed back in a form that would expose a wrong `path`:
- **(1)** `status.layerStatuses[0].arn` = `arn:aws:lambda:us-west-2:…:layer:ack-ref-test-layer:1` — the **versioned** ARN reached AWS and was attached, which is what `Status.ACKResourceMetadata.ARN` resolves to. `Status.LayerARN` would have produced the unversioned form and been rejected.
- **(2)** `aws lambda get-alias` returns `FunctionVersion = 1` — the bare version string was sent, not an ARN. Note `Version.status.version = "1"` while `status.qualifier` is `null`, confirming `Status.Version` is the populated field and the right choice of `path`.
- **(3)** `status.functionARN` = `…function:ack-ref-test-fn:ack-ref-test-alias` — the URL is scoped to the referenced alias. `GetFunctionUrlConfig` does not echo `Qualifier`, so this is the only way to observe that the resolved alias name was sent.
- **(4)** The layer published successfully from the referenced bucket (`…layer:ack-ref-test-layer:1`), which is the available signal because `Content` is write-only.
**The zero-delta counts are not vacuous.** With no delta the runtime logs nothing per reconcile, so the log signal was proven live by injecting drift out-of-band (`aws lambda update-function-configuration --description …`). The controller detected it 33s later, logged `desired resource state has changed` with `diff paths: [['Spec','Description']]`, and reverted it. So reconciles were running during the measurement window and the delta log appears when there is one — the only diff observed on any test resource was the injected one, never a reference field.
<details>
<summary>Test manifests and observed state</summary>
Reference chain: s3 `Bucket` → `LayerVersion` → `Function` → `Version` → `Alias` → `FunctionUrlConfig`. Every manifest sets the `*Ref` and omits the concrete field.
```yaml
apiVersion: lambda.services.k8s.aws/v1alpha1
kind: LayerVersion
metadata:
name: ack-ref-test-layer
spec:
layerName: ack-ref-test-layer
compatibleRuntimes: [python3.12]
content:
s3BucketRef: # gap 4; content.s3Bucket omitted
from:
name: ack-lambda-ref-test-bucket
s3Key: layer.zip
---
apiVersion: lambda.services.k8s.aws/v1alpha1
kind: Function
metadata:
name: ack-ref-test-fn
spec:
name: ack-ref-test-fn
role: arn:aws:iam::…:role/ref-test-lambda-role
runtime: python3.12
handler: lambda_function.handler
code:
s3BucketRef:
from:
name: ack-lambda-ref-test-bucket
s3Key: fn.zip
layerRefs: # gap 1; spec.layers omitted
- from:
name: ack-ref-test-layer
---
apiVersion: lambda.services.k8s.aws/v1alpha1
kind: Alias
metadata:
name: ack-ref-test-alias
spec:
name: ack-ref-test-alias
functionRef:
from:
name: ack-ref-test-fn
functionVersionRef: # gap 2; spec.functionVersion omitted
from:
name: ack-ref-test-version
---
apiVersion: lambda.services.k8s.aws/v1alpha1
kind: FunctionURLConfig
metadata:
name: ack-ref-test-furl
spec:
functionRef:
from:
name: ack-ref-test-fn
qualifierRef: # gap 3; spec.qualifier omitted
from:
name: ack-ref-test-alias
authType: NONE
```
Observed state — conditions, concrete field absent, `*Ref` present:
```
--- layerversion/ack-ref-test-layer
{"refsResolved":"True","synced":"True","concrete":null,"ref":{"from":{"name":"ack-lambda-ref-test-bucket"}}}
--- function/ack-ref-test-fn
{"refsResolved":"True","synced":"True","concrete":null,"ref":[{"from":{"name":"ack-ref-test-layer"}}]}
--- alias.lambda.services.k8s.aws/ack-ref-test-alias
{"refsResolved":"True","synced":"True","concrete":null,"ref":{"from":{"name":"ack-ref-test-version"}}}
--- functionurlconfig/ack-ref-test-furl
{"refsResolved":"True","synced":"True","concrete":null,"ref":{"from":{"name":"ack-ref-test-alias"}}}
```
AWS-side confirmation:
```
$ kubectl get layerversion ack-ref-test-layer -o json | jq -c '.status'
{"arn":"arn:aws:lambda:us-west-2:…:layer:ack-ref-test-layer:1","versionNumber":1}
$ kubectl get function ack-ref-test-fn -o json | jq -c '.status.layerStatuses'
[{"arn":"arn:aws:lambda:us-west-2:…:layer:ack-ref-test-layer:1","codeSize":343}]
$ aws lambda get-alias --function-name ack-ref-test-fn --name ack-ref-test-alias \
--query '[FunctionVersion,AliasArn]' --output text
1 arn:aws:lambda:us-west-2:…:function:ack-ref-test-fn:ack-ref-test-alias
$ kubectl get functionurlconfig ack-ref-test-furl -o json | jq -c '.status'
{"functionARN":"arn:aws:lambda:us-west-2:…:function:ack-ref-test-fn:ack-ref-test-alias",
"functionURL":"https://….lambda-url.us-west-2.on.aws/"}
```
Delta window (resync 60s, 21:35:55Z → 21:40:35Z, 4 resyncs):
```
ack-ref-test-layer: 0
ack-ref-test-layer-concrete: 0
ack-ref-test-fn: 0
ack-ref-test-version: 0
ack-ref-test-alias: 0
ack-ref-test-furl: 0
```
Drift-injection control, proving the delta log is live and reconciles were running:
```
$ aws lambda update-function-configuration --function-name ack-ref-test-fn \
--description "DRIFT-INJECTED-1786483918"
# 33s later:
{"level":"info","ts":"2026-08-11T21:32:32.839Z","logger":"ackrt",
"msg":"desired resource state has changed","kind":"Function","name":"ack-ref-test-fn",
"generation":2,"diff":[{"Path":{"Parts":["Spec","Description"]} ...
diff paths: [['Spec', 'Description']]
$ aws lambda get-function-configuration --function-name ack-ref-test-fn --query Description
reference audit remediation test function # reverted
```
Input matrix for gap 4 (`LayerVersion.Content.S3Bucket`), which carries the `set: ignore` change:
| Input | Result |
| --- | --- |
| `s3BucketRef` supplied, concrete omitted | `Synced=True`, ref preserved in `.spec`, concrete absent, layer published from the referenced bucket |
| concrete `s3Bucket` supplied, no ref | `Synced=True`, `.spec.content` retains `{"s3Bucket":"…","s3Key":"layer.zip"}` and publishes — previously this was wiped to `{}` after create |
| neither | n/a — `Content` is API-required and has no server default, so there is nothing to late-initialize |
</details>
### Audit findings deliberately *not* wired
Four further fields on these resources hold another resource's identifier but are **polymorphic** — they accept more than one resource type. The code-generator takes exactly one `resource` per field and the generated resolver instantiates one concrete Kind, so wiring one arm would privilege it invisibly, and because the ref/concrete choice is per field, it would lock out any user needing a mixed-type list. Reported here so these are not read as overlooked, with the alternatives enumerated for whoever decides how to express unions:
| Field | Accepted types |
| --- | --- |
| `Function.DeadLetterConfig.TargetARN` | sqs `Queue`, sns `Topic` |
| `Function.FileSystemConfigs.ARN` | efs `AccessPoint`, s3files `AccessPoint` (the validation pattern admits both) |
| `EventSourceMapping.SourceAccessConfigurations.URI` | secretsmanager `Secret`, ec2 `Subnet`, ec2 `SecurityGroup`, or a plain RabbitMQ virtual-host name — selected by the sibling `type_` |
| `{Function,Alias,Version}.FunctionEventInvokeConfig.DestinationConfig.On{Failure,Success}.Destination` | sns `Topic`, sqs `Queue`, s3 `Bucket`, lambda `Function`, eventbridge `EventBus` |
The concrete field already accepts every one of these types, so omitting the reference costs users nothing they have today.
Three further items surfaced and are **out of scope**, each deserving its own change:
- **`EventSourceMapping.SelfManagedKafkaEventSourceConfig.SchemaRegistryConfig.AccessConfigs.URI`** → secretsmanager `Secret`. This was wired in an earlier revision of this PR and has been **removed again**, because it cannot be verified end to end. The reference resolves correctly, but the resource can never reach `Synced`: the API rejects it with `SchemaRegistryConfig is only available for Provisioned Mode. To configure Schema Registry, please enable Provisioned Mode by specifying MinimumPollers in ProvisionedPollerConfig`, and `ProvisionedPollerConfig` is held out of the CRD by `ignore.field_paths`, so `MinimumPollers` is unreachable through this controller. Worth noting for whoever picks this up: because both Kafka branches share the `KafkaSchemaRegistryAccessConfig` shape, the CRD already advertises `selfManagedKafkaEventSourceConfig.…accessConfigs[].uriRef` today with no resolver behind it, so setting it is currently silently ignored. Un-suppressing `ProvisionedPollerConfig` and adding the resolver belong together in one change — which would also make the already-merged `AmazonManagedKafka` twin usable, since it is blocked by the same gate.
- **`Function.LoggingConfig.LogGroup`** → cloudwatchlogs `LogGroup` is a genuine, monomorphic gap. `sdkCreate` writes `Spec.LoggingConfig` back, so it needs `set: ignore`, which in turn stops the server-side defaults for the three sibling enum leaves (`LogFormat`, `ApplicationLogLevel`, `SystemLogLevel`) from landing in the spec and requires a `late_initialize`/`skip_incomplete_check` cascade across the whole struct. That changes convergence behaviour for every existing `Function` and adds a new `cloudwatchlogs-controller` dependency.
- **`FunctionCode.SourceKMSKeyArn`** and **`CreateEventSourceMappingInput.KMSKeyArn`** are both suppressed via `ignore.field_paths` and are textbook kms `Key` references. They cannot be fixed with a `references` block while suppressed; un-ignoring them is a separate, larger change.
### Checklist
- [x] Workspace refreshed (runtime, code-generator, controller) before generating
- [x] `service_name` omitted for same-service targets, set for cross-service
- [x] `path` matches the form the resource's Describe response returns
- [x] Regenerated with `ack-workspace build lambda`; controller compiles
- [x] Generated artifacts committed (`apis/`, `pkg/resource/`, `config/crd/`, `helm/`)
- [x] `go.mod` updated and pinned (cross-service only) — n/a, no new dependency
- [x] `ATTRIBUTION.md` regenerated (cross-service only) — n/a, no new dependency
- [x] All 4 fields verified against the four pass criteria, delta-free across 4 reconciles
/label release/minor
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.1 parent 24bbc64 commit f36a674
24 files changed
Lines changed: 682 additions & 27 deletions
File tree
- apis/v1alpha1
- config/crd/bases
- helm/crds
- pkg/resource
- alias
- function_url_config
- function
- layer_version
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
| 10 | + | |
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
129 | 132 | | |
130 | 133 | | |
131 | 134 | | |
| |||
179 | 182 | | |
180 | 183 | | |
181 | 184 | | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
182 | 188 | | |
183 | 189 | | |
184 | 190 | | |
| |||
266 | 272 | | |
267 | 273 | | |
268 | 274 | | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
269 | 279 | | |
270 | 280 | | |
271 | 281 | | |
| |||
275 | 285 | | |
276 | 286 | | |
277 | 287 | | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
278 | 293 | | |
279 | 294 | | |
280 | 295 | | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
281 | 305 | | |
282 | 306 | | |
283 | 307 | | |
| |||
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
139 | 139 | | |
140 | 140 | | |
141 | 141 | | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
142 | 159 | | |
143 | 160 | | |
144 | 161 | | |
| |||
200 | 217 | | |
201 | 218 | | |
202 | 219 | | |
203 | | - | |
204 | 220 | | |
205 | 221 | | |
206 | 222 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
286 | 286 | | |
287 | 287 | | |
288 | 288 | | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
289 | 308 | | |
290 | 309 | | |
291 | 310 | | |
| |||
Lines changed: 17 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
119 | 136 | | |
120 | 137 | | |
121 | 138 | | |
| |||
0 commit comments