Skip to content

Commit fa72dd0

Browse files
committed
Serialize models with exclude_unset, not exclude_defaults
resource.update and update_status serialized Pydantic models with model_dump(exclude_defaults=True), which asks "is this field different from its default?". The correct question for server-side apply is "did the caller set this field?", which exclude_unset answers. exclude_defaults also regressed with newer datamodel-code-generator. It emits object defaults as a raw dict with validate_default=True instead of a default_factory. The default is validated into a model instance at construction, which doesn't compare equal to the declared dict default, so exclude_defaults fails to exclude it. Unset fields like spec.providerConfigRef then leaked into every composed resource. exclude_unset is immune to how a default is represented: a field the caller didn't touch is absent from model_fields_set. It also keeps fields the caller explicitly set to their default value, which is more correct under server-side apply, where setting a field claims ownership of it. The apiVersion and kind workaround stays. Functions build models with kwargs and rarely pass these, so they're unset and excluded either way. See #207 for more detail. Signed-off-by: Nic Cope <nicc@rk0n.org>
1 parent 8392a41 commit fa72dd0

7 files changed

Lines changed: 252 additions & 15 deletions

File tree

crossplane/function/resource.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel
4040
"""
4141
match source:
4242
case pydantic.BaseModel():
43-
data = source.model_dump(exclude_defaults=True, warnings=False)
44-
# In Pydantic, exclude_defaults=True in model_dump excludes fields
45-
# that have their value equal to the default. If a field like
46-
# apiVersion is set to its default value 's3.aws.upbound.io/v1beta2'
47-
# (and not explicitly provided during initialization), it will be
48-
# excluded from the serialized output.
43+
# exclude_unset emits only the fields the caller explicitly set.
44+
# Crossplane treats desired resources as server-side apply intent,
45+
# so a function should own exactly the fields it has an opinion
46+
# about and leave the rest to the API server.
47+
data = source.model_dump(exclude_unset=True, warnings=False)
48+
# apiVersion and kind identify the resource but are rarely passed
49+
# as kwargs, so they're usually unset. Add them back explicitly.
4950
data["apiVersion"] = source.apiVersion
5051
data["kind"] = source.kind
5152
r.resource.update(data)
@@ -71,11 +72,11 @@ def update_status(
7172
status: The status to set, as a dictionary or Pydantic model.
7273
7374
Sets ``r.resource.status`` from the supplied status. When the status
74-
is a Pydantic model, fields set to their default value are excluded,
75-
matching the behavior of :func:`update`.
75+
is a Pydantic model, fields the caller didn't explicitly set are
76+
excluded, matching the behavior of :func:`update`.
7677
"""
7778
if isinstance(status, pydantic.BaseModel):
78-
status = status.model_dump(exclude_defaults=True, warnings=False)
79+
status = status.model_dump(exclude_unset=True, warnings=False)
7980
update(r, {"status": status})
8081

8182

tests/test_resource.py

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222

2323
import crossplane.function.proto.v1.run_function_pb2 as fnv1
2424
from crossplane.function import logging, resource
25-
from tests.testdata.models.io.upbound.aws.s3 import v1beta2
25+
from tests.testdata.models.io.upbound.aws.s3 import v1beta2 as s3v1beta2
26+
from tests.testdata.models.io.upbound.m.aws.iam.accountalias import (
27+
v1beta1 as accountaliasv1beta1,
28+
)
2629

2730

2831
class TestResource(unittest.TestCase):
@@ -59,13 +62,28 @@ class TestCase:
5962
{"apiVersion": "example.org", "kind": "XR"}
6063
),
6164
),
62-
status=v1beta2.ForProvider(region="us-west-2"),
65+
status=s3v1beta2.ForProvider(region="us-west-2"),
6366
want={
6467
"apiVersion": "example.org",
6568
"kind": "XR",
6669
"status": {"region": "us-west-2"},
6770
},
6871
),
72+
TestCase(
73+
reason="Fields the caller set should be kept, while unset "
74+
"fields are omitted.",
75+
r=fnv1.Resource(
76+
resource=resource.dict_to_struct(
77+
{"apiVersion": "example.org", "kind": "XR"}
78+
),
79+
),
80+
status=s3v1beta2.ForProvider(region="us-west-2", forceDestroy=False),
81+
want={
82+
"apiVersion": "example.org",
83+
"kind": "XR",
84+
"status": {"region": "us-west-2", "forceDestroy": False},
85+
},
86+
),
6987
TestCase(
7088
reason="Setting status on an empty resource should work.",
7189
r=fnv1.Resource(),
@@ -131,11 +149,16 @@ class TestCase:
131149
),
132150
),
133151
TestCase(
134-
reason="Updating from a Pydantic model should work.",
152+
# This model uses the default_factory form that older
153+
# datamodel-code-generator emits for fields with an object
154+
# default. providerConfigRef has such a default but isn't set
155+
# here, so it must not be emitted.
156+
reason="Updating from a Pydantic model with default_factory "
157+
"object defaults should omit unset fields.",
135158
r=fnv1.Resource(),
136-
source=v1beta2.Bucket(
137-
spec=v1beta2.Spec(
138-
forProvider=v1beta2.ForProvider(region="us-west-2"),
159+
source=s3v1beta2.Bucket(
160+
spec=s3v1beta2.Spec(
161+
forProvider=s3v1beta2.ForProvider(region="us-west-2"),
139162
),
140163
),
141164
want=fnv1.Resource(
@@ -148,6 +171,53 @@ class TestCase:
148171
),
149172
),
150173
),
174+
TestCase(
175+
# This model uses the validate_default=True form that newer
176+
# datamodel-code-generator emits for fields with an object
177+
# default. providerConfigRef has such a default but isn't set
178+
# here, so it must not be emitted.
179+
reason="Updating from a Pydantic model with validate_default "
180+
"object defaults should omit unset fields.",
181+
r=fnv1.Resource(),
182+
source=accountaliasv1beta1.AccountAlias(
183+
spec=accountaliasv1beta1.Spec(forProvider={"x": "y"}),
184+
),
185+
want=fnv1.Resource(
186+
resource=resource.dict_to_struct(
187+
{
188+
"apiVersion": "iam.aws.m.upbound.io/v1beta1",
189+
"kind": "AccountAlias",
190+
"spec": {"forProvider": {"x": "y"}},
191+
}
192+
),
193+
),
194+
),
195+
TestCase(
196+
# managementPolicies defaults to ["*"] and is set to ["*"]
197+
# here. A field the caller sets is one it has an opinion about
198+
# and should own, even when the value equals the default.
199+
reason="A field the caller explicitly set to its default value "
200+
"should be emitted.",
201+
r=fnv1.Resource(),
202+
source=accountaliasv1beta1.AccountAlias(
203+
spec=accountaliasv1beta1.Spec(
204+
forProvider={"x": "y"},
205+
managementPolicies=["*"],
206+
),
207+
),
208+
want=fnv1.Resource(
209+
resource=resource.dict_to_struct(
210+
{
211+
"apiVersion": "iam.aws.m.upbound.io/v1beta1",
212+
"kind": "AccountAlias",
213+
"spec": {
214+
"forProvider": {"x": "y"},
215+
"managementPolicies": ["*"],
216+
},
217+
}
218+
),
219+
),
220+
),
151221
]
152222

153223
for case in cases:

tests/testdata/models/io/upbound/m/__init__.py

Whitespace-only changes.

tests/testdata/models/io/upbound/m/aws/__init__.py

Whitespace-only changes.

tests/testdata/models/io/upbound/m/aws/iam/__init__.py

Whitespace-only changes.

tests/testdata/models/io/upbound/m/aws/iam/accountalias/__init__.py

Whitespace-only changes.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# generated by datamodel-codegen:
2+
# filename: workdir/iam_aws_m_upbound_io_v1beta1_accountalias.yaml
3+
4+
from __future__ import annotations
5+
6+
from typing import Any, Literal
7+
8+
from pydantic import AwareDatetime, BaseModel, Field
9+
10+
from ......k8s.apimachinery.pkg.apis.meta import v1
11+
12+
13+
class ProviderConfigRef(BaseModel):
14+
kind: str
15+
"""
16+
Kind of the referenced object.
17+
"""
18+
name: str
19+
"""
20+
Name of the referenced object.
21+
"""
22+
23+
24+
class WriteConnectionSecretToRef(BaseModel):
25+
name: str
26+
"""
27+
Name of the secret.
28+
"""
29+
30+
31+
class Spec(BaseModel):
32+
forProvider: dict[str, Any]
33+
initProvider: dict[str, Any] | None = None
34+
"""
35+
THIS IS A BETA FIELD. It will be honored
36+
unless the Management Policies feature flag is disabled.
37+
InitProvider holds the same fields as ForProvider, with the exception
38+
of Identifier and other resource reference fields. The fields that are
39+
in InitProvider are merged into ForProvider when the resource is created.
40+
The same fields are also added to the terraform ignore_changes hook, to
41+
avoid updating them after creation. This is useful for fields that are
42+
required on creation, but we do not desire to update them after creation,
43+
for example because of an external controller is managing them, like an
44+
autoscaler.
45+
"""
46+
managementPolicies: (
47+
list[Literal['Observe', 'Create', 'Update', 'Delete', 'LateInitialize', '*']]
48+
| None
49+
) = ['*']
50+
"""
51+
THIS IS A BETA FIELD. It is on by default but can be opted out
52+
through a Crossplane feature flag.
53+
ManagementPolicies specify the array of actions Crossplane is allowed to
54+
take on the managed and external resources.
55+
See the design doc for more information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223
56+
and this one: https://github.com/crossplane/crossplane/blob/444267e84783136daa93568b364a5f01228cacbe/design/one-pager-ignore-changes.md
57+
"""
58+
providerConfigRef: ProviderConfigRef | None = Field(
59+
{'kind': 'ClusterProviderConfig', 'name': 'default'}, validate_default=True
60+
)
61+
"""
62+
ProviderConfigReference specifies how the provider that will be used to
63+
create, observe, update, and delete this managed resource should be
64+
configured.
65+
"""
66+
writeConnectionSecretToRef: WriteConnectionSecretToRef | None = None
67+
"""
68+
WriteConnectionSecretToReference specifies the namespace and name of a
69+
Secret to which any connection details for this managed resource should
70+
be written. Connection details frequently include the endpoint, username,
71+
and password required to connect to the managed resource.
72+
"""
73+
74+
75+
class AtProvider(BaseModel):
76+
id: str | None = None
77+
78+
79+
class Condition(BaseModel):
80+
lastTransitionTime: AwareDatetime
81+
"""
82+
LastTransitionTime is the last time this condition transitioned from one
83+
status to another.
84+
"""
85+
message: str | None = None
86+
"""
87+
A Message containing details about this condition's last transition from
88+
one status to another, if any.
89+
"""
90+
observedGeneration: int | None = None
91+
"""
92+
ObservedGeneration represents the .metadata.generation that the condition was set based upon.
93+
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
94+
with respect to the current state of the instance.
95+
"""
96+
reason: str
97+
"""
98+
A Reason for this condition's last transition from one status to another.
99+
"""
100+
status: str
101+
"""
102+
Status of this condition; is it currently True, False, or Unknown?
103+
"""
104+
type: str
105+
"""
106+
Type of this condition. At most one of each condition type may apply to
107+
a resource at any point in time.
108+
"""
109+
110+
111+
class Status(BaseModel):
112+
atProvider: AtProvider | None = None
113+
conditions: list[Condition] | None = None
114+
"""
115+
Conditions of the resource.
116+
"""
117+
observedGeneration: int | None = None
118+
"""
119+
ObservedGeneration is the latest metadata.generation
120+
which resulted in either a ready state, or stalled due to error
121+
it can not recover from without human intervention.
122+
"""
123+
124+
125+
class AccountAlias(BaseModel):
126+
apiVersion: Literal['iam.aws.m.upbound.io/v1beta1'] | None = (
127+
'iam.aws.m.upbound.io/v1beta1'
128+
)
129+
"""
130+
APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
131+
"""
132+
kind: Literal['AccountAlias'] | None = 'AccountAlias'
133+
"""
134+
Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
135+
"""
136+
metadata: v1.ObjectMeta | None = None
137+
"""
138+
Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
139+
"""
140+
spec: Spec
141+
"""
142+
AccountAliasSpec defines the desired state of AccountAlias
143+
"""
144+
status: Status | None = None
145+
"""
146+
AccountAliasStatus defines the observed state of AccountAlias.
147+
"""
148+
149+
150+
class AccountAliasList(BaseModel):
151+
apiVersion: str | None = None
152+
"""
153+
APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
154+
"""
155+
items: list[AccountAlias]
156+
"""
157+
List of accountaliases. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md
158+
"""
159+
kind: str | None = None
160+
"""
161+
Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
162+
"""
163+
metadata: v1.ListMeta | None = None
164+
"""
165+
Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
166+
"""

0 commit comments

Comments
 (0)