crossplane-function-pythonic 0.4.2__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ import asyncio
3
3
  import importlib
4
4
  import inflect
5
5
  import inspect
6
+ import json
7
+ import kr8s
6
8
  import logging
7
9
  import pathlib
8
10
  import sys
@@ -11,10 +13,13 @@ from crossplane.function.proto.v1 import run_function_pb2 as fnv1
11
13
 
12
14
  from . import (
13
15
  command,
14
- composite,
15
16
  function,
16
17
  protobuf,
17
18
  )
19
+ from .composite import BaseComposite
20
+
21
+ INFLECT = inflect.engine()
22
+ INFLECT.classical(all=False)
18
23
 
19
24
 
20
25
  class Command(command.Command):
@@ -73,12 +78,12 @@ class Command(command.Command):
73
78
  help='A YAML file or directory of YAML files specifying required resources to pass to the Function pipeline.',
74
79
  )
75
80
  parser.add_argument(
76
- '--secret-store', '-s',
81
+ '--required-schemas', '-s',
77
82
  action='append',
78
83
  type=pathlib.Path,
79
84
  default=[],
80
85
  metavar='PATH',
81
- help='A YAML file or directory of YAML files specifying Secrets to use to resolve connections and credentials.',
86
+ help='A JSON file or directory of JSON files specifying required schemas to pass to the Function pipeline.',
82
87
  )
83
88
  parser.add_argument(
84
89
  '--include-full-xr', '-x',
@@ -108,18 +113,17 @@ class Command(command.Command):
108
113
 
109
114
  async def run(self):
110
115
  if self.args.kube_context:
111
- kapi = Kr8sApi(self.args.kube_context, self.logger)
116
+ api = await kr8s.asyncio.api(context=self.args.kube_context)
112
117
  else:
113
- kapi = None
114
- composite = await self.setup_composite(kapi)
118
+ api = None
119
+ composite = await self.setup_composite(api)
115
120
  observed = self.collect_resources(self.args.observed_resources)
116
- composition = await self.setup_composition(composite, kapi)
121
+ composition = await self.setup_composition(composite, api)
117
122
  resources = self.collect_resources(self.args.required_resources)
118
- resources += self.collect_resources(self.args.secret_store)
119
- resources.sort(key=lambda resource: str(resource.metadata.name))
123
+ schemas = self.collect_schemas(self.args.required_schemas)
120
124
  context = self.setup_context()
121
125
 
122
- render = await self.render(composite, observed, composition, resources, context, kapi, self.args.render_unknowns, self.args.crossplane_v1)
126
+ render = await self.render(composite, observed, composition, resources, schemas, context, api, self.args.render_unknowns, self.args.crossplane_v1)
123
127
  if not render:
124
128
  sys.exit(1)
125
129
 
@@ -154,11 +158,11 @@ class Command(command.Command):
154
158
  print('---')
155
159
  print(str(render.context), end='')
156
160
 
157
- async def setup_composite(self, kapi=None):
161
+ async def setup_composite(self, api=None):
158
162
  # Obtain the Composite to render.
159
163
  if self.args.composite.is_file():
160
164
  return protobuf.Yaml(self.args.composite.read_text())
161
- if not kapi:
165
+ if not api:
162
166
  print(f"Composite \"{self.args.composite}\" is not a file", file=sys.stderr)
163
167
  sys.exit(1)
164
168
  composite = str(self.args.composite).split(':')
@@ -172,13 +176,13 @@ class Command(command.Command):
172
176
  else:
173
177
  print(f"Composite \"{self.args.composite}\" is not kind:apiVersion:namespace:name", file=sys.stderr)
174
178
  sys.exit(1)
175
- composite = await kapi.get(composite[0], composite[1], namespace, composite[-1])
179
+ composite = await self.kr8s_get(api, composite[0], composite[1], namespace, composite[-1])
176
180
  if not composite:
177
181
  print(f"Composite \"{self.args.composite}\" not found", file=sys.stderr)
178
182
  sys.exit(1)
179
183
  return composite
180
184
 
181
- async def setup_composition(self, composite, kapi=None):
185
+ async def setup_composition(self, composite, api=None):
182
186
  # Obtain the Composition that will be used to render the Composite.
183
187
  if not self.args.composition:
184
188
  return None
@@ -208,7 +212,7 @@ class Command(command.Command):
208
212
  if not inspect.isclass(clazz):
209
213
  print(f"Composition class {self.args.composition} is not a class", file=sys.stderr)
210
214
  sys.exit(1)
211
- if not issubclass(clazz, composite.BaseComposite):
215
+ if not issubclass(clazz, BaseComposite):
212
216
  print(f"Composition class {self.args.composition} is not a subclass of BaseComposite", file=sys.stderr)
213
217
  sys.exit(1)
214
218
  return self.create_composition(composite, self.args.composition)
@@ -242,8 +246,25 @@ class Command(command.Command):
242
246
  else:
243
247
  print(f"Specified resource is not a file or a directory: {entry}", file=sys.stderr)
244
248
  sys.exit(1)
249
+ resources.sort(key=lambda resource: str(resource.metadata.name))
245
250
  return resources
246
251
 
252
+ def collect_schemas(self, entries):
253
+ schemas = []
254
+ for entry in entries:
255
+ if entry.is_file():
256
+ document = json.loads(entry.read_text())
257
+ schemas.append(protobuf.Value(None, None, document))
258
+ elif entry.is_dir():
259
+ for file in entry.iterdir():
260
+ if file.suffix == '.json':
261
+ document = json.loads(file.read_text())
262
+ schemas.append(protobuf.Value(None, None, document))
263
+ else:
264
+ print(f"Specified resource is not a file or a directory: {entry}", file=sys.stderr)
265
+ sys.exit(1)
266
+ return schemas
267
+
247
268
  def setup_context(self):
248
269
  # Load the request context with any specified command line options.
249
270
  context = protobuf.Map()
@@ -265,35 +286,35 @@ class Command(command.Command):
265
286
  context[key_value[0]] = protobuf.Yaml(key_value[1])
266
287
  return context
267
288
 
268
- async def render(self, composite, observed=[], composition=None, resources=[], context=None, kapi=None, render_unknowns=False, crossplane_v1=False):
289
+ async def render(self, composite, observed=[], composition=None, resources=[], schemas=[], context=None, api=None, render_unknowns=False, crossplane_v1=False, composite_observeds=True):
269
290
  # Create the request used when running Composition steps.
270
291
  request = protobuf.Message(None, 'request', fnv1.RunFunctionRequest.DESCRIPTOR, fnv1.RunFunctionRequest())
271
292
  if context is not None:
272
293
  request.context = context
273
294
 
274
295
  # Establish the request observed composite.
275
- await self.set_resource(composite, request.observed.composite, resources, kapi)
296
+ await self.set_resource(composite, request.observed.composite, resources, api)
276
297
  # Establish the manually configured observed resources.
277
298
  if observed:
278
299
  async with asyncio.TaskGroup() as group:
279
300
  for resource in observed:
280
301
  name = resource.metadata.annotations['crossplane.io/composition-resource-name']
281
302
  if name:
282
- group.create_task(self.set_resource(resource, request.observed.resources[name], resources, kapi))
283
- if kapi:
303
+ group.create_task(self.set_resource(resource, request.observed.resources[name], resources, api))
304
+ if api and composite_observeds:
284
305
  refs = composite.spec.crossplane.resourceRefs
285
306
  if not refs:
286
307
  refs = composite.spec.resourceRefs
287
308
  if refs:
288
309
  async with asyncio.TaskGroup() as group:
289
310
  for ref in refs:
290
- group.create_task(self.get_composite_ref(composite, ref, request, resources, kapi))
311
+ group.create_task(self.get_composite_ref(composite, ref, request, resources, api))
291
312
 
292
313
  if not composition:
293
314
  if composite.apiVersion in ('pythonic.crossplane.io/v1alpha1', 'pythonic.fortra.com/v1alpha1') and composite.kind == 'Composite':
294
315
  composition = self.create_composition(composite)
295
316
  else:
296
- if not kapi:
317
+ if not api:
297
318
  print('"composition" required', file=sys.stderr)
298
319
  return None
299
320
  revision = composite.spec.crossplane.compositionRevisionRef
@@ -303,7 +324,7 @@ class Command(command.Command):
303
324
  if not revision.name:
304
325
  print('Composite does not contain a CompositionRevision name', file=sys.stderr)
305
326
  return None
306
- composition = await kapi.get('CompositionRevision', 'apiextensions.crossplane.io/v1', None, revision.name)
327
+ composition = await self.kr8s_get(api, 'CompositionRevision', 'apiextensions.crossplane.io/v1', None, revision.name)
307
328
  if not composition:
308
329
  print(f"Compositioin \"{revision.name}\" not found", file=sys.stderr)
309
330
  return None
@@ -350,14 +371,18 @@ class Command(command.Command):
350
371
  # Fetch the step bootstrap resources specified.
351
372
  request.required_resources()
352
373
  for requirement in step.requirements.requiredResources:
353
- await self.set_required(requirement.requirementName, requirement, request.required_resources, resources, kapi)
374
+ await self.set_required(requirement.requirementName, requirement, request.required_resources, resources, api)
354
375
  # Fetch the required resources requested.
355
376
  for name, selector in requirements.resources:
356
- await self.set_required(name, selector, request.required_resources, resources, kapi)
377
+ await self.set_required(name, selector, request.required_resources, resources, api)
357
378
  # Fetch the now deprecated extra resources requested.
358
379
  request.extra_resources()
359
380
  for name, selector in requirements.extra_resources:
360
- await self.set_required(name, selector, request.extra_resources, resources, kapi)
381
+ await self.set_required(name, selector, request.extra_resources, resources, api)
382
+ # Fetch the schemas requested.
383
+ request.required_schemas()
384
+ for name, selector in requirements.schemas:
385
+ await self.set_schema(name, selector, request.required_schemas, schemas, api)
361
386
  # Run the step using the function-pythonic function runner.
362
387
  response = protobuf.Message(
363
388
  None,
@@ -365,13 +390,15 @@ class Command(command.Command):
365
390
  fnv1.RunFunctionResponse.DESCRIPTOR,
366
391
  await runner.RunFunction(request._message, None),
367
392
  )
393
+ # Copy the response context to the request context to use in subsequent steps.
394
+ request.context = response.context
368
395
  # All done if there is a fatal result.
369
396
  for result in response.results:
370
397
  if result.severity == fnv1.Severity.SEVERITY_FATAL:
371
398
  fatal = True
372
399
  break
373
- # Copy the response context to the request context to use in subsequent steps.
374
- request.context = response.context
400
+ if fatal:
401
+ break
375
402
  # Exit this loop if the function has not requested additional extra/required resources.
376
403
  if response.requirements == requirements:
377
404
  break
@@ -479,21 +506,21 @@ class Command(command.Command):
479
506
  ),
480
507
  )
481
508
 
482
- async def get_composite_ref(self, composite, ref, request, resources, kapi):
509
+ async def get_composite_ref(self, composite, ref, request, resources, api):
483
510
  namespace = ref.namespace
484
511
  if not namespace:
485
512
  namespace = composite.metadata.namespace
486
513
  if not namespace:
487
514
  namespace = None
488
- source = await kapi.get(ref.kind, ref.apiVersion, namespace, ref.name)
515
+ source = await self.kr8s_get(api, ref.kind, ref.apiVersion, namespace, ref.name)
489
516
  if source:
490
517
  name = source.metadata.annotations['crossplane.io/composition-resource-name']
491
518
  if name:
492
519
  destination = request.observed.resources[name]
493
520
  if not destination: # Do not override manual observed
494
- await self.set_resource(source, destination, resources, kapi)
521
+ await self.set_resource(source, destination, resources, api)
495
522
 
496
- async def set_required(self, name, selector, requireds, resources=[], kapi=None):
523
+ async def set_required(self, name, selector, requireds, resources=[], api=None):
497
524
  if not name:
498
525
  return
499
526
  name = str(name)
@@ -505,23 +532,23 @@ class Command(command.Command):
505
532
  or (selector.namespace == resource.metadata.namespace)
506
533
  ):
507
534
  if selector.match_name == resource.metadata.name:
508
- await self.set_resource(resource, items[protobuf.append], resources, kapi)
535
+ await self.set_resource(resource, items[protobuf.append], resources, api)
509
536
  elif selector.match_labels.labels:
510
537
  for key, value in selector.match_labels.labels:
511
538
  if value != resource.metadata.labels[key]:
512
539
  break
513
540
  else:
514
- await self.set_resource(resource, items[protobuf.append], resources, kapi)
515
- if not len(items) and kapi:
541
+ await self.set_resource(resource, items[protobuf.append], resources, api)
542
+ if not len(items) and api:
516
543
  if len(selector.match_name):
517
- resource = await kapi.get(selector.kind, selector.api_version, selector.namespace, selector.match_name)
544
+ resource = await self.kr8s_get(api, selector.kind, selector.api_version, selector.namespace, selector.match_name)
518
545
  if resource:
519
- await self.set_resource(resource, items[protobuf.append], resources, kapi)
546
+ await self.set_resource(resource, items[protobuf.append], resources, api)
520
547
  elif len(selector.match_labels.labels):
521
- for resource in await kapi.list(selector.kind, selector.api_version, selector.namespace, selector.match_labels.labels):
522
- await self.set_resource(resource, items[protobuf.append], resources, kapi)
548
+ for resource in await self.kr8s_list(api, selector.kind, selector.api_version, selector.namespace, selector.match_labels.labels):
549
+ await self.set_resource(resource, items[protobuf.append], resources, api)
523
550
 
524
- async def set_resource(self, source, destination, resources=[], kapi=None):
551
+ async def set_resource(self, source, destination, resources=[], api=None):
525
552
  destination.resource = source
526
553
  namespace = source.spec.writeConnectionSecretToRef.namespace or source.metadata.namespace
527
554
  name = source.spec.writeConnectionSecretToRef.name
@@ -533,13 +560,88 @@ class Command(command.Command):
533
560
  connection = resource
534
561
  break
535
562
  else:
536
- if kapi:
537
- connection = await kapi.get('Secret', 'v1', namespace, name)
563
+ if api:
564
+ connection = await self.kr8s_get(api, 'Secret', 'v1', namespace, name)
538
565
  if connection:
539
566
  destination.connection_details()
540
567
  for key, value in connection.data:
541
568
  destination.connection_details[key] = protobuf.B64Decode(value)
542
569
 
570
+ async def set_schema(self, name, selector, schemas, documents=[], api=None):
571
+ if not name:
572
+ return
573
+ name = str(name)
574
+ schema = schemas[name].openapi_v3
575
+ schema() # Force this to get created
576
+ gvk = protobuf.Map(kind=selector.kind)
577
+ version = str(selector.api_version)
578
+ if '/' in version:
579
+ gvk.group, gvk.version = version.split('/', 1)
580
+ else:
581
+ gvk.group = ''
582
+ gvk.version = version
583
+ for document in documents:
584
+ if self.find_schema(gvk, document, schema):
585
+ return
586
+ if api:
587
+ if gvk.group == '':
588
+ url = f"api/{gvk.version}"
589
+ else:
590
+ url = f"apis/{gvk.group}/{gvk.version}"
591
+ try:
592
+ async with api.call_api(base='/openapi/v3', version='', url=url) as response:
593
+ document = protobuf.Value(None, None, response.json())
594
+ except kr8s.NotFoundError:
595
+ return
596
+ self.find_schema(gvk, document, schema)
597
+
598
+ def find_schema(self, gvk, document, schema):
599
+ for name, s in document.components.schemas:
600
+ gvks = s['x-kubernetes-group-version-kind']
601
+ if len(gvks) == 1 and gvks[0] == gvk:
602
+ self.resolve_ref(document, set(), f"#/components/schemas/{name}", schema)
603
+ return True
604
+ return False
605
+
606
+ def resolve_ref(self, document, visiting, ref, schema):
607
+ if not ref:
608
+ return
609
+ ref = str(ref)
610
+ if ref in visiting:
611
+ return
612
+ d = None
613
+ for segment in ref.split('/'):
614
+ if segment == '#':
615
+ d = document
616
+ else:
617
+ d = d[segment]
618
+ if not d:
619
+ return
620
+ visiting.add(ref)
621
+ try:
622
+ for name, value in d:
623
+ self.copy_schema(document, visiting, name, value, schema)
624
+ finally:
625
+ visiting.remove(ref)
626
+
627
+ def copy_schema(self, document, visiting, key, value, schema):
628
+ if key == '$ref':
629
+ self.resolve_ref(document, visiting, value, schema)
630
+ elif key == 'allOf':
631
+ if value._isList and len(value) == 1:
632
+ self.resolve_ref(document, visiting, value[0]['$ref'], schema)
633
+ else:
634
+ if value._isMap:
635
+ s = schema[key]
636
+ for n, v in value:
637
+ self.copy_schema(document, visiting, n, v, s)
638
+ elif value._isList:
639
+ s = schema[key]
640
+ for ix, v in enumerate(value):
641
+ self.copy_schema(document, visiting, ix, v, s)
642
+ else:
643
+ schema[key] = value
644
+
543
645
  def copy_resource(self, source, destination):
544
646
  destination.resource = source.resource
545
647
  destination.connection_details()
@@ -572,69 +674,53 @@ class Command(command.Command):
572
674
  condition['message'] = message
573
675
  return condition
574
676
 
575
-
576
- class Kr8sApi:
577
- def __init__(self, context=None, logger=None):
578
- self.kr8s = importlib.import_module('kr8s')
579
- self.inflect = inflect.engine()
580
- self.inflect.classical(all=False)
581
- self.context = context
582
- self.logger = logger
583
- self._api = None
584
-
585
- async def api(self):
586
- if not self._api:
587
- self._api = await self.kr8s.asyncio.api(context=self.context)
588
- return self._api
589
-
590
- async def get(self, kind, apiVersion, namespace, name):
591
- clazz = self._get_clazz(kind, apiVersion, namespace)
677
+ async def kr8s_get(self, api, kind, apiVersion, namespace, name):
678
+ namespaced = namespace and len(namespace)
679
+ clazz = self.kr8s_class(kind, apiVersion, namespaced)
592
680
  try:
593
681
  fullName = [str(kind), str(apiVersion), str(name)]
594
- if namespace and len(namespace):
682
+ if namespaced:
595
683
  fullName.insert(-1, str(namespace))
596
- resource = await clazz.get(str(name), namespace=str(namespace), api=await self.api())
684
+ resource = await clazz.get(str(name), namespace=str(namespace), api=api)
597
685
  else:
598
- resource = await clazz.get(str(name), api=await self.api())
686
+ resource = await clazz.get(str(name), api=api)
599
687
  resource = protobuf.Value(None, None, resource.raw)
600
688
  result = 'found'
601
- except self.kr8s.NotFoundError:
689
+ except kr8s.NotFoundError:
602
690
  resource = None
603
691
  result = 'missing'
604
- if self.logger:
605
- self.logger.debug(f"Resource {result}: {':'.join(fullName)}")
692
+ self.logger.debug(f"Resource {result}: {':'.join(fullName)}")
606
693
  return resource
607
694
 
608
- async def list(self, kind, apiVersion, namespace, labelSelector):
609
- clazz = self._get_clazz(kind, apiVersion, namespace)
695
+ async def kr8s_list(self, api, kind, apiVersion, namespace, labelSelector):
696
+ namespaced = namespace and len(namespace)
697
+ clazz = self.kr8s_class(kind, apiVersion, namespaced)
610
698
  resources = [
611
699
  protobuf.Value(None, None, resource.raw)
612
700
  async for resource in clazz.list(
613
- namespace=str(namespace) if namespace and len(namespace) else None,
614
- label_selector={
615
- label: str(value)
616
- for label, value in labelSelector
617
- },
618
- api=await self.api()
701
+ namespace=str(namespace) if namespaced else None,
702
+ label_selector={
703
+ label: str(value)
704
+ for label, value in labelSelector
705
+ },
706
+ api=api,
619
707
  )
620
708
  ]
621
709
  if self.logger.isEnabledFor(logging.DEBUG):
622
710
  fullName = [str(kind), str(apiVersion)]
623
- if namespace and len(namespace):
711
+ if namespaced:
624
712
  fullName.append(str(namespace))
625
713
  fullName.append('&'.join(f"{label}={value}" for label, value in labelSelector))
626
714
  if resources:
627
- result = f"found {self.inflect.number_to_words(len(resources))}"
715
+ result = f"found {INFLECT.number_to_words(len(resources))}"
628
716
  else:
629
717
  result = 'missing'
630
718
  self.logger.debug(f"Resources {result}: {':'.join(fullName)}")
631
719
  return resources
632
720
 
633
- def _get_clazz(self, kind, apiVersion, namespaced):
634
- kind = str(kind)
635
- apiVersion = str(apiVersion)
721
+ def kr8s_class(self, kind, apiVersion, namespaced):
636
722
  try:
637
- return self.kr8s.asyncio.objects.get_class(kind, apiVersion, True)
723
+ return kr8s.asyncio.objects.get_class(str(kind), str(apiVersion), True)
638
724
  except KeyError:
639
725
  pass
640
- return self.kr8s.asyncio.objects.new_class(kind, apiVersion, True, bool(namespaced) and len(namespaced), plural=self.inflect.plural_noun(kind))
726
+ return kr8s.asyncio.objects.new_class(str(kind), str(apiVersion), True, namespaced, plural=INFLECT.plural_noun(str(kind)).lower())