diff --git a/function/__version__.py b/function/__version__.py index 9042cae..680aab1 100644 --- a/function/__version__.py +++ b/function/__version__.py @@ -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" diff --git a/function/fn.py b/function/fn.py index 7e12e5a..dc6ebb4 100644 --- a/function/fn.py +++ b/function/fn.py @@ -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") @@ -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], @@ -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, diff --git a/tests/test_fn.py b/tests/test_fn.py index f244c9b..53a1645 100644 --- a/tests/test_fn.py +++ b/tests/test_fn.py @@ -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")