function-sdk-python/crossplane/function/resource.py

164 lines
5.4 KiB
Python

# Copyright 2023 The Crossplane Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A composition function SDK."""
import dataclasses
import datetime
import pydantic
from google.protobuf import json_format
from google.protobuf import struct_pb2 as structpb
import crossplane.function.proto.v1.run_function_pb2 as fnv1
# TODO(negz): Do we really need dict_to_struct and struct_to_dict? They don't do
# much, but are perhaps useful for discoverability/"documentation" purposes.
def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel):
"""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.
"""
match source:
case pydantic.BaseModel():
data = source.model_dump(exclude_defaults=True, warnings=False)
# In Pydantic, exclude_defaults=True in model_dump excludes fields
# that have their value equal to the default. If a field like
# apiVersion is set to its default value 's3.aws.upbound.io/v1beta2'
# (and not explicitly provided during initialization), it will be
# excluded from the serialized output.
data['apiVersion'] = source.apiVersion
data['kind'] = source.kind
r.resource.update(data)
case structpb.Struct():
# TODO(negz): Use struct_to_dict and update to match other semantics?
r.resource.MergeFrom(source)
case dict():
r.resource.update(source)
case _:
t = type(source)
msg = f"Unsupported type: {t}"
raise TypeError(msg)
def dict_to_struct(d: dict) -> 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.
"""
return json_format.ParseDict(d, structpb.Struct())
def struct_to_dict(s: structpb.Struct) -> dict:
"""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.
"""
return json_format.MessageToDict(s, preserving_proto_field_name=True)
@dataclasses.dataclass
class Condition:
"""A status condition."""
"""Type of the condition - e.g. Ready."""
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: structpb.Struct, typ: str) -> Condition:
"""Get the supplied status condition of the supplied resource.
Args:
resource: A Crossplane resource.
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.
"""
unknown = Condition(typ=typ, status="Unknown")
if not resource or "status" not in resource:
return unknown
if not resource["status"] or "conditions" not in resource["status"]:
return unknown
for c in resource["status"]["conditions"]:
if c["type"] != typ:
continue
condition = Condition(
typ=c["type"],
status=c["status"],
)
if "message" in c:
condition.message = c["message"]
if "reason" in c:
condition.reason = c["reason"]
if "lastTransitionTime" in c:
condition.last_transition_time = datetime.datetime.fromisoformat(
c["lastTransitionTime"]
)
return condition
return unknown
@dataclasses.dataclass
class Credentials:
"""Credentials."""
type: str
data: dict
def get_credentials(req: structpb.Struct, name: str) -> Credentials:
"""Get the supplied credentials."""
empty = Credentials(type="data", data={})
if not req or "credentials" not in req:
return empty
if not req["credentials"] or name not in req["credentials"]:
return empty
return Credentials(
type=req["credentials"][name]["type"],
data=struct_to_dict(req["credentials"][name]["data"]),
)