Skip to content

Commit 78346f4

Browse files
Let a function require the capabilities it can't work without
has_capability answers one capability at a time, so a function that can't work correctly without some - because it relies on Crossplane honoring a response field an older Crossplane would ignore - had to write its own check and its own fatal result, one capability at a time. missing_capabilities returns which of several capabilities Crossplane doesn't advertise, and require_capabilities adds one fatal result naming all of them, so an author fixes them in one go rather than a run per capability. A Crossplane that advertises no capabilities predates capability advertisement, so every capability is missing and the result says so - the fix there is to upgrade, not to enable a feature. if not request.require_capabilities(req, rsp, fnv1.CAPABILITY_DEPENDENCIES): return rsp Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Steven Borrelli <steve@borrelli.org>
1 parent 20115c7 commit 78346f4

2 files changed

Lines changed: 215 additions & 1 deletion

File tree

‎crossplane/function/request.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import dataclasses
1818

1919
import crossplane.function.proto.v1.run_function_pb2 as fnv1
20-
from crossplane.function import resource
20+
from crossplane.function import resource, response
2121

2222

2323
@dataclasses.dataclass
@@ -183,6 +183,79 @@ def has_capability(
183183
return cap in req.meta.capabilities
184184

185185

186+
def missing_capabilities(
187+
req: fnv1.RunFunctionRequest,
188+
*caps: fnv1.Capability.ValueType,
189+
) -> list[fnv1.Capability.ValueType]:
190+
"""Find the capabilities Crossplane doesn't advertise.
191+
192+
Args:
193+
req: The RunFunctionRequest to check.
194+
*caps: The capabilities to check for, e.g. fnv1.CAPABILITY_DEPENDENCIES.
195+
196+
Returns:
197+
The capabilities in caps that aren't present in the request metadata,
198+
in the order given and without duplicates. Empty if all are present.
199+
200+
If Crossplane doesn't advertise its capabilities at all, every capability
201+
is missing. See advertises_capabilities.
202+
203+
missing = request.missing_capabilities(req, fnv1.CAPABILITY_DEPENDENCIES)
204+
"""
205+
missing: list[fnv1.Capability.ValueType] = []
206+
for cap in caps:
207+
if not has_capability(req, cap) and cap not in missing:
208+
missing.append(cap)
209+
return missing
210+
211+
212+
def require_capabilities(
213+
req: fnv1.RunFunctionRequest,
214+
rsp: fnv1.RunFunctionResponse,
215+
*caps: fnv1.Capability.ValueType,
216+
) -> bool:
217+
"""Require that Crossplane advertises the supplied capabilities.
218+
219+
Args:
220+
req: The RunFunctionRequest to check.
221+
rsp: The RunFunctionResponse to add a fatal result to.
222+
*caps: The capabilities the function requires.
223+
224+
Returns:
225+
True if Crossplane advertises every capability in caps. Otherwise
226+
False, with one fatal result added to rsp naming every missing
227+
capability.
228+
229+
Use this when a function can't work correctly without a capability -
230+
for example because it relies on Crossplane honoring a response field
231+
that an older Crossplane would ignore. A fatal result stops the pipeline,
232+
which is easier to diagnose than a composition that silently behaves
233+
differently.
234+
235+
A Crossplane that predates capability advertisement (v2.2) has none of
236+
them as far as the function can tell, so every capability is missing and
237+
the fatal result says so.
238+
239+
if not request.require_capabilities(req, rsp, fnv1.CAPABILITY_DEPENDENCIES):
240+
return rsp
241+
"""
242+
missing = missing_capabilities(req, *caps)
243+
if not missing:
244+
return True
245+
246+
msg = (
247+
"This function requires Crossplane capabilities that Crossplane doesn't "
248+
f"advertise: {', '.join(fnv1.Capability.Name(cap) for cap in missing)}."
249+
)
250+
if not advertises_capabilities(req):
251+
msg += (
252+
" Crossplane doesn't advertise any capabilities, so it predates"
253+
" capability advertisement (v2.2)."
254+
)
255+
response.fatal(rsp, msg)
256+
return False
257+
258+
186259
def get_required_schema(req: fnv1.RunFunctionRequest, name: str) -> dict | None:
187260
"""Get a required OpenAPI schema by name from the request.
188261

‎tests/test_request.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,147 @@ class TestCase:
342342
got = request.has_capability(case.req, case.cap)
343343
self.assertEqual(case.want, got, case.reason)
344344

345+
def test_missing_capabilities(self) -> None:
346+
@dataclasses.dataclass
347+
class TestCase:
348+
reason: str
349+
req: fnv1.RunFunctionRequest
350+
caps: list[fnv1.Capability.ValueType]
351+
want: list[fnv1.Capability.ValueType]
352+
353+
cases = [
354+
TestCase(
355+
reason="Should return nothing when no capabilities are required.",
356+
req=fnv1.RunFunctionRequest(),
357+
caps=[],
358+
want=[],
359+
),
360+
TestCase(
361+
reason="Should return nothing when every capability is advertised.",
362+
req=fnv1.RunFunctionRequest(
363+
meta=fnv1.RequestMeta(
364+
capabilities=[
365+
fnv1.CAPABILITY_CAPABILITIES,
366+
fnv1.CAPABILITY_REQUIRED_SCHEMAS,
367+
fnv1.CAPABILITY_DEPENDENCIES,
368+
],
369+
),
370+
),
371+
caps=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_REQUIRED_SCHEMAS],
372+
want=[],
373+
),
374+
TestCase(
375+
reason="Should return missing capabilities in order, once each.",
376+
req=fnv1.RunFunctionRequest(
377+
meta=fnv1.RequestMeta(
378+
capabilities=[
379+
fnv1.CAPABILITY_CAPABILITIES,
380+
fnv1.CAPABILITY_REQUIRED_SCHEMAS,
381+
],
382+
),
383+
),
384+
caps=[
385+
fnv1.CAPABILITY_DEPENDENCIES,
386+
fnv1.CAPABILITY_REQUIRED_SCHEMAS,
387+
fnv1.CAPABILITY_CREDENTIALS,
388+
fnv1.CAPABILITY_DEPENDENCIES,
389+
],
390+
want=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_CREDENTIALS],
391+
),
392+
TestCase(
393+
reason="Should return every capability when none are advertised.",
394+
req=fnv1.RunFunctionRequest(),
395+
caps=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_REQUIRED_SCHEMAS],
396+
want=[fnv1.CAPABILITY_DEPENDENCIES, fnv1.CAPABILITY_REQUIRED_SCHEMAS],
397+
),
398+
]
399+
400+
for case in cases:
401+
got = request.missing_capabilities(case.req, *case.caps)
402+
self.assertEqual(case.want, got, case.reason)
403+
404+
def test_require_capabilities(self) -> None:
405+
@dataclasses.dataclass
406+
class TestCase:
407+
reason: str
408+
req: fnv1.RunFunctionRequest
409+
caps: list[fnv1.Capability.ValueType]
410+
want: bool
411+
want_results: list[fnv1.Result]
412+
413+
cases = [
414+
TestCase(
415+
reason="Should pass when no capabilities are required, whatever "
416+
"Crossplane advertises.",
417+
req=fnv1.RunFunctionRequest(),
418+
caps=[],
419+
want=True,
420+
want_results=[],
421+
),
422+
TestCase(
423+
reason="Should pass, adding no result, when all are advertised.",
424+
req=fnv1.RunFunctionRequest(
425+
meta=fnv1.RequestMeta(
426+
capabilities=[
427+
fnv1.CAPABILITY_CAPABILITIES,
428+
fnv1.CAPABILITY_DEPENDENCIES,
429+
],
430+
),
431+
),
432+
caps=[fnv1.CAPABILITY_DEPENDENCIES],
433+
want=True,
434+
want_results=[],
435+
),
436+
TestCase(
437+
reason="Should fail with one fatal result naming every missing "
438+
"capability.",
439+
req=fnv1.RunFunctionRequest(
440+
meta=fnv1.RequestMeta(
441+
capabilities=[
442+
fnv1.CAPABILITY_CAPABILITIES,
443+
fnv1.CAPABILITY_REQUIRED_SCHEMAS,
444+
],
445+
),
446+
),
447+
caps=[
448+
fnv1.CAPABILITY_DEPENDENCIES,
449+
fnv1.CAPABILITY_REQUIRED_SCHEMAS,
450+
fnv1.CAPABILITY_CREDENTIALS,
451+
],
452+
want=False,
453+
want_results=[
454+
fnv1.Result(
455+
severity=fnv1.SEVERITY_FATAL,
456+
message="This function requires Crossplane capabilities that "
457+
"Crossplane doesn't advertise: CAPABILITY_DEPENDENCIES, "
458+
"CAPABILITY_CREDENTIALS.",
459+
),
460+
],
461+
),
462+
TestCase(
463+
reason="Should say Crossplane predates capability advertisement when "
464+
"it advertises none.",
465+
req=fnv1.RunFunctionRequest(),
466+
caps=[fnv1.CAPABILITY_DEPENDENCIES],
467+
want=False,
468+
want_results=[
469+
fnv1.Result(
470+
severity=fnv1.SEVERITY_FATAL,
471+
message="This function requires Crossplane capabilities that "
472+
"Crossplane doesn't advertise: CAPABILITY_DEPENDENCIES. "
473+
"Crossplane doesn't advertise any capabilities, so it predates "
474+
"capability advertisement (v2.2).",
475+
),
476+
],
477+
),
478+
]
479+
480+
for case in cases:
481+
rsp = fnv1.RunFunctionResponse()
482+
got = request.require_capabilities(case.req, rsp, *case.caps)
483+
self.assertEqual(case.want, got, case.reason)
484+
self.assertEqual(case.want_results, list(rsp.results), case.reason)
485+
345486
def test_get_required_schema(self) -> None:
346487
@dataclasses.dataclass
347488
class TestCase:

0 commit comments

Comments
 (0)