crossplane-function-pythonic 0.5.0__py3-none-any.whl → 0.6.1__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.
@@ -1,2 +1,2 @@
1
1
  # This is set at build time, using "hatch version"
2
- __version__ = "0.5.0"
2
+ __version__ = "0.6.1"
@@ -1,10 +1,12 @@
1
1
 
2
2
  import datetime
3
3
  from google.protobuf.duration_pb2 import Duration
4
- from crossplane.function.proto.v1 import run_function_pb2 as fnv1
5
4
 
6
- from . import auto_ready
7
- from . import protobuf
5
+ from . import (
6
+ auto_ready,
7
+ protobuf,
8
+ )
9
+ from .proto.v1 import run_function_pb2 as fnv1
8
10
 
9
11
 
10
12
  _notset = object()
@@ -104,11 +106,13 @@ class BaseComposite:
104
106
  )
105
107
  self.response = protobuf.Message(None, 'response', response.DESCRIPTOR, response)
106
108
  self.logger = logger
109
+ self.capabilities = Capabilities(self.request.meta.capabilities)
107
110
  self.parameters = self.request.input.parameters
108
111
  self.credentials = Credentials(self.request)
109
112
  self.context = self.response.context
110
113
  self.environment = self.context['apiextensions.crossplane.io/environment']
111
114
  self.requireds = Requireds(self)
115
+ self.schemas = Schemas(self)
112
116
  self.resources = Resources(self)
113
117
  self.autoReady = True
114
118
  self.usages = False
@@ -123,6 +127,7 @@ class BaseComposite:
123
127
  self.metadata = self.observed.metadata
124
128
  self.spec = self.observed.spec
125
129
  self.status = self.desired.status
130
+ self.output = self.response.output
126
131
  self.conditions = Conditions(observed, self.response)
127
132
  self.results = Results(self.response)
128
133
  self.events = Results(self.response) # Deprecated, use self.results
@@ -136,6 +141,30 @@ class BaseComposite:
136
141
  raise NotImplementedError()
137
142
 
138
143
 
144
+ class Capabilities:
145
+ def __init__(self, capabilities):
146
+ self._capabilities = capabilities
147
+
148
+ def __bool__(self):
149
+ return fnv1.CAPABILITY_CAPABILITIES in self._capabilities
150
+
151
+ @property
152
+ def requireds(self):
153
+ return fnv1.CAPABILITY_REQUIRED_RESOURCES in self._capabilities if self else None
154
+
155
+ @property
156
+ def credentials(self):
157
+ return fnv1.CAPABILITY_CREDENTIALS in self._capabilities if self else None
158
+
159
+ @property
160
+ def conditions(self):
161
+ return fnv1.CAPABILITY_CONDITIONS in self._capabilities if self else None
162
+
163
+ @property
164
+ def schemas(self):
165
+ return fnv1.CAPABILITY_REQUIRED_SCHEMAS in self._capabilities if self else None
166
+
167
+
139
168
  class Credentials:
140
169
  def __init__(self, request):
141
170
  self.__dict__['_request'] = request
@@ -558,6 +587,131 @@ class RequiredResource:
558
587
  return bool(self.observed)
559
588
 
560
589
 
590
+ class Schemas:
591
+ def __init__(self, composite):
592
+ self._composite = composite
593
+ self._cache = {}
594
+
595
+ def __getattr__(self, key):
596
+ return self[key]
597
+
598
+ def __getitem__(self, key):
599
+ schema = self._cache.get(key)
600
+ if not schema:
601
+ schema = Schema(self._composite, key)
602
+ self._cache[key] = schema
603
+ return schema
604
+
605
+ def __bool__(self):
606
+ return bool(len(self))
607
+
608
+ def __len__(self):
609
+ names = set()
610
+ for name, schema in self._composite.request.required_schemas:
611
+ names.add(name)
612
+ for name, selector in self._composite.response.requirements.schemas:
613
+ names.add(name)
614
+ return len(names)
615
+
616
+ def __contains__(self, key):
617
+ if key in self._composite.request.required_schemas:
618
+ return True
619
+ if key in self._composite.response.requirements.schemas:
620
+ return True
621
+ return False
622
+
623
+ def __iter__(self):
624
+ names = set()
625
+ for name, schema in self._composite.request.required_schemas:
626
+ names.add(name)
627
+ for name, selector in self._composite.response.requirements.schemas:
628
+ names.add(name)
629
+ for name in sorted(names):
630
+ yield name, self[name]
631
+
632
+
633
+ class Schema:
634
+ def __init__(self, composite, name):
635
+ self.name = name
636
+ self._selector = composite.response.requirements.schemas[name]
637
+ self._schema = composite.request.required_schemas[name].openapi_v3
638
+
639
+ def __call__(self, kind=_notset, apiVersion=_notset):
640
+ self._selector()
641
+ if kind != _notset:
642
+ # Allow for apiVersion in the first arg and kind in the second arg
643
+ if '/' in kind or kind == 'v1':
644
+ if apiVersion != _notset:
645
+ self.kind = apiVersion
646
+ apiVersion = kind
647
+ else:
648
+ self.kind = kind
649
+ if apiVersion != _notset:
650
+ self.apiVersion = apiVersion
651
+ return self
652
+
653
+ @property
654
+ def apiVersion(self):
655
+ return self._selector.api_version
656
+
657
+ @apiVersion.setter
658
+ def apiVersion(self, apiVersion):
659
+ self._selector.api_version = apiVersion
660
+
661
+ @property
662
+ def kind(self):
663
+ return self._selector.kind
664
+
665
+ @kind.setter
666
+ def kind(self, kind):
667
+ self._selector.kind = kind
668
+
669
+ def __enter__(self):
670
+ return self
671
+
672
+ def __exit__(self, exc_type, exc_value, traceback):
673
+ pass
674
+
675
+ def __aenter__(self):
676
+ return self
677
+
678
+ def __aexit__(self, exc_type, exc_value, traceback):
679
+ pass
680
+
681
+ def __getattr__(self, key):
682
+ return self[key]
683
+
684
+ def __getitem__(self, key):
685
+ return self._schema[key]
686
+
687
+ def __bool__(self):
688
+ return bool(self._schema)
689
+
690
+ def __len__(self):
691
+ return len(self._schema)
692
+
693
+ def __contains__(self, item):
694
+ return item in self._schema
695
+
696
+ def __iter__(self):
697
+ for key, value in self._schema:
698
+ yield key, value
699
+
700
+ def __hash__(self):
701
+ return hash(self._schema)
702
+
703
+ def __eq__(self, other):
704
+ if instance(other, Schema):
705
+ other = other._schema
706
+ return self._schema == other
707
+
708
+ def __str__(self):
709
+ return str(self._schema)
710
+
711
+ def __format__(self, spec='yaml'):
712
+ return format(self,_schema, spec)
713
+
714
+
561
715
  class Conditions:
562
716
  def __init__(self, observed, response=None):
563
717
  self._observed = observed
@@ -7,8 +7,8 @@ import logging
7
7
  import sys
8
8
 
9
9
  import grpc
10
- from crossplane.function.proto.v1 import run_function_pb2 as fnv1
11
- from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
10
+ from .proto.v1 import run_function_pb2 as fnv1
11
+ from .proto.v1 import run_function_pb2_grpc as grpcv1
12
12
  from .. import pythonic
13
13
 
14
14
  logger = logging.getLogger(__name__)
@@ -121,8 +121,13 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
121
121
  except Exception as e:
122
122
  return self.fatal(request, logger, 'Compose', e)
123
123
 
124
- if requireds := self.get_requireds(step, composite):
125
- logger.debug(f"Requireds requested: {','.join(requireds)}")
124
+ schemas = self.get_schemas(step, composite)
125
+ requireds = self.get_requireds(step, composite)
126
+ if schemas or requireds:
127
+ if schemas:
128
+ logger.debug(f"Required schemas: {','.join(schemas)}")
129
+ if requireds:
130
+ logger.debug(f"Required resources: {','.join(requireds)}")
126
131
  else:
127
132
  self.process_usages(composite)
128
133
  self.process_unknowns(composite)
@@ -155,11 +160,21 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
155
160
  ]
156
161
  )
157
162
 
163
+ def get_schemas(self, step, composite):
164
+ schemas = []
165
+ for name, schema in composite.schemas:
166
+ if len(schema.kind) and len(schema.apiVersion):
167
+ s = pythonic.Map(kind=schema.kind, apiVersion=schema.apiVersion)
168
+ if s != step.schemas[name]:
169
+ step.schemas[name] = s
170
+ schemas.append(name)
171
+ return schemas
172
+
158
173
  def get_requireds(self, step, composite):
159
174
  requireds = []
160
175
  for name, required in composite.requireds:
161
- if len(required.apiVersion) and len(required.kind):
162
- r = pythonic.Map(apiVersion=required.apiVersion, kind=required.kind)
176
+ if len(required.kind) and len(required.apiVersion):
177
+ r = pythonic.Map(kind=required.kind, apiVersion=required.apiVersion)
163
178
  if len(required.namespace):
164
179
  r.namespace = required.namespace
165
180
  if len(required.matchName):
@@ -7,7 +7,6 @@ import shlex
7
7
  import signal
8
8
  import sys
9
9
 
10
- import crossplane.function.proto.v1.run_function_pb2_grpc as grpcv1
11
10
  import grpc
12
11
 
13
12
  from . import (
@@ -15,6 +14,8 @@ from . import (
15
14
  command,
16
15
  function,
17
16
  )
17
+ from .proto.v1 import run_function_pb2_grpc as grpcv1
18
+
18
19
 
19
20
  logger = logging.getLogger(__name__)
20
21
 
@@ -45,6 +46,12 @@ class Command(command.Command):
45
46
  parser.add_argument(
46
47
  '--packages',
47
48
  action='store_true',
49
+ dest='packages_configmaps',
50
+ help='Discover python packages from function-pythonic ConfigMaps, deprecated use --packages-configmaps'
51
+ )
52
+ parser.add_argument(
53
+ '--packages-configmaps',
54
+ action='store_true',
48
55
  help='Discover python packages from function-pythonic ConfigMaps.'
49
56
  )
50
57
  parser.add_argument(
@@ -57,7 +64,17 @@ class Command(command.Command):
57
64
  action='append',
58
65
  default=[],
59
66
  metavar='NAMESPACE',
60
- help='Namespaces to discover function-pythonic ConfigMaps in, default is cluster wide.',
67
+ help='Namespaces to discover function-pythonic ConfigMaps and Secrets in, default is cluster wide.',
68
+ )
69
+ parser.add_argument(
70
+ '--packages-environmentconfigs',
71
+ action='store_true',
72
+ help='Also Discover python packages from function-pythonic EnvironmentConfigs.'
73
+ )
74
+ parser.add_argument(
75
+ '--packages-compositions',
76
+ action='store_true',
77
+ help='Also Discover python packages from function-pythonic Compositions.'
61
78
  )
62
79
  parser.add_argument(
63
80
  '--packages-dir',
@@ -75,7 +92,9 @@ class Command(command.Command):
75
92
  if not self.args.tls_certs_dir and not self.args.insecure:
76
93
  print('Either --tls-certs-dir or --insecure must be specified', file=sys.stderr)
77
94
  sys.exit(1)
78
-
95
+ if (self.args.packages_environmentconfigs or self.args.packages_compositions) and self.args.packages_namespace:
96
+ print('--packages-namespace cannot be used with --packages-environment-configs or --packages-compositions', file=sys.stderr)
97
+ sys.exit(1)
79
98
  if self.args.pip_install:
80
99
  import pip._internal.cli.main
81
100
  pip._internal.cli.main.main(['install', '--user', *shlex.split(self.args.pip_install)])
@@ -108,15 +127,18 @@ class Command(command.Command):
108
127
  )
109
128
  await grpc_server.start()
110
129
 
111
- if self.args.packages:
130
+ if self.args.packages_configmaps or self.args.packages_secrets or self.args.packages_environmentconfigs or self.args.packages_compositions:
112
131
  from . import packages
113
132
  async with asyncio.TaskGroup() as tasks:
114
133
  tasks.create_task(grpc_server.wait_for_termination())
115
134
  tasks.create_task(packages.operator(
116
135
  grpc_server,
117
136
  grpc_runner,
137
+ self.args.packages_configmaps,
118
138
  self.args.packages_secrets,
119
139
  self.args.packages_namespace,
140
+ self.args.packages_environmentconfigs,
141
+ self.args.packages_compositions,
120
142
  self.args.packages_dir,
121
143
  ))
122
144
  else:
@@ -10,27 +10,38 @@ import kopf
10
10
  GRPC_SERVER = None
11
11
  GRPC_RUNNER = None
12
12
  PACKAGES_DIR = None
13
- PACKAGE_LABEL = {'function-pythonic.package': kopf.PRESENT}
13
+ PACKAGE_LABEL = 'function-pythonic.package'
14
+ PACKAGE_LABELS = {PACKAGE_LABEL: kopf.PRESENT}
14
15
 
15
16
 
16
- def operator(grpc_server, grpc_runner, packages_secrets, packages_namespaces, packages_dir):
17
+ def operator(grpc_server, grpc_runner, packages_configmaps, packages_secrets, packages_namespaces, packages_environmentconfigs, packages_compositions, packages_dir):
17
18
  logging.getLogger('kopf.objects').setLevel(logging.INFO)
18
19
  global GRPC_SERVER, GRPC_RUNNER, PACKAGES_DIR
19
20
  GRPC_SERVER = grpc_server
20
21
  GRPC_RUNNER = grpc_runner
21
22
  PACKAGES_DIR = pathlib.Path(packages_dir).expanduser().resolve()
22
23
  sys.path.insert(0, str(PACKAGES_DIR))
24
+ if packages_configmaps:
25
+ on_resource('', 'v1', 'configmaps')
23
26
  if packages_secrets:
24
- kopf.on.create('', 'v1', 'secrets', labels=PACKAGE_LABEL)(create)
25
- kopf.on.resume('', 'v1', 'secrets', labels=PACKAGE_LABEL)(create)
26
- kopf.on.update('', 'v1', 'secrets', labels=PACKAGE_LABEL)(update)
27
- kopf.on.delete('', 'v1', 'secrets', labels=PACKAGE_LABEL)(delete)
27
+ on_resource('', 'v1', 'secrets')
28
+ if not packages_namespaces:
29
+ if packages_environmentconfigs:
30
+ on_resource('apiextensions.crossplane.io', 'v1beta1', 'environmentconfigs')
31
+ if packages_compositions:
32
+ on_resource('apiextensions.crossplane.io', 'v1', 'compositions')
28
33
  return kopf.operator(
29
34
  standalone=True,
30
35
  clusterwide=not packages_namespaces,
31
36
  namespaces=packages_namespaces,
32
37
  )
33
38
 
39
+ def on_resource(group, version, plural):
40
+ kopf.on.create(group, version, plural, labels=PACKAGE_LABELS)(create)
41
+ kopf.on.resume(group, version, plural, labels=PACKAGE_LABELS)(create)
42
+ kopf.on.update(group, version, plural, labels=PACKAGE_LABELS)(update)
43
+ kopf.on.delete(group, version, plural, labels=PACKAGE_LABELS)(delete)
44
+
34
45
 
35
46
  @kopf.on.startup()
36
47
  async def startup(settings, **_):
@@ -42,107 +53,128 @@ async def cleanup(**_):
42
53
  await GRPC_SERVER.stop(5)
43
54
 
44
55
 
45
- @kopf.on.create('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
46
- @kopf.on.resume('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
47
- async def create(body, logger, **_):
48
- package_dir = get_package_dir(body, logger)
49
- if package_dir:
50
- secret = body['kind'] == 'Secret'
51
- for name, text in body.get('data', {}).items():
52
- package_file_write(package_dir, name, secret, text, 'Created', logger)
53
-
54
-
55
- @kopf.on.update('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
56
- async def update(body, old, logger, **_):
57
- old_package_dir = get_package_dir(old)
58
- if old_package_dir:
59
- old_data = old.get('data', {})
60
- else:
61
- old_data = {}
62
- old_names = set(old_data.keys())
63
- package_dir = get_package_dir(body, logger)
64
- if package_dir:
65
- secret = body['kind'] == 'Secret'
66
- for name, text in body.get('data', {}).items():
67
- if package_dir == old_package_dir and text == old_data.get(name, None):
68
- action = 'Unchanged'
69
- else:
70
- action = 'Updated' if package_dir == old_package_dir and name in old_names else 'Created'
71
- package_file_write(package_dir, name, secret, text, action, logger)
72
- if package_dir == old_package_dir:
73
- old_names.discard(name)
74
- if old_package_dir:
75
- for name in old_names:
76
- package_file_unlink(old_package_dir, name, 'Removed', logger)
77
-
78
-
79
- @kopf.on.delete('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
80
- async def delete(old, logger, **_):
81
- package_dir = get_package_dir(old)
82
- if package_dir:
83
- for name in old.get('data', {}).keys():
84
- package_file_unlink(package_dir, name, 'Deleted', logger)
85
-
86
-
87
- def get_package_dir(body, logger=None):
88
- package = body.get('metadata', {}).get('labels', {}).get('function-pythonic.package', None)
56
+ async def create(resource, labels, body, logger, **_):
57
+ resource_create(resource, labels, 'Created', body, logger)
58
+
59
+
60
+ async def update(resource, labels, body, old, logger, **_):
61
+ resource_delete(resource, labels, 'Removed', old, logger)
62
+ resource_create(resource, labels, 'Added', body, logger)
63
+
64
+
65
+ async def delete(resource, labels, body, logger, **_):
66
+ resource_delete(resource, labels, 'Deleted', body, logger)
67
+
68
+
69
+ def resource_create(resource, labels, action, body, logger):
70
+ package_dir = resource_package_dir(resource, labels, logger)
71
+ if not package_dir:
72
+ return
73
+ if resource.plural in ('configmaps', 'secrets', 'environmentconfigs'):
74
+ package_create(resource, action, package_dir, body.get('data', {}), logger)
75
+ elif resource.plural == 'compositions':
76
+ for step in body.get('spec', {}).get('pipeline', []):
77
+ input = step.get('input')
78
+ if input and input.get('apiVersion') == 'pythonic.fn.crossplane.io/v1alpha1':
79
+ package_create(resource, action, package_dir, input.get('packages', {}), logger)
80
+
81
+
82
+ def resource_delete(resource, labels, action, body, logger):
83
+ package_dir = resource_package_dir(resource, labels, logger)
84
+ if not package_dir:
85
+ return
86
+ if resource.plural in ('configmaps', 'secrets', 'environmentconfigs'):
87
+ package_delete(action, package_dir, body.get('data', {}), logger)
88
+ elif resource.plural == 'compositions':
89
+ for step in body.get('spec', {}).get('pipeline', []):
90
+ input = step.get('input')
91
+ if input and input.get('apiVersion') == 'pythonic.fn.crossplane.io/v1alpha1':
92
+ package_delete(action, package_dir, step.get('input', {}).get('packages', {}), logger)
93
+
94
+
95
+ def resource_package_dir(resource, labels, logger):
96
+ package = labels.get(PACKAGE_LABEL)
89
97
  if package is None:
90
98
  if logger:
91
- logger.error('function-pythonic.package label is missing')
99
+ logger.error(f"{PACKAGE_LABEL} label is missing")
92
100
  return None
93
101
  package_dir = PACKAGES_DIR
94
- if package:
102
+ if resource.plural in ('configmaps', 'secrets') and package:
95
103
  for segment in package.split('.'):
96
104
  if not segment.isidentifier():
97
- if logger:
98
- logger.error('Package has invalid package name: %s', package)
105
+ logger.error('Package has invalid package name: %s', package)
99
106
  return None
100
107
  package_dir = package_dir / segment
101
108
  return package_dir
102
109
 
103
110
 
104
- def package_file_write(package_dir, name, secret, text, action, logger):
105
- package_file = package_dir / name
106
- if action != 'Unchanged':
107
- package_file.parent.mkdir(parents=True, exist_ok=True)
108
- if secret:
109
- package_file.write_bytes(base64.b64decode(text.encode('utf-8')))
110
- else:
111
- package_file.write_text(text)
112
- module, name = package_file_name(package_file)
113
- if module:
114
- if action != 'Unchanged':
115
- GRPC_RUNNER.invalidate_module(name)
116
- logger.info(f"{action} module: {name}")
117
- else:
118
- logger.info(f"{action} file: {name}")
119
-
120
-
121
- def package_file_unlink(package_dir, name, action, logger):
122
- package_file = package_dir / name
123
- package_file.unlink(missing_ok=True)
124
- module, name = package_file_name(package_file)
125
- if module:
126
- GRPC_RUNNER.invalidate_module(name)
127
- logger.info(f"{action} module: {name}")
128
- else:
129
- logger.info(f"{action} file: {name}")
130
- package_dir = package_file.parent
131
- while (
132
- package_dir.is_relative_to(PACKAGES_DIR)
133
- and package_dir.is_dir()
134
- and not list(package_dir.iterdir())
135
- ):
136
- package_dir.rmdir()
137
- module = str(package_dir.relative_to(PACKAGES_DIR)).replace('/', '.')
138
- if module != '.':
139
- GRPC_RUNNER.invalidate_module(module)
140
- logger.info(f"{action} package: {module}")
141
- package_dir = package_dir.parent
142
-
143
-
144
- def package_file_name(package_file):
145
- name = str(package_file.relative_to(PACKAGES_DIR))
111
+ def package_create(resource, action, package_dir, package, logger):
112
+ for name, value in package.items():
113
+ if validate_entry(name, value, logger):
114
+ package_name = package_dir / name
115
+ if isinstance(value, str):
116
+ package_name.parent.mkdir(parents=True, exist_ok=True)
117
+ if resource.plural == 'secrets':
118
+ package_name.write_bytes(base64.b64decode(value.encode('utf-8')))
119
+ else:
120
+ package_name.write_text(value)
121
+ module, name = package_file_name(package_name)
122
+ if module:
123
+ GRPC_RUNNER.invalidate_module(name)
124
+ logger.info(f"{action} module: {name}")
125
+ else:
126
+ logger.info(f"{action} file: {name}")
127
+ elif isinstance(value, dict):
128
+ package_create(resource, action, package_name, value, logger)
129
+
130
+
131
+ def package_delete(action, package_dir, package, logger):
132
+ for name, value in package.items():
133
+ if validate_entry(name, value, logger):
134
+ package_name = package_dir / name
135
+ if isinstance(value, str):
136
+ package_name.unlink(missing_ok=True)
137
+ module, name = package_file_name(package_name)
138
+ if module:
139
+ GRPC_RUNNER.invalidate_module(name)
140
+ logger.info(f"{action} module: {name}")
141
+ else:
142
+ logger.info(f"{action} file: {name}")
143
+ parent = package_name.parent
144
+ while (
145
+ parent.is_relative_to(PACKAGES_DIR)
146
+ and parent.is_dir()
147
+ and not list(parent.iterdir())
148
+ ):
149
+ parent.rmdir()
150
+ module = str(parent.relative_to(PACKAGES_DIR)).replace('/', '.')
151
+ if module != '.':
152
+ GRPC_RUNNER.invalidate_module(module)
153
+ logger.info(f"{action} package: {module}")
154
+ parent = parent.parent
155
+ elif isinstance(value, dict):
156
+ package_delete(action, package_name, value, logger)
157
+
158
+
159
+ def validate_entry(name, value, logger):
160
+ if isinstance(value, str):
161
+ if not name.endswith('.py'):
162
+ if '.' in name or '/' in name:
163
+ logger.error(f"Python package file name is not valid: {name}")
164
+ return False
165
+ return True
166
+ name = name[:-3]
167
+ elif not isinstance(value, dict):
168
+ logger.error(f"Python package \"{name}\" value is not a valid type: {value.__class__}")
169
+ return False
170
+ if name.isidentifier():
171
+ return True
172
+ logger.error(f"Python package name is not an identifier: {name}")
173
+ return False
174
+
175
+
176
+ def package_file_name(package_name):
177
+ name = str(package_name.relative_to(PACKAGES_DIR))
146
178
  if name.endswith('.py'):
147
179
  return True, name[:-3].replace('/', '.')
148
180
  return False, name