crossplane-function-pythonic 0.1.4__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,432 @@
1
+
2
+ import pathlib
3
+ import sys
4
+ import yaml
5
+ from crossplane.function.proto.v1 import run_function_pb2 as fnv1
6
+
7
+ from . import (
8
+ command,
9
+ function,
10
+ protobuf,
11
+ )
12
+
13
+
14
+ class Command(command.Command):
15
+ name = 'render'
16
+ help = 'Render a function-pythonic Composition'
17
+
18
+ @classmethod
19
+ def add_parser_arguments(cls, parser):
20
+ cls.add_function_arguments(parser)
21
+ parser.add_argument(
22
+ 'composite',
23
+ type=pathlib.Path,
24
+ metavar='PATH',
25
+ help='A YAML file containing the Composite resource to render.',
26
+ )
27
+ parser.add_argument(
28
+ 'composition',
29
+ type=pathlib.Path,
30
+ nargs='?',
31
+ metavar='PATH/CLASS',
32
+ help='A YAML file containing the Composition resource or the complete path of a function=-pythonic BaseComposite subclass.',
33
+ )
34
+ parser.add_argument(
35
+ '--context-files',
36
+ action='append',
37
+ default=[],
38
+ metavar='KEY=PATH',
39
+ help='Context key-value pairs to pass to the Function pipeline. Values must be files containing YAML/JSON.',
40
+ )
41
+ parser.add_argument(
42
+ '--context-values',
43
+ action='append',
44
+ default=[],
45
+ metavar='KEY=VALUE',
46
+ help='Context key-value pairs to pass to the Function pipeline. Values must be YAML/JSON. Keys take precedence over --context-files.',
47
+ )
48
+ parser.add_argument(
49
+ '--observed-resources', '-o',
50
+ action='append',
51
+ type=pathlib.Path,
52
+ default=[],
53
+ metavar='PATH',
54
+ help='A YAML file or directory of YAML files specifying the observed state of composed resources.'
55
+ )
56
+ parser.add_argument(
57
+ '--extra-resources',
58
+ action='append',
59
+ type=pathlib.Path,
60
+ default=[],
61
+ metavar='PATH',
62
+ help='A YAML file or directory of YAML files specifying required resources (deprecated, use --required-resources).',
63
+ )
64
+ parser.add_argument(
65
+ '--required-resources', '-e',
66
+ action='append',
67
+ type=pathlib.Path,
68
+ default=[],
69
+ metavar='PATH',
70
+ help='A YAML file or directory of YAML files specifying required resources to pass to the Function pipeline.',
71
+ )
72
+ parser.add_argument(
73
+ '--function-credentials',
74
+ action='append',
75
+ type=pathlib.Path,
76
+ default=[],
77
+ metavar='PATH',
78
+ help='A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR.',
79
+ )
80
+ parser.add_argument(
81
+ '--include-full-xr', '-x',
82
+ action='store_true',
83
+ help="Include a direct copy of the input XR's spedc and metadata fields in the rendered output.",
84
+ )
85
+ parser.add_argument(
86
+ '--include-function-results', '-r',
87
+ action='store_true',
88
+ help='Include informational and warning messages from Functions in the rendered output as resources of kind: Result..',
89
+ )
90
+ parser.add_argument(
91
+ '--include-context', '-c',
92
+ action='store_true',
93
+ help='Include the context in the rendered output as a resource of kind: Context.',
94
+ )
95
+
96
+ def initialize(self):
97
+ self.initialize_function()
98
+
99
+ async def run(self):
100
+ # Obtain the Composite to render.
101
+ if not self.args.composite.is_file():
102
+ print(f"Composite \"{self.args.composite}\" is not a file", file=sys.stderr)
103
+ sys.exit(1)
104
+ composite = protobuf.Yaml(self.args.composite.read_text())
105
+
106
+ # Obtain the Composition that will be used to render the Composite.
107
+ if composite.apiVersion in ('pythonic.crossplane.io/v1alpha1', 'pythonic.fortra.com/v1alpha1') and composite.kind == 'Composite':
108
+ if self.args.composition:
109
+ print('Composite type of "composite.pythonic.crossplane.io" does not use "composition" argument', file=sys.stderr)
110
+ sys.exit(1)
111
+ composition = self.create_composition(composite, '')
112
+ else:
113
+ if not self.args.composition:
114
+ print('"composition" argument required', file=sys.stderr)
115
+ sys.exit(1)
116
+ if self.args.composition.is_file():
117
+ composition = protobuf.Yaml(self.args.composition.read_text())
118
+ else:
119
+ composite = self.args.composition.rsplit('.', 1)
120
+ if len(composite) == 1:
121
+ print(f"Composition class name does not include module: {self.args.composition}", file=sys.stderr)
122
+ sys.exit(1)
123
+ try:
124
+ module = importlib.import_module(composite[0])
125
+ except Exception as e:
126
+ print(f"Unable to import composition class: {composite[0]}", file=sys.stderr)
127
+ sys.exit(1)
128
+ clazz = getattr(module, composite[1], None)
129
+ if not clazz:
130
+ print(f"Composition class {composite[0]} does not define: {composite[1]}", file=sys.stderr)
131
+ sys.exit(1)
132
+ if not inspect.isclass(clazz):
133
+ print(f"Composition class {self.args.composition} is not a class", file=sys.stderr)
134
+ sys.exit(1)
135
+ if not issubclass(clazz, pythonic.BaseComposite):
136
+ print(f"Composition class {self.args.composition} is not a subclass of BaseComposite", file=sys.stderr)
137
+ sys.exit(1)
138
+ composition = self.create_composition(composite, str(self.args.composition))
139
+
140
+ # Build up the RunFunctionRequest protobuf message used to call function-pythonic.
141
+ request = protobuf.Message(None, 'request', fnv1.RunFunctionRequest.DESCRIPTOR, fnv1.RunFunctionRequest())
142
+
143
+ # Load the request context with any specified command line options.
144
+ for entry in self.args.context_files:
145
+ key_path = entry.split('=', 1)
146
+ if len(key_path) != 2:
147
+ print(f"Invalid --context-files: {entry}", file=sys.stderr)
148
+ sys.exit(1)
149
+ path = pathlib.Path(key_path[1])
150
+ if not path.is_file():
151
+ print(f"Invalid --context-files {path} is not a file", file=sys.stderr)
152
+ sys.exit(1)
153
+ request.context[key_path[0]] = protobuf.Yaml(path.read_text())
154
+ for entry in self.args.context_values:
155
+ key_value = entry.split('=', 1)
156
+ if len(key_value) != 2:
157
+ print(f"Invalid --context-values: {entry}", file=sys.stderr)
158
+ sys.exit(1)
159
+ request.context[key_value[0]] = protobuf.Yaml(key_value[1])
160
+
161
+ # Establish the request observed composite and specifed observed resources.
162
+ request.observed.composite.resource = composite
163
+ for resource in self.collect_resources(self.args.observed_resources):
164
+ name = resource.metadata.annotations['crossplane.io/composition-resource-name']
165
+ if name:
166
+ request.observed.resources[str(name)].resource = resource
167
+
168
+ # Collect specified required/extra resources.
169
+ requireds = [resource for resource in self.collect_resources(self.args.required_resources)]
170
+ requireds += [resource for resource in self.collect_resources(self.args.extra_resources)]
171
+
172
+ # Collect specified credential secrets.
173
+ credentials = []
174
+ for credential in self.collect_resources(self.args.function_credentials):
175
+ if credential.apiVersion == 'v1' and credential.kind == 'Secret':
176
+ credentials.append(credential)
177
+
178
+ # These will hold the response conditions and results.
179
+ conditions = protobuf.List()
180
+ results = protobuf.List()
181
+
182
+ # Create a function-pythonic function runner used to run pipeline steps.
183
+ runner = function.FunctionRunner(self.args.debug, self.args.render_unknowns)
184
+ fatal = False
185
+
186
+ # Process the composition pipeline steps.
187
+ for step in composition.spec.pipeline:
188
+ if step.functionRef.name != 'function-pythonic':
189
+ print(f"Only function-pythonic functions can be run: {step.functionRef.name}", file=sys.stderr)
190
+ sys.exit(1)
191
+ if not step.input.step:
192
+ step.input.step = step.step
193
+ request.input = step.input
194
+
195
+ # Supply step requested credentials.
196
+ request.credentials()
197
+ for fn_credential in step.credentials:
198
+ if fn_credential.source == 'Secret' and fn_credential.secretRef:
199
+ for credential in credentials:
200
+ if credential.metadata.namespace == fn_credential.secretRef.namespace and credential.metadata.name == fn_credential.secretRef.name:
201
+ data = request.credentials[str(fn_credential.name)].credential_data.data
202
+ data()
203
+ for key, value in credential.data:
204
+ data[key] = protobuf.B64Decode(value)
205
+ break
206
+ else:
207
+ print(f"Step \"{step.step}\" secret not found: {fn_credential.secretRef.namespace} {fn_credential.secretRef.name}", file=sys.stderr)
208
+ sys.exit(1)
209
+
210
+ # Track what extra/required resources have been processed.
211
+ requirements = protobuf.Message(None, 'requirements', fnv1.Requirements.DESCRIPTOR, fnv1.Requirements())
212
+ for _ in range(5):
213
+ # Fetch the step bootstrap resources specified.
214
+ request.required_resources()
215
+ for requirement in step.requirements:
216
+ self.fetch_requireds(requireds, requirement.requirementName, requirement, request.required_resources)
217
+ # Fetch the required resources requested.
218
+ for name, selector in requirements.resources:
219
+ self.fetch_requireds(requireds, name, selector, request.required_resources)
220
+ # Fetch the now deprecated extra resources requested.
221
+ request.extra_resources()
222
+ for name, selector in requirements.extra_resources:
223
+ self.fetch_requireds(requireds, name, selector, request.extra_resources)
224
+ # Run the step using the function-pythonic function runner.
225
+ response = protobuf.Message(
226
+ None,
227
+ 'response',
228
+ fnv1.RunFunctionResponse.DESCRIPTOR,
229
+ await runner.RunFunction(request._message, None),
230
+ )
231
+ # All done if there is a fatal result.
232
+ for result in response.results:
233
+ if result.severity == fnv1.Severity.SEVERITY_FATAL:
234
+ fatal = True
235
+ break
236
+ # Copy the response context to the request context to use in subsequent steps.
237
+ request.context = response.context
238
+ # Exit this loop if the function has not requested additional extra/required resources.
239
+ if response.requirements == requirements:
240
+ break
241
+ # Establish the new set of requested extra/required resoruces.
242
+ requirements = response.requirements
243
+
244
+ # Copy the response desired state to the request desired state to use in subsequent steps.
245
+ request.desired.resources()
246
+ self.copy_resource(response.desired.composite, request.desired.composite)
247
+ for name, resource in response.desired.resources:
248
+ self.copy_resource(resource, request.desired.resources[name])
249
+
250
+ # Collect the step's returned conditions.
251
+ for condition in response.conditions:
252
+ if condition.type not in ('Ready', 'Synced', 'Healthy'):
253
+ conditions[protobuf.append] = self.create_condition(condition.type, condition.status, condition.reason, condition.message)
254
+ # Collect the step's returned results.
255
+ for result in response.results:
256
+ ix = len(results)
257
+ results[ix].apiVersion = 'render.crossplane.io/v1beta1'
258
+ results[ix].kind = 'Result'
259
+ results[ix].step = step.step
260
+ results[ix].severity = fnv1.Severity.Name(result.severity._value)
261
+ if result.reason:
262
+ results[ix].reason = result.reason
263
+ if result.message:
264
+ results[ix].message = result.message
265
+
266
+ # All done if a fatal result was returned
267
+ if fatal:
268
+ break
269
+
270
+ # Collect and format all the returned desired composed resources.
271
+ resources = protobuf.List()
272
+ unready = protobuf.List()
273
+ prefix = composite.metadata.labels['crossplane.io/composite']
274
+ if not prefix:
275
+ prefix = composite.metadata.name
276
+ for name, resource in request.desired.resources:
277
+ if resource.ready != fnv1.Ready.READY_TRUE:
278
+ unready[protobuf.append] = name
279
+ resource = resource.resource
280
+ observed = request.observed.resources[name].resource
281
+ if observed:
282
+ for key in ('namespace', 'generateName', 'name'):
283
+ if observed.metadata[key]:
284
+ resource.metadata[key] = observed.metadata[key]
285
+ if not resource.metadata.name and not resource.metadata.generateName:
286
+ resource.metadata.generateName = f"{prefix}-"
287
+ if composite.metadata.namespace:
288
+ resource.metadata.namespace = composite.metadata.namespace
289
+ resource.metadata.annotations['crossplane.io/composition-resource-name'] = name
290
+ resource.metadata.labels['crossplane.io/composite'] = prefix
291
+ if composite.metadata.labels['crossplane.io/claim-name'] and composite.metadata.labels['crossplane.io/claim-namespace']:
292
+ resource.metadata.labels['crossplane.io/claim-namespace'] = composite.metadata.labels['crossplane.io/claim-namespace']
293
+ resource.metadata.labels['crossplane.io/claim-name'] = composite.metadata.labels['crossplane.io/claim-name']
294
+ elif composite.spec.claimRef.namespace and composite.spec.claimRef.name:
295
+ resource.metadata.labels['crossplane.io/claim-namespace'] = composite.spec.claimRef.namespace
296
+ resource.metadata.labels['crossplane.io/claim-name'] = composite.spec.claimRef.name
297
+ resource.metadata.ownerReferences[0].controller = True
298
+ resource.metadata.ownerReferences[0].blockOwnerDeletion = True
299
+ resource.metadata.ownerReferences[0].apiVersion = composite.apiVersion
300
+ resource.metadata.ownerReferences[0].kind = composite.kind
301
+ resource.metadata.ownerReferences[0].name = composite.metadata.name
302
+ resource.metadata.ownerReferences[0].uid = ''
303
+ resources[protobuf.append] = resource
304
+
305
+ # Format the returned desired composite
306
+ composite = protobuf.Map()
307
+ for name, value in request.desired.composite.resource:
308
+ composite[name] = value
309
+ composite.apiVersion = request.observed.composite.resource.apiVersion
310
+ composite.kind = request.observed.composite.resource.kind
311
+ if self.args.include_full_xr:
312
+ composite.metadata = request.observed.composite.resource.metadata
313
+ if request.observed.composite.resource.spec:
314
+ composite.spec = request.observed.composite.resource.spec
315
+ else:
316
+ if request.observed.composite.resource.metadata.namespace:
317
+ composite.metadata.namespace = request.observed.composite.resource.metadata.namespace
318
+ composite.metadata.name = request.observed.composite.resource.metadata.name
319
+ # Add in the composite's status.conditions.
320
+ if request.desired.composite.ready == fnv1.Ready.READY_FALSE:
321
+ condition = self.create_condition('Ready', False, 'Creating')
322
+ elif request.desired.composite.ready == fnv1.Ready.READY_UNSPECIFIED and len(unready):
323
+ condition = self.create_condition('Ready', False, 'Creating', f"Unready resources: {', '.join(str(name) for name in unready)}")
324
+ else:
325
+ condition = self.create_condition('Ready', True, 'Available')
326
+ composite.status.conditions[protobuf.append] = condition
327
+ for condition in conditions:
328
+ composite.status.conditions[protobuf.append] = condition
329
+
330
+ # Print the composite.
331
+ print('---')
332
+ print(str(composite), end='')
333
+ # Print the composed resources.
334
+ for resource in sorted(resources, key=lambda resource: str(resource.metadata.annotations['crossplane.io/composition-resource-name'])):
335
+ print('---')
336
+ print(str(resource), end='')
337
+ # Print the results (AKA events) if requested.
338
+ if self.args.include_function_results:
339
+ for result in results:
340
+ print('---')
341
+ print(str(result), end='')
342
+ # Print the final context if requested.
343
+ if self.args.include_context:
344
+ print('---')
345
+ print(
346
+ str(protobuf.Map(
347
+ apiVersion = 'render.crossplane.io/v1beta1',
348
+ kind = 'Context',
349
+ fields = request.context,
350
+ )),
351
+ end='',
352
+ )
353
+
354
+ def create_composition(self, composite, module):
355
+ composition = protobuf.Map()
356
+ composition.apiVersion = 'apiextensions.crossplane.io/v1'
357
+ composition.kind = 'Composition'
358
+ composition.metadata.name = 'function-pythonic-render'
359
+ composition.spec.compositeTypeRef.apiVersion = composite.apiVersion
360
+ composition.spec.compositeTypeRef.kind = composite.kind
361
+ composition.spec.mode = 'Pipeline'
362
+ composition.spec.pipeline[0].step = 'function-pythonic-render'
363
+ composition.spec.pipeline[0].functionRef.name = 'function-pythonic'
364
+ composition.spec.pipeline[0].input.apiVersion = 'pythonic.fn.crossplane.io/v1alpha1'
365
+ composition.spec.pipeline[0].input.kind = 'Composite'
366
+ composition.spec.pipeline[0].input.composite = module
367
+ return composition
368
+
369
+ def collect_resources(self, resources):
370
+ files = []
371
+ for resource in resources:
372
+ if resource.is_file():
373
+ files.append(resource)
374
+ elif resource.is_dir():
375
+ for file in resource.iterdir():
376
+ if file.suffix in ('.yaml', '.yml'):
377
+ files.append(file)
378
+ else:
379
+ print(f"Specified resource is not a file or a directory: {resource}", file=sys.stderr)
380
+ sys.exit(1)
381
+ for file in files:
382
+ for document in yaml.safe_load_all(file.read_text()):
383
+ yield protobuf.Value(None, None, document)
384
+
385
+ def fetch_requireds(self, requireds, name, selector, resources):
386
+ if not name:
387
+ return
388
+ name = str(name)
389
+ items = resources[name].items
390
+ items() # Force this to get created
391
+ for required in requireds:
392
+ if selector.api_version == required.apiVersion and selector.kind == required.kind:
393
+ if selector.match_name == required.metadata.name:
394
+ items[protobuf.append].resource = required
395
+ elif selector.match_labels.labels:
396
+ for key, value in selector.match_labels.labels:
397
+ if value != required.metadata.labels[key]:
398
+ break
399
+ else:
400
+ items[protobuf.append].resource = required
401
+
402
+ def copy_resource(self, source, destination):
403
+ destination.resource = source.resource
404
+ destination.connection_details()
405
+ for key, value in source.connection_details:
406
+ destination.connection_details[key] = value
407
+ destination.ready = source.ready
408
+
409
+ def create_condition(self, type, status, reason, message=None):
410
+ if isinstance(status, protobuf.FieldMessage):
411
+ if status._value == fnv1.Status.STATUS_CONDITION_TRUE:
412
+ status = 'True'
413
+ elif status._value == fnv1.Status.STATUS_CONDITION_FALSE:
414
+ status = 'False'
415
+ else:
416
+ status = 'Unknown'
417
+ elif isinstance(status, bool):
418
+ if status:
419
+ status = 'True'
420
+ else:
421
+ status = 'False'
422
+ elif status is None:
423
+ status = 'Unknown'
424
+ condition = {
425
+ 'type': type,
426
+ 'status': status,
427
+ 'reason': reason,
428
+ 'lastTransitionTime': '2026-01-01T00:00:00Z'
429
+ }
430
+ if message:
431
+ condition['message'] = message
432
+ return condition
@@ -0,0 +1,13 @@
1
+
2
+ from . import (
3
+ command,
4
+ __about__,
5
+ )
6
+
7
+
8
+ class Command(command.Command):
9
+ name = 'version'
10
+ help = 'Print the function-oythonic version'
11
+
12
+ async def run(self):
13
+ print(__about__.__version__)