orchestrator-core 3.1.2rc4__py3-none-any.whl → 3.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.
- orchestrator/__init__.py +1 -1
- orchestrator/api/api_v1/endpoints/processes.py +6 -9
- orchestrator/cli/generator/generator/workflow.py +13 -1
- orchestrator/cli/generator/templates/modify_product.j2 +9 -0
- orchestrator/db/__init__.py +2 -0
- orchestrator/db/loaders.py +51 -3
- orchestrator/db/models.py +13 -0
- orchestrator/db/queries/__init__.py +0 -0
- orchestrator/db/queries/subscription.py +85 -0
- orchestrator/db/queries/subscription_instance.py +28 -0
- orchestrator/domain/base.py +162 -44
- orchestrator/domain/context_cache.py +62 -0
- orchestrator/domain/helpers.py +41 -1
- orchestrator/domain/subscription_instance_transform.py +114 -0
- orchestrator/graphql/resolvers/process.py +3 -3
- orchestrator/graphql/resolvers/product.py +2 -2
- orchestrator/graphql/resolvers/product_block.py +2 -2
- orchestrator/graphql/resolvers/resource_type.py +2 -2
- orchestrator/graphql/resolvers/workflow.py +2 -2
- orchestrator/graphql/utils/get_query_loaders.py +6 -48
- orchestrator/graphql/utils/get_subscription_product_blocks.py +8 -1
- orchestrator/migrations/versions/schema/2025-03-06_42b3d076a85b_subscription_instance_as_json_function.py +33 -0
- orchestrator/migrations/versions/schema/2025-03-06_42b3d076a85b_subscription_instance_as_json_function.sql +40 -0
- orchestrator/migrations/versions/schema/2025-04-09_fc5c993a4b4a_add_cascade_constraint_on_processes_.py +44 -0
- orchestrator/services/processes.py +28 -9
- orchestrator/services/subscriptions.py +36 -6
- orchestrator/settings.py +3 -0
- orchestrator/utils/functional.py +9 -0
- orchestrator/utils/redis.py +6 -0
- orchestrator/workflow.py +29 -6
- orchestrator/workflows/utils.py +40 -5
- {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/METADATA +9 -8
- {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/RECORD +36 -28
- /orchestrator/migrations/versions/schema/{2025-10-19_4fjdn13f83ga_add_validate_product_type_task.py → 2025-01-19_4fjdn13f83ga_add_validate_product_type_task.py} +0 -0
- {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/WHEEL +0 -0
- {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/licenses/LICENSE +0 -0
orchestrator/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2019-2025 SURF, GÉANT.
|
|
1
|
+
# Copyright 2019-2025 SURF, GÉANT, ESnet.
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -40,13 +40,7 @@ from orchestrator.db.filters import Filter
|
|
|
40
40
|
from orchestrator.db.filters.process import filter_processes
|
|
41
41
|
from orchestrator.db.sorting import Sort, SortOrder
|
|
42
42
|
from orchestrator.db.sorting.process import sort_processes
|
|
43
|
-
from orchestrator.schemas import
|
|
44
|
-
ProcessIdSchema,
|
|
45
|
-
ProcessResumeAllSchema,
|
|
46
|
-
ProcessSchema,
|
|
47
|
-
ProcessStatusCounts,
|
|
48
|
-
Reporter,
|
|
49
|
-
)
|
|
43
|
+
from orchestrator.schemas import ProcessIdSchema, ProcessResumeAllSchema, ProcessSchema, ProcessStatusCounts, Reporter
|
|
50
44
|
from orchestrator.security import authenticate
|
|
51
45
|
from orchestrator.services.process_broadcast_thread import api_broadcast_process_data
|
|
52
46
|
from orchestrator.services.processes import (
|
|
@@ -139,9 +133,12 @@ async def new_process(
|
|
|
139
133
|
request: Request,
|
|
140
134
|
json_data: list[dict[str, Any]] | None = Body(...),
|
|
141
135
|
user: str = Depends(user_name),
|
|
136
|
+
user_model: OIDCUserModel | None = Depends(authenticate),
|
|
142
137
|
) -> dict[str, UUID]:
|
|
143
138
|
broadcast_func = api_broadcast_process_data(request)
|
|
144
|
-
process_id = start_process(
|
|
139
|
+
process_id = start_process(
|
|
140
|
+
workflow_key, user_inputs=json_data, user_model=user_model, user=user, broadcast_func=broadcast_func
|
|
141
|
+
)
|
|
145
142
|
|
|
146
143
|
return {"id": process_id}
|
|
147
144
|
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
11
|
# See the License for the specific language governing permissions and
|
|
12
12
|
# limitations under the License.
|
|
13
|
-
|
|
14
13
|
from collections.abc import Callable
|
|
15
14
|
from functools import partial, wraps
|
|
15
|
+
from importlib import metadata
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
|
+
import semver
|
|
19
20
|
import structlog
|
|
20
21
|
from jinja2 import Environment
|
|
21
22
|
|
|
@@ -123,7 +124,17 @@ def get_product_workflow_path(config: dict, workflow_type: str) -> Path:
|
|
|
123
124
|
return product_workflow_folder(config) / Path(f"{workflow_type}_{file_name}").with_suffix(".py")
|
|
124
125
|
|
|
125
126
|
|
|
127
|
+
def eval_pydantic_forms_version() -> bool:
|
|
128
|
+
updated_version = semver.Version.parse("2.0.0")
|
|
129
|
+
|
|
130
|
+
installed_version = metadata.version("pydantic-forms")
|
|
131
|
+
installed_semver = semver.Version.parse(installed_version)
|
|
132
|
+
|
|
133
|
+
return installed_semver >= updated_version
|
|
134
|
+
|
|
135
|
+
|
|
126
136
|
def render_template(environment: Environment, config: dict, template: str, workflow: str = "") -> str:
|
|
137
|
+
use_updated_readonly_field = eval_pydantic_forms_version()
|
|
127
138
|
product_block = root_product_block(config)
|
|
128
139
|
types_to_import = get_name_spaced_types_to_import(product_block["fields"])
|
|
129
140
|
fields = get_input_fields(product_block)
|
|
@@ -152,6 +163,7 @@ def render_template(environment: Environment, config: dict, template: str, workf
|
|
|
152
163
|
product_types_module=get_product_types_module(),
|
|
153
164
|
workflows_module=get_workflows_module(),
|
|
154
165
|
workflow_validations=workflow_validations if workflow else [],
|
|
166
|
+
use_updated_readonly_field=use_updated_readonly_field,
|
|
155
167
|
)
|
|
156
168
|
|
|
157
169
|
|
|
@@ -7,7 +7,12 @@ from typing import Annotated
|
|
|
7
7
|
import structlog
|
|
8
8
|
from pydantic import AfterValidator, ConfigDict, model_validator
|
|
9
9
|
from pydantic_forms.types import FormGenerator, State, UUIDstr
|
|
10
|
+
|
|
11
|
+
{%- if use_updated_readonly_field is true %}
|
|
12
|
+
from pydantic_forms.validators import read_only_field
|
|
13
|
+
{%- else %}
|
|
10
14
|
from pydantic_forms.validators import ReadOnlyField
|
|
15
|
+
{%- endif %}
|
|
11
16
|
|
|
12
17
|
from orchestrator.forms import FormPage
|
|
13
18
|
from orchestrator.forms.validators import CustomerId, Divider
|
|
@@ -52,7 +57,11 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
|
|
|
52
57
|
divider_1: Divider
|
|
53
58
|
|
|
54
59
|
{% for field in fields if not field.modifiable is defined -%}
|
|
60
|
+
{% if use_updated_readonly_field is true -%}
|
|
61
|
+
{{ field.name }}: read_only_field({{ product_block.name }}.{{ field.name }})
|
|
62
|
+
{% else -%}
|
|
55
63
|
{{ field.name }}: ReadOnlyField({{ product_block.name }}.{{ field.name }})
|
|
64
|
+
{% endif -%}
|
|
56
65
|
{% endfor -%}
|
|
57
66
|
|
|
58
67
|
{% for field in fields if field.modifiable is defined -%}
|
orchestrator/db/__init__.py
CHANGED
|
@@ -19,6 +19,7 @@ from orchestrator.db.database import Database, transactional
|
|
|
19
19
|
from orchestrator.db.models import ( # noqa: F401
|
|
20
20
|
EngineSettingsTable,
|
|
21
21
|
FixedInputTable,
|
|
22
|
+
InputStateTable,
|
|
22
23
|
ProcessStepTable,
|
|
23
24
|
ProcessSubscriptionTable,
|
|
24
25
|
ProcessTable,
|
|
@@ -85,6 +86,7 @@ __all__ = [
|
|
|
85
86
|
"SubscriptionMetadataTable",
|
|
86
87
|
"ResourceTypeTable",
|
|
87
88
|
"FixedInputTable",
|
|
89
|
+
"InputStateTable",
|
|
88
90
|
"EngineSettingsTable",
|
|
89
91
|
"WorkflowTable",
|
|
90
92
|
"SubscriptionCustomerDescriptionTable",
|
orchestrator/db/loaders.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from functools import reduce
|
|
2
|
-
from typing import Any, Callable, Iterator, NamedTuple, cast
|
|
2
|
+
from typing import Any, Callable, Iterable, Iterator, NamedTuple, cast
|
|
3
3
|
|
|
4
4
|
import structlog
|
|
5
5
|
from sqlalchemy import inspect
|
|
@@ -131,12 +131,15 @@ def init_model_loaders() -> None:
|
|
|
131
131
|
_MODEL_LOADERS[model] = dict(_inspect_model(model))
|
|
132
132
|
|
|
133
133
|
|
|
134
|
-
def
|
|
134
|
+
def _lookup_attr_loaders(model: type[DbBaseModel], attr: str) -> list[AttrLoader]:
|
|
135
135
|
"""Return loader(s) for an attribute on the given model."""
|
|
136
|
+
if not _MODEL_LOADERS:
|
|
137
|
+
# Ensure loaders are always initialized
|
|
138
|
+
init_model_loaders()
|
|
136
139
|
return _MODEL_LOADERS.get(model, {}).get(attr, [])
|
|
137
140
|
|
|
138
141
|
|
|
139
|
-
def
|
|
142
|
+
def _join_attr_loaders(loaders: list[AttrLoader]) -> Load | None:
|
|
140
143
|
"""Given 1 or more attribute loaders, instantiate and chain them together."""
|
|
141
144
|
if not loaders:
|
|
142
145
|
return None
|
|
@@ -151,3 +154,48 @@ def join_attr_loaders(loaders: list[AttrLoader]) -> Load | None:
|
|
|
151
154
|
return getattr(final_loader, next.loader_fn.__name__)(next.attr)
|
|
152
155
|
|
|
153
156
|
return reduce(chain_loader_func, other_loaders, loader_fn)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _split_path(query_path: str) -> Iterable[str]:
|
|
160
|
+
yield from (field for field in query_path.split("."))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_query_loaders_for_model_paths(root_model: type[DbBaseModel], model_paths: list[str]) -> list[Load]:
|
|
164
|
+
"""Get sqlalchemy query loaders to use for the model based on the paths."""
|
|
165
|
+
# Sort by length to find the longest match first
|
|
166
|
+
model_paths.sort(key=lambda x: x.count("."), reverse=True)
|
|
167
|
+
|
|
168
|
+
def get_loader_for_path(model_path: str) -> tuple[str, Load | None]:
|
|
169
|
+
next_model = root_model
|
|
170
|
+
|
|
171
|
+
matched_fields: list[str] = []
|
|
172
|
+
path_loaders: list[AttrLoader] = []
|
|
173
|
+
|
|
174
|
+
for field in _split_path(model_path):
|
|
175
|
+
if not (attr_loaders := _lookup_attr_loaders(next_model, field)):
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
matched_fields.append(field)
|
|
179
|
+
path_loaders.extend(attr_loaders)
|
|
180
|
+
next_model = attr_loaders[-1].next_model
|
|
181
|
+
|
|
182
|
+
return ".".join(matched_fields), _join_attr_loaders(path_loaders)
|
|
183
|
+
|
|
184
|
+
query_loaders: dict[str, Load] = {}
|
|
185
|
+
|
|
186
|
+
for path in model_paths:
|
|
187
|
+
matched_path, loader = get_loader_for_path(path)
|
|
188
|
+
if not matched_path or not loader or matched_path in query_loaders:
|
|
189
|
+
continue
|
|
190
|
+
if any(known_path.startswith(f"{matched_path}.") for known_path in query_loaders):
|
|
191
|
+
continue
|
|
192
|
+
query_loaders[matched_path] = loader
|
|
193
|
+
|
|
194
|
+
loaders = list(query_loaders.values())
|
|
195
|
+
logger.debug(
|
|
196
|
+
"Generated query loaders for paths",
|
|
197
|
+
root_model=root_model,
|
|
198
|
+
model_paths=model_paths,
|
|
199
|
+
query_loaders=[str(i.path) for i in loaders],
|
|
200
|
+
)
|
|
201
|
+
return loaders
|
orchestrator/db/models.py
CHANGED
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import enum
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
|
+
from uuid import UUID
|
|
18
19
|
|
|
19
20
|
import sqlalchemy
|
|
20
21
|
import structlog
|
|
@@ -43,6 +44,7 @@ from sqlalchemy.exc import DontWrapMixin
|
|
|
43
44
|
from sqlalchemy.ext.associationproxy import association_proxy
|
|
44
45
|
from sqlalchemy.ext.orderinglist import ordering_list
|
|
45
46
|
from sqlalchemy.orm import Mapped, deferred, mapped_column, object_session, relationship, undefer
|
|
47
|
+
from sqlalchemy.sql.functions import GenericFunction
|
|
46
48
|
from sqlalchemy_utils import TSVectorType, UUIDType
|
|
47
49
|
|
|
48
50
|
from orchestrator.config.assignee import Assignee
|
|
@@ -667,3 +669,14 @@ class EngineSettingsTable(BaseModel):
|
|
|
667
669
|
global_lock = mapped_column(Boolean(), default=False, nullable=False, primary_key=True)
|
|
668
670
|
running_processes = mapped_column(Integer(), default=0, nullable=False)
|
|
669
671
|
__table_args__: tuple = (CheckConstraint(running_processes >= 0, name="check_running_processes_positive"), {})
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class SubscriptionInstanceAsJsonFunction(GenericFunction):
|
|
675
|
+
# Added in migration 42b3d076a85b
|
|
676
|
+
name = "subscription_instance_as_json"
|
|
677
|
+
|
|
678
|
+
type = pg.JSONB()
|
|
679
|
+
inherit_cache = True
|
|
680
|
+
|
|
681
|
+
def __init__(self, sub_inst_id: UUID):
|
|
682
|
+
super().__init__(sub_inst_id)
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright 2019-2025 SURF.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import UUID as SA_UUID
|
|
17
|
+
from sqlalchemy import cast as sa_cast
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
from sqlalchemy.orm import raiseload
|
|
20
|
+
|
|
21
|
+
from orchestrator.db import SubscriptionInstanceRelationTable, SubscriptionInstanceTable, db
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _eagerload_subscription_instances(
|
|
25
|
+
subscription_id: UUID | str, instance_attributes: list[str]
|
|
26
|
+
) -> list[SubscriptionInstanceTable]:
|
|
27
|
+
"""Given a subscription id, recursively query all depends_on subscription instances with the instance_attributes eagerloaded.
|
|
28
|
+
|
|
29
|
+
Note: accessing instance attributes on the result that were not explicitly loaded will
|
|
30
|
+
trigger a sqlalchemy error.
|
|
31
|
+
"""
|
|
32
|
+
from orchestrator.db.loaders import get_query_loaders_for_model_paths
|
|
33
|
+
|
|
34
|
+
# CTE to recursively get all subscription instance ids the subscription depends on
|
|
35
|
+
instance_ids_cte = (
|
|
36
|
+
select(
|
|
37
|
+
sa_cast(None, SA_UUID(as_uuid=True)).label("in_use_by_id"),
|
|
38
|
+
SubscriptionInstanceTable.subscription_instance_id.label("depends_on_id"),
|
|
39
|
+
)
|
|
40
|
+
.where(SubscriptionInstanceTable.subscription_id == subscription_id)
|
|
41
|
+
.cte(name="recursive_instance_ids", recursive=True)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
cte_alias = instance_ids_cte.alias()
|
|
45
|
+
rel_alias = select(SubscriptionInstanceRelationTable).alias()
|
|
46
|
+
|
|
47
|
+
instance_ids = instance_ids_cte.union(
|
|
48
|
+
select(rel_alias.c.in_use_by_id, rel_alias.c.depends_on_id).where(
|
|
49
|
+
rel_alias.c.in_use_by_id == cte_alias.c.depends_on_id
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
select_all_instance_ids = select(instance_ids.c.depends_on_id).subquery()
|
|
54
|
+
|
|
55
|
+
# Eagerload specified instance attributes
|
|
56
|
+
query_loaders = get_query_loaders_for_model_paths(SubscriptionInstanceTable, instance_attributes)
|
|
57
|
+
# Prevent unwanted lazyloading of all other attributes
|
|
58
|
+
query_loaders += [raiseload("*")] # type: ignore[list-item] # todo fix this type
|
|
59
|
+
stmt = (
|
|
60
|
+
select(SubscriptionInstanceTable)
|
|
61
|
+
.where(SubscriptionInstanceTable.subscription_instance_id.in_(select(select_all_instance_ids)))
|
|
62
|
+
.options(*query_loaders)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return db.session.scalars(stmt).all() # type: ignore[return-value] # todo fix this type
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def eagerload_all_subscription_instances(subscription_id: UUID | str) -> list[SubscriptionInstanceTable]:
|
|
69
|
+
"""Recursively find the subscription's depends_on instances and resolve relations for SubscriptionModel.from_subscription_id()."""
|
|
70
|
+
instance_attributes = [
|
|
71
|
+
"subscription.product",
|
|
72
|
+
"product_block",
|
|
73
|
+
"values.resource_type",
|
|
74
|
+
"depends_on",
|
|
75
|
+
"in_use_by",
|
|
76
|
+
]
|
|
77
|
+
return _eagerload_subscription_instances(subscription_id, instance_attributes)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def eagerload_all_subscription_instances_only_inuseby(subscription_id: UUID | str) -> list[SubscriptionInstanceTable]:
|
|
81
|
+
"""Recursively find the subscription's depends_on instances and resolve their in_use_by relations."""
|
|
82
|
+
instance_attributes = [
|
|
83
|
+
"in_use_by",
|
|
84
|
+
]
|
|
85
|
+
return _eagerload_subscription_instances(subscription_id, instance_attributes)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright 2019-2025 SURF.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
|
|
18
|
+
from orchestrator.db import db
|
|
19
|
+
from orchestrator.db.models import SubscriptionInstanceAsJsonFunction
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_subscription_instance_dict(subscription_instance_id: UUID) -> dict:
|
|
23
|
+
"""Query the subscription instance as aggregated JSONB and returns it as a dict.
|
|
24
|
+
|
|
25
|
+
Note: all values are returned as lists and have to be transformed by the caller.
|
|
26
|
+
It was attempted to do this in the DB query but this gave worse performance.
|
|
27
|
+
"""
|
|
28
|
+
return db.session.execute(select(SubscriptionInstanceAsJsonFunction(subscription_instance_id))).scalar_one()
|