crossplane-function-pythonic 0.6.1__py3-none-any.whl → 0.6.3__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.
@@ -0,0 +1,880 @@
1
+ import asyncio
2
+ import importlib
3
+ import inspect
4
+ import json
5
+ import logging
6
+ import pathlib
7
+ import sys
8
+
9
+ import inflect
10
+ import kr8s
11
+ import yaml
12
+
13
+ from . import (
14
+ command,
15
+ function,
16
+ protobuf,
17
+ )
18
+ from .composite import BaseComposite
19
+ from .proto.v1 import run_function_pb2 as fnv1
20
+
21
+ INFLECT = inflect.engine()
22
+ INFLECT.classical(all=False)
23
+
24
+
25
+ class Command(command.Command):
26
+ name = "diff"
27
+ help = "Compare the results of a local Composition with a live Composite"
28
+
29
+ @classmethod
30
+ def add_parser_arguments(cls, parser):
31
+ cls.add_function_arguments(parser)
32
+ parser.add_argument(
33
+ "composition",
34
+ type=pathlib.Path,
35
+ metavar="COMPOSITION",
36
+ help="A YAML file containing the Composition resource.",
37
+ )
38
+ parser.add_argument(
39
+ "composite",
40
+ type=pathlib.Path,
41
+ nargs="?",
42
+ metavar="COMPOSITE",
43
+ help="kind:apiVersion:namespace:name of cluster Composite.",
44
+ )
45
+ parser.add_argument(
46
+ "--kube-context",
47
+ "-k",
48
+ action="append",
49
+ metavar="CONTEXT",
50
+ help="The kubectl context to use to obtain external resources from, such as required resources, connections, etc.",
51
+ )
52
+
53
+ def initialize(self):
54
+ if self.args:
55
+ self.initialize_function()
56
+ self.logger = logging.getLogger(__name__)
57
+
58
+ async def run(self):
59
+ if self.args.kube_context:
60
+ api = await kr8s.asyncio.api(context=self.args.kube_context)
61
+ else:
62
+ api = None
63
+ composite = await self.setup_composite(api)
64
+ observed = self.collect_resources(self.args.observed_resources)
65
+ composition = await self.setup_composition(composite, api)
66
+ resources = self.collect_resources(self.args.required_resources)
67
+ schemas = self.collect_schemas(self.args.required_schemas)
68
+ context = self.setup_context()
69
+
70
+ render = await self.render(
71
+ composite,
72
+ observed,
73
+ composition,
74
+ resources,
75
+ schemas,
76
+ context,
77
+ api,
78
+ self.args.render_unknowns,
79
+ self.args.crossplane_v1,
80
+ )
81
+ if not render:
82
+ sys.exit(1)
83
+
84
+ if self.args.include_full_xr:
85
+ render.composite.metadata = composite.metadata
86
+ del render.composite.metadata.managedFields
87
+ if composite.spec:
88
+ render.composite.spec = composite.spec
89
+ else:
90
+ if composite.metadata.namespace:
91
+ render.composite.metadata.namespace = composite.metadata.namespace
92
+ render.composite.metadata.name = composite.metadata.name
93
+
94
+ # Print the composite.
95
+ print("---")
96
+ print(str(render.composite), end="")
97
+ # Print Composite connection if requested.
98
+ if self.args.include_connection_xr:
99
+ print("---")
100
+ print(
101
+ str(
102
+ protobuf.Map(
103
+ apiVersion="render.crossplane.io/v1beta1",
104
+ kind="Connection",
105
+ values=render.connection,
106
+ )
107
+ ),
108
+ end="",
109
+ )
110
+ # Print the composed resources.
111
+ for resource in sorted(
112
+ render.resources,
113
+ key=lambda resource: str(
114
+ resource.metadata.annotations["crossplane.io/composition-resource-name"]
115
+ ),
116
+ ):
117
+ print("---")
118
+ print(str(resource), end="")
119
+ # Print the results (AKA events) if requested.
120
+ if self.args.include_function_results:
121
+ for result in render.results:
122
+ result.apiVersion = "render.crossplane.io/v1beta1"
123
+ result.kind = "Result"
124
+ print("---")
125
+ print(str(result), end="")
126
+ # Print the final context if requested.
127
+ if self.args.include_context:
128
+ print("---")
129
+ print(
130
+ str(
131
+ protobuf.Map(
132
+ apiVersion="render.crossplane.io/v1beta1",
133
+ kind="Context",
134
+ values=render.context,
135
+ )
136
+ ),
137
+ end="",
138
+ )
139
+
140
+ async def setup_composite(self, api=None):
141
+ # Obtain the Composite to render.
142
+ if self.args.composite.is_file():
143
+ return protobuf.Yaml(self.args.composite.read_text())
144
+ if not api:
145
+ print(f'Composite "{self.args.composite}" is not a file', file=sys.stderr)
146
+ sys.exit(1)
147
+ composite = str(self.args.composite).split(":")
148
+ if len(composite) == 3:
149
+ namespace = None
150
+ elif len(composite) == 4:
151
+ if len(composite[2]):
152
+ namespace = composite[2]
153
+ else:
154
+ namespace = None
155
+ else:
156
+ print(
157
+ f'Composite "{self.args.composite}" is not kind:apiVersion:namespace:name',
158
+ file=sys.stderr,
159
+ )
160
+ sys.exit(1)
161
+ composite = await self.kr8s_get(
162
+ api, composite[0], composite[1], namespace, composite[-1]
163
+ )
164
+ if not composite:
165
+ print(f'Composite "{self.args.composite}" not found', file=sys.stderr)
166
+ sys.exit(1)
167
+ return composite
168
+
169
+ async def setup_composition(self, composite, api=None):
170
+ # Obtain the Composition that will be used to render the Composite.
171
+ if not self.args.composition:
172
+ return None
173
+ if self.args.composition.is_file():
174
+ composition = self.args.composition.read_text()
175
+ if self.args.composition.suffix == ".py":
176
+ return self.create_composition(composite, composition)
177
+ composition = protobuf.Yaml(composition)
178
+ if not len(composition.spec.pipeline):
179
+ print(
180
+ f"Composition file does not contain any pipeline steps: {self.args.composition}",
181
+ file=sys.stderr,
182
+ )
183
+ sys.exit(1)
184
+ return composition
185
+ composition = str(self.args.composition).rsplit(".", 1)
186
+ if len(composition) == 1:
187
+ print(
188
+ f"Composition class name does not include module: {self.args.composition}",
189
+ file=sys.stderr,
190
+ )
191
+ sys.exit(1)
192
+ try:
193
+ module = importlib.import_module(composition[0])
194
+ except Exception as e: # noqa: BLE001
195
+ print(e)
196
+ print(
197
+ f"Unable to import composition module: {composition[0]}",
198
+ file=sys.stderr,
199
+ )
200
+ sys.exit(1)
201
+ clazz = getattr(module, composition[1], None)
202
+ if not clazz:
203
+ print(
204
+ f"Composition class {composition[0]} does not define: {composition[1]}",
205
+ file=sys.stderr,
206
+ )
207
+ sys.exit(1)
208
+ if not inspect.isclass(clazz):
209
+ print(
210
+ f"Composition class {self.args.composition} is not a class",
211
+ file=sys.stderr,
212
+ )
213
+ sys.exit(1)
214
+ if not issubclass(clazz, BaseComposite):
215
+ print(
216
+ f"Composition class {self.args.composition} is not a subclass of BaseComposite",
217
+ file=sys.stderr,
218
+ )
219
+ sys.exit(1)
220
+ return self.create_composition(composite, self.args.composition)
221
+
222
+ def create_composition(self, composite, module=""):
223
+ composition = protobuf.Map()
224
+ composition.apiVersion = "apiextensions.crossplane.io/v1"
225
+ composition.kind = "Composition"
226
+ composition.metadata.name = "function-pythonic-render"
227
+ composition.spec.compositeTypeRef.apiVersion = composite.apiVersion
228
+ composition.spec.compositeTypeRef.kind = composite.kind
229
+ composition.spec.mode = "Pipeline"
230
+ composition.spec.pipeline[0].step = "function-pythonic-render"
231
+ composition.spec.pipeline[0].functionRef.name = "function-pythonic"
232
+ composition.spec.pipeline[
233
+ 0
234
+ ].input.apiVersion = "pythonic.fn.crossplane.io/v1alpha1"
235
+ composition.spec.pipeline[0].input.kind = "Composite"
236
+ composition.spec.pipeline[0].input.composite = str(module)
237
+ return composition
238
+
239
+ def collect_resources(self, entries):
240
+ resources = []
241
+ for entry in entries:
242
+ if entry.is_file():
243
+ for document in yaml.safe_load_all(entry.read_text()):
244
+ resources.append(protobuf.Value(None, None, document))
245
+ elif entry.is_dir():
246
+ for file in entry.iterdir():
247
+ if file.suffix in (".yaml", ".yml"):
248
+ for document in yaml.safe_load_all(file.read_text()):
249
+ resources.append(protobuf.Value(None, None, document))
250
+ else:
251
+ print(
252
+ f"Specified resource is not a file or a directory: {entry}",
253
+ file=sys.stderr,
254
+ )
255
+ sys.exit(1)
256
+ resources.sort(key=lambda resource: str(resource.metadata.name))
257
+ return resources
258
+
259
+ def collect_schemas(self, entries):
260
+ schemas = []
261
+ for entry in entries:
262
+ if entry.is_file():
263
+ document = json.loads(entry.read_text())
264
+ schemas.append(protobuf.Value(None, None, document))
265
+ elif entry.is_dir():
266
+ for file in entry.iterdir():
267
+ if file.suffix == ".json":
268
+ document = json.loads(file.read_text())
269
+ schemas.append(protobuf.Value(None, None, document))
270
+ else:
271
+ print(
272
+ f"Specified resource is not a file or a directory: {entry}",
273
+ file=sys.stderr,
274
+ )
275
+ sys.exit(1)
276
+ return schemas
277
+
278
+ def setup_context(self):
279
+ # Load the request context with any specified command line options.
280
+ context = protobuf.Map()
281
+ for entry in self.args.context_files:
282
+ key_path = entry.split("=", 1)
283
+ if len(key_path) != 2:
284
+ print(f"Invalid --context-files: {entry}", file=sys.stderr)
285
+ sys.exit(1)
286
+ path = pathlib.Path(key_path[1])
287
+ if not path.is_file():
288
+ print(f"Invalid --context-files {path} is not a file", file=sys.stderr)
289
+ sys.exit(1)
290
+ context[key_path[0]] = protobuf.Yaml(path.read_text())
291
+ for entry in self.args.context_values:
292
+ key_value = entry.split("=", 1)
293
+ if len(key_value) != 2:
294
+ print(f"Invalid --context-values: {entry}", file=sys.stderr)
295
+ sys.exit(1)
296
+ context[key_value[0]] = protobuf.Yaml(key_value[1])
297
+ return context
298
+
299
+ async def render(
300
+ self,
301
+ composite,
302
+ observeds=None,
303
+ composition=None,
304
+ resources=None,
305
+ schemas=None,
306
+ context=None,
307
+ api=None,
308
+ render_unknowns=False,
309
+ crossplane_v1=False,
310
+ composite_observeds=True,
311
+ ):
312
+ # Create the request used when running Composition steps.
313
+ if schemas is None:
314
+ schemas = []
315
+ if resources is None:
316
+ resources = []
317
+ if observeds is None:
318
+ observeds = []
319
+ request = protobuf.Message(
320
+ None,
321
+ "request",
322
+ fnv1.RunFunctionRequest.DESCRIPTOR,
323
+ fnv1.RunFunctionRequest(),
324
+ )
325
+ if context is not None:
326
+ request.context = context
327
+
328
+ # Establish the request observed composite.
329
+ await self.set_resource(composite, request.observed.composite, resources, api)
330
+ # Establish the manually configured observed resources.
331
+ if observeds:
332
+ async with asyncio.TaskGroup() as group:
333
+ for resource in observeds:
334
+ name = resource.metadata.annotations[
335
+ "crossplane.io/composition-resource-name"
336
+ ]
337
+ if name:
338
+ group.create_task(
339
+ self.set_resource(
340
+ resource,
341
+ request.observed.resources[name],
342
+ resources,
343
+ api,
344
+ )
345
+ )
346
+ if api and composite_observeds:
347
+ refs = composite.spec.crossplane.resourceRefs
348
+ if not refs:
349
+ refs = composite.spec.resourceRefs
350
+ if refs:
351
+ async with asyncio.TaskGroup() as group:
352
+ for ref in refs:
353
+ group.create_task(
354
+ self.get_composite_ref(
355
+ composite, ref, request, resources, api
356
+ )
357
+ )
358
+
359
+ if not composition:
360
+ if (
361
+ composite.apiVersion
362
+ in ("pythonic.crossplane.io/v1alpha1", "pythonic.fortra.com/v1alpha1")
363
+ and composite.kind == "Composite"
364
+ ):
365
+ composition = self.create_composition(composite)
366
+ else:
367
+ if not api:
368
+ print('"composition" required', file=sys.stderr)
369
+ return None
370
+ revision = composite.spec.crossplane.compositionRevisionRef
371
+ if not revision.name:
372
+ # Crossplane V1 location
373
+ revision = composite.spec.compositionRevisionRef
374
+ if not revision.name:
375
+ print(
376
+ "Composite does not contain a CompositionRevision name",
377
+ file=sys.stderr,
378
+ )
379
+ return None
380
+ composition = await self.kr8s_get(
381
+ api,
382
+ "CompositionRevision",
383
+ "apiextensions.crossplane.io/v1",
384
+ None,
385
+ revision.name,
386
+ )
387
+ if not composition:
388
+ print(f'Compositioin "{revision.name}" not found', file=sys.stderr)
389
+ return None
390
+
391
+ # These will hold the response conditions and results.
392
+ conditions = protobuf.List()
393
+ results = protobuf.List()
394
+
395
+ # Create a function-pythonic function runner used to run pipeline steps.
396
+ runner = function.FunctionRunner(render_unknowns, crossplane_v1)
397
+ fatal = False
398
+
399
+ # Process the composition pipeline steps.
400
+ for step in composition.spec.pipeline:
401
+ if step.functionRef.name != "function-pythonic":
402
+ print(
403
+ f"Only function-pythonic functions can be run: {step.functionRef.name}",
404
+ file=sys.stderr,
405
+ )
406
+ return None
407
+ if not step.input.step:
408
+ step.input.step = step.step
409
+ request.input = step.input
410
+
411
+ # Supply step requested credentials.
412
+ request.credentials()
413
+ for credential in step.credentials:
414
+ if credential.source == "Secret" and credential.secretRef:
415
+ namespace = credential.secretRef.namespace
416
+ name = credential.secretRef.name
417
+ if namespace and name:
418
+ for resource in resources:
419
+ if (
420
+ resource.kind == "Secret"
421
+ and resource.apiVersion == "v1"
422
+ and resource.metadata.namespace == namespace
423
+ and resource.metadata.name == name
424
+ ):
425
+ data = request.credentials[
426
+ credential.name
427
+ ].credential_data.data
428
+ data()
429
+ for key, value in resource.data:
430
+ data[key] = protobuf.B64Decode(value)
431
+ break
432
+ else:
433
+ print(
434
+ f'Step "{step.step}" secret not found: {namespace}/{name}',
435
+ file=sys.stderr,
436
+ )
437
+ return None
438
+
439
+ # Track what extra/required resources have been processed.
440
+ requirements = protobuf.Message(
441
+ None, "requirements", fnv1.Requirements.DESCRIPTOR, fnv1.Requirements()
442
+ )
443
+ for _ in range(5):
444
+ # Fetch the step bootstrap resources specified.
445
+ request.required_resources()
446
+ for requirement in step.requirements.requiredResources:
447
+ await self.set_required(
448
+ requirement.requirementName,
449
+ requirement,
450
+ request.required_resources,
451
+ resources,
452
+ api,
453
+ )
454
+ # Fetch the required resources requested.
455
+ for name, selector in requirements.resources:
456
+ await self.set_required(
457
+ name, selector, request.required_resources, resources, api
458
+ )
459
+ # Fetch the now deprecated extra resources requested.
460
+ request.extra_resources()
461
+ for name, selector in requirements.extra_resources:
462
+ await self.set_required(
463
+ name, selector, request.extra_resources, resources, api
464
+ )
465
+ # Fetch the schemas requested.
466
+ request.required_schemas()
467
+ for name, selector in requirements.schemas:
468
+ await self.set_schema(
469
+ name, selector, request.required_schemas, schemas, api
470
+ )
471
+ # Run the step using the function-pythonic function runner.
472
+ response = protobuf.Message(
473
+ None,
474
+ "response",
475
+ fnv1.RunFunctionResponse.DESCRIPTOR,
476
+ await runner.RunFunction(request._message, None),
477
+ )
478
+ # Copy the response context to the request context to use in subsequent steps.
479
+ request.context = response.context
480
+ # All done if there is a fatal result.
481
+ for result in response.results:
482
+ if result.severity == fnv1.Severity.SEVERITY_FATAL:
483
+ fatal = True
484
+ break
485
+ if fatal:
486
+ break
487
+ # Exit this loop if the function has not requested additional extra/required resources.
488
+ if response.requirements == requirements:
489
+ break
490
+ # Establish the new set of requested extra/required resoruces.
491
+ requirements = response.requirements
492
+
493
+ # Copy the response desired state to the request desired state to use in subsequent steps.
494
+ request.desired.resources()
495
+ self.copy_resource(response.desired.composite, request.desired.composite)
496
+ for name, resource in response.desired.resources:
497
+ self.copy_resource(resource, request.desired.resources[name])
498
+
499
+ # Collect the step's returned conditions.
500
+ for condition in response.conditions:
501
+ if condition.type not in ("Ready", "Synced", "Healthy"):
502
+ conditions[protobuf.append] = self.create_condition(
503
+ condition.type,
504
+ condition.status,
505
+ condition.reason,
506
+ condition.message,
507
+ )
508
+ # Collect the step's returned results.
509
+ for result in response.results:
510
+ ix = len(results)
511
+ results[ix].step = step.step
512
+ results[ix].severity = fnv1.Severity.Name(result.severity._value)
513
+ if result.reason:
514
+ results[ix].reason = result.reason
515
+ if result.message:
516
+ results[ix].message = result.message
517
+
518
+ # All done if a fatal result was returned
519
+ if fatal:
520
+ break
521
+
522
+ # Collect and format all the returned desired composed resources.
523
+ resources = protobuf.List()
524
+ unready = protobuf.List()
525
+ prefix = composite.metadata.labels["crossplane.io/composite"]
526
+ if not prefix:
527
+ prefix = composite.metadata.name
528
+ for name, resource in request.desired.resources:
529
+ if resource.ready == fnv1.Ready.READY_TRUE:
530
+ ready = True
531
+ elif resource.ready == fnv1.Ready.READY_FALSE:
532
+ ready = False
533
+ else:
534
+ ready = None
535
+ if not ready:
536
+ unready[protobuf.append] = name
537
+ resource = resource.resource
538
+ observed = request.observed.resources[name].resource
539
+ if observed:
540
+ for key in ("namespace", "generateName", "name"):
541
+ if observed.metadata[key]:
542
+ resource.metadata[key] = observed.metadata[key]
543
+ if not resource.metadata.name and not resource.metadata.generateName:
544
+ resource.metadata.generateName = f"{prefix}-"
545
+ if composite.metadata.namespace:
546
+ resource.metadata.namespace = composite.metadata.namespace
547
+ resource.metadata.annotations["crossplane.io/composition-resource-name"] = (
548
+ name
549
+ )
550
+ resource.metadata.labels["crossplane.io/composite"] = prefix
551
+ if (
552
+ composite.metadata.labels["crossplane.io/claim-name"]
553
+ and composite.metadata.labels["crossplane.io/claim-namespace"]
554
+ ):
555
+ resource.metadata.labels["crossplane.io/claim-namespace"] = (
556
+ composite.metadata.labels["crossplane.io/claim-namespace"]
557
+ )
558
+ resource.metadata.labels["crossplane.io/claim-name"] = (
559
+ composite.metadata.labels["crossplane.io/claim-name"]
560
+ )
561
+ elif composite.spec.claimRef.namespace and composite.spec.claimRef.name:
562
+ resource.metadata.labels["crossplane.io/claim-namespace"] = (
563
+ composite.spec.claimRef.namespace
564
+ )
565
+ resource.metadata.labels["crossplane.io/claim-name"] = (
566
+ composite.spec.claimRef.name
567
+ )
568
+ resource.metadata.ownerReferences[0].controller = True
569
+ resource.metadata.ownerReferences[0].blockOwnerDeletion = True
570
+ resource.metadata.ownerReferences[0].apiVersion = composite.apiVersion
571
+ resource.metadata.ownerReferences[0].kind = composite.kind
572
+ resource.metadata.ownerReferences[0].name = composite.metadata.name
573
+ resource.metadata.ownerReferences[0].uid = ""
574
+ resource.ready = ready
575
+ resources[protobuf.append] = resource
576
+
577
+ # Format the returned desired composite
578
+ composite = protobuf.Map()
579
+ for name, value in request.desired.composite.resource:
580
+ composite[name] = value
581
+ composite.apiVersion = request.observed.composite.resource.apiVersion
582
+ composite.kind = request.observed.composite.resource.kind
583
+ # Add in the composite's status.conditions.
584
+ if request.desired.composite.ready == fnv1.Ready.READY_FALSE:
585
+ condition = self.create_condition("Ready", False, "Creating")
586
+ elif request.desired.composite.ready == fnv1.Ready.READY_UNSPECIFIED and len(
587
+ unready
588
+ ):
589
+ condition = self.create_condition(
590
+ "Ready",
591
+ False,
592
+ "Creating",
593
+ f"Unready resources: {','.join(str(name) for name in unready)}",
594
+ )
595
+ else:
596
+ condition = self.create_condition("Ready", True, "Available")
597
+ composite.status.conditions[protobuf.append] = condition
598
+ for condition in conditions:
599
+ composite.status.conditions[protobuf.append] = condition
600
+
601
+ return protobuf.Map(
602
+ composite=composite,
603
+ connection=request.desired.composite.connection_details,
604
+ resources=resources,
605
+ results=results,
606
+ context=request.context,
607
+ observed=request.observed,
608
+ )
609
+
610
+ async def get_composite_ref(self, composite, ref, request, resources, api):
611
+ namespace = ref.namespace
612
+ if not namespace:
613
+ namespace = composite.metadata.namespace
614
+ if not namespace:
615
+ namespace = None
616
+ source = await self.kr8s_get(api, ref.kind, ref.apiVersion, namespace, ref.name)
617
+ if source:
618
+ name = source.metadata.annotations[
619
+ "crossplane.io/composition-resource-name"
620
+ ]
621
+ if name:
622
+ destination = request.observed.resources[name]
623
+ if not destination: # Do not override manual observed
624
+ await self.set_resource(source, destination, resources, api)
625
+
626
+ async def set_required(self, name, selector, requireds, resources=None, api=None):
627
+ if resources is None:
628
+ resources = []
629
+ if not name:
630
+ return
631
+ name = str(name)
632
+ items = requireds[name].items
633
+ items() # Force this to get created
634
+ for resource in resources:
635
+ if (
636
+ selector.api_version == resource.apiVersion
637
+ and selector.kind == resource.kind
638
+ and (
639
+ (
640
+ not len(selector.namespace)
641
+ and not len(resource.metadata.namespace)
642
+ )
643
+ or (selector.namespace == resource.metadata.namespace)
644
+ )
645
+ ):
646
+ if selector.match_name == resource.metadata.name:
647
+ await self.set_resource(
648
+ resource, items[protobuf.append], resources, api
649
+ )
650
+ elif selector.match_labels.labels:
651
+ for key, value in selector.match_labels.labels:
652
+ if value != resource.metadata.labels[key]:
653
+ break
654
+ else:
655
+ await self.set_resource(
656
+ resource, items[protobuf.append], resources, api
657
+ )
658
+ if not len(items) and api:
659
+ if len(selector.match_name):
660
+ resource = await self.kr8s_get(
661
+ api,
662
+ selector.kind,
663
+ selector.api_version,
664
+ selector.namespace,
665
+ selector.match_name,
666
+ )
667
+ if resource:
668
+ await self.set_resource(
669
+ resource, items[protobuf.append], resources, api
670
+ )
671
+ elif len(selector.match_labels.labels):
672
+ for resource in await self.kr8s_list(
673
+ api,
674
+ selector.kind,
675
+ selector.api_version,
676
+ selector.namespace,
677
+ selector.match_labels.labels,
678
+ ):
679
+ await self.set_resource(
680
+ resource, items[protobuf.append], resources, api
681
+ )
682
+
683
+ async def set_resource(self, source, destination, resources=None, api=None):
684
+ if resources is None:
685
+ resources = []
686
+ destination.resource = source
687
+ namespace = (
688
+ source.spec.writeConnectionSecretToRef.namespace
689
+ or source.metadata.namespace
690
+ )
691
+ name = source.spec.writeConnectionSecretToRef.name
692
+ if namespace and name:
693
+ connection = None
694
+ for resource in resources:
695
+ if (
696
+ resource.kind == "Secret"
697
+ and resource.apiVersion == "v1"
698
+ and resource.metadata.namespace == namespace
699
+ and resource.metadata.name == name
700
+ ):
701
+ connection = resource
702
+ break
703
+ else:
704
+ if api:
705
+ connection = await self.kr8s_get(
706
+ api, "Secret", "v1", namespace, name
707
+ )
708
+ if connection:
709
+ destination.connection_details()
710
+ for key, value in connection.data:
711
+ destination.connection_details[key] = protobuf.B64Decode(value)
712
+
713
+ async def set_schema(self, name, selector, schemas, documents=None, api=None):
714
+ if documents is None:
715
+ documents = []
716
+ if not name:
717
+ return
718
+ name = str(name)
719
+ schema = schemas[name].openapi_v3
720
+ schema() # Force this to get created
721
+ gvk = protobuf.Map(kind=selector.kind)
722
+ version = str(selector.api_version)
723
+ if "/" in version:
724
+ gvk.group, gvk.version = version.split("/", 1)
725
+ else:
726
+ gvk.group = ""
727
+ gvk.version = version
728
+ for document in documents:
729
+ if self.find_schema(gvk, document, schema):
730
+ return
731
+ if api:
732
+ if gvk.group == "":
733
+ url = f"api/{gvk.version}"
734
+ else:
735
+ url = f"apis/{gvk.group}/{gvk.version}"
736
+ try:
737
+ async with api.call_api(
738
+ base="/openapi/v3", version="", url=url
739
+ ) as response:
740
+ document = protobuf.Value(None, None, response.json())
741
+ except kr8s.NotFoundError:
742
+ return
743
+ self.find_schema(gvk, document, schema)
744
+
745
+ def find_schema(self, gvk, document, schema):
746
+ for name, s in document.components.schemas:
747
+ gvks = s["x-kubernetes-group-version-kind"]
748
+ if len(gvks) == 1 and gvks[0] == gvk:
749
+ self.resolve_ref(
750
+ document, set(), f"#/components/schemas/{name}", schema
751
+ )
752
+ return True
753
+ return False
754
+
755
+ def resolve_ref(self, document, visiting, ref, schema):
756
+ if not ref:
757
+ return
758
+ ref = str(ref)
759
+ if ref in visiting:
760
+ return
761
+ d = None
762
+ for segment in ref.split("/"):
763
+ if segment == "#":
764
+ d = document
765
+ else:
766
+ d = d[segment]
767
+ if not d:
768
+ return
769
+ visiting.add(ref)
770
+ try:
771
+ for name, value in d:
772
+ self.copy_schema(document, visiting, name, value, schema)
773
+ finally:
774
+ visiting.remove(ref)
775
+
776
+ def copy_schema(self, document, visiting, key, value, schema):
777
+ if key == "$ref":
778
+ self.resolve_ref(document, visiting, value, schema)
779
+ elif key == "allOf":
780
+ if value._isList and len(value) == 1:
781
+ self.resolve_ref(document, visiting, value[0]["$ref"], schema)
782
+ else:
783
+ if value._isMap:
784
+ s = schema[key]
785
+ for n, v in value:
786
+ self.copy_schema(document, visiting, n, v, s)
787
+ elif value._isList:
788
+ s = schema[key]
789
+ for ix, v in enumerate(value):
790
+ self.copy_schema(document, visiting, ix, v, s)
791
+ else:
792
+ schema[key] = value
793
+
794
+ def copy_resource(self, source, destination):
795
+ destination.resource = source.resource
796
+ destination.connection_details()
797
+ for key, value in source.connection_details:
798
+ destination.connection_details[key] = value
799
+ destination.ready = source.ready
800
+
801
+ def create_condition(self, type, status, reason, message=None):
802
+ if isinstance(status, protobuf.FieldMessage):
803
+ if status._value == fnv1.Status.STATUS_CONDITION_TRUE:
804
+ status = "True"
805
+ elif status._value == fnv1.Status.STATUS_CONDITION_FALSE:
806
+ status = "False"
807
+ else:
808
+ status = "Unknown"
809
+ elif isinstance(status, bool):
810
+ if status:
811
+ status = "True"
812
+ else:
813
+ status = "False"
814
+ elif status is None:
815
+ status = "Unknown"
816
+ condition = {
817
+ "type": type,
818
+ "status": status,
819
+ "reason": reason,
820
+ "lastTransitionTime": "2026-01-01T00:00:00Z",
821
+ }
822
+ if message:
823
+ condition["message"] = message
824
+ return condition
825
+
826
+ async def kr8s_get(self, api, kind, apiVersion, namespace, name):
827
+ namespaced = namespace and len(namespace)
828
+ clazz = self.kr8s_class(kind, apiVersion, namespaced)
829
+ try:
830
+ fullName = [str(kind), str(apiVersion), str(name)]
831
+ if namespaced:
832
+ fullName.insert(-1, str(namespace))
833
+ resource = await clazz.get(str(name), namespace=str(namespace), api=api)
834
+ else:
835
+ resource = await clazz.get(str(name), api=api)
836
+ resource = protobuf.Value(None, None, resource.raw)
837
+ result = "found"
838
+ except kr8s.NotFoundError:
839
+ resource = None
840
+ result = "missing"
841
+ self.logger.debug(f"Resource {result}: {':'.join(fullName)}")
842
+ return resource
843
+
844
+ async def kr8s_list(self, api, kind, apiVersion, namespace, labelSelector):
845
+ namespaced = namespace and len(namespace)
846
+ clazz = self.kr8s_class(kind, apiVersion, namespaced)
847
+ resources = [
848
+ protobuf.Value(None, None, resource.raw)
849
+ async for resource in clazz.list(
850
+ namespace=str(namespace) if namespaced else None,
851
+ label_selector={label: str(value) for label, value in labelSelector},
852
+ api=api,
853
+ )
854
+ ]
855
+ if self.logger.isEnabledFor(logging.DEBUG):
856
+ fullName = [str(kind), str(apiVersion)]
857
+ if namespaced:
858
+ fullName.append(str(namespace))
859
+ fullName.append(
860
+ "&".join(f"{label}={value}" for label, value in labelSelector)
861
+ )
862
+ if resources:
863
+ result = f"found {INFLECT.number_to_words(len(resources))}"
864
+ else:
865
+ result = "missing"
866
+ self.logger.debug(f"Resources {result}: {':'.join(fullName)}")
867
+ return resources
868
+
869
+ def kr8s_class(self, kind, apiVersion, namespaced):
870
+ try:
871
+ return kr8s.asyncio.objects.get_class(str(kind), str(apiVersion), True)
872
+ except KeyError:
873
+ pass
874
+ return kr8s.asyncio.objects.new_class(
875
+ str(kind),
876
+ str(apiVersion),
877
+ True,
878
+ namespaced,
879
+ plural=INFLECT.plural_noun(str(kind)).lower(),
880
+ )