Skip to content
This repository was archived by the owner on Dec 10, 2025. It is now read-only.

Commit 0905329

Browse files
author
Patrick J. McNerthney
committed
Major refactor to support Usages
1 parent 5891000 commit 0905329

File tree

22 files changed

+1063
-419
lines changed

22 files changed

+1063
-419
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ jobs:
217217
# pushes them as a multi-platform package. We only push the package it the
218218
# XPKG_ACCESS_ID and XPKG_TOKEN secrets were provided.
219219
push-xpkg:
220-
# Don't publish unless we were run with an explicit version.
221-
if: ${{ inputs.version != '' }}
220+
# Don't publish unless the main branch or we were run with an explicit version
221+
if: ${{ github.ref == 'refs/heads/main' || inputs.version != '' }}
222222
needs:
223223
- build-xpkg
224224
runs-on: ubuntu-24.04

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ overridden for all composed resource by setting the Composite `self.unknownsFata
9595
to False, or at the individual composed resource level by setting the
9696
`Resource.unknownsFatal` field to False.
9797

98+
## Usage Dependencies
99+
100+
function-pythonic can be configured to automatically create
101+
[Crossplane Usages](https://docs.crossplane.io/latest/managed-resources/usages/)
102+
dependencies between resources. Modifying the above VPC example with:
103+
```yaml
104+
self.usages = True
105+
106+
vpc = self.resources.VPC('ec2.aws.crossplane.io/v1beta1', 'VPC')
107+
vpc.spec.forProvider.region = 'us-east-1
108+
vpc.spec.forProvider.cidrBlock = '10.0.0.0/16'
109+
110+
subnet = self.resources.SubnetA('ec2.aws.crossplane.io/v1beta1', 'Subnet')
111+
subnet.spec.forProvider.region = 'us-east-1'
112+
subnet.spec.forProvider.vpcId = vpc.status.atProvider.vpcId
113+
subnet.spec.forProvider.availabilityZone = 'us-east-1a'
114+
subnet.spec.forProvider.cidrBlock = '10.0.0.0/20'
115+
```
116+
Will generate the appropriate Crossplane Usage resource.
117+
98118
## Pythonic access of Protobuf Messages
99119

100120
All Protobuf messages are wrapped by a set of python classes which enable using
@@ -201,6 +221,7 @@ The BaseComposite also provides access to the following Crossplane Function leve
201221
| self.requireds | Requireds | Request and read additional local Kubernetes resources |
202222
| self.resources | Resources | Define and process composed resources |
203223
| self.unknownsFatal | Boolean | Terminate the composition if already created resources are assigned unknown values, default True |
224+
| self.usages| Boolean | Generate Crossplane Usages for resource dependencies, default False |
204225
| self.autoReady | Boolean | Perform auto ready processing on all composed resources, default True |
205226

206227
### Composed Resources
@@ -227,6 +248,7 @@ Resource class:
227248
| Resource.connection | Connection | The resource connection details |
228249
| Resource.ready | Boolean | The resource ready state |
229250
| Resource.unknownsFatal | Boolean | Terminate the composition if this resource has been created and is assigned unknown values, default is Composite.unknownsFatal |
251+
| Resource.usages | Boolean | Generate Crossplane Usages for this resource, default is Composite.autoReady |
230252
| Resource.autoReady | Boolean | Perform auto ready processing on this resource, default is Composite.autoReady |
231253

232254
### Required Resources (AKA Extra Resources)

crossplane/pythonic/__init__.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11

2-
import base64
32

43
from .composite import BaseComposite
5-
from .protobuf import append, Map, List, Unknown, Yaml, Json
6-
B64Encode = lambda s: base64.b64encode(s.encode('utf-8')).decode('utf-8')
7-
B64Decode = lambda s: base64.b64decode(s.encode('utf-8')).decode('utf-8')
4+
from .protobuf import append, Map, List, Unknown, Yaml, Json, B64Encode, B64Decode
85

96
__all__ = [
107
'BaseComposite',

crossplane/pythonic/composite.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(self, request, logger):
3131
self.resources = Resources(self)
3232
self.unknownsFatal = True
3333
self.autoReady = True
34+
self.usages = False
3435

3536
observed = self.request.observed.composite
3637
desired = self.response.desired.composite
@@ -49,7 +50,7 @@ def __init__(self, request, logger):
4950
def ttl(self):
5051
if self.response.meta.ttl.nanos:
5152
return float(self.response.meta.ttl.seconds) + (float(self.response.meta.ttl.nanos) / 1000000000.0)
52-
return self.response.meta.ttl.seconds
53+
return int(self.response.meta.ttl.seconds)
5354

5455
@ttl.setter
5556
def ttl(self, ttl):
@@ -61,7 +62,7 @@ def ttl(self, ttl):
6162
if ttl.is_integer():
6263
self.response.meta.ttl.nanos = 0
6364
else:
64-
self.response.meta.ttl.nanos = int((ttl - self.response.meta.ttl.seconds) * 1000000000)
65+
self.response.meta.ttl.nanos = int((ttl - int(self.response.meta.ttl.seconds)) * 1000000000)
6566
else:
6667
raise ValueError('ttl must be an int or float')
6768

@@ -78,7 +79,7 @@ def ready(self):
7879
def ready(self, ready):
7980
if ready:
8081
ready = fnv1.Ready.READY_TRUE
81-
elif ready == None or (isinstance(ready, protobuf.Values) and ready._isUnknown):
82+
elif ready == None or (isinstance(ready, protobuf.Value) and ready._isUnknown):
8283
ready = fnv1.Ready.READY_UNSPECIFIED
8384
else:
8485
ready = fnv1.Ready.READY_FALSE
@@ -184,6 +185,7 @@ def __init__(self, composite, name):
184185
self.connection = Connection(observed)
185186
self.unknownsFatal = None
186187
self.autoReady = None
188+
self.usages = None
187189

188190
def __call__(self, apiVersion=_notset, kind=_notset, namespace=_notset, name=_notset):
189191
self.desired()
@@ -199,15 +201,15 @@ def __call__(self, apiVersion=_notset, kind=_notset, namespace=_notset, name=_no
199201

200202
@property
201203
def apiVersion(self):
202-
return self.observed.apiVersion
204+
return self.desired.apiVersion
203205

204206
@apiVersion.setter
205207
def apiVersion(self, apiVersion):
206208
self.desired.apiVersion = apiVersion
207209

208210
@property
209211
def kind(self):
210-
return self.observed.kind
212+
return self.desired.kind
211213

212214
@kind.setter
213215
def kind(self, kind):
@@ -265,7 +267,7 @@ def ready(self):
265267
def ready(self, ready):
266268
if ready:
267269
ready = fnv1.Ready.READY_TRUE
268-
elif ready == None or (isinstance(ready, protobuf.Values) and ready._isUnknown):
270+
elif ready == None or (isinstance(ready, protobuf.Value) and ready._isUnknown):
269271
ready = fnv1.Ready.READY_UNSPECIFIED
270272
else:
271273
ready = fnv1.Ready.READY_FALSE
@@ -376,8 +378,8 @@ def matchLabels(self, labels):
376378
elif isinstance(entry, (list, tuple)):
377379
self._selector.match_labels.labels[entry[0]] = entry[1]
378380

379-
def __getitem__(self, key):
380-
return RequiredResource(self.name, self._resources.items[key])
381+
def __getitem__(self, ix):
382+
return RequiredResource(self.name, ix, self._resources.items[ix])
381383

382384
def __bool__(self):
383385
return bool(self._resources.items)
@@ -391,8 +393,9 @@ def __iter__(self):
391393

392394

393395
class RequiredResource:
394-
def __init__(self, name, resource):
396+
def __init__(self, name, ix, resource):
395397
self.name = name
398+
self.ix = ix
396399
self.observed = resource.resource
397400
self.apiVersion = self.observed.apiVersion
398401
self.kind = self.observed.kind
@@ -487,7 +490,7 @@ def status(self, status):
487490
condition.status = fnv1.Status.STATUS_CONDITION_TRUE
488491
elif status == None:
489492
condition.status = fnv1.Status.STATUS_CONDITION_UNKNOWN
490-
elif isinstance(status, protobuf.Values) and status._isUnknown:
493+
elif isinstance(status, protobuf.Value) and status._isUnknown:
491494
condition.status = fnv1.Status.STATUS_CONDITION_UNSPECIFIED
492495
else:
493496
condition.status = fnv1.Status.STATUS_CONDITION_FALSE
@@ -521,7 +524,7 @@ def lastTransitionTime(self):
521524
if observed.type == self.type:
522525
time = observed.lastTransitionTime
523526
if time:
524-
return datetime.datetime.fromisoformat(time)
527+
return datetime.datetime.fromisoformat(str(time))
525528
return None
526529

527530
@property
@@ -534,7 +537,7 @@ def claim(self, claim):
534537
condition = self._find_condition(True)
535538
if claim:
536539
condition.target = fnv1.Target.TARGET_COMPOSITE_AND_CLAIM
537-
elif claim == None or (isinstance(claim, protobuf.Values) and claim._isUnknown):
540+
elif claim == None or (isinstance(claim, protobuf.Value) and claim._isUnknown):
538541
condition.target = fnv1.Target.TARGET_UNSPECIFIED
539542
else:
540543
condition.target = fnv1.Target.TARGET_COMPOSITE
@@ -711,7 +714,7 @@ def claim(self, claim):
711714
if bool(self):
712715
if claim:
713716
self._result.target = fnv1.Target.TARGET_COMPOSITE_AND_CLAIM
714-
elif claim == None or (isinstance(claim, protobuf.Values) and claim._isUnknown):
717+
elif claim == None or (isinstance(claim, protobuf.Value) and claim._isUnknown):
715718
self._result.target = fnv1.Target.TARGET_UNSPECIFIED
716719
else:
717720
self._result.target = fnv1.Target.TARGET_COMPOSITE

crossplane/pythonic/function.py

Lines changed: 115 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ async def run_function(self, request):
9999
return self.fatal(request, logger, 'Instantiate', e)
100100

101101
step = composite.context._pythonic[step]
102-
iteration = (step.iteration or 0) + 1
102+
iteration = int(step.iteration) + 1
103103
step.iteration = iteration
104104
composite.context.iteration = iteration
105105
logger.debug(f"Starting compose, {ordinal(len(composite.context._pythonic))} step, {ordinal(iteration)} pass")
@@ -111,7 +111,40 @@ async def run_function(self, request):
111111
except Exception as e:
112112
return self.fatal(request, logger, 'Compose', e)
113113

114-
requested = []
114+
if requireds := self.get_requireds(step, composite):
115+
logger.info(f"Requireds requested: {','.join(requireds)}")
116+
else:
117+
self.process_usages(composite)
118+
self.process_unknowns(composite)
119+
self.process_auto_readies(composite)
120+
logger.info('Completed compose')
121+
122+
return composite.response._message
123+
124+
def fatal(self, request, logger, message, exception=None):
125+
if exception:
126+
message += ' exceptiion'
127+
logger.exception(message)
128+
m = str(exception)
129+
if not m:
130+
m = exception.__class__.__name__
131+
message += ': ' + m
132+
else:
133+
logger.error(message)
134+
return fnv1.RunFunctionResponse(
135+
meta=fnv1.ResponseMeta(
136+
tag=request.meta.tag,
137+
),
138+
results=[
139+
fnv1.Result(
140+
severity=fnv1.SEVERITY_FATAL,
141+
message=message,
142+
)
143+
]
144+
)
145+
146+
def get_requireds(self, step, composite):
147+
requireds = []
115148
for name, required in composite.requireds:
116149
if required.apiVersion and required.kind:
117150
r = pythonic.Map(apiVersion=required.apiVersion, kind=required.kind)
@@ -123,11 +156,84 @@ async def run_function(self, request):
123156
r.matchLabels[key] = value
124157
if r != step.requireds[name]:
125158
step.requireds[name] = r
126-
requested.append(name)
127-
if requested:
128-
logger.info(f"Requireds requested: {','.join(requested)}")
129-
return composite.response._message
159+
requireds.append(name)
160+
return requireds
161+
162+
def process_usages(self, composite):
163+
for _, resource in sorted(entry for entry in composite.resources):
164+
dependencies = resource.desired._getDependencies
165+
if dependencies:
166+
if self.debug:
167+
for destination, source in sorted(dependencies.items()):
168+
destination = self.trimFullName(destination)
169+
source = self.trimFullName(source)
170+
logger.debug(f"Dependency: {destination} = {source}")
171+
if resource.usages or (resource.usages is None and composite.usages):
172+
resources = {}
173+
requireds = {}
174+
for destination, source in sorted(dependencies.items()):
175+
name = source.split('.')
176+
if (len(name) > 5 and
177+
name[0] == 'request' and
178+
name[1] == 'observed' and
179+
name[2] == 'resources' and
180+
name[4] == 'resource'
181+
):
182+
if name[3] not in resources:
183+
resources[name[3]] = []
184+
resources[name[3]].append(f"{'.'.join(destination.split('.')[5:])} = {'.'.join(name[5:])}")
185+
elif (len(name) > 5 and
186+
name[0] == 'request' and
187+
name[1] == 'extra_resources' and
188+
name[3].startswith('items[') and name[3][-1] == ']' and
189+
name[4] == 'resource'
190+
):
191+
key = (name[2], int(name[3][6:-1]))
192+
if key not in requireds:
193+
requireds[key] = []
194+
requireds[key].append(f"{'.'.join(destination.split('.')[5:])} = [{key[1]}]{'.'.join(name[5:])}")
195+
for name, dependencies in resources.items():
196+
source = composite.resources[name]
197+
name = [resource.name, str(source.kind)]
198+
if source.metadata.namespace:
199+
name.append(str(source.metadata.namespace))
200+
name.append(str(source.observed.metadata.name))
201+
usage = composite.resources['_'.join(name)]('apiextensions.crossplane.io/v1beta1', 'Usage')
202+
#usage = composite.resources['_'.join(name)]('protection.crossplane.io/v1beta1', 'Usage')
203+
if resource.metadata.namespace:
204+
usage.metadata.namespace = resource.metadata.namespace
205+
usage.spec.reason = '\n'.join(dependencies)
206+
usage.spec.replayDeletion = True
207+
usage.spec.by.apiVersion = resource.apiVersion
208+
usage.spec.by.kind = resource.kind
209+
usage.spec.by.resourceRef.name = resource.observed.metadata.name
210+
usage.spec.of.apiVersion = source.apiVersion
211+
usage.spec.of.kind = source.kind
212+
if source.metadata.namespace:
213+
usage.spec.of.resourceRef.namespace = source.metadata.namespace
214+
usage.spec.of.resourceRef.name = source.observed.metadata.name
215+
for key, dependencies in requireds.items():
216+
source = composite.requireds[key[0]][key[1]]
217+
name = [resource.name, str(source.kind)]
218+
if source.metadata.namespace:
219+
name.append(str(source.metadata.namespace))
220+
name.append(str(source.metadata.name))
221+
usage = composite.resources['_'.join(name)]('apiextensions.crossplane.io/v1beta1', 'Usage')
222+
#usage = composite.resources['_'.join(name)]('protection.crossplane.io/v1beta1', 'Usage')
223+
if resource.metadata.namespace:
224+
usage.metadata.namespace = resource.metadata.namespace
225+
usage.spec.reason = '\n'.join(dependencies)
226+
usage.spec.replayDeletion = True
227+
usage.spec.by.apiVersion = resource.apiVersion
228+
usage.spec.by.kind = resource.kind
229+
usage.spec.by.resourceRef.name = resource.observed.metadata.name
230+
usage.spec.of.apiVersion = source.apiVersion
231+
usage.spec.of.kind = source.kind
232+
if source.metadata.namespace:
233+
usage.spec.of.resourceRef.namespace = source.metadata.namespace
234+
usage.spec.of.resourceRef.name = source.observed.metadata.name
130235

236+
def process_unknowns(self, composite):
131237
unknownResources = []
132238
warningResources = []
133239
fatalResources = []
@@ -190,42 +296,19 @@ async def run_function(self, request):
190296
if event:
191297
event(reason, message)
192298

299+
def process_auto_readies(self, composite):
193300
for name, resource in composite.resources:
194301
if resource.autoReady or (resource.autoReady is None and composite.autoReady):
195302
if resource.ready is None:
196303
if resource.conditions.Ready.status:
197304
resource.ready = True
198305

199-
logger.info('Completed compose')
200-
return composite.response._message
201-
202-
def fatal(self, request, logger, message, exception=None):
203-
if exception:
204-
message += ' exceptiion'
205-
logger.exception(message)
206-
m = str(exception)
207-
if not m:
208-
m = exception.__class__.__name__
209-
message += ': ' + m
210-
else:
211-
logger.error(message)
212-
return fnv1.RunFunctionResponse(
213-
meta=fnv1.ResponseMeta(
214-
tag=request.meta.tag,
215-
),
216-
results=[
217-
fnv1.Result(
218-
severity=fnv1.SEVERITY_FATAL,
219-
message=message,
220-
)
221-
]
222-
)
223-
224306
def trimFullName(self, name):
225307
name = name.split('.')
226308
for values in (
309+
('request', 'observed', 'composite', 'resource'),
227310
('request', 'observed', 'resources', None, 'resource'),
228-
('request', 'extra_resources', None, 'items', 'resource'),
311+
('request', 'extra_resources', None, 'items', None, 'resource'),
229312
('response', 'desired', 'resources', None, 'resource'),
230313
):
231314
if len(values) < len(name):

0 commit comments

Comments
 (0)