Skip to content

Commit 21eb1ef

Browse files
authored
Merge pull request #205 from negz/helpers
Add convenience helpers for conditions, status, and resource names
2 parents 934784c + 8c644cf commit 21eb1ef

4 files changed

Lines changed: 315 additions & 3 deletions

File tree

crossplane/function/resource.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import dataclasses
1818
import datetime
19+
import hashlib
1920

2021
import pydantic
2122
from google.protobuf import json_format
@@ -59,6 +60,25 @@ def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel
5960
raise TypeError(msg)
6061

6162

63+
def update_status(
64+
r: fnv1.Resource,
65+
status: dict | pydantic.BaseModel,
66+
) -> None:
67+
"""Update a resource's status.
68+
69+
Args:
70+
r: A composite or composed resource to update.
71+
status: The status to set, as a dictionary or Pydantic model.
72+
73+
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`.
76+
"""
77+
if isinstance(status, pydantic.BaseModel):
78+
status = status.model_dump(exclude_defaults=True, warnings=False)
79+
update(r, {"status": status})
80+
81+
6282
def dict_to_struct(d: dict) -> structpb.Struct:
6383
"""Create a Struct well-known type from the supplied dict.
6484
@@ -99,21 +119,41 @@ class Condition:
99119
last_transition_time: datetime.time | None = None
100120

101121

102-
def get_condition(resource: structpb.Struct, typ: str) -> Condition:
122+
def get_condition(
123+
resource: structpb.Struct | fnv1.Resource | None,
124+
typ: str,
125+
) -> Condition:
103126
"""Get the supplied status condition of the supplied resource.
104127
105128
Args:
106-
resource: A Crossplane resource.
129+
resource: A Crossplane resource. Can be a protobuf Struct (the raw
130+
resource), an fnv1.Resource wrapper, or None. When an
131+
fnv1.Resource is supplied, the Struct is extracted automatically.
132+
When None is supplied, an unknown condition is returned.
107133
typ: The type of status condition to get (e.g. Ready).
108134
109135
Returns:
110136
The requested status condition.
111137
112138
A status condition is always returned. If the status condition isn't present
113139
in the supplied resource, a condition with status "Unknown" is returned.
140+
141+
Accepting fnv1.Resource and None makes it safe to pass the result of a
142+
protobuf map ``.get()`` call directly. This avoids auto-vivification, which
143+
silently inserts a default entry when using bracket access on a missing
144+
key::
145+
146+
# Safe — .get() returns None without mutating the map.
147+
c = get_condition(req.observed.resources.get("bucket"), "Ready")
148+
149+
# Unsafe — bracket access auto-vivifies an empty Resource.
150+
c = get_condition(req.observed.resources["bucket"].resource, "Ready")
114151
"""
115152
unknown = Condition(typ=typ, status="Unknown")
116153

154+
if isinstance(resource, fnv1.Resource):
155+
resource = resource.resource
156+
117157
if not resource or "status" not in resource:
118158
return unknown
119159

@@ -140,3 +180,37 @@ def get_condition(resource: structpb.Struct, typ: str) -> Condition:
140180
return condition
141181

142182
return unknown
183+
184+
185+
_DNS_LABEL_MAX = 63
186+
_HASH_LEN = 5
187+
188+
189+
def child_name(*parts: str, sep: str = "-") -> str:
190+
"""Build a deterministic, DNS-label-safe name for a child resource.
191+
192+
Args:
193+
*parts: Name components to join (e.g. parent name, suffix).
194+
sep: Separator between parts. Defaults to "-".
195+
196+
Returns:
197+
A name that is at most 63 characters long.
198+
199+
Composition functions often derive child resource names from a parent
200+
name and a discriminator. The resulting name must be a valid DNS label
201+
(at most 63 characters). This function joins the parts, appends a
202+
deterministic 5-character hash suffix for uniqueness, and truncates
203+
the prefix to fit within the limit.
204+
205+
The hash suffix is always appended, even for short names, so that
206+
names are visually consistent regardless of length::
207+
208+
child_name("my-xr", "bucket") # "my-xr-bucket-a1b2c"
209+
child_name("my-very-long-xr-name",
210+
"with-a-very-long-suffix") # truncated to 63 chars
211+
"""
212+
full = sep.join(parts)
213+
h = hashlib.sha256(full.encode()).hexdigest()[:_HASH_LEN]
214+
max_prefix = _DNS_LABEL_MAX - _HASH_LEN - 1
215+
prefix = full[:max_prefix].rstrip(sep)
216+
return f"{prefix}{sep}{h}"

crossplane/function/response.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,44 @@ def fatal(rsp: fnv1.RunFunctionResponse, message: str) -> None:
8181
)
8282

8383

84+
_STATUS_MAP = {
85+
"True": fnv1.STATUS_CONDITION_TRUE,
86+
"False": fnv1.STATUS_CONDITION_FALSE,
87+
"Unknown": fnv1.STATUS_CONDITION_UNKNOWN,
88+
}
89+
90+
91+
def set_conditions(
92+
rsp: fnv1.RunFunctionResponse,
93+
*conditions: resource.Condition,
94+
) -> None:
95+
"""Set one or more conditions on the composite resource (XR).
96+
97+
Args:
98+
rsp: The RunFunctionResponse to update.
99+
*conditions: The conditions to set.
100+
101+
Each condition is appended to ``rsp.conditions``. Crossplane uses the
102+
conditions returned by a function to set custom status conditions on
103+
the composite resource.
104+
105+
The ``last_transition_time`` field of each condition is ignored.
106+
Crossplane sets the transition time itself.
107+
108+
Do not set the ``Ready`` condition type. Crossplane manages it based
109+
on resource readiness.
110+
"""
111+
for condition in conditions:
112+
c = fnv1.Condition(
113+
type=condition.typ,
114+
status=_STATUS_MAP.get(condition.status, fnv1.STATUS_CONDITION_UNKNOWN),
115+
reason=condition.reason or "",
116+
)
117+
if condition.message:
118+
c.message = condition.message
119+
rsp.conditions.append(c)
120+
121+
84122
def set_output(rsp: fnv1.RunFunctionResponse, output: dict | structpb.Struct) -> None:
85123
"""Set the output field in a RunFunctionResponse for operation functions.
86124

tests/test_resource.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,56 @@ class TestResource(unittest.TestCase):
2929
def setUp(self) -> None:
3030
logging.configure(level=logging.Level.DISABLED)
3131

32+
def test_update_status(self) -> None:
33+
@dataclasses.dataclass
34+
class TestCase:
35+
reason: str
36+
r: fnv1.Resource
37+
status: dict | pydantic.BaseModel
38+
want: dict
39+
40+
cases = [
41+
TestCase(
42+
reason="Setting status from a dict should work.",
43+
r=fnv1.Resource(
44+
resource=resource.dict_to_struct(
45+
{"apiVersion": "example.org", "kind": "XR"}
46+
),
47+
),
48+
status={"ready": True},
49+
want={
50+
"apiVersion": "example.org",
51+
"kind": "XR",
52+
"status": {"ready": True},
53+
},
54+
),
55+
TestCase(
56+
reason="Setting status from a Pydantic model should work.",
57+
r=fnv1.Resource(
58+
resource=resource.dict_to_struct(
59+
{"apiVersion": "example.org", "kind": "XR"}
60+
),
61+
),
62+
status=v1beta2.ForProvider(region="us-west-2"),
63+
want={
64+
"apiVersion": "example.org",
65+
"kind": "XR",
66+
"status": {"region": "us-west-2"},
67+
},
68+
),
69+
TestCase(
70+
reason="Setting status on an empty resource should work.",
71+
r=fnv1.Resource(),
72+
status={"replicas": 3},
73+
want={"status": {"replicas": 3}},
74+
),
75+
]
76+
77+
for case in cases:
78+
resource.update_status(case.r, case.status)
79+
got = resource.struct_to_dict(case.r.resource)
80+
self.assertEqual(case.want, got, case.reason)
81+
3282
def test_add(self) -> None:
3383
@dataclasses.dataclass
3484
class TestCase:
@@ -112,11 +162,17 @@ def test_get_condition(self) -> None:
112162
@dataclasses.dataclass
113163
class TestCase:
114164
reason: str
115-
res: structpb.Struct
165+
res: structpb.Struct | fnv1.Resource | None
116166
typ: str
117167
want: resource.Condition
118168

119169
cases = [
170+
TestCase(
171+
reason="Return an unknown condition if the resource is None.",
172+
res=None,
173+
typ="Ready",
174+
want=resource.Condition(typ="Ready", status="Unknown"),
175+
),
120176
TestCase(
121177
reason="Return an unknown condition if the resource has no status.",
122178
res=resource.dict_to_struct({}),
@@ -197,6 +253,31 @@ class TestCase:
197253
),
198254
),
199255
),
256+
TestCase(
257+
reason="Unwrap an fnv1.Resource to get the condition from its Struct.",
258+
res=fnv1.Resource(
259+
resource=resource.dict_to_struct(
260+
{
261+
"status": {
262+
"conditions": [
263+
{
264+
"type": "Ready",
265+
"status": "True",
266+
}
267+
]
268+
}
269+
}
270+
),
271+
),
272+
typ="Ready",
273+
want=resource.Condition(typ="Ready", status="True"),
274+
),
275+
TestCase(
276+
reason="Return an unknown condition from an empty fnv1.Resource.",
277+
res=fnv1.Resource(),
278+
typ="Ready",
279+
want=resource.Condition(typ="Ready", status="Unknown"),
280+
),
200281
]
201282

202283
for case in cases:
@@ -324,6 +405,59 @@ class TestCase:
324405
got = resource.struct_to_dict(case.s)
325406
self.assertEqual(case.want, got, "-want, +got")
326407

408+
def test_child_name(self) -> None:
409+
@dataclasses.dataclass
410+
class TestCase:
411+
reason: str
412+
parts: list[str]
413+
want: str
414+
415+
cases = [
416+
TestCase(
417+
reason="A short name should be joined with a hash suffix.",
418+
parts=["my-xr", "bucket"],
419+
want="my-xr-bucket-05ecb",
420+
),
421+
TestCase(
422+
reason="A single part should get a hash suffix.",
423+
parts=["my-xr"],
424+
want="my-xr-9d53f",
425+
),
426+
TestCase(
427+
reason="A long name should be truncated to fit within 63 characters.",
428+
parts=["a" * 40, "b" * 40],
429+
want="a" * 40 + "-" + "b" * 16 + "-" + "f5e42",
430+
),
431+
TestCase(
432+
reason="A name that would end with a trailing separator "
433+
"after truncation should have the separator stripped.",
434+
parts=["a" * 56 + "-", "x"],
435+
# Without stripping, this would be "aaa..a--<hash>".
436+
# The trailing separator from the truncation is stripped.
437+
want="a" * 56 + "-" + "995eb",
438+
),
439+
TestCase(
440+
reason="The same inputs should always produce the same name.",
441+
parts=["parent", "child"],
442+
want="parent-child-2f0c9",
443+
),
444+
]
445+
446+
for case in cases:
447+
got = resource.child_name(*case.parts)
448+
self.assertEqual(case.want, got, case.reason)
449+
self.assertLessEqual(len(got), 63, case.reason)
450+
451+
def test_child_name_deterministic(self) -> None:
452+
a = resource.child_name("parent", "child")
453+
b = resource.child_name("parent", "child")
454+
self.assertEqual(a, b)
455+
456+
def test_child_name_unique(self) -> None:
457+
a = resource.child_name("parent", "child-a")
458+
b = resource.child_name("parent", "child-b")
459+
self.assertNotEqual(a, b)
460+
327461

328462
if __name__ == "__main__":
329463
unittest.main()

0 commit comments

Comments
 (0)