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}"
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.
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().
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.
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.
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.
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")
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