Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion function/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@
"""The version of the function."""

# This is set at build time, using "hatch version"
__version__ = "0.0.21"
__version__ = "0.0.22"
31 changes: 27 additions & 4 deletions function/fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ async def RunFunction(

observed_xr = resource.struct_to_dict(req.observed.composite.resource)
observed_xr_name = observed_xr.get("metadata").get("name")
# A namespaced composite may only compose namespaced resources -- for a
# cluster scoped Request, Crossplane rejects the whole reconcile with
# "cannot apply cluster scoped composed resource ... for a namespaced
# composite resource". The composite's own namespace is the only signal
# needed to pick the right API group.
observed_xr_namespace = observed_xr.get("metadata").get("namespace")
fqdn = observed_xr["spec"].get("endpoint")
cmds = observed_xr["spec"].get("cmds")
remove_container = observed_xr["spec"].get("removeContainer")
Expand Down Expand Up @@ -107,7 +113,9 @@ async def RunFunction(
"isRemovedCheck": removed_logic,
}

resource_data = construct_request_resource(name, jsonrpc_ops, jsonrpc_cfg)
resource_data = construct_request_resource(
name, jsonrpc_ops, jsonrpc_cfg, namespace=observed_xr_namespace
)

resource.update(
rsp.desired.resources[name],
Expand Down Expand Up @@ -201,10 +209,25 @@ def get_envs(environment: dict) -> tuple[int, str, bool]:
return port, scheme, insecure_skip_tls_verify


def construct_request_resource(name: str, ops: dict, config: dict) -> dict:
"""Construct the resource request for the given data."""
REQUEST_API_VERSION = "http.crossplane.io/v1alpha2"
REQUEST_API_VERSION_NAMESPACED = "http.m.crossplane.io/v1alpha2"


def construct_request_resource(
name: str, ops: dict, config: dict, namespace: str | None = None
) -> dict:
"""Construct the resource request for the given data.

``namespace`` is the composite's namespace, or None when it is cluster
scoped. It selects the API group only: Crossplane places a composed
namespaced resource in the composite's namespace by itself, and
provider-http defaults ``providerConfigRef`` to the ProviderConfig named
``default`` there -- so neither has to be spelled out here.
"""
return {
"apiVersion": "http.crossplane.io/v1alpha2",
"apiVersion": (
REQUEST_API_VERSION_NAMESPACED if namespace else REQUEST_API_VERSION
),
"kind": "Request",
"metadata": {
"name": name,
Expand Down
63 changes: 63 additions & 0 deletions tests/test_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,66 @@ async def test_run_function_generates_request(self) -> None:
actions,
{"CREATE", "UPDATE", "OBSERVE", "REMOVE"},
)

async def test_namespaced_composite_gets_namespaced_request(self) -> None:
"""A namespaced composite composes a namespaced Request.

Crossplane refuses to apply a cluster scoped composed resource for a
namespaced composite, failing the whole reconcile -- so the API group
has to follow the composite's scope. The test above covers the cluster
scoped case, where the composite carries no namespace.
"""

composite = {
"apiVersion": "netclab.dev/v1alpha1",
"kind": "CliConfig",
"metadata": {"name": "eoscommand-1", "namespace": "lab"},
"spec": {
"endpoint": "ceos01.default.svc.cluster.local",
"cmds": {"ip prefix-list PL-Loopback0": {}},
},
}

secret = {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {"name": "eos-creds", "namespace": "crossplane-system"},
"type": "Opaque",
"data": {"basicAuth": "YXJpc3RhOmFyaXN0YQ=="},
}

environment = {
"restconf": {"scheme": "https", "port": 6020},
"jsonrpc": {"scheme": "http", "port": 6021},
}

req = fnv1.RunFunctionRequest(
input=resource.dict_to_struct({"version": "v1beta2"}),
observed=fnv1.State(
composite=fnv1.Resource(resource=resource.dict_to_struct(composite))
),
required_resources={
"eos-creds": fnv1.Resources(
items=[fnv1.Resource(resource=resource.dict_to_struct(secret))]
)
},
context=structpb.Struct(
fields={
"apiextensions.crossplane.io/environment": structpb.Value(
struct_value=resource.dict_to_struct(environment)
)
}
),
)

resp = await fn.FunctionRunner().RunFunction(req, None)

self.assertGreater(len(resp.desired.resources), 0, "no desired resources")
for name, composed in resp.desired.resources.items():
result = MessageToDict(composed.resource)
self.assertEqual(
result["apiVersion"],
"http.m.crossplane.io/v1alpha2",
f"{name} is not namespaced",
)
self.assertEqual(result["kind"], "Request")