function.resource

A composition function SDK.

  1# Copyright 2023 The Crossplane Authors.
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#     http://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""A composition function SDK."""
 16
 17import dataclasses
 18import datetime
 19import hashlib
 20
 21import pydantic
 22from google.protobuf import json_format
 23from google.protobuf import struct_pb2 as structpb
 24
 25import crossplane.function.proto.v1.run_function_pb2 as fnv1
 26
 27# TODO(negz): Do we really need dict_to_struct and struct_to_dict? They don't do
 28# much, but are perhaps useful for discoverability/"documentation" purposes.
 29
 30
 31def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel):
 32    """Update a composite or composed resource.
 33
 34    Use update to add or update the supplied resource. If the resource doesn't
 35    exist, it'll be added. If the resource does exist, it'll be updated. The
 36    update method semantics are the same as a dictionary's update method. Fields
 37    that don't exist will be added. Fields that exist will be overwritten.
 38
 39    The source can be a dictionary, a protobuf Struct, or a Pydantic model.
 40    """
 41    match source:
 42        case pydantic.BaseModel():
 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            #
 48            # by_alias emits each field under its alias, which is its real
 49            # wire name. datamodel-code-generator aliases fields whose KRM
 50            # name collides with a Python keyword or builtin (e.g. it emits a
 51            # bool_ attribute aliased to bool, continue_ aliased to continue).
 52            # Without by_alias those fields serialize under the Python name and
 53            # don't match the resource's schema. It's a no-op for ordinary
 54            # fields, which have no alias.
 55            data = source.model_dump(exclude_unset=True, by_alias=True, warnings=False)
 56            # apiVersion and kind identify the resource but are rarely passed
 57            # as kwargs, so they're usually unset. Add them back explicitly.
 58            data["apiVersion"] = source.apiVersion
 59            data["kind"] = source.kind
 60            r.resource.update(data)
 61        case structpb.Struct():
 62            # TODO(negz): Use struct_to_dict and update to match other semantics?
 63            r.resource.MergeFrom(source)
 64        case dict():
 65            r.resource.update(source)
 66        case _:
 67            t = type(source)
 68            msg = f"Unsupported type: {t}"
 69            raise TypeError(msg)
 70
 71
 72def update_status(
 73    r: fnv1.Resource,
 74    status: dict | pydantic.BaseModel,
 75) -> None:
 76    """Update a resource's status.
 77
 78    Args:
 79        r: A composite or composed resource to update.
 80        status: The status to set, as a dictionary or Pydantic model.
 81
 82    Sets ``r.resource.status`` from the supplied status. When the status
 83    is a Pydantic model, fields the caller didn't explicitly set are
 84    excluded and aliased fields are emitted under their wire names,
 85    matching the behavior of :func:`update`.
 86    """
 87    if isinstance(status, pydantic.BaseModel):
 88        status = status.model_dump(exclude_unset=True, by_alias=True, warnings=False)
 89    update(r, {"status": status})
 90
 91
 92def dict_to_struct(d: dict) -> structpb.Struct:
 93    """Create a Struct well-known type from the supplied dict.
 94
 95    Functions must return desired resources encoded as a protobuf struct. This
 96    function makes it possible to work with a Python dict, then convert it to a
 97    struct in a RunFunctionResponse.
 98    """
 99    return json_format.ParseDict(d, structpb.Struct())
100
101
102def struct_to_dict(s: structpb.Struct) -> dict:
103    """Create a dict from the supplied Struct well-known type.
104
105    Crossplane sends observed and desired resources to a function encoded as a
106    protobuf struct. This function makes it possible to convert resources to a
107    dictionary.
108    """
109    return json_format.MessageToDict(s, preserving_proto_field_name=True)
110
111
112@dataclasses.dataclass
113class Condition:
114    """A status condition."""
115
116    """Type of the condition - e.g. Ready."""
117    typ: str
118
119    """Status of the condition - True, False, or Unknown."""
120    status: str
121
122    """Reason for the condition status - typically CamelCase."""
123    reason: str | None = None
124
125    """Optional message."""
126    message: str | None = None
127
128    """The last time the status transitioned to this status."""
129    last_transition_time: datetime.time | None = None
130
131
132def get_condition(
133    resource: structpb.Struct | fnv1.Resource | None,
134    typ: str,
135) -> Condition:
136    """Get the supplied status condition of the supplied resource.
137
138    Args:
139        resource: A Crossplane resource. Can be a protobuf Struct (the raw
140            resource), an fnv1.Resource wrapper, or None. When an
141            fnv1.Resource is supplied, the Struct is extracted automatically.
142            When None is supplied, an unknown condition is returned.
143        typ: The type of status condition to get (e.g. Ready).
144
145    Returns:
146        The requested status condition.
147
148    A status condition is always returned. If the status condition isn't present
149    in the supplied resource, a condition with status "Unknown" is returned.
150
151    Accepting fnv1.Resource and None makes it safe to pass the result of a
152    protobuf map ``.get()`` call directly. This avoids auto-vivification, which
153    silently inserts a default entry when using bracket access on a missing
154    key::
155
156        # Safe — .get() returns None without mutating the map.
157        c = get_condition(req.observed.resources.get("bucket"), "Ready")
158
159        # Unsafe — bracket access auto-vivifies an empty Resource.
160        c = get_condition(req.observed.resources["bucket"].resource, "Ready")
161    """
162    unknown = Condition(typ=typ, status="Unknown")
163
164    if isinstance(resource, fnv1.Resource):
165        resource = resource.resource
166
167    if not resource or "status" not in resource:
168        return unknown
169
170    if not resource["status"] or "conditions" not in resource["status"]:
171        return unknown
172
173    for c in resource["status"]["conditions"]:
174        if c["type"] != typ:
175            continue
176
177        condition = Condition(
178            typ=c["type"],
179            status=c["status"],
180        )
181        if "message" in c:
182            condition.message = c["message"]
183        if "reason" in c:
184            condition.reason = c["reason"]
185        if "lastTransitionTime" in c:
186            condition.last_transition_time = datetime.datetime.fromisoformat(
187                c["lastTransitionTime"]
188            )
189
190        return condition
191
192    return unknown
193
194
195_DNS_LABEL_MAX = 63
196_HASH_LEN = 5
197
198
199def child_name(*parts: str, sep: str = "-") -> str:
200    """Build a deterministic, DNS-label-safe name for a child resource.
201
202    Args:
203        *parts: Name components to join (e.g. parent name, suffix).
204        sep: Separator between parts. Defaults to "-".
205
206    Returns:
207        A name that is at most 63 characters long.
208
209    Composition functions often derive child resource names from a parent
210    name and a discriminator. The resulting name must be a valid DNS label
211    (at most 63 characters). This function joins the parts, appends a
212    deterministic 5-character hash suffix for uniqueness, and truncates
213    the prefix to fit within the limit.
214
215    The hash suffix is always appended, even for short names, so that
216    names are visually consistent regardless of length::
217
218        child_name("my-xr", "bucket")       # "my-xr-bucket-a1b2c"
219        child_name("my-very-long-xr-name",
220                   "with-a-very-long-suffix") # truncated to 63 chars
221    """
222    full = sep.join(parts)
223    h = hashlib.sha256(full.encode()).hexdigest()[:_HASH_LEN]
224    max_prefix = _DNS_LABEL_MAX - _HASH_LEN - 1
225    prefix = full[:max_prefix].rstrip(sep)
226    return f"{prefix}{sep}{h}"
def update( r: crossplane.function.proto.v1.run_function_pb2.Resource, source: dict | google.protobuf.struct_pb2.Struct | pydantic.main.BaseModel):
32def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel):
33    """Update a composite or composed resource.
34
35    Use update to add or update the supplied resource. If the resource doesn't
36    exist, it'll be added. If the resource does exist, it'll be updated. The
37    update method semantics are the same as a dictionary's update method. Fields
38    that don't exist will be added. Fields that exist will be overwritten.
39
40    The source can be a dictionary, a protobuf Struct, or a Pydantic model.
41    """
42    match source:
43        case pydantic.BaseModel():
44            # exclude_unset emits only the fields the caller explicitly set.
45            # Crossplane treats desired resources as server-side apply intent,
46            # so a function should own exactly the fields it has an opinion
47            # about and leave the rest to the API server.
48            #
49            # by_alias emits each field under its alias, which is its real
50            # wire name. datamodel-code-generator aliases fields whose KRM
51            # name collides with a Python keyword or builtin (e.g. it emits a
52            # bool_ attribute aliased to bool, continue_ aliased to continue).
53            # Without by_alias those fields serialize under the Python name and
54            # don't match the resource's schema. It's a no-op for ordinary
55            # fields, which have no alias.
56            data = source.model_dump(exclude_unset=True, by_alias=True, warnings=False)
57            # apiVersion and kind identify the resource but are rarely passed
58            # as kwargs, so they're usually unset. Add them back explicitly.
59            data["apiVersion"] = source.apiVersion
60            data["kind"] = source.kind
61            r.resource.update(data)
62        case structpb.Struct():
63            # TODO(negz): Use struct_to_dict and update to match other semantics?
64            r.resource.MergeFrom(source)
65        case dict():
66            r.resource.update(source)
67        case _:
68            t = type(source)
69            msg = f"Unsupported type: {t}"
70            raise TypeError(msg)

Update a composite or composed resource.

Use update to add or update the supplied resource. If the resource doesn't exist, it'll be added. If the resource does exist, it'll be updated. The update method semantics are the same as a dictionary's update method. Fields that don't exist will be added. Fields that exist will be overwritten.

The source can be a dictionary, a protobuf Struct, or a Pydantic model.

def update_status( r: crossplane.function.proto.v1.run_function_pb2.Resource, status: dict | pydantic.main.BaseModel) -> None:
73def update_status(
74    r: fnv1.Resource,
75    status: dict | pydantic.BaseModel,
76) -> None:
77    """Update a resource's status.
78
79    Args:
80        r: A composite or composed resource to update.
81        status: The status to set, as a dictionary or Pydantic model.
82
83    Sets ``r.resource.status`` from the supplied status. When the status
84    is a Pydantic model, fields the caller didn't explicitly set are
85    excluded and aliased fields are emitted under their wire names,
86    matching the behavior of :func:`update`.
87    """
88    if isinstance(status, pydantic.BaseModel):
89        status = status.model_dump(exclude_unset=True, by_alias=True, warnings=False)
90    update(r, {"status": status})

Update a resource's status.

Arguments:
  • r: A composite or composed resource to update.
  • status: The status to set, as a dictionary or Pydantic model.

Sets r.resource.status from the supplied status. When the status is a Pydantic model, fields the caller didn't explicitly set are excluded and aliased fields are emitted under their wire names, matching the behavior of update().

def dict_to_struct(d: dict) -> google.protobuf.struct_pb2.Struct:
 93def dict_to_struct(d: dict) -> structpb.Struct:
 94    """Create a Struct well-known type from the supplied dict.
 95
 96    Functions must return desired resources encoded as a protobuf struct. This
 97    function makes it possible to work with a Python dict, then convert it to a
 98    struct in a RunFunctionResponse.
 99    """
100    return json_format.ParseDict(d, structpb.Struct())

Create a Struct well-known type from the supplied dict.

Functions must return desired resources encoded as a protobuf struct. This function makes it possible to work with a Python dict, then convert it to a struct in a RunFunctionResponse.

def struct_to_dict(s: google.protobuf.struct_pb2.Struct) -> dict:
103def struct_to_dict(s: structpb.Struct) -> dict:
104    """Create a dict from the supplied Struct well-known type.
105
106    Crossplane sends observed and desired resources to a function encoded as a
107    protobuf struct. This function makes it possible to convert resources to a
108    dictionary.
109    """
110    return json_format.MessageToDict(s, preserving_proto_field_name=True)

Create a dict from the supplied Struct well-known type.

Crossplane sends observed and desired resources to a function encoded as a protobuf struct. This function makes it possible to convert resources to a dictionary.

@dataclasses.dataclass
class Condition:
113@dataclasses.dataclass
114class Condition:
115    """A status condition."""
116
117    """Type of the condition - e.g. Ready."""
118    typ: str
119
120    """Status of the condition - True, False, or Unknown."""
121    status: str
122
123    """Reason for the condition status - typically CamelCase."""
124    reason: str | None = None
125
126    """Optional message."""
127    message: str | None = None
128
129    """The last time the status transitioned to this status."""
130    last_transition_time: datetime.time | None = None

A status condition.

Condition( typ: str, status: str, reason: str | None = None, message: str | None = None, last_transition_time: datetime.time | None = None)
typ: str

Status of the condition - True, False, or Unknown.

status: str

Reason for the condition status - typically CamelCase.

reason: str | None = None

Optional message.

message: str | None = None

The last time the status transitioned to this status.

last_transition_time: datetime.time | None = None
def get_condition( resource: google.protobuf.struct_pb2.Struct | crossplane.function.proto.v1.run_function_pb2.Resource | None, typ: str) -> Condition:
133def get_condition(
134    resource: structpb.Struct | fnv1.Resource | None,
135    typ: str,
136) -> Condition:
137    """Get the supplied status condition of the supplied resource.
138
139    Args:
140        resource: A Crossplane resource. Can be a protobuf Struct (the raw
141            resource), an fnv1.Resource wrapper, or None. When an
142            fnv1.Resource is supplied, the Struct is extracted automatically.
143            When None is supplied, an unknown condition is returned.
144        typ: The type of status condition to get (e.g. Ready).
145
146    Returns:
147        The requested status condition.
148
149    A status condition is always returned. If the status condition isn't present
150    in the supplied resource, a condition with status "Unknown" is returned.
151
152    Accepting fnv1.Resource and None makes it safe to pass the result of a
153    protobuf map ``.get()`` call directly. This avoids auto-vivification, which
154    silently inserts a default entry when using bracket access on a missing
155    key::
156
157        # Safe — .get() returns None without mutating the map.
158        c = get_condition(req.observed.resources.get("bucket"), "Ready")
159
160        # Unsafe — bracket access auto-vivifies an empty Resource.
161        c = get_condition(req.observed.resources["bucket"].resource, "Ready")
162    """
163    unknown = Condition(typ=typ, status="Unknown")
164
165    if isinstance(resource, fnv1.Resource):
166        resource = resource.resource
167
168    if not resource or "status" not in resource:
169        return unknown
170
171    if not resource["status"] or "conditions" not in resource["status"]:
172        return unknown
173
174    for c in resource["status"]["conditions"]:
175        if c["type"] != typ:
176            continue
177
178        condition = Condition(
179            typ=c["type"],
180            status=c["status"],
181        )
182        if "message" in c:
183            condition.message = c["message"]
184        if "reason" in c:
185            condition.reason = c["reason"]
186        if "lastTransitionTime" in c:
187            condition.last_transition_time = datetime.datetime.fromisoformat(
188                c["lastTransitionTime"]
189            )
190
191        return condition
192
193    return unknown

Get the supplied status condition of the supplied resource.

Arguments:
  • resource: A Crossplane resource. Can be a protobuf Struct (the raw resource), an fnv1.Resource wrapper, or None. When an fnv1.Resource is supplied, the Struct is extracted automatically. When None is supplied, an unknown condition is returned.
  • typ: The type of status condition to get (e.g. Ready).
Returns:

The requested status condition.

A status condition is always returned. If the status condition isn't present in the supplied resource, a condition with status "Unknown" is returned.

Accepting fnv1.Resource and None makes it safe to pass the result of a protobuf map .get() call directly. This avoids auto-vivification, which silently inserts a default entry when using bracket access on a missing key::

# Safe — .get() returns None without mutating the map.
c = get_condition(req.observed.resources.get("bucket"), "Ready")

# Unsafe — bracket access auto-vivifies an empty Resource.
c = get_condition(req.observed.resources["bucket"]function.resource, "Ready")
def child_name(*parts: str, sep: str = '-') -> str:
200def child_name(*parts: str, sep: str = "-") -> str:
201    """Build a deterministic, DNS-label-safe name for a child resource.
202
203    Args:
204        *parts: Name components to join (e.g. parent name, suffix).
205        sep: Separator between parts. Defaults to "-".
206
207    Returns:
208        A name that is at most 63 characters long.
209
210    Composition functions often derive child resource names from a parent
211    name and a discriminator. The resulting name must be a valid DNS label
212    (at most 63 characters). This function joins the parts, appends a
213    deterministic 5-character hash suffix for uniqueness, and truncates
214    the prefix to fit within the limit.
215
216    The hash suffix is always appended, even for short names, so that
217    names are visually consistent regardless of length::
218
219        child_name("my-xr", "bucket")       # "my-xr-bucket-a1b2c"
220        child_name("my-very-long-xr-name",
221                   "with-a-very-long-suffix") # truncated to 63 chars
222    """
223    full = sep.join(parts)
224    h = hashlib.sha256(full.encode()).hexdigest()[:_HASH_LEN]
225    max_prefix = _DNS_LABEL_MAX - _HASH_LEN - 1
226    prefix = full[:max_prefix].rstrip(sep)
227    return f"{prefix}{sep}{h}"

Build a deterministic, DNS-label-safe name for a child resource.

Arguments:
  • *parts: Name components to join (e.g. parent name, suffix).
  • sep: Separator between parts. Defaults to "-".
Returns:

A name that is at most 63 characters long.

Composition functions often derive child resource names from a parent name and a discriminator. The resulting name must be a valid DNS label (at most 63 characters). This function joins the parts, appends a deterministic 5-character hash suffix for uniqueness, and truncates the prefix to fit within the limit.

The hash suffix is always appended, even for short names, so that names are visually consistent regardless of length::

child_name("my-xr", "bucket")       # "my-xr-bucket-a1b2c"
child_name("my-very-long-xr-name",
           "with-a-very-long-suffix") # truncated to 63 chars