From 9a0c58f4b4b64dff44827d10ed42e10a6dcee159 Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 00:11:34 +0200 Subject: [PATCH 01/10] update hook engine to process `.mako` files selectively, preserve non-`.mako` files, and improve binary file handling --- README.md | 2 +- StandFramework/stand/stand.py | 15 +++++++++++---- docs/application-manifest.md | 9 +++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4475fe1..533759b 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,7 @@ Mako-шаблоны получают контекст: - `cluster` - приложение/кластер, образ и общие preferences; - `apps` - все инстансы стенда, чтобы сервисы могли ссылаться друг на друга. -Если у инстанса указан `hooks`, путь должен вести в директорию с `hook.sh.mako`. Все файлы директории рендерятся, загружаются на сервер и `hook.sh` запускается после старта контейнера. +Если у инстанса указан `hooks`, путь должен вести в директорию с `hook.sh.mako`. Файлы с суффиксом `.mako` рендерятся, остальные копируются без изменений; затем всё дерево загружается на сервер и `hook.sh` запускается после старта контейнера. ## Проверка манифеста diff --git a/StandFramework/stand/stand.py b/StandFramework/stand/stand.py index 089f1b0..117cb15 100644 --- a/StandFramework/stand/stand.py +++ b/StandFramework/stand/stand.py @@ -464,14 +464,21 @@ def add_app_hook(self, instance: InstanceApp) -> None: for template_path in sorted(path for path in hook_path.rglob("*") if path.is_file()): relative_path = template_path.relative_to(hook_path) - if relative_path.name.endswith(".mako"): + is_mako_template = relative_path.suffix == ".mako" + if is_mako_template: relative_path = relative_path.with_name(relative_path.name.removesuffix(".mako")) - content = self.render_app_template(template_path, instance) + content = ( + self.render_app_template(template_path, instance) + if is_mako_template + else template_path.read_bytes() + ) output_path = local_hook_dir / relative_path Path(output_path.parent).mkdir(parents=True, exist_ok=True) - with open(output_path, "w") as f: - f.write(content) + if isinstance(content, str): + output_path.write_text(content, encoding="utf-8") + else: + output_path.write_bytes(content) self.add_upload_asset(instance, UploadAsset( content=content, diff --git a/docs/application-manifest.md b/docs/application-manifest.md index 5316572..b2d6da7 100644 --- a/docs/application-manifest.md +++ b/docs/application-manifest.md @@ -404,14 +404,15 @@ bootstrap/ Движок: -1. Рекурсивно рендерит каждый файл как Mako-текст. -2. Удаляет суффикс `.mako`. +1. Рекурсивно обрабатывает файлы: файлы с суффиксом `.mako` рендерит как + Mako-текст, остальные копирует без изменений. +2. Удаляет суффикс `.mako` только у отрендеренных файлов. 3. Загружает дерево в `/home//hook//` с mode `644`. 4. Выполняет `hook.sh` из корня дерева. 5. После успеха удаляет remote-каталог. -Не помещайте бинарные файлы: все ресурсы читаются как текст. Даже файл без -`.mako` проходит renderer. +Обычные, в том числе бинарные, ресурсы можно помещать в hook-каталог без +суффикса `.mako`: движок сохраняет их имена и содержимое без изменений. Надёжный hook должен: From 4880d939f02f3d9d916d15f1a766c9e8df6d1195 Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 15:40:39 +0200 Subject: [PATCH 02/10] add resource-based asset resolution and validation for hooks, enhance manifest parser, update examples, and extend CLI functionality --- App/__init__.py | 9 +- ManifestParser/__init__.py | 142 ++++++++++- ManifestParser/validation.py | 42 +++- README.md | 63 ++++- StandBuilder/__init__.py | 16 +- StandFramework/stand/stand.py | 68 +++++- .../mongo/migrations}/example.settings.json | 0 .../mongo/migrations}/example.users.json | 0 demo/{ => stand}/app-registry/dozzle/app.yml | 4 +- .../dozzle/dozzle-instance.yml.mako | 0 .../{ => stand}/app-registry/kafka-ui/app.yml | 0 .../kafka-ui/connection.json.mako | 0 .../app-registry/kafka-ui/kafka-ui.yml.mako | 0 demo/{ => stand}/app-registry/mongo/app.yml | 0 .../app-registry/mongo/connection.json.mako | 0 .../app-registry/mongo/hook/hook.sh.mako | 0 .../mongo/mongo-instance.yml.mako | 0 demo/{ => stand}/app-registry/redis/app.yml | 0 .../app-registry/redis/connection.json.mako | 0 .../redis/redis-instance.yml.mako | 0 .../{ => stand}/app-registry/redpanda/app.yml | 0 .../redpanda/connection.json.mako | 0 .../redpanda/migration/hook.sh.mako | 0 .../redpanda/redpanda-instance.yml.mako | 0 demo/{ => stand}/app-registry/registries.yml | 0 demo/{ => stand}/cloud-init.yaml.mako | 0 demo/{ => stand}/stand.yml | 14 +- docs/README.md | 6 +- docs/application-manifest.md | 27 ++- docs/operations.md | 24 +- docs/stand-manifest.md | 36 ++- main.py | 40 +++- stands-engine | 47 +++- stands-engine.ps1 | 39 ++- tests/test_app_resources.py | 4 +- tests/test_cli.py | 21 +- tests/test_container_launcher.py | 68 ++++++ tests/test_hook_assets.py | 226 ++++++++++++++++++ tests/test_hook_resources.py | 222 +++++++++++++++++ tests/test_mongo_hook.py | 4 +- tests/test_redpanda_hook.py | 4 +- 41 files changed, 1046 insertions(+), 80 deletions(-) rename demo/{app-registry/mongo/hook/migration => resources/mongo/migrations}/example.settings.json (100%) rename demo/{app-registry/mongo/hook/migration => resources/mongo/migrations}/example.users.json (100%) rename demo/{ => stand}/app-registry/dozzle/app.yml (83%) rename demo/{ => stand}/app-registry/dozzle/dozzle-instance.yml.mako (100%) rename demo/{ => stand}/app-registry/kafka-ui/app.yml (100%) rename demo/{ => stand}/app-registry/kafka-ui/connection.json.mako (100%) rename demo/{ => stand}/app-registry/kafka-ui/kafka-ui.yml.mako (100%) rename demo/{ => stand}/app-registry/mongo/app.yml (100%) rename demo/{ => stand}/app-registry/mongo/connection.json.mako (100%) rename demo/{ => stand}/app-registry/mongo/hook/hook.sh.mako (100%) rename demo/{ => stand}/app-registry/mongo/mongo-instance.yml.mako (100%) rename demo/{ => stand}/app-registry/redis/app.yml (100%) rename demo/{ => stand}/app-registry/redis/connection.json.mako (100%) rename demo/{ => stand}/app-registry/redis/redis-instance.yml.mako (100%) rename demo/{ => stand}/app-registry/redpanda/app.yml (100%) rename demo/{ => stand}/app-registry/redpanda/connection.json.mako (100%) rename demo/{ => stand}/app-registry/redpanda/migration/hook.sh.mako (100%) rename demo/{ => stand}/app-registry/redpanda/redpanda-instance.yml.mako (100%) rename demo/{ => stand}/app-registry/registries.yml (100%) rename demo/{ => stand}/cloud-init.yaml.mako (100%) rename demo/{ => stand}/stand.yml (89%) create mode 100644 tests/test_container_launcher.py create mode 100644 tests/test_hook_assets.py create mode 100644 tests/test_hook_resources.py diff --git a/App/__init__.py b/App/__init__.py index 5dbed81..3887913 100644 --- a/App/__init__.py +++ b/App/__init__.py @@ -1,8 +1,14 @@ from dataclasses import dataclass, field -from pathlib import Path +from pathlib import Path, PurePosixPath from ShellCollect import ShellCommand, Port, Image, ShellCollect +@dataclass(kw_only=True, frozen=True) +class HookAsset: + source: Path + dest: PurePosixPath + + @dataclass(kw_only=True) class App: name: str @@ -11,6 +17,7 @@ class App: ram: int oom_priority: int | None = None hook_path: Path | None = None + hook_assets: list[HookAsset] = field(default_factory=list) preferences: dict[str, str] = field(default_factory=dict) @dataclass(kw_only=True) diff --git a/ManifestParser/__init__.py b/ManifestParser/__init__.py index 32bf601..1a4879f 100644 --- a/ManifestParser/__init__.py +++ b/ManifestParser/__init__.py @@ -11,6 +11,9 @@ SECRET_TAG = "!secret" SECRET_ENV_PREFIX = "SECRET_" SECRET_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") +RESOURCE_URI_PATTERN = re.compile( + r"^resource://(?P[A-Za-z][A-Za-z0-9_-]*)(?:/(?P.*))?$" +) class SecretReference(str): @@ -76,12 +79,21 @@ def parse_yml(path_to_yml: Path) -> dict: return data -def parse_manifest(path_to_manifest: Path, operation: str = "create") -> dict: +def parse_manifest( + path_to_manifest: Path, + operation: str = "create", + resource_roots: dict[str, Path] | None = None, +) -> dict: if operation not in {"create", "destroy"}: raise ValueError("Manifest operation must be 'create' or 'destroy'") data = parse_yml(path_to_manifest) - resolved_data = _resolve_dep_manifests(data, path_to_manifest) + resolved_data = _resolve_dep_manifests( + data, + path_to_manifest, + resource_roots or {}, + require_resources=operation == "create", + ) resolved_data = _resolve_secrets(resolved_data, operation) _normalize_local_resource_paths(resolved_data, path_to_manifest.parent) validate_manifest(resolved_data) @@ -148,9 +160,26 @@ def _is_optional_destroy_secret(path: str) -> bool: return ".preferences." in path or path.endswith(".preferences") -def _resolve_dep_manifests(data: dict, manifest_path: Path) -> dict: +def _resolve_dep_manifests( + data: dict, + manifest_path: Path, + resource_roots: dict[str, Path] | None = None, + require_resources: bool = True, +) -> dict: + resource_roots = resource_roots or {} + _normalize_declared_hook_asset_paths( + data, + manifest_path.parent, + resource_roots, + require_resources, + ) resolved = { - key: _resolve_value(value, manifest_path) + key: _resolve_value( + value, + manifest_path, + resource_roots, + require_resources, + ) for key, value in data.items() if key != "from_dep_manifest" } @@ -174,7 +203,12 @@ def _resolve_dep_manifests(data: dict, manifest_path: Path) -> dict: dep_manifest_path = _resolve_path(dep_manifest, manifest_path.parent) dep_data = parse_yml(dep_manifest_path) - resolved_dep_data = _resolve_dep_manifests(dep_data, dep_manifest_path) + resolved_dep_data = _resolve_dep_manifests( + dep_data, + dep_manifest_path, + resource_roots, + require_resources, + ) conflicting_keys = { key @@ -191,14 +225,32 @@ def _resolve_dep_manifests(data: dict, manifest_path: Path) -> dict: return merged -def _resolve_value(value, current_manifest_path: Path): +def _resolve_value( + value, + current_manifest_path: Path, + resource_roots: dict[str, Path], + require_resources: bool, +): if isinstance(value, dict): - resolved = _resolve_dep_manifests(value, current_manifest_path) + resolved = _resolve_dep_manifests( + value, + current_manifest_path, + resource_roots, + require_resources, + ) _normalize_local_resource_paths(resolved, current_manifest_path.parent) return resolved if isinstance(value, list): - return [_resolve_value(item, current_manifest_path) for item in value] + return [ + _resolve_value( + item, + current_manifest_path, + resource_roots, + require_resources, + ) + for item in value + ] return value @@ -225,8 +277,13 @@ def _normalize_template_hook_and_connection_paths(data: dict, base_dir: Path) -> instances = data.get("instances") if isinstance(instances, dict): for instance in instances.values(): - if isinstance(instance, dict) and isinstance(instance.get("hooks"), str): - instance["hooks"] = str(_resolve_path(instance["hooks"], base_dir).resolve()) + if not isinstance(instance, dict): + continue + hooks = instance.get("hooks") + if isinstance(hooks, str): + instance["hooks"] = str(_resolve_path(hooks, base_dir).resolve()) + elif isinstance(hooks, dict) and isinstance(hooks.get("path"), str): + hooks["path"] = str(_resolve_path(hooks["path"], base_dir).resolve()) if isinstance(data.get("connection"), str): data["connection"] = str(_resolve_path(data["connection"], base_dir).resolve()) @@ -240,3 +297,68 @@ def _normalize_local_resource_paths(data: dict, base_dir: Path) -> None: profile["cloud-init"] = str(_resolve_path(profile["cloud-init"], base_dir).resolve()) _normalize_template_hook_and_connection_paths(data, base_dir) + + +def _normalize_declared_hook_asset_paths( + data: dict, + base_dir: Path, + resource_roots: dict[str, Path], + require_resources: bool, +) -> None: + app_candidates = [] + if isinstance(data.get("apps"), dict): + app_candidates.extend(data["apps"].values()) + if isinstance(data.get("instances"), dict): + app_candidates.append(data) + + for app in app_candidates: + if not isinstance(app, dict) or not isinstance(app.get("instances"), dict): + continue + for instance in app["instances"].values(): + if not isinstance(instance, dict): + continue + hooks = instance.get("hooks") + if not isinstance(hooks, dict) or not isinstance(hooks.get("assets"), list): + continue + for asset in hooks["assets"]: + if not isinstance(asset, dict) or not isinstance(asset.get("source"), str): + continue + asset["source"] = _resolve_hook_asset_source( + asset["source"], + base_dir, + resource_roots, + require_resources, + ) + + +def _resolve_hook_asset_source( + source: str, + base_dir: Path, + resource_roots: dict[str, Path], + require_resources: bool, +) -> str: + match = RESOURCE_URI_PATTERN.fullmatch(source) + if source.startswith("resource://") and match is None: + raise ValueError(f"Invalid resource URI: {source!r}") + + if match is None: + return str(_resolve_path(source, base_dir).resolve()) + + resource_name = match.group("name") + resource_root = resource_roots.get(resource_name) + if resource_root is None: + if not require_resources: + return source + raise ValueError( + f"Hook asset references unknown resource {resource_name!r}: {source}" + ) + + relative = Path(match.group("path") or ".") + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"Resource path must not escape its root: {source}") + + root = resource_root.resolve() + resolved = (root / relative).resolve() + if not resolved.is_relative_to(root): + raise ValueError(f"Resource path escapes its root: {source}") + return str(resolved) diff --git a/ManifestParser/validation.py b/ManifestParser/validation.py index 2782788..05e4096 100644 --- a/ManifestParser/validation.py +++ b/ManifestParser/validation.py @@ -1,3 +1,6 @@ +from pathlib import PurePosixPath + + def validate_manifest(manifest: dict) -> None: _require_mapping(manifest, "manifest") @@ -182,7 +185,44 @@ def _validate_instances( if "preferences" in instance: _require_mapping(instance["preferences"], f"{instance_path}.preferences") if "hooks" in instance: - _require_string_key(instance, "hooks", instance_path) + _validate_hooks(instance["hooks"], f"{instance_path}.hooks") + + +def _validate_hooks(hooks: object, path: str) -> None: + if isinstance(hooks, str): + if not hooks: + raise ValueError(f"{path} must be a non-empty string") + return + + _require_mapping(hooks, path) + unknown_fields = sorted(set(hooks) - {"path", "assets"}) + if unknown_fields: + raise ValueError(f"{path} contains unknown fields: {unknown_fields}") + + _require_string_key(hooks, "path", path) + assets = hooks.get("assets", []) + if not isinstance(assets, list): + raise ValueError(f"{path}.assets must be a list") + + for index, asset in enumerate(assets): + asset_path = f"{path}.assets[{index}]" + _require_mapping(asset, asset_path) + unknown_asset_fields = sorted(set(asset) - {"source", "dest"}) + if unknown_asset_fields: + raise ValueError( + f"{asset_path} contains unknown fields: {unknown_asset_fields}" + ) + _require_string_key(asset, "source", asset_path) + destination = _require_string_key(asset, "dest", asset_path) + destination_path = PurePosixPath(destination) + if ( + "\\" in destination + or destination_path.is_absolute() + or ".." in destination_path.parts + ): + raise ValueError( + f"{asset_path}.dest must be a relative POSIX path without '..'" + ) def _validate_node_profiles(node_profiles: dict) -> None: diff --git a/README.md b/README.md index 533759b..8700da7 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,9 @@ uv sync ```bash uv run stands-engine --help -uv run stands-engine create demo/stand.yml +uv run stands-engine \ + --resource project-assets=demo/resources \ + create demo/stand/stand.yml ``` Прежний вариант `python main.py ...` остается совместимым. @@ -109,8 +111,39 @@ podman build -f Containerfile -t stands-engine:local . Для повседневного запуска используйте launcher. Он автоматически выберет Podman или Docker, примонтирует текущий каталог только для чтения и сохранит ключи, configsets и connection output в `.stands-engine/`: ```bash -./stands-engine --env-file dev.env create demo/stand.yml -./stands-engine --env-file dev.env destroy demo/stand.yml +./stands-engine \ + --env-file dev.env \ + --resource project-assets=demo/resources \ + create demo/stand/stand.yml +./stands-engine --env-file dev.env destroy demo/stand/stand.yml +``` + +Каталоги из других репозиториев подключаются как именованные read-only resources. +Один resource можно использовать для нескольких hooks, указывая подкаталоги +относительно его корня: + +```bash +./stands-engine \ + --env-file dev.env \ + --resource project-assets=/home/user/projects/payment-service/deploy/assets \ + create demo/stand/stand.yml +``` + +Манифест обращается к такому каталогу через переносимый URI +`resource://project-assets/...`; launcher сам заменяет host path на путь внутри +контейнера. `--resource` можно повторять. При прямом запуске Python CLI синтаксис +тот же, но каталог читается непосредственно с host filesystem: + +```text +project-assets/ +├── mongo/migrations/*.json +└── redpanda/acl-map.sh +``` + +```bash +uv run stands-engine \ + --resource project-assets=/home/user/projects/payment-service/deploy/assets \ + create demo/stand/stand.yml ``` Явный выбор runtime или опубликованного image: @@ -120,14 +153,16 @@ podman build -f Containerfile -t stands-engine:local . --runtime docker \ --image registry.example.com/stands-engine:0.1.0 \ --env-file dev.env \ - create demo/stand.yml + --resource project-assets=demo/resources \ + create demo/stand/stand.yml ``` PowerShell на Windows, macOS или Linux: ```powershell -.\stands-engine.ps1 create .\demo\stand.yml -EnvFile dev.env -.\stands-engine.ps1 destroy .\demo\stand.yml -EnvFile dev.env +.\stands-engine.ps1 create .\demo\stand\stand.yml -EnvFile dev.env ` + -Resource "project-assets=.\demo\resources" +.\stands-engine.ps1 destroy .\demo\stand\stand.yml -EnvFile dev.env ``` Launcher переопределяет локальные абсолютные пути из env-файла контейнерными: @@ -150,8 +185,10 @@ docker run --rm \ -e OUTPUT__FILE_PATH=/data/output \ -v "$PWD:/workspace:ro" \ -v "$PWD/.stands-engine:/data" \ + -v "$PWD/demo/resources:/resources/project-assets:ro" \ registry.example.com/stands-engine:0.1.0 \ - create /workspace/demo/stand.yml + --resource project-assets=/resources/project-assets \ + create /workspace/demo/stand/stand.yml ``` В CI передавайте секреты через защищенные переменные pipeline. Для воспроизводимого запуска используйте version tag или digest, а не изменяемый `latest`. @@ -223,26 +260,30 @@ set +a ## Быстрый старт -Демо-стенд находится в [demo/stand.yml](demo/stand.yml). Он поднимает Redpanda, Kafka UI, Redis и MongoDB на трех серверах и использует публичные Docker Hub образы. +Демо разделено на описание стенда в [demo/stand](demo/stand) и подключаемые +данные в [demo/resources](demo/resources). Стенд поднимает Redpanda, Kafka UI, +Redis и MongoDB на трёх серверах и использует публичные Docker Hub образы. ```bash set -a source dev.env set +a -python main.py create demo/stand.yml +python main.py \ + --resource project-assets=demo/resources \ + create demo/stand/stand.yml ``` Удаление стенда: ```bash -python main.py destroy demo/stand.yml +python main.py destroy demo/stand/stand.yml ``` CLI сейчас намеренно небольшой: ```bash -python main.py +python main.py [--resource NAME=PATH] ``` ## Манифест стенда diff --git a/StandBuilder/__init__.py b/StandBuilder/__init__.py index ba76843..3cc1867 100644 --- a/StandBuilder/__init__.py +++ b/StandBuilder/__init__.py @@ -1,6 +1,6 @@ -from pathlib import Path +from pathlib import Path, PurePosixPath -from App import App, ClusterApp, ConfigFile, RoleApp +from App import App, ClusterApp, ConfigFile, HookAsset, RoleApp from ShellCollect import Image, ImageRegistry, Port from StandFramework import Node, Stand, StandState from config.config import Config @@ -107,13 +107,23 @@ def _build_cluster( instances = [] instances_by_name = {} for instance_name, instance_data in app_data["instances"].items(): + hooks = instance_data.get("hooks") + hook_path = hooks if isinstance(hooks, str) else hooks.get("path") if hooks else None + hook_assets = hooks.get("assets", []) if isinstance(hooks, dict) else [] instance = App( name=instance_name, role=roles[instance_data["role"]], cpu=instance_data["cpu"], ram=instance_data["ram"], oom_priority=instance_data.get("oom_priority"), - hook_path=Path(instance_data["hooks"]) if "hooks" in instance_data else None, + hook_path=Path(hook_path) if hook_path is not None else None, + hook_assets=[ + HookAsset( + source=Path(asset["source"]), + dest=PurePosixPath(asset["dest"]), + ) + for asset in hook_assets + ], preferences=instance_data.get("preferences", {}), ) instances.append(instance) diff --git a/StandFramework/stand/stand.py b/StandFramework/stand/stand.py index 117cb15..12d48f3 100644 --- a/StandFramework/stand/stand.py +++ b/StandFramework/stand/stand.py @@ -449,25 +449,14 @@ def add_app_hook(self, instance: InstanceApp) -> None: if instance.app.hook_path is None: return - hook_path = Path(instance.app.hook_path) - if not hook_path.is_dir(): - raise Exception(f"Hook path is not a directory: {hook_path}") - - hook_sh = hook_path / "hook.sh.mako" - if not hook_sh.is_file(): - raise Exception(f"Hook path must contain hook.sh.mako: {hook_sh}") + hook_files = self._collect_hook_files(instance) for_group = instance.cluster.name + "---" + instance.app.name remote_hook_dir = f"/home/{self.app_user}/hook/{instance.app.name}" local_hook_dir = Path(self.path_folder_configset / f"{instance.cluster.name}--{instance.app.name}" / "hook") Path(local_hook_dir).mkdir(parents=True, exist_ok=True) - for template_path in sorted(path for path in hook_path.rglob("*") if path.is_file()): - relative_path = template_path.relative_to(hook_path) - is_mako_template = relative_path.suffix == ".mako" - if is_mako_template: - relative_path = relative_path.with_name(relative_path.name.removesuffix(".mako")) - + for template_path, relative_path, is_mako_template in hook_files: content = ( self.render_app_template(template_path, instance) if is_mako_template @@ -496,6 +485,58 @@ def add_app_hook(self, instance: InstanceApp) -> None: full_login=True, )) + def validate_hook_sources(self) -> None: + for instance in self.instance_apps.values(): + if instance.app.hook_path is not None: + self._collect_hook_files(instance) + + @staticmethod + def _collect_hook_files(instance: InstanceApp) -> list[tuple[Path, Path, bool]]: + hook_path = Path(instance.app.hook_path) + if not hook_path.is_dir(): + raise ValueError(f"Hook path is not a directory: {hook_path}") + + hook_sh = hook_path / "hook.sh.mako" + if not hook_sh.is_file(): + raise ValueError(f"Hook path must contain hook.sh.mako: {hook_sh}") + + collected: list[tuple[Path, Path, bool]] = [] + destinations: dict[Path, Path] = {} + + def add_tree(source_root: Path, destination_root: Path, render_mako: bool) -> None: + if not source_root.is_dir(): + raise ValueError(f"Hook asset source is not a directory: {source_root}") + + resolved_root = source_root.resolve() + for source_path in sorted(path for path in source_root.rglob("*") if path.is_file()): + resolved_source = source_path.resolve() + if not resolved_source.is_relative_to(resolved_root): + raise ValueError( + f"Hook asset path escapes its source directory: {source_path}" + ) + + relative_path = source_path.relative_to(source_root) + is_mako_template = render_mako and relative_path.suffix == ".mako" + if is_mako_template: + relative_path = relative_path.with_name( + relative_path.name.removesuffix(".mako") + ) + output_path = destination_root / relative_path + if output_path in destinations: + raise ValueError( + f"Hook file collision for instance {instance.app.name!r} at " + f"{output_path.as_posix()}: {destinations[output_path]} and {source_path}" + ) + destinations[output_path] = source_path + collected.append((source_path, output_path, is_mako_template)) + + add_tree(hook_path, Path(), render_mako=True) + for asset in instance.app.hook_assets: + destination = Path(*asset.dest.parts) + add_tree(asset.source, destination, render_mako=False) + + return sorted(collected, key=lambda item: item[1].as_posix()) + def launch_apps(self) -> None: for _, instance in self.instance_apps.items(): self.shell_script.extend(ShellCollect.up_container( @@ -516,6 +557,7 @@ def launch_apps(self) -> None: self.add_app_hook(instance) def up(self, diagnostic: bool | SShExecutorDiagnostArgs = False): + self.validate_hook_sources() self.create_servers() self.render_deploy_configset() self.settings_runtime() diff --git a/demo/app-registry/mongo/hook/migration/example.settings.json b/demo/resources/mongo/migrations/example.settings.json similarity index 100% rename from demo/app-registry/mongo/hook/migration/example.settings.json rename to demo/resources/mongo/migrations/example.settings.json diff --git a/demo/app-registry/mongo/hook/migration/example.users.json b/demo/resources/mongo/migrations/example.users.json similarity index 100% rename from demo/app-registry/mongo/hook/migration/example.users.json rename to demo/resources/mongo/migrations/example.users.json diff --git a/demo/app-registry/dozzle/app.yml b/demo/stand/app-registry/dozzle/app.yml similarity index 83% rename from demo/app-registry/dozzle/app.yml rename to demo/stand/app-registry/dozzle/app.yml index 088716d..1490c47 100644 --- a/demo/app-registry/dozzle/app.yml +++ b/demo/stand/app-registry/dozzle/app.yml @@ -2,8 +2,8 @@ version: 1 name: dozzle image: - registry: docker - path: amir20/dozzle + registry: local + path: infra_depence/amir20/dozzle version: v10.6.11 roles: diff --git a/demo/app-registry/dozzle/dozzle-instance.yml.mako b/demo/stand/app-registry/dozzle/dozzle-instance.yml.mako similarity index 100% rename from demo/app-registry/dozzle/dozzle-instance.yml.mako rename to demo/stand/app-registry/dozzle/dozzle-instance.yml.mako diff --git a/demo/app-registry/kafka-ui/app.yml b/demo/stand/app-registry/kafka-ui/app.yml similarity index 100% rename from demo/app-registry/kafka-ui/app.yml rename to demo/stand/app-registry/kafka-ui/app.yml diff --git a/demo/app-registry/kafka-ui/connection.json.mako b/demo/stand/app-registry/kafka-ui/connection.json.mako similarity index 100% rename from demo/app-registry/kafka-ui/connection.json.mako rename to demo/stand/app-registry/kafka-ui/connection.json.mako diff --git a/demo/app-registry/kafka-ui/kafka-ui.yml.mako b/demo/stand/app-registry/kafka-ui/kafka-ui.yml.mako similarity index 100% rename from demo/app-registry/kafka-ui/kafka-ui.yml.mako rename to demo/stand/app-registry/kafka-ui/kafka-ui.yml.mako diff --git a/demo/app-registry/mongo/app.yml b/demo/stand/app-registry/mongo/app.yml similarity index 100% rename from demo/app-registry/mongo/app.yml rename to demo/stand/app-registry/mongo/app.yml diff --git a/demo/app-registry/mongo/connection.json.mako b/demo/stand/app-registry/mongo/connection.json.mako similarity index 100% rename from demo/app-registry/mongo/connection.json.mako rename to demo/stand/app-registry/mongo/connection.json.mako diff --git a/demo/app-registry/mongo/hook/hook.sh.mako b/demo/stand/app-registry/mongo/hook/hook.sh.mako similarity index 100% rename from demo/app-registry/mongo/hook/hook.sh.mako rename to demo/stand/app-registry/mongo/hook/hook.sh.mako diff --git a/demo/app-registry/mongo/mongo-instance.yml.mako b/demo/stand/app-registry/mongo/mongo-instance.yml.mako similarity index 100% rename from demo/app-registry/mongo/mongo-instance.yml.mako rename to demo/stand/app-registry/mongo/mongo-instance.yml.mako diff --git a/demo/app-registry/redis/app.yml b/demo/stand/app-registry/redis/app.yml similarity index 100% rename from demo/app-registry/redis/app.yml rename to demo/stand/app-registry/redis/app.yml diff --git a/demo/app-registry/redis/connection.json.mako b/demo/stand/app-registry/redis/connection.json.mako similarity index 100% rename from demo/app-registry/redis/connection.json.mako rename to demo/stand/app-registry/redis/connection.json.mako diff --git a/demo/app-registry/redis/redis-instance.yml.mako b/demo/stand/app-registry/redis/redis-instance.yml.mako similarity index 100% rename from demo/app-registry/redis/redis-instance.yml.mako rename to demo/stand/app-registry/redis/redis-instance.yml.mako diff --git a/demo/app-registry/redpanda/app.yml b/demo/stand/app-registry/redpanda/app.yml similarity index 100% rename from demo/app-registry/redpanda/app.yml rename to demo/stand/app-registry/redpanda/app.yml diff --git a/demo/app-registry/redpanda/connection.json.mako b/demo/stand/app-registry/redpanda/connection.json.mako similarity index 100% rename from demo/app-registry/redpanda/connection.json.mako rename to demo/stand/app-registry/redpanda/connection.json.mako diff --git a/demo/app-registry/redpanda/migration/hook.sh.mako b/demo/stand/app-registry/redpanda/migration/hook.sh.mako similarity index 100% rename from demo/app-registry/redpanda/migration/hook.sh.mako rename to demo/stand/app-registry/redpanda/migration/hook.sh.mako diff --git a/demo/app-registry/redpanda/redpanda-instance.yml.mako b/demo/stand/app-registry/redpanda/redpanda-instance.yml.mako similarity index 100% rename from demo/app-registry/redpanda/redpanda-instance.yml.mako rename to demo/stand/app-registry/redpanda/redpanda-instance.yml.mako diff --git a/demo/app-registry/registries.yml b/demo/stand/app-registry/registries.yml similarity index 100% rename from demo/app-registry/registries.yml rename to demo/stand/app-registry/registries.yml diff --git a/demo/cloud-init.yaml.mako b/demo/stand/cloud-init.yaml.mako similarity index 100% rename from demo/cloud-init.yaml.mako rename to demo/stand/cloud-init.yaml.mako diff --git a/demo/stand.yml b/demo/stand/stand.yml similarity index 89% rename from demo/stand.yml rename to demo/stand/stand.yml index 178c6e8..b39256e 100644 --- a/demo/stand.yml +++ b/demo/stand/stand.yml @@ -1,4 +1,4 @@ -version: 1 +/version: 1 stand: project: demo @@ -32,7 +32,11 @@ apps: cpu: 1000 ram: 3072 oom_priority: -200 - hooks: migration + hooks: + path: migration + assets: + - source: resource://project-assets/redpanda + dest: . redpanda-seed-1: role: base-seed cpu: 3500 @@ -79,7 +83,11 @@ apps: cpu: 2000 ram: 2048 oom_priority: -300 - hooks: hook + hooks: + path: hook + assets: + - source: resource://project-assets/mongo/migrations + dest: migration mongo-instance-1: role: member cpu: 2000 diff --git a/docs/README.md b/docs/README.md index 204e4f5..ff97c5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,11 +28,11 @@ Stands Engine разделяет инфраструктуру на три уро ## Примеры -- [`demo/stand.yml`](../demo/stand.yml) — полный стенд с несколькими приложениями +- [`demo/stand/stand.yml`](../demo/stand/stand.yml) — полный стенд с несколькими приложениями и нодами. -- [`demo/app-registry`](../demo/app-registry) — готовые описания Redis, MongoDB, +- [`demo/stand/app-registry`](../demo/stand/app-registry) — готовые описания Redis, MongoDB, Redpanda, Kafka UI и Dozzle. -- [`demo/cloud-init.yaml.mako`](../demo/cloud-init.yaml.mako) — cloud-init для +- [`demo/stand/cloud-init.yaml.mako`](../demo/stand/cloud-init.yaml.mako) — cloud-init для поддерживаемого Podman runtime. > `create` создаёт реальные облачные ресурсы. Перед запуском проверьте provider diff --git a/docs/application-manifest.md b/docs/application-manifest.md index b2d6da7..fc60fbf 100644 --- a/docs/application-manifest.md +++ b/docs/application-manifest.md @@ -395,8 +395,7 @@ url = f"redis://{quote(user, safe='')}:{quote(password, safe='')}@{endpoint}:637 ```text bootstrap/ ├── hook.sh.mako -└── migration/ - └── initial.json.mako +└── helpers.sh ``` Путь подключается к конкретному инстансу через `instances..hooks`. @@ -414,6 +413,24 @@ bootstrap/ Обычные, в том числе бинарные, ресурсы можно помещать в hook-каталог без суффикса `.mako`: движок сохраняет их имена и содержимое без изменений. +Stand-specific миграции необязательно хранить рядом с переиспользуемым hook. +Их можно подключить из прикладного проекта: + +```yaml +hooks: + path: hook + assets: + - source: resource://project-assets/mongo/migrations + dest: migration +``` + +Resource задаётся при запуске через +`--resource project-assets=/path/to/application/assets`. Один корень может +обслуживать assets нескольких hooks. Движок рекурсивно добавляет +содержимое `source` в `dest`. Внешние assets всегда копируются буквально, включая +файлы с суффиксом `.mako`, и не могут перезаписывать файлы базового hook или +другого asset. + Надёжный hook должен: - завершаться при ошибке и возвращать ненулевой exit code; @@ -422,8 +439,8 @@ bootstrap/ - не печатать секреты; - использовать относительные пути от корня hook. -Примеры: [`mongo/hook`](../demo/app-registry/mongo/hook) и -[`redpanda/migration`](../demo/app-registry/redpanda/migration). +Примеры: [`mongo/hook`](../demo/stand/app-registry/mongo/hook) и +[`redpanda/migration`](../demo/stand/app-registry/redpanda/migration). ## 8. Проверка приложения @@ -471,6 +488,6 @@ bootstrap/ - [ ] Все templates успешно рендерятся и разбираются. Готовые эталоны находятся в -[`demo/app-registry`](../demo/app-registry): Redis — простой stateful service, +[`demo/stand/app-registry`](../demo/stand/app-registry): Redis — простой stateful service, Redpanda — несколько ролей, Kafka UI — зависимость, MongoDB — hook и connection, Dozzle — node agent. diff --git a/docs/operations.md b/docs/operations.md index 984ccc9..4daa560 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -192,7 +192,7 @@ users: настраивает user systemd, linger, Podman socket и сеть `app-net`. Готовый поддерживаемый шаблон: -[`demo/cloud-init.yaml.mako`](../demo/cloud-init.yaml.mako). +[`demo/stand/cloud-init.yaml.mako`](../demo/stand/cloud-init.yaml.mako). ### Изменения cloud-init @@ -250,7 +250,7 @@ source dev.env set +a uv run python -c \ - 'from pathlib import Path; from ManifestParser import parse_manifest; parse_manifest(Path("demo/stand.yml")); print("manifest: OK")' + 'from pathlib import Path; from ManifestParser import parse_manifest; parse_manifest(Path("demo/stand/stand.yml"), resource_roots={"project-assets": Path("demo/resources")}); print("manifest: OK")' ``` Команда не создаёт ресурсы. Она проверяет YAML, dependencies, secrets, связи и @@ -273,27 +273,30 @@ provision layer содержит Pulumi preview. ### Локально ```bash -uv run stands-engine create demo/stand.yml -uv run stands-engine destroy demo/stand.yml +uv run stands-engine --resource project-assets=demo/resources create demo/stand/stand.yml +uv run stands-engine destroy demo/stand/stand.yml ``` Совместимый вариант: ```bash -python main.py create demo/stand.yml +python main.py --resource project-assets=demo/resources create demo/stand/stand.yml ``` CLI принимает только: ```text -stands-engine +stands-engine [--resource NAME=PATH] ``` ### Через container launcher ```bash -./stands-engine --env-file dev.env create demo/stand.yml -./stands-engine --env-file dev.env destroy demo/stand.yml +./stands-engine \ + --env-file dev.env \ + --resource project-assets=demo/resources \ + create demo/stand/stand.yml +./stands-engine --env-file dev.env destroy demo/stand/stand.yml ``` Явный runtime/image: @@ -303,7 +306,8 @@ stands-engine --runtime docker \ --image registry.example.test/stands-engine:0.1.0 \ --env-file dev.env \ - create demo/stand.yml + --resource project-assets=demo/resources \ + create demo/stand/stand.yml ``` Launcher: @@ -359,7 +363,7 @@ AWS-compatible variables. Hetzner token записывается в stack config ## 9. Lifecycle `destroy` ```bash -uv run stands-engine destroy demo/stand.yml +uv run stands-engine destroy demo/stand/stand.yml ``` `destroy`: diff --git a/docs/stand-manifest.md b/docs/stand-manifest.md index 58e6425..1f457e2 100644 --- a/docs/stand-manifest.md +++ b/docs/stand-manifest.md @@ -219,7 +219,7 @@ stand-specific preferences не следует одновременно объя | `ram` | Обязательный положительный integer в десятичных MB | | `oom_priority` | Необязательный integer `-1000..1000` | | `preferences` | Необязательный mapping параметров инстанса | -| `hooks` | Необязательный путь к каталогу hook | +| `hooks` | Необязательный путь либо конфигурация hook и внешних assets | Имена инстансов глобальны для всего стенда, включая разные приложения. Повтор запрещён. @@ -227,6 +227,36 @@ stand-specific preferences не следует одновременно объя Относительный `hooks` разрешается от манифеста, в котором поле объявлено. Если поле находится в `stand.yml`, путь указывайте относительно `stand.yml`. +Для переиспользуемого hook и миграций из прикладного проекта используется +расширенная форма: + +```yaml +instances: + mongo-main: + role: member + cpu: 1000 + ram: 2048 + hooks: + path: hook + assets: + - source: resource://project-assets/mongo/migrations + dest: migration +``` + +`path` задаёт базовый hook с обязательным `hook.sh.mako`. Каждый `source` должен +указывать на каталог, а `dest` — на безопасный относительный POSIX-каталог внутри +hook. Значение `.` добавляет файлы в корень hook. Содержимое assets копируется как +есть и не обрабатывается Mako. + +Именованный корень передаётся одинаково launcher-у и локальному CLI: + +```bash +./stands-engine --resource project-assets=/path/to/project/assets create stand.yml +uv run stands-engine --resource project-assets=/path/to/project/assets create stand.yml +``` + +При `destroy` подключать resources не требуется. + ### Connection instance Если подключённый `app.yml` объявляет `connection`, стенд обязан выбрать @@ -377,7 +407,7 @@ key или для list/mapping. Подставленное значение вс ## 11. Полный пример -Полный актуальный пример находится в [`demo/stand.yml`](../demo/stand.yml). Он +Полный актуальный пример находится в [`demo/stand/stand.yml`](../demo/stand/stand.yml). Он показывает: - общий node profile; @@ -402,7 +432,7 @@ source dev.env set +a uv run python -c \ - 'from pathlib import Path; from ManifestParser import parse_manifest; parse_manifest(Path("demo/stand.yml")); print("manifest: OK")' + 'from pathlib import Path; from ManifestParser import parse_manifest; parse_manifest(Path("demo/stand/stand.yml"), resource_roots={"project-assets": Path("demo/resources")}); print("manifest: OK")' ``` Это раскрывает dependencies, разрешает secrets, нормализует пути и проверяет diff --git a/main.py b/main.py index f89b40d..159f745 100644 --- a/main.py +++ b/main.py @@ -2,12 +2,16 @@ from importlib.metadata import PackageNotFoundError, version import sys from pathlib import Path +import re from config.config import Config from ManifestParser import parse_manifest from StandBuilder import build_stand +RESOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + + def application_version() -> str: try: return version("stands-engine") @@ -21,6 +25,13 @@ def build_parser() -> argparse.ArgumentParser: description="Create or destroy an infrastructure stand from a YAML manifest.", ) parser.add_argument("--version", action="version", version=f"%(prog)s {application_version()}") + parser.add_argument( + "--resource", + action="append", + default=[], + metavar="NAME=PATH", + help="named local directory available to hook assets (repeatable)", + ) parser.add_argument("operation", choices=("create", "destroy")) parser.add_argument("manifest", type=Path, help="path to the stand YAML manifest") return parser @@ -32,6 +43,24 @@ def parse_args(argv: list[str]) -> tuple[bool, Path]: return args.operation == "destroy", args.manifest +def parse_resource_roots(values: list[str]) -> dict[str, Path]: + resources = {} + for value in values: + name, separator, raw_path = value.partition("=") + if not separator or not RESOURCE_NAME_PATTERN.fullmatch(name) or not raw_path: + raise ValueError( + f"Invalid resource {value!r}; expected NAME=PATH with a valid name" + ) + if name in resources: + raise ValueError(f"Resource {name!r} was specified more than once") + + path = Path(raw_path).expanduser().resolve() + if not path.is_dir(): + raise ValueError(f"Resource {name!r} is not a directory: {path}") + resources[name] = path + return resources + + def load_private_key(path_to_key: Path) -> str: if not path_to_key.exists(): return "" @@ -41,13 +70,20 @@ def load_private_key(path_to_key: Path) -> str: def main(argv: list[str]) -> int: - is_destroy, path_to_stand_manifest = parse_args(argv) + args = build_parser().parse_args(argv[1:]) + is_destroy = args.operation == "destroy" + path_to_stand_manifest = args.manifest try: + resource_roots = parse_resource_roots(args.resource) config = Config() path_to_key = config.stand.path_to_key operation = "destroy" if is_destroy else "create" - stand_data = parse_manifest(path_to_stand_manifest, operation=operation) + stand_data = parse_manifest( + path_to_stand_manifest, + operation=operation, + resource_roots=resource_roots, + ) stand = build_stand(stand_data, config, private_key=load_private_key(path_to_key)) except (FileNotFoundError, TypeError, ValueError) as exc: print(exc) diff --git a/stands-engine b/stands-engine index 4ec1350..47ee09f 100755 --- a/stands-engine +++ b/stands-engine @@ -9,6 +9,7 @@ Options: --runtime Container runtime (auto-detected by default) --image OCI image (default: stands-engine:local) --env-file Environment file passed to the container + --resource Read-only directory for hook assets (repeatable) -h, --help Show this help Environment: @@ -22,6 +23,9 @@ image="${STANDS_ENGINE_IMAGE:-stands-engine:local}" env_file="" operation="" manifest="" +resource_names=() +resource_paths=() +resource_count=0 while (($#)); do case "$1" in @@ -37,6 +41,30 @@ while (($#)); do env_file="${2:?--env-file requires a value}" shift 2 ;; + --resource) + resource_spec="${2:?--resource requires NAME=PATH}" + resource_name="${resource_spec%%=*}" + resource_path="${resource_spec#*=}" + if [[ "${resource_spec}" != *=* || ! "${resource_name}" =~ ^[A-Za-z][A-Za-z0-9_-]*$ || -z "${resource_path}" ]]; then + echo "Invalid resource '${resource_spec}'; expected NAME=PATH with a valid name." >&2 + exit 2 + fi + for ((index=0; index&2 + exit 2 + fi + done + if [[ ! -d "${resource_path}" ]]; then + echo "Resource '${resource_name}' is not a directory: ${resource_path}" >&2 + exit 1 + fi + resolved_resource_path="$(cd "${resource_path}" && pwd -P)" + resource_names+=("${resource_name}") + resource_paths+=("${resolved_resource_path}") + resource_count=$((resource_count + 1)) + shift 2 + ;; -h|--help) usage exit 0 @@ -152,9 +180,22 @@ run_args+=( --env STAND__PATH_TO_KEY=/data/keys/id_ed25519 --env STAND__PATH_TO_CONFIGSET=/data/configsets --env OUTPUT__FILE_PATH=/data/output - "${image}" - "${operation}" - "${container_manifest}" ) +for ((index=0; index\"$DOCKER_ARGS\"\n", + encoding="utf-8", + ) + docker.chmod(docker.stat().st_mode | stat.S_IXUSR) + arguments = root / "docker-args" + + result = subprocess.run( + [ + "bash", + str(LAUNCHER), + "--runtime", + "docker", + "--resource", + f"project-assets={resource}", + "create", + str(manifest), + ], + cwd=root, + text=True, + capture_output=True, + env={ + **os.environ, + "PATH": f"{binary}:{os.environ['PATH']}", + "DOCKER_ARGS": str(arguments), + }, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + argv = arguments.read_text(encoding="utf-8").splitlines() + self.assertIn( + f"{resource.resolve()}:/resources/project-assets:ro", + argv, + ) + resource_index = argv.index("--resource") + self.assertEqual( + argv[resource_index + 1], + "project-assets=/resources/project-assets", + ) + self.assertEqual(argv[-2:], ["create", "/workspace/demo/stand/stand.yml"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hook_assets.py b/tests/test_hook_assets.py new file mode 100644 index 0000000..f324955 --- /dev/null +++ b/tests/test_hook_assets.py @@ -0,0 +1,226 @@ +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import unittest + +from App import App, ClusterApp, HookAsset, RoleApp +from StandFramework import Stand +from StandFramework.stand.stand import InstanceApp + + +class HookAssetTests(unittest.TestCase): + def build_stand(self, hook_path: Path, configset_path: Path): + role = RoleApp(name="member", ports=[]) + app = App( + name="mongo-1", + role=role, + cpu=500, + ram=512, + hook_path=hook_path, + ) + cluster = ClusterApp( + name="mongo", + image=None, + preferences={}, + instances_app=[app], + ) + instance = InstanceApp( + app=app, + cluster=cluster, + node=SimpleNamespace(private_ip="10.0.0.2"), + ) + stand = object.__new__(Stand) + stand.app_user = "app" + stand.path_folder_configset = configset_path + stand.instance_apps = {app.name: instance} + stand.shell_script = [] + + assets = [] + stand.add_upload_asset = lambda current_instance, asset: assets.append(asset) + return stand, instance, assets + + def test_only_mako_files_are_rendered_and_renamed(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "source" + hook_path.mkdir() + (hook_path / "hook.sh.mako").write_text( + "echo ${instance.name}\n", + encoding="utf-8", + ) + (hook_path / "literal.txt").write_text( + "${THIS_MUST_STAY_LITERAL}\n", + encoding="utf-8", + ) + (hook_path / "literal.MAKO").write_text( + "${CASE_SENSITIVE_SUFFIX}\n", + encoding="utf-8", + ) + + stand, instance, assets = self.build_stand(hook_path, root / "configset") + stand.add_app_hook(instance) + + assets_by_dest = {asset.dest: asset.content for asset in assets} + remote_root = "/home/app/hook/mongo-1" + self.assertEqual(assets_by_dest[f"{remote_root}/hook.sh"], "echo mongo-1\n") + self.assertEqual( + assets_by_dest[f"{remote_root}/literal.txt"], + b"${THIS_MUST_STAY_LITERAL}\n", + ) + self.assertEqual( + assets_by_dest[f"{remote_root}/literal.MAKO"], + b"${CASE_SENSITIVE_SUFFIX}\n", + ) + + local_root = root / "configset" / "mongo--mongo-1" / "hook" + self.assertEqual((local_root / "hook.sh").read_text(), "echo mongo-1\n") + self.assertFalse((local_root / "hook.sh.mako").exists()) + self.assertEqual( + (local_root / "literal.txt").read_bytes(), + b"${THIS_MUST_STAY_LITERAL}\n", + ) + + def test_binary_files_and_nested_paths_are_preserved(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "source" + nested_path = hook_path / "migration" + nested_path.mkdir(parents=True) + (hook_path / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + binary_content = b"\x00\xff${not-a-template}\x80" + (nested_path / "fixture.bin").write_bytes(binary_content) + + stand, instance, assets = self.build_stand(hook_path, root / "configset") + stand.add_app_hook(instance) + + binary_asset = next( + asset for asset in assets if asset.dest.endswith("/migration/fixture.bin") + ) + self.assertEqual(binary_asset.content, binary_content) + local_file = ( + root + / "configset" + / "mongo--mongo-1" + / "hook" + / "migration" + / "fixture.bin" + ) + self.assertEqual(local_file.read_bytes(), binary_content) + + def test_hook_sh_mako_remains_required(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "source" + hook_path.mkdir() + (hook_path / "hook.sh").write_text("#!/bin/sh\n", encoding="utf-8") + + stand, instance, _ = self.build_stand(hook_path, root / "configset") + + with self.assertRaisesRegex(Exception, "must contain hook.sh.mako"): + stand.add_app_hook(instance) + + def test_external_asset_tree_is_copied_without_rendering(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "hook" + hook_path.mkdir() + (hook_path / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + migrations = root / "project" / "migrations" + migrations.mkdir(parents=True) + (migrations / "001.js.mako").write_bytes(b"${MONGO_LITERAL}\x00") + + stand, instance, assets = self.build_stand(hook_path, root / "configset") + instance.app.hook_assets = [ + HookAsset(source=migrations, dest=PurePosixPath("migration")) + ] + + stand.add_app_hook(instance) + + asset = next(item for item in assets if item.dest.endswith("001.js.mako")) + self.assertEqual(asset.content, b"${MONGO_LITERAL}\x00") + self.assertEqual( + ( + root + / "configset" + / "mongo--mongo-1" + / "hook" + / "migration" + / "001.js.mako" + ).read_bytes(), + b"${MONGO_LITERAL}\x00", + ) + + def test_external_asset_cannot_overwrite_base_hook_file(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "hook" + (hook_path / "migration").mkdir(parents=True) + (hook_path / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + (hook_path / "migration" / "001.js").write_text("base", encoding="utf-8") + migrations = root / "project" / "migrations" + migrations.mkdir(parents=True) + (migrations / "001.js").write_text("external", encoding="utf-8") + + stand, instance, _ = self.build_stand(hook_path, root / "configset") + instance.app.hook_assets = [ + HookAsset(source=migrations, dest=PurePosixPath("migration")) + ] + + with self.assertRaisesRegex(ValueError, "Hook file collision"): + stand.validate_hook_sources() + + def test_external_asset_can_be_added_to_hook_root(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "hook" + hook_path.mkdir() + (hook_path / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + config = root / "project" / "redpanda" + config.mkdir(parents=True) + (config / "acl-map.sh").write_text("declare -A TOPICS=()\n", encoding="utf-8") + + stand, instance, assets = self.build_stand(hook_path, root / "configset") + instance.app.hook_assets = [ + HookAsset(source=config, dest=PurePosixPath(".")) + ] + + stand.add_app_hook(instance) + + root_asset = next(item for item in assets if item.dest.endswith("acl-map.sh")) + self.assertEqual(root_asset.dest, "/home/app/hook/mongo-1/acl-map.sh") + + def test_root_asset_cannot_overwrite_hook_script(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "hook" + hook_path.mkdir() + (hook_path / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + asset = root / "asset" + asset.mkdir() + (asset / "hook.sh").write_text("external\n", encoding="utf-8") + + stand, instance, _ = self.build_stand(hook_path, root / "configset") + instance.app.hook_assets = [ + HookAsset(source=asset, dest=PurePosixPath(".")) + ] + + with self.assertRaisesRegex(ValueError, "Hook file collision"): + stand.validate_hook_sources() + + def test_missing_external_asset_is_rejected_during_preflight(self): + with TemporaryDirectory() as directory: + root = Path(directory) + hook_path = root / "hook" + hook_path.mkdir() + (hook_path / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + stand, instance, _ = self.build_stand(hook_path, root / "configset") + instance.app.hook_assets = [ + HookAsset(source=root / "missing", dest=PurePosixPath("migration")) + ] + + with self.assertRaisesRegex(ValueError, "not a directory"): + stand.validate_hook_sources() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hook_resources.py b/tests/test_hook_resources.py new file mode 100644 index 0000000..576fb1b --- /dev/null +++ b/tests/test_hook_resources.py @@ -0,0 +1,222 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +from ManifestParser import parse_manifest +from StandBuilder import _build_cluster, _build_registries + + +STAND_MANIFEST = """ +stand: + project: demo + env: test + users: {sudo: admin, app: app} + ssh: {key_name_admin: admin-key} +node_profiles: + default: + location: hel1 + type_serv: cpx11 + image: rocky-10 + network: demo + cloud-init: cloud-init.yml.mako +registries: + local: {url: registry.example.test} +apps: + mongo: + from_dep_manifest: app/app.yml + instances: + mongo-1: + role: member + cpu: 500 + ram: 512 + hooks: + path: hook + assets: + - source: resource://project/migrations/mongo + dest: migration +nodes: + node-1: + apps: [mongo-1] +""" + +APP_MANIFEST = """ +name: mongo +image: {registry: local, path: mongo, version: "8"} +roles: + member: {} +templates: + pod: + path: mongo.yml.mako + dest: /home/app/mongo.yml + owner: app + mode: "644" +""" + + +class HookResourceTests(unittest.TestCase): + def create_manifest_tree(self, root: Path) -> tuple[Path, Path]: + app = root / "app" + (app / "hook").mkdir(parents=True) + (app / "app.yml").write_text(APP_MANIFEST, encoding="utf-8") + (app / "hook" / "hook.sh.mako").write_text("#!/bin/sh\n", encoding="utf-8") + manifest = root / "stand.yml" + manifest.write_text(STAND_MANIFEST, encoding="utf-8") + + project = root / "external-project" + (project / "migrations" / "mongo").mkdir(parents=True) + return manifest, project + + def test_resource_uri_resolves_to_named_root(self): + with TemporaryDirectory() as directory: + manifest, project = self.create_manifest_tree(Path(directory)) + + data = parse_manifest(manifest, resource_roots={"project": project}) + + hooks = data["apps"]["mongo"]["instances"]["mongo-1"]["hooks"] + self.assertEqual(hooks["path"], str((manifest.parent / "app" / "hook").resolve())) + self.assertEqual( + hooks["assets"][0]["source"], + str((project / "migrations" / "mongo").resolve()), + ) + cluster, instances = _build_cluster( + "mongo", + data["apps"]["mongo"], + _build_registries(data["registries"]), + ) + self.assertEqual(cluster.instances_app[0], instances["mongo-1"]) + self.assertEqual( + instances["mongo-1"].hook_assets[0].source, + (project / "migrations" / "mongo").resolve(), + ) + + def test_create_rejects_unknown_resource(self): + with TemporaryDirectory() as directory: + manifest, _ = self.create_manifest_tree(Path(directory)) + + with self.assertRaisesRegex(ValueError, "unknown resource 'project'"): + parse_manifest(manifest) + + def test_relative_asset_source_is_relative_to_stand_manifest(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest, _ = self.create_manifest_tree(root) + relative_source = root / "stand-assets" / "migrations" + relative_source.mkdir(parents=True) + manifest.write_text( + STAND_MANIFEST.replace( + "resource://project/migrations/mongo", + "stand-assets/migrations", + ), + encoding="utf-8", + ) + + data = parse_manifest(manifest) + + source = data["apps"]["mongo"]["instances"]["mongo-1"]["hooks"]["assets"][0]["source"] + self.assertEqual(source, str(relative_source.resolve())) + + def test_destroy_does_not_require_resource_checkout(self): + with TemporaryDirectory() as directory: + manifest, _ = self.create_manifest_tree(Path(directory)) + + data = parse_manifest(manifest, operation="destroy") + + source = data["apps"]["mongo"]["instances"]["mongo-1"]["hooks"]["assets"][0]["source"] + self.assertEqual(source, "resource://project/migrations/mongo") + + def test_resource_traversal_is_rejected(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest, project = self.create_manifest_tree(root) + manifest.write_text( + STAND_MANIFEST.replace( + "resource://project/migrations/mongo", + "resource://project/../outside", + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "must not escape"): + parse_manifest(manifest, resource_roots={"project": project}) + + def test_resource_symlink_escape_is_rejected(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest, project = self.create_manifest_tree(root) + outside = root / "outside" + outside.mkdir() + (project / "escape").symlink_to(outside, target_is_directory=True) + manifest.write_text( + STAND_MANIFEST.replace( + "resource://project/migrations/mongo", + "resource://project/escape", + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "escapes its root"): + parse_manifest(manifest, resource_roots={"project": project}) + + def test_unsafe_destination_is_rejected(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest, project = self.create_manifest_tree(root) + manifest.write_text( + STAND_MANIFEST.replace("dest: migration", "dest: ../migration"), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "relative POSIX path"): + parse_manifest(manifest, resource_roots={"project": project}) + + def test_multiple_assets_can_share_one_resource_root(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest, project = self.create_manifest_tree(root) + redpanda = project / "redpanda" + redpanda.mkdir() + manifest.write_text( + STAND_MANIFEST.replace( + " dest: migration", + " dest: migration\n" + " - source: resource://project/redpanda\n" + " dest: .", + ), + encoding="utf-8", + ) + + data = parse_manifest(manifest, resource_roots={"project": project}) + + assets = data["apps"]["mongo"]["instances"]["mongo-1"]["hooks"]["assets"] + self.assertEqual( + assets[0]["source"], + str((project / "migrations" / "mongo").resolve()), + ) + self.assertEqual(assets[1]["source"], str(redpanda.resolve())) + self.assertEqual(assets[1]["dest"], ".") + + def test_demo_manifest_uses_one_shared_resource_root(self): + repository = Path(__file__).parents[1] + demo = repository / "demo" + + data = parse_manifest( + demo / "stand" / "stand.yml", + operation="destroy", + resource_roots={"project-assets": demo / "resources"}, + ) + + redpanda = data["apps"]["redpanda"]["instances"]["redpanda-master"]["hooks"] + mongo = data["apps"]["mongo"]["instances"]["mongo-instance"]["hooks"] + self.assertEqual( + redpanda["assets"][0]["source"], + str((demo / "resources" / "redpanda").resolve()), + ) + self.assertEqual(redpanda["assets"][0]["dest"], ".") + self.assertEqual( + mongo["assets"][0]["source"], + str((demo / "resources" / "mongo" / "migrations").resolve()), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mongo_hook.py b/tests/test_mongo_hook.py index 85f7ad6..13aa44d 100644 --- a/tests/test_mongo_hook.py +++ b/tests/test_mongo_hook.py @@ -13,7 +13,9 @@ from App import App, ClusterApp, RoleApp -REGISTRY_PATH = Path(__file__).parents[1] / "demo" / "app-registry" / "mongo" +REGISTRY_PATH = ( + Path(__file__).parents[1] / "demo" / "stand" / "app-registry" / "mongo" +) HOOK_TEMPLATE = REGISTRY_PATH / "hook" / "hook.sh.mako" POD_TEMPLATE = REGISTRY_PATH / "mongo-instance.yml.mako" diff --git a/tests/test_redpanda_hook.py b/tests/test_redpanda_hook.py index 028ea9b..49b7380 100644 --- a/tests/test_redpanda_hook.py +++ b/tests/test_redpanda_hook.py @@ -13,7 +13,9 @@ from App import App, ClusterApp, RoleApp -REGISTRY_PATH = Path(__file__).parents[1] / "demo" / "app-registry" / "redpanda" +REGISTRY_PATH = ( + Path(__file__).parents[1] / "demo" / "stand" / "app-registry" / "redpanda" +) HOOK_TEMPLATE = REGISTRY_PATH / "migration" / "hook.sh.mako" CONFIG_MAP = """\ declare -A TOPICS=( From bc74d778aaa74b3e7dc9e9df29d7b87df8212637 Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 17:01:18 +0200 Subject: [PATCH 03/10] add NDJSON result output for `create`/`destroy` operations, ensure structured logging to stderr, and enhance output file handling --- InfraBaseLib/SShExecutor/diagnostic.py | 5 +- InfraBaseLib/SShExecutor/executor.py | 5 ++ InfraBaseLib/metal_provision/provision.py | 21 +++--- README.md | 20 +++++- StandFramework/stand/stand.py | 35 ++++++++-- docs/operations.md | 34 ++++++++-- main.py | 26 +++---- tests/test_cli.py | 82 ++++++++++++++++++++++- tests/test_connection_output.py | 41 +++++++++++- tests/test_diagnostic_streams.py | 77 +++++++++++++++++++++ 10 files changed, 305 insertions(+), 41 deletions(-) create mode 100644 tests/test_diagnostic_streams.py diff --git a/InfraBaseLib/SShExecutor/diagnostic.py b/InfraBaseLib/SShExecutor/diagnostic.py index 4c6e613..2d20579 100644 --- a/InfraBaseLib/SShExecutor/diagnostic.py +++ b/InfraBaseLib/SShExecutor/diagnostic.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +import sys from time import perf_counter from typing import Any @@ -146,7 +147,7 @@ def print_event( if max_retries: parts.append(f"max_retries={max_retries}") - print(" ".join(parts)) + print(" ".join(parts), file=sys.stderr) def print_host_summary(self, host_name: str, stats: "PyinfraHostStats", status: str) -> None: now = datetime.now().isoformat(timespec="milliseconds") @@ -167,7 +168,7 @@ def print_host_summary(self, host_name: str, stats: "PyinfraHostStats", status: f"duration_ms={duration_ms}", ] - print(" ".join(parts)) + print(" ".join(parts), file=sys.stderr) stats.summary_printed = True diff --git a/InfraBaseLib/SShExecutor/executor.py b/InfraBaseLib/SShExecutor/executor.py index 444d4f4..228aa5d 100644 --- a/InfraBaseLib/SShExecutor/executor.py +++ b/InfraBaseLib/SShExecutor/executor.py @@ -169,5 +169,10 @@ def run( ) run_ops(self.state) + if self.state.failed_hosts: + failed_hosts = ", ".join( + sorted(host.name for host in self.state.failed_hosts) + ) + raise RuntimeError(f"PyInfra failed on hosts: {failed_hosts}") finally: disconnect_all(self.state) diff --git a/InfraBaseLib/metal_provision/provision.py b/InfraBaseLib/metal_provision/provision.py index 80b0788..fddb655 100644 --- a/InfraBaseLib/metal_provision/provision.py +++ b/InfraBaseLib/metal_provision/provision.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +import sys from urllib.parse import urlencode from typing import Optional from pulumi import automation as auto @@ -86,7 +87,7 @@ def _log_resource_event(self, metadata, prefix: str = "", respect_ignored: bool op = getattr(metadata, "op", "") urn = getattr(metadata, "urn", None) name = self.resource_name_from_urn(urn) - print(f"[pulumi] {prefix}{op}: {resource_type}::{name}") + print(f"[pulumi] {prefix}{op}: {resource_type}::{name}", file=sys.stderr) @@ -95,18 +96,18 @@ def event_handler(self, event) -> None: if event.resource_pre_event: meta = event.resource_pre_event.metadata if meta.op != "same": - print(f"[DIFF] {meta.urn}") - print(f" op: {meta.op}") - print(f" diffs: {getattr(meta, 'diffs', None)}") - print(f" detailed_diff: {getattr(meta, 'detailed_diff', None)}") - print(f" olds keys: {list(getattr(meta, 'olds', {}).keys())}") - print(f" news keys: {list(getattr(meta, 'news', {}).keys())}") + print(f"[DIFF] {meta.urn}", file=sys.stderr) + print(f" op: {meta.op}", file=sys.stderr) + print(f" diffs: {getattr(meta, 'diffs', None)}", file=sys.stderr) + print(f" detailed_diff: {getattr(meta, 'detailed_diff', None)}", file=sys.stderr) + print(f" olds keys: {list(getattr(meta, 'olds', {}).keys())}", file=sys.stderr) + print(f" news keys: {list(getattr(meta, 'news', {}).keys())}", file=sys.stderr) if event.diagnostic_event: diag = event.diagnostic_event severity = getattr(diag, "severity", None) message = getattr(diag, "message", "") if severity in ("error", "warning"): - print(f"[pulumi:{severity}] {message.strip()}") + print(f"[pulumi:{severity}] {message.strip()}", file=sys.stderr) if event.resource_pre_event: self._log_resource_event(event.resource_pre_event.metadata) @@ -123,9 +124,9 @@ def event_handler(self, event) -> None: if event.summary_event: changes = getattr(event.summary_event, "resource_changes", None) - print(f"[pulumi] summary: {changes}") + print(f"[pulumi] summary: {changes}", file=sys.stderr) except Exception as exc: - print(f"[pulumi:event-handler-warning] {exc}") + print(f"[pulumi:event-handler-warning] {exc}", file=sys.stderr) def init_stack(self, server_program: Callable[[], None]) -> None: self.ensure_pulumi_cli_installed() diff --git a/README.md b/README.md index 8700da7..719719c 100644 --- a/README.md +++ b/README.md @@ -225,9 +225,9 @@ OUTPUT__FILE_PATH= - `STAND__PASSPHRASE` - passphrase для Pulumi secrets provider. - `STAND__PATH_TO_KEY` - путь к приватному ключу стенда. Если файла нет, Stands Engine создаст ключ и сохранит его туда. - `STAND__PATH_TO_CONFIGSET` - локальный каталог для отрендеренных конфигов приложений и хуков. -- `OUTPUT__CONSOLE` - печатать итоговый JSON с данными подключения после успешного `create`; по умолчанию `true`. +- `OUTPUT__CONSOLE` - печатать итоговую NDJSON-запись с данными подключения после успешного `create`; по умолчанию `true`. - `OUTPUT__CONSOLE_SECRETS` - показывать настоящие password и URL в консоли; по умолчанию они заменяются на `***`. -- `OUTPUT__FILE` - сохранять полный JSON с данными подключения в файл; по умолчанию `false`. +- `OUTPUT__FILE` - сохранять полный форматированный JSON с данными подключения в файл; по умолчанию `false`. - `OUTPUT__FILE_PATH` - каталог для итогового JSON. Обязателен, если `OUTPUT__FILE=true`. ### Секреты манифеста @@ -439,7 +439,21 @@ import json Шаблон получает тот же контекст `node`, `instance`, `role`, `cluster` и `apps`, что и шаблоны запуска. Результатом должен быть один JSON-объект с непустыми `endpoint`, `credentials.user`, `credentials.password` и портом от `1` до `65535`. Поле `url` необязательно; внутри `credentials` можно добавлять параметры приложения. -После успешного запуска всех приложений и hooks Stands Engine объединяет результаты по именам приложений. Консольный результат по умолчанию маскирует password и весь URL. Файловый результат всегда содержит реальные значения, создаётся с правами `0600` и поэтому должен храниться как секрет. Имя файла формируется как `__.json` внутри `OUTPUT__FILE_PATH`, по тому же правилу, что и имя каталога configset. +После успешного запуска всех приложений и hooks Stands Engine объединяет результаты по именам приложений и печатает одну компактную NDJSON-запись. Поле `id_stand` совпадает с именем каталога configset: `__`. Например: + +```json +{"id_stand":"owner_demo_test","redis":{"endpoint":"10.0.0.2","port":6379,"credentials":{"user":"admin","password":"***"},"url":"***"}} +``` + +Консольный результат по умолчанию маскирует password и весь URL. Файловый результат использует ту же структуру, но записывается как форматированный обычный JSON, всегда содержит реальные значения и создаётся с правами `0600`, поэтому должен храниться как секрет. Имя файла формируется как `__.json` внутри `OUTPUT__FILE_PATH`. + +Успешный `destroy` печатает отдельную запись: + +```json +{"id_stand":"owner_demo_test","operation":"destroy","status":"success"} +``` + +Во время `create`/`destroy` stdout зарезервирован для NDJSON-результатов. Диагностика Pulumi, PyInfra и сообщения об ошибках отправляются в stderr. Коды завершения: `0` — успех, `1` — ошибка конфигурации или выполнения, `2` — неверный CLI-вызов, `130` — прерывание `Ctrl+C`. При ошибке stdout остаётся пустым. Mako-шаблоны получают контекст: diff --git a/StandFramework/stand/stand.py b/StandFramework/stand/stand.py index 12d48f3..30c35b1 100644 --- a/StandFramework/stand/stand.py +++ b/StandFramework/stand/stand.py @@ -383,6 +383,15 @@ def build_connections(self) -> dict[str, dict]: if cluster.connection_template is not None } + @property + def id_stand(self) -> str: + return Path(self.path_folder_configset).name + + def validate_result_contract(self) -> None: + cluster = self.clusters_app.get("id_stand") + if cluster is not None and cluster.connection_template is not None: + raise ValueError("Connection app name 'id_stand' is reserved for result output") + @staticmethod def mask_connections(connections: dict[str, dict]) -> dict[str, dict]: masked = deepcopy(connections) @@ -393,8 +402,12 @@ def mask_connections(connections: dict[str, dict]) -> dict[str, dict]: return masked @staticmethod - def connections_json(connections: dict[str, dict]) -> str: - return json.dumps(connections, ensure_ascii=False, indent=2) + "\n" + def result_ndjson(result: dict) -> str: + return json.dumps(result, ensure_ascii=False, separators=(",", ":")) + "\n" + + @staticmethod + def result_json(result: dict) -> str: + return json.dumps(result, ensure_ascii=False, indent=2) + "\n" def output_connections(self) -> None: if not self.output_console and not self.output_file: @@ -405,7 +418,8 @@ def output_connections(self) -> None: console_connections = ( connections if self.output_console_secrets else self.mask_connections(connections) ) - print(self.connections_json(console_connections), end="") + console_result = {"id_stand": self.id_stand, **console_connections} + print(self.result_ndjson(console_result), end="") if self.output_file: if self.output_file_directory is None: @@ -413,9 +427,7 @@ def output_connections(self) -> None: if self.output_file_directory.exists() and not self.output_file_directory.is_dir(): raise ValueError("OUTPUT__FILE_PATH must point to a directory") self.output_file_directory.mkdir(parents=True, exist_ok=True) - output_file_path = self.output_file_directory / ( - f"{self.state.owner}_{self.state.project}_{self.state.env}.json" - ) + output_file_path = self.output_file_directory / f"{self.id_stand}.json" descriptor = os.open( output_file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, @@ -423,7 +435,15 @@ def output_connections(self) -> None: ) os.fchmod(descriptor, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as output: - output.write(self.connections_json(connections)) + output.write(self.result_json({"id_stand": self.id_stand, **connections})) + + def output_destroy_result(self) -> None: + result = { + "id_stand": self.id_stand, + "operation": "destroy", + "status": "success", + } + print(self.result_ndjson(result), end="") def render_deploy_configset(self) -> None: Path(self.path_folder_configset).mkdir(parents=True, exist_ok=True) @@ -557,6 +577,7 @@ def launch_apps(self) -> None: self.add_app_hook(instance) def up(self, diagnostic: bool | SShExecutorDiagnostArgs = False): + self.validate_result_contract() self.validate_hook_sources() self.create_servers() self.render_deploy_configset() diff --git a/docs/operations.md b/docs/operations.md index 4daa560..69c5be8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -372,6 +372,8 @@ uv run stands-engine destroy demo/stand/stand.yml 2. Допускает неразрешённые app/registry secrets. 3. Выбирает существующий Pulumi stack. 4. Выполняет `pulumi destroy`. +5. После успешного удаления печатает NDJSON-запись с `id_stand`, + `operation: "destroy"` и `status: "success"`. Команда не удаляет локальные SSH keys, configsets, connection files, S3 stack metadata или bucket. @@ -409,29 +411,53 @@ Connection templates определяются приложениями, но п | Переменная | Default | Поведение | |---|---|---| -| `OUTPUT__CONSOLE` | `true` | Печатает общий JSON после успешного create | +| `OUTPUT__CONSOLE` | `true` | Печатает одну NDJSON-запись после успешного create | | `OUTPUT__CONSOLE_SECRETS` | `false` | Показывает настоящие password и URL | -| `OUTPUT__FILE` | `false` | Сохраняет полный JSON | +| `OUTPUT__FILE` | `false` | Сохраняет полный форматированный JSON | | `OUTPUT__FILE_PATH` | — | Каталог, обязательный при file output | Консоль по умолчанию заменяет `credentials.password` и `url` на `***`. Дополнительные secret-подобные поля внутри `credentials` автоматически не маскируются. +Во время `create`/`destroy` stdout содержит только компактные NDJSON-результаты — +по одному JSON-объекту на строку. Для `create` первым полем идёт `id_stand`, +равный имени configset-каталога, а connection-приложения остаются +верхнеуровневыми полями: + +```json +{"id_stand":"owner_demo_test","redis":{"endpoint":"10.0.0.2","port":6379,"credentials":{"user":"admin","password":"***"},"url":"***"}} +``` + +Имя connection-приложения `id_stand` зарезервировано; такой `create` завершается +до запуска Pulumi. + Файл: ```text /__.json ``` -содержит реальные значения и создаётся с mode `0600`. Рассматривайте его как -секрет. File output выполняется только после успешных приложений и hooks. +содержит ту же структуру с реальными значениями, записанную как обычный +многострочный JSON с отступами, и создаётся с mode `0600`. Рассматривайте файл +как секрет. File output выполняется только после успешных приложений и hooks. Формат самого connection template описан в [application guide](application-manifest.md#6-connection-template). ## 12. Диагностика +Диагностика Pulumi и PyInfra, а также сообщения об ошибках движка отправляются в +stderr и не смешиваются с NDJSON в stdout. При ошибке результирующая запись не +печатается. + +| Exit code | Значение | +|---|---| +| `0` | Успешное выполнение, `--help` или `--version` | +| `1` | Ошибка конфигурации или выполнения, включая Pulumi, PyInfra и SSH | +| `2` | Неверные аргументы командной строки | +| `130` | Выполнение прервано через `Ctrl+C` | + ### Ошибка до Pulumi Проверьте: diff --git a/main.py b/main.py index 159f745..07b1eee 100644 --- a/main.py +++ b/main.py @@ -85,24 +85,24 @@ def main(argv: list[str]) -> int: resource_roots=resource_roots, ) stand = build_stand(stand_data, config, private_key=load_private_key(path_to_key)) - except (FileNotFoundError, TypeError, ValueError) as exc: - print(exc) - return 1 - if is_destroy: - stand.destroy() - return 0 + if is_destroy: + stand.destroy() + stand.output_destroy_result() + return 0 - if not path_to_key.exists(): - with open(path_to_key, "w") as f: - f.write(stand.key.private) + if not path_to_key.exists(): + with open(path_to_key, "w") as f: + f.write(stand.key.private) - try: stand.up(diagnostic=True) - except (FileNotFoundError, TypeError, ValueError) as exc: - print(exc) + return 0 + except KeyboardInterrupt: + print("Interrupted", file=sys.stderr) + return 130 + except Exception as exc: + print(exc, file=sys.stderr) return 1 - return 0 def cli() -> int: diff --git a/tests/test_cli.py b/tests/test_cli.py index e2db03c..9a36bbf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,8 @@ -from contextlib import redirect_stdout +from contextlib import redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path from tempfile import TemporaryDirectory +from types import SimpleNamespace from unittest.mock import patch import unittest @@ -43,6 +44,85 @@ def test_duplicate_resource_is_rejected(self): [f"project={directory}", f"project={directory}"] ) + def test_invalid_arguments_exit_with_code_2(self): + error = StringIO() + with ( + self.assertRaises(SystemExit) as raised, + redirect_stderr(error), + ): + main.main(["stands-engine", "invalid", "stand.yml"]) + + self.assertEqual(raised.exception.code, 2) + self.assertTrue(error.getvalue()) + + def test_runtime_error_returns_1_and_only_writes_stderr(self): + stdout = StringIO() + stderr = StringIO() + with ( + patch.object(main, "Config", side_effect=RuntimeError("configuration failed")), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + exit_code = main.main(["stands-engine", "create", "stand.yml"]) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "configuration failed\n") + + def test_keyboard_interrupt_returns_130(self): + stdout = StringIO() + stderr = StringIO() + with ( + patch.object(main, "Config", side_effect=KeyboardInterrupt), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + exit_code = main.main(["stands-engine", "create", "stand.yml"]) + + self.assertEqual(exit_code, 130) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "Interrupted\n") + + def test_successful_destroy_outputs_result_after_destroy(self): + with TemporaryDirectory() as directory: + private_key = Path(directory) / "id_ed25519" + config = SimpleNamespace(stand=SimpleNamespace(path_to_key=private_key)) + stand = unittest.mock.Mock() + + with ( + patch.object(main, "Config", return_value=config), + patch.object(main, "parse_manifest", return_value={}), + patch.object(main, "build_stand", return_value=stand), + ): + exit_code = main.main(["stands-engine", "destroy", "stand.yml"]) + + self.assertEqual(exit_code, 0) + stand.destroy.assert_called_once_with() + stand.output_destroy_result.assert_called_once_with() + + def test_failed_destroy_returns_1_without_result(self): + with TemporaryDirectory() as directory: + private_key = Path(directory) / "id_ed25519" + config = SimpleNamespace(stand=SimpleNamespace(path_to_key=private_key)) + stand = unittest.mock.Mock() + stand.destroy.side_effect = RuntimeError("destroy failed") + stdout = StringIO() + stderr = StringIO() + + with ( + patch.object(main, "Config", return_value=config), + patch.object(main, "parse_manifest", return_value={}), + patch.object(main, "build_stand", return_value=stand), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + exit_code = main.main(["stands-engine", "destroy", "stand.yml"]) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual(stderr.getvalue(), "destroy failed\n") + stand.output_destroy_result.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_connection_output.py b/tests/test_connection_output.py index 483f7dd..cb13e6c 100644 --- a/tests/test_connection_output.py +++ b/tests/test_connection_output.py @@ -166,6 +166,7 @@ def test_console_is_masked_and_file_contains_full_result(self): stand.output_file = True stand.output_file_directory = output_directory stand.state = SimpleNamespace(owner="owner", project="demo", env="test") + stand.path_folder_configset = Path("configsets/owner_demo_test") stand.build_connections = lambda: connections console = StringIO() @@ -173,12 +174,50 @@ def test_console_is_masked_and_file_contains_full_result(self): stand.output_connections() console_data = json.loads(console.getvalue()) + self.assertEqual(console_data["id_stand"], "owner_demo_test") self.assertEqual(console_data["redis"]["credentials"]["user"], "admin") self.assertEqual(console_data["redis"]["credentials"]["password"], "***") self.assertEqual(console_data["redis"]["url"], "***") - self.assertEqual(json.loads(output_path.read_text()), connections) + self.assertEqual(console.getvalue().count("\n"), 1) + + file_content = output_path.read_text() + file_data = json.loads(file_content) + self.assertEqual( + file_data, + {"id_stand": "owner_demo_test", **connections}, + ) + self.assertGreater(file_content.count("\n"), 1) + self.assertTrue(file_content.startswith("{\n \"id_stand\"")) + self.assertTrue(file_content.endswith("\n")) self.assertEqual(stat.S_IMODE(output_path.stat().st_mode), 0o600) + def test_destroy_result_is_one_ndjson_record(self): + stand = object.__new__(Stand) + stand.path_folder_configset = Path("configsets/owner_demo_test") + + console = StringIO() + with redirect_stdout(console): + stand.output_destroy_result() + + self.assertEqual(console.getvalue().count("\n"), 1) + self.assertEqual( + json.loads(console.getvalue()), + { + "id_stand": "owner_demo_test", + "operation": "destroy", + "status": "success", + }, + ) + + def test_id_stand_connection_name_is_reserved(self): + stand = object.__new__(Stand) + stand.clusters_app = { + "id_stand": SimpleNamespace(connection_template=Path("connection.json.mako")) + } + + with self.assertRaisesRegex(ValueError, "reserved"): + stand.validate_result_contract() + def test_existing_file_cannot_be_used_as_output_directory(self): with TemporaryDirectory() as directory: output_path = Path(directory) / "connections.json" diff --git a/tests/test_diagnostic_streams.py b/tests/test_diagnostic_streams.py new file mode 100644 index 0000000..765b1f7 --- /dev/null +++ b/tests/test_diagnostic_streams.py @@ -0,0 +1,77 @@ +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from types import SimpleNamespace +from unittest.mock import patch +import unittest + +from InfraBaseLib.SShExecutor.executor import SShExecutor +from InfraBaseLib.SShExecutor.diagnostic import PyinfraDiagnostic, PyinfraHostStats +from InfraBaseLib.metal_provision.provision import MetalProvision + + +class DiagnosticStreamsTests(unittest.TestCase): + def test_pulumi_events_only_write_to_stderr(self): + metadata = SimpleNamespace( + type="hcloud:index/server:Server", + op="create", + urn="urn:pulumi:test::demo::hcloud:index/server:Server::node-1", + diffs=["server_type"], + detailed_diff=None, + olds={}, + news={"server_type": "cx22"}, + ) + event = SimpleNamespace( + resource_pre_event=SimpleNamespace(metadata=metadata), + diagnostic_event=SimpleNamespace(severity="warning", message="warning text"), + res_outputs_event=None, + res_op_failed_event=None, + summary_event=SimpleNamespace(resource_changes={"create": 1}), + ) + provision = object.__new__(MetalProvision) + stdout = StringIO() + stderr = StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + provision.event_handler(event) + + self.assertEqual(stdout.getvalue(), "") + self.assertIn("[DIFF]", stderr.getvalue()) + self.assertIn("[pulumi:warning] warning text", stderr.getvalue()) + self.assertIn("[pulumi] create:", stderr.getvalue()) + self.assertIn("[pulumi] summary:", stderr.getvalue()) + + def test_pyinfra_diagnostics_only_write_to_stderr(self): + diagnostic = PyinfraDiagnostic() + stats = PyinfraHostStats(expected_operations=1, started=1, completed=1, success=1) + stdout = StringIO() + stderr = StringIO() + + with redirect_stdout(stdout), redirect_stderr(stderr): + diagnostic.print_host_summary("10.0.0.2", stats, status="complete") + + self.assertEqual(stdout.getvalue(), "") + self.assertIn("[pyinfra-host-summary]", stderr.getvalue()) + self.assertIn("host=10.0.0.2", stderr.getvalue()) + + def test_pyinfra_failed_host_raises_runtime_error(self): + executor = object.__new__(SShExecutor) + failed_host = type("FailedHost", (), {"name": "10.0.0.3"})() + executor.state = SimpleNamespace( + failed_hosts={failed_host}, + inventory=SimpleNamespace(), + ) + executor.uploader = SimpleNamespace(upload_files=[]) + + with ( + patch("InfraBaseLib.SShExecutor.executor.connect_all"), + patch("InfraBaseLib.SShExecutor.executor.run_ops"), + patch("InfraBaseLib.SShExecutor.executor.disconnect_all") as disconnect_all, + self.assertRaisesRegex(RuntimeError, "PyInfra failed on hosts: 10.0.0.3"), + ): + executor.run([]) + + disconnect_all.assert_called_once_with(executor.state) + + +if __name__ == "__main__": + unittest.main() From 1486a436b434b7602c7217fadc45318a72c373e3 Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 21:09:28 +0200 Subject: [PATCH 04/10] add support for multiple `--env-file` entries --- README.md | 17 ++- docs/operations.md | 12 +- stands-engine | 13 ++- stands-engine.ps1 | 9 +- tests/test_container_launcher.py | 190 +++++++++++++++++++++++++++++-- 5 files changed, 218 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 719719c..dc2ae00 100644 --- a/README.md +++ b/README.md @@ -112,12 +112,17 @@ podman build -f Containerfile -t stands-engine:local . ```bash ./stands-engine \ - --env-file dev.env \ + --env-file common.env \ + --env-file stands/dev.env \ --resource project-assets=demo/resources \ create demo/stand/stand.yml -./stands-engine --env-file dev.env destroy demo/stand/stand.yml +./stands-engine --env-file common.env --env-file stands/dev.env destroy demo/stand/stand.yml ``` +`--env-file` можно повторять. Файлы применяются слева направо, поэтому значения +из более позднего файла переопределяют одноимённые значения из предыдущих. Это +позволяет хранить общие настройки отдельно от настроек конкретного стенда. + Каталоги из других репозиториев подключаются как именованные read-only resources. Один resource можно использовать для нескольких hooks, указывая подкаталоги относительно его корня: @@ -165,6 +170,14 @@ PowerShell на Windows, macOS или Linux: .\stands-engine.ps1 destroy .\demo\stand\stand.yml -EnvFile dev.env ``` +Несколько файлов в PowerShell передаются массивом в том же порядке приоритета: + +```powershell +.\stands-engine.ps1 create .\demo\stand\stand.yml ` + -EnvFile common.env,stands\dev.env ` + -Resource "project-assets=.\demo\resources" +``` + Launcher переопределяет локальные абсолютные пути из env-файла контейнерными: ```env diff --git a/docs/operations.md b/docs/operations.md index 69c5be8..1127dae 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -293,12 +293,16 @@ stands-engine [--resource NAME=PATH] ```bash ./stands-engine \ - --env-file dev.env \ + --env-file common.env \ + --env-file stands/dev.env \ --resource project-assets=demo/resources \ create demo/stand/stand.yml -./stands-engine --env-file dev.env destroy demo/stand/stand.yml +./stands-engine --env-file common.env --env-file stands/dev.env destroy demo/stand/stand.yml ``` +`--env-file` можно повторять: файлы загружаются слева направо, и значения из +последующих файлов переопределяют значения из предыдущих. + Явный runtime/image: ```bash @@ -324,8 +328,8 @@ Launcher: PowerShell: ```powershell -.\stands-engine.ps1 create .\demo\stand.yml -EnvFile dev.env -.\stands-engine.ps1 destroy .\demo\stand.yml -EnvFile dev.env +.\stands-engine.ps1 create .\demo\stand.yml -EnvFile common.env,stands\dev.env +.\stands-engine.ps1 destroy .\demo\stand.yml -EnvFile common.env,stands\dev.env ``` ## 8. Lifecycle `create` diff --git a/stands-engine b/stands-engine index 47ee09f..248397d 100755 --- a/stands-engine +++ b/stands-engine @@ -8,7 +8,7 @@ Usage: ./stands-engine [options] Options: --runtime Container runtime (auto-detected by default) --image OCI image (default: stands-engine:local) - --env-file Environment file passed to the container + --env-file Environment file passed to the container (repeatable) --resource Read-only directory for hook assets (repeatable) -h, --help Show this help @@ -20,7 +20,8 @@ EOF runtime="${CONTAINER_RUNTIME:-}" image="${STANDS_ENGINE_IMAGE:-stands-engine:local}" -env_file="" +env_files=() +env_file_count=0 operation="" manifest="" resource_names=() @@ -38,7 +39,8 @@ while (($#)); do shift 2 ;; --env-file) - env_file="${2:?--env-file requires a value}" + env_files+=("${2:?--env-file requires a value}") + env_file_count=$((env_file_count + 1)) shift 2 ;; --resource) @@ -158,14 +160,15 @@ if [[ "$(uname -s)" == "Linux" ]]; then fi fi -if [[ -n "${env_file}" ]]; then +for ((index=0; index&2 exit 1 fi env_file_dir="$(cd "$(dirname "${env_file}")" && pwd -P)" run_args+=(--env-file "${env_file_dir}/$(basename "${env_file}")") -fi +done workspace_mount="${workspace}:/workspace:ro" data_mount="${data_dir}:/data" diff --git a/stands-engine.ps1 b/stands-engine.ps1 index d1f2d9b..54ca8b8 100644 --- a/stands-engine.ps1 +++ b/stands-engine.ps1 @@ -12,7 +12,7 @@ param( [string]$Image = $(if ($env:STANDS_ENGINE_IMAGE) { $env:STANDS_ENGINE_IMAGE } else { "stands-engine:local" }), - [string]$EnvFile, + [string[]]$EnvFile = @(), [string[]]$Resource = @() ) @@ -55,8 +55,11 @@ $dataDirectory = Join-Path $currentDirectory ".stands-engine" } $runArgs = @("run", "--rm") -if ($EnvFile) { - $resolvedEnvFile = (Resolve-Path -LiteralPath $EnvFile).Path +foreach ($envFilePath in $EnvFile) { + if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) { + throw "Environment file does not exist: $envFilePath" + } + $resolvedEnvFile = (Resolve-Path -LiteralPath $envFilePath).Path $runArgs += @("--env-file", $resolvedEnvFile) } diff --git a/tests/test_container_launcher.py b/tests/test_container_launcher.py index cc697f3..3b10e78 100644 --- a/tests/test_container_launcher.py +++ b/tests/test_container_launcher.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import shutil import stat import subprocess from tempfile import TemporaryDirectory @@ -10,6 +11,18 @@ class ContainerLauncherTests(unittest.TestCase): + @staticmethod + def write_fake_runtime(root: Path) -> tuple[Path, Path]: + binary = root / "bin" + binary.mkdir() + docker = binary / "docker" + docker.write_text( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >\"$DOCKER_ARGS\"\n", + encoding="utf-8", + ) + docker.chmod(docker.stat().st_mode | stat.S_IXUSR) + return binary, root / "docker-args" + def test_named_resource_is_mounted_and_forwarded(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -18,15 +31,7 @@ def test_named_resource_is_mounted_and_forwarded(self): manifest.write_text("stand: {}\n", encoding="utf-8") resource = root / "demo" / "resources" resource.mkdir(parents=True) - binary = root / "bin" - binary.mkdir() - docker = binary / "docker" - docker.write_text( - "#!/bin/sh\nprintf '%s\\n' \"$@\" >\"$DOCKER_ARGS\"\n", - encoding="utf-8", - ) - docker.chmod(docker.stat().st_mode | stat.S_IXUSR) - arguments = root / "docker-args" + binary, arguments = self.write_fake_runtime(root) result = subprocess.run( [ @@ -63,6 +68,173 @@ def test_named_resource_is_mounted_and_forwarded(self): ) self.assertEqual(argv[-2:], ["create", "/workspace/demo/stand/stand.yml"]) + def test_multiple_env_files_are_forwarded_in_order(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "stand.yml" + manifest.write_text("stand: {}\n", encoding="utf-8") + common_env = root / "common.env" + common_env.write_text("VALUE=common\n", encoding="utf-8") + stand_env = root / "stand settings.env" + stand_env.write_text("VALUE=stand\n", encoding="utf-8") + binary, arguments = self.write_fake_runtime(root) + + result = subprocess.run( + [ + "bash", + str(LAUNCHER), + "--runtime", + "docker", + "--env-file", + str(common_env), + "--env-file", + str(stand_env), + "create", + str(manifest), + ], + cwd=root, + text=True, + capture_output=True, + env={ + **os.environ, + "PATH": f"{binary}:{os.environ['PATH']}", + "DOCKER_ARGS": str(arguments), + }, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + argv = arguments.read_text(encoding="utf-8").splitlines() + env_file_indices = [ + index for index, argument in enumerate(argv) if argument == "--env-file" + ] + self.assertEqual(len(env_file_indices), 2) + self.assertEqual( + [argv[index + 1] for index in env_file_indices], + [str(common_env.resolve()), str(stand_env.resolve())], + ) + first_managed_env = argv.index("--env") + self.assertGreater(first_managed_env, env_file_indices[-1]) + + def test_single_env_file_remains_supported(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "stand.yml" + manifest.write_text("stand: {}\n", encoding="utf-8") + env_file = root / "stand.env" + env_file.write_text("VALUE=stand\n", encoding="utf-8") + binary, arguments = self.write_fake_runtime(root) + + result = subprocess.run( + [ + "bash", + str(LAUNCHER), + "--runtime", + "docker", + "--env-file", + str(env_file), + "create", + str(manifest), + ], + cwd=root, + text=True, + capture_output=True, + env={ + **os.environ, + "PATH": f"{binary}:{os.environ['PATH']}", + "DOCKER_ARGS": str(arguments), + }, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + argv = arguments.read_text(encoding="utf-8").splitlines() + env_file_index = argv.index("--env-file") + self.assertEqual(argv[env_file_index + 1], str(env_file.resolve())) + self.assertEqual(argv.count("--env-file"), 1) + + def test_missing_env_file_fails_before_runtime_is_started(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "stand.yml" + manifest.write_text("stand: {}\n", encoding="utf-8") + existing_env = root / "common.env" + existing_env.write_text("VALUE=common\n", encoding="utf-8") + missing_env = root / "missing.env" + binary, arguments = self.write_fake_runtime(root) + + result = subprocess.run( + [ + "bash", + str(LAUNCHER), + "--runtime", + "docker", + "--env-file", + str(existing_env), + "--env-file", + str(missing_env), + "create", + str(manifest), + ], + cwd=root, + text=True, + capture_output=True, + env={ + **os.environ, + "PATH": f"{binary}:{os.environ['PATH']}", + "DOCKER_ARGS": str(arguments), + }, + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn(f"Environment file does not exist: {missing_env}", result.stderr) + self.assertFalse(arguments.exists()) + + @unittest.skipUnless(shutil.which("pwsh"), "pwsh is not installed") + def test_powershell_multiple_env_files_are_forwarded_in_order(self): + with TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "stand.yml" + manifest.write_text("stand: {}\n", encoding="utf-8") + common_env = root / "common.env" + common_env.write_text("VALUE=common\n", encoding="utf-8") + stand_env = root / "stand.env" + stand_env.write_text("VALUE=stand\n", encoding="utf-8") + binary, arguments = self.write_fake_runtime(root) + launcher = LAUNCHER.with_suffix(".ps1") + + def ps_quote(path: Path) -> str: + return str(path).replace("'", "''") + + command = ( + f"& '{ps_quote(launcher)}' create '{ps_quote(manifest)}' " + f"-Runtime docker -EnvFile @('{ps_quote(common_env)}'," + f"'{ps_quote(stand_env)}')" + ) + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", command], + cwd=root, + text=True, + capture_output=True, + env={ + **os.environ, + "PATH": f"{binary}:{os.environ['PATH']}", + "DOCKER_ARGS": str(arguments), + }, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + argv = arguments.read_text(encoding="utf-8").splitlines() + env_file_indices = [ + index for index, argument in enumerate(argv) if argument == "--env-file" + ] + self.assertEqual( + [argv[index + 1] for index in env_file_indices], + [str(common_env.resolve()), str(stand_env.resolve())], + ) + if __name__ == "__main__": unittest.main() From 85a426fb5e58ff051008cb9d53ba5ee269f5debc Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 21:31:40 +0200 Subject: [PATCH 05/10] add `validate` operation to CLI, introduce preflight validation for stands --- README.md | 7 +++- StandFramework/stand/stand.py | 70 ++++++++++++++++---------------- docs/application-manifest.md | 14 +++---- docs/operations.md | 41 +++++++++++++------ docs/stand-manifest.md | 15 ++++--- main.py | 12 +++++- stands-engine | 38 ++++++++++------- stands-engine.ps1 | 34 ++++++++++------ tests/test_cli.py | 48 ++++++++++++++++++++++ tests/test_container_launcher.py | 34 ++++++++++++++++ 10 files changed, 222 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index dc2ae00..211e43d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ uv sync ```bash uv run stands-engine --help +uv run stands-engine \ + --resource project-assets=demo/resources \ + validate demo/stand/stand.yml uv run stands-engine \ --resource project-assets=demo/resources \ create demo/stand/stand.yml @@ -296,7 +299,7 @@ python main.py destroy demo/stand/stand.yml CLI сейчас намеренно небольшой: ```bash -python main.py [--resource NAME=PATH] +python main.py [--resource NAME=PATH] ``` ## Манифест стенда @@ -466,7 +469,7 @@ import json {"id_stand":"owner_demo_test","operation":"destroy","status":"success"} ``` -Во время `create`/`destroy` stdout зарезервирован для NDJSON-результатов. Диагностика Pulumi, PyInfra и сообщения об ошибках отправляются в stderr. Коды завершения: `0` — успех, `1` — ошибка конфигурации или выполнения, `2` — неверный CLI-вызов, `130` — прерывание `Ctrl+C`. При ошибке stdout остаётся пустым. +Во время `validate`/`create`/`destroy` stdout зарезервирован для NDJSON-результатов. Успешный `validate` печатает `{"operation":"validate","status":"success"}`. Диагностика preflight, Pulumi, PyInfra и сообщения об ошибках отправляются в stderr. Коды завершения: `0` — успех, `1` — ошибка конфигурации или выполнения, `2` — неверный CLI-вызов, `130` — прерывание `Ctrl+C`. При ошибке stdout остаётся пустым. Mako-шаблоны получают контекст: diff --git a/StandFramework/stand/stand.py b/StandFramework/stand/stand.py index 30c35b1..da204d7 100644 --- a/StandFramework/stand/stand.py +++ b/StandFramework/stand/stand.py @@ -73,7 +73,7 @@ class Stand: state: StandState path_folder_configset: Path - provision: MetalProvision = field(init=False) + provision: MetalProvision | None = field(init=False, default=None) void_provision: Callable[[], None] = field(init=False) inventory: dict[str, list[str]] = field(default_factory=dict, init=False) node_groups: dict[str, str] = field(default_factory=dict, init=False) @@ -87,6 +87,7 @@ class Stand: instance_apps: dict[str, InstanceApp] = field(default_factory=dict, init=False) _registries: dict[str, ImageRegistry] = field(default_factory=dict, init=False) _connection_templates: dict[str, Template] = field(default_factory=dict, init=False) + _preflight_validated: bool = field(default=False, init=False) output_console: bool = True output_console_secrets: bool = False @@ -99,21 +100,7 @@ def __post_init__(self, private_key: str, key_name_admin: str, clusters: list[Cl self.instance_apps = {} self.key = Keys(private=private_key) - if self.backend is None: - self.backend = ConfigBackend() - - self.provision = MetalProvision( - s3_bucket=self.backend.s3.bucket, - s3_region=self.backend.s3.region, - s3_endpoint=self.backend.s3.endpoint, - passphrase=self.state.passphrase, - s3_access_key=self.backend.s3.access_key, - s3_secret_key=self.backend.s3.secret_key, - stand_name=self.state.env, - project_name=self.state.project, - user_name=self.state.owner, - provider_token=self.backend.hcloud.token, - ) + self.provision = None self.clusters_app = {cluster.name: cluster for cluster in clusters} @@ -125,19 +112,6 @@ def __post_init__(self, private_key: str, key_name_admin: str, clusters: list[Cl self._registries[registry.url] = registry - if cluster.connection_template is not None: - template_path = cluster.connection_template - if not template_path.is_file(): - raise ValueError( - f"Connection template for app {cluster_name!r} does not exist: {template_path}" - ) - try: - self._connection_templates[cluster_name] = Template(filename=str(template_path)) - except Exception as exc: - raise ValueError( - f"Invalid connection template for app {cluster_name!r}: {template_path}: {exc}" - ) from exc - for instance in cluster.instances_app: self.instance_apps[instance.name] = InstanceApp( app = instance, @@ -172,6 +146,25 @@ def __post_init__(self, private_key: str, key_name_admin: str, clusters: list[Cl self.void_provision = designer.get_program(servers_for_provision) + def ensure_provision(self) -> MetalProvision: + if self.provision is not None: + return self.provision + if self.backend is None: + self.backend = ConfigBackend() + self.provision = MetalProvision( + s3_bucket=self.backend.s3.bucket, + s3_region=self.backend.s3.region, + s3_endpoint=self.backend.s3.endpoint, + passphrase=self.state.passphrase, + s3_access_key=self.backend.s3.access_key, + s3_secret_key=self.backend.s3.secret_key, + stand_name=self.state.env, + project_name=self.state.project, + user_name=self.state.owner, + provider_token=self.backend.hcloud.token, + ) + return self.provision + def build_node_labels(self, node: Node) -> dict[str, str]: labels = { "stand_name": self.sanitize_label_value(self.state.env), @@ -195,10 +188,10 @@ def sanitize_label_key(cls, value: str) -> str: def destroy(self) -> None: - self.provision.destroy(self.void_provision) + self.ensure_provision().destroy(self.void_provision) def create_servers(self) -> None: - result = self.provision.create(self.void_provision) + result = self.ensure_provision().create(self.void_provision) for name, node in self.nodes.items(): public_ip = result.outputs.get(f"server_{name}_public_ip") @@ -318,7 +311,10 @@ def render_connection(self, cluster: ClusterApp) -> dict: raise ValueError(f"App {cluster.name!r} has no connection_instance") instance = self.instance_apps[instance_name] - template = self._connection_templates[cluster.name] + template = self._connection_templates.get(cluster.name) + if template is None: + template = Template(filename=str(cluster.connection_template)) + self._connection_templates[cluster.name] = template try: rendered = template.render( node=instance.node, @@ -510,6 +506,12 @@ def validate_hook_sources(self) -> None: if instance.app.hook_path is not None: self._collect_hook_files(instance) + def validate_preflight(self) -> None: + from StandFramework.preflight import StandPreflightValidator + + StandPreflightValidator(self).validate() + self._preflight_validated = True + @staticmethod def _collect_hook_files(instance: InstanceApp) -> list[tuple[Path, Path, bool]]: hook_path = Path(instance.app.hook_path) @@ -577,8 +579,8 @@ def launch_apps(self) -> None: self.add_app_hook(instance) def up(self, diagnostic: bool | SShExecutorDiagnostArgs = False): - self.validate_result_contract() - self.validate_hook_sources() + if not getattr(self, "_preflight_validated", False): + self.validate_preflight() self.create_servers() self.render_deploy_configset() self.settings_runtime() diff --git a/docs/application-manifest.md b/docs/application-manifest.md index fc60fbf..c4bd6eb 100644 --- a/docs/application-manifest.md +++ b/docs/application-manifest.md @@ -446,14 +446,12 @@ Resource задаётся при запуске через До реального стенда: -1. Отрендерите каждый Mako-шаблон с тестовыми `node`, `instance`, `role`, - `cluster`, `apps`. -2. Разберите pod через `yaml.safe_load`. -3. Проверьте image, имена, resources, volumes и ports. -4. Разберите connection через `json.loads` и проверьте контракт. -5. Проверьте hook повторным выполнением. -6. Подключите приложение к минимальному тестовому `stand.yml` и выполните - статическую проверку из [stand guide](stand-manifest.md#проверка-манифеста). +1. Подключите приложение к минимальному тестовому `stand.yml` и выполните + `stands-engine validate` из [stand guide](stand-manifest.md#проверка-манифеста). +2. Проверьте в отрендерованном Pod image, имена, resources, volumes и ports, + специфичные для приложения. +3. Отдельно протестируйте фактическое выполнение hook и поведение приложения — + локальный preflight проверяет Mako render, но не исполняет shell-команды. Подход к unit-тесту рендеринга показан в [`tests/test_app_resources.py`](../tests/test_app_resources.py). diff --git a/docs/operations.md b/docs/operations.md index 1127dae..bc3cd31 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -240,21 +240,35 @@ credentials. Структурные secrets остаются обязатель `!secret` не шифрует configsets и connection files. Не публикуйте их в Git, логи или незащищённые CI artifacts. -## 6. Статическая проверка +## 6. Локальная проверка до provision -До provision выполните полный parser: +Перед каждым `create` движок автоматически выполняет локальный preflight. Ту же +проверку можно запустить отдельно для разработки и CI: ```bash set -a source dev.env set +a -uv run python -c \ - 'from pathlib import Path; from ManifestParser import parse_manifest; parse_manifest(Path("demo/stand/stand.yml"), resource_roots={"project-assets": Path("demo/resources")}); print("manifest: OK")' +uv run stands-engine \ + --resource project-assets=demo/resources \ + validate demo/stand/stand.yml +``` + +Команда требует те же manifest secrets, что и `create`, но не требует Hetzner и +S3 credentials. Она не создаёт ресурсы, SSH keys, configsets или connection +files. При успехе stdout содержит одну NDJSON-запись: + +```json +{"operation":"validate","status":"success"} ``` -Команда не создаёт ресурсы. Она проверяет YAML, dependencies, secrets, связи и -нормализует пути. +Preflight проверяет manifest dependencies и связи, наличие файлов, Mako-render +cloud-init/application/hook/connection templates, структуру cloud-init и Pod +YAML, connection JSON contract, upload destinations/modes и конфликты hostPort. +Для рендера используются зарезервированные тестовые IP; реальные manifest +preferences и secrets сохраняются, поэтому обнаруживаются ошибки экранирования. +Независимые ошибки собираются в один отчёт на stderr без вывода secret values. Дополнительно вручную проверьте: @@ -265,8 +279,9 @@ uv run python -c \ - доступность registries; - возможность записи key/configset/output paths. -Проект пока не предоставляет `preview` или `validate` через CLI, хотя внутренний -provision layer содержит Pulumi preview. +Проверка является локальной: доступность Hetzner network/image/server type/SSH +key, S3 backend, registry и container images не проверяется. Pulumi preview не +запускается. ## 7. Запуск @@ -286,7 +301,7 @@ python main.py --resource project-assets=demo/resources create demo/stand/stand. CLI принимает только: ```text -stands-engine [--resource NAME=PATH] +stands-engine [--resource NAME=PATH] ``` ### Через container launcher @@ -338,8 +353,8 @@ PowerShell: 1. Загрузка внешней конфигурации. 2. Parsing manifest, dependencies и secrets. -3. Validation и сборка модели; разворачивание agents. -4. Выбор/создание Pulumi stack в S3 backend. +3. Validation, сборка модели, разворачивание agents и полный локальный preflight. +4. Только после успешного preflight — выбор/создание Pulumi stack в S3 backend. 5. Создание Hetzner servers, attachment к network, cloud-init и labels. 6. Получение public/private IP и подготовка SSH inventory. 7. Локальный рендеринг templates и hook assets. @@ -470,7 +485,7 @@ stderr и не смешиваются с NDJSON в stdout. При ошибке - путь/расширение manifest; - `from_dep_manifest` и локальные ресурсы; - список отсутствующих `SECRET_*`; -- статический parser. +- отчёт `stands-engine validate`. ### Pulumi/S3 @@ -507,7 +522,7 @@ ss -ltn ## Checklist перед `create` -- [ ] Manifest прошёл статический parser. +- [ ] `stands-engine validate` завершился успешно с актуальными secrets. - [ ] Проверено количество и стоимость Hetzner servers. - [ ] Token, network, SSH key, locations, images и server types существуют. - [ ] S3 backend доступен и сохранены identity/passphrase. diff --git a/docs/stand-manifest.md b/docs/stand-manifest.md index 1f457e2..7dbf768 100644 --- a/docs/stand-manifest.md +++ b/docs/stand-manifest.md @@ -424,19 +424,22 @@ names, credentials и размеры серверов. ## Проверка манифеста -Отдельной CLI-команды `validate` пока нет. Выполните parser напрямую: +Для полной локальной проверки выполните: ```bash set -a source dev.env set +a -uv run python -c \ - 'from pathlib import Path; from ManifestParser import parse_manifest; parse_manifest(Path("demo/stand/stand.yml"), resource_roots={"project-assets": Path("demo/resources")}); print("manifest: OK")' +uv run stands-engine \ + --resource project-assets=demo/resources \ + validate demo/stand/stand.yml ``` -Это раскрывает dependencies, разрешает secrets, нормализует пути и проверяет -связи, но не создаёт облачные ресурсы. +Команда раскрывает dependencies, разрешает secrets, нормализует пути, проверяет +связи и локально рендерит cloud-init, app, hook и connection Mako templates. Она +не создаёт облачные ресурсы, ключи или configsets и не требует Hetzner/S3 +credentials. Валидатор проверяет: @@ -448,6 +451,8 @@ uv run python -c \ - глобальную уникальность инстансов; - profiles и размещение; - agents и конфликты генерируемых имён; +- существование templates, Mako render и структуру результирующих YAML/JSON; +- upload paths/modes и конфликты hostPort на одном узле; - обязательные secrets. После проверки YAML отдельно убедитесь, что provider resources реально diff --git a/main.py b/main.py index 07b1eee..6e494c1 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,7 @@ def application_version() -> str: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="stands-engine", - description="Create or destroy an infrastructure stand from a YAML manifest.", + description="Validate, create, or destroy an infrastructure stand from a YAML manifest.", ) parser.add_argument("--version", action="version", version=f"%(prog)s {application_version()}") parser.add_argument( @@ -32,7 +32,7 @@ def build_parser() -> argparse.ArgumentParser: metavar="NAME=PATH", help="named local directory available to hook assets (repeatable)", ) - parser.add_argument("operation", choices=("create", "destroy")) + parser.add_argument("operation", choices=("validate", "create", "destroy")) parser.add_argument("manifest", type=Path, help="path to the stand YAML manifest") return parser @@ -72,6 +72,7 @@ def load_private_key(path_to_key: Path) -> str: def main(argv: list[str]) -> int: args = build_parser().parse_args(argv[1:]) is_destroy = args.operation == "destroy" + is_validate = args.operation == "validate" path_to_stand_manifest = args.manifest try: @@ -86,11 +87,18 @@ def main(argv: list[str]) -> int: ) stand = build_stand(stand_data, config, private_key=load_private_key(path_to_key)) + if is_validate: + stand.validate_preflight() + print(stand.result_ndjson({"operation": "validate", "status": "success"}), end="") + return 0 + if is_destroy: stand.destroy() stand.output_destroy_result() return 0 + stand.validate_preflight() + if not path_to_key.exists(): with open(path_to_key, "w") as f: f.write(stand.key.private) diff --git a/stands-engine b/stands-engine index 248397d..b5f455e 100755 --- a/stands-engine +++ b/stands-engine @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: ./stands-engine [options] +Usage: ./stands-engine [options] Options: --runtime Container runtime (auto-detected by default) @@ -71,7 +71,7 @@ while (($#)); do usage exit 0 ;; - create|destroy) + validate|create|destroy) if [[ -n "${operation}" ]]; then echo "Operation was specified more than once." >&2 usage >&2 @@ -144,9 +144,6 @@ case "${manifest_dir}" in ;; esac -data_dir="${cwd}/.stands-engine" -mkdir -p "${data_dir}/keys" "${data_dir}/configsets" "${data_dir}/output" - run_args=(run --rm) if [[ -t 0 && -t 1 ]]; then run_args+=(-it) @@ -171,19 +168,32 @@ for ((index=0; index Date: Fri, 31 Jul 2026 21:32:05 +0200 Subject: [PATCH 06/10] add `validate` operation to CLI, introduce preflight validation for stands --- StandFramework/preflight.py | 317 ++++++++++++++++++++++++++++++++++++ tests/test_preflight.py | 171 +++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 StandFramework/preflight.py create mode 100644 tests/test_preflight.py diff --git a/StandFramework/preflight.py b/StandFramework/preflight.py new file mode 100644 index 0000000..5a4e8b8 --- /dev/null +++ b/StandFramework/preflight.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +from typing import TYPE_CHECKING, Any + +from mako.template import Template +import yaml + +from InfraBaseLib.SShExecutor.uploder import UploadFilesCollector +from InfraBaseLib.helpers.cloud_init import CloudInit + +if TYPE_CHECKING: + from StandFramework.stand.stand import InstanceApp, Stand + + +MODE_PATTERN = re.compile(r"^[0-7]{3,4}$") +OWNER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*[$]?$") +PLACEHOLDER_SSH_PUBLIC_KEY = ( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKf7nE4A2Xqv1S8U0X7P9mHq0Wz6bV3tR5cY2aL8dF1 " + "stands-engine-preflight" +) + + +@dataclass(frozen=True) +class PreflightIssue: + subject: str + message: str + + +class PreflightValidationError(ValueError): + def __init__(self, issues: list[PreflightIssue]): + self.issues = issues + lines = [f"Local preflight found {len(issues)} error(s):"] + lines.extend( + f" {index}. {issue.subject}: {issue.message}" + for index, issue in enumerate(issues, start=1) + ) + super().__init__("\n".join(lines)) + + +class StandPreflightValidator: + """Validate all locally knowable deployment inputs without writing files.""" + + def __init__(self, stand: Stand): + self.stand = stand + self.issues: list[PreflightIssue] = [] + self._secret_values = self._collect_sensitive_values() + self._host_ports: dict[tuple[int, int, str], str] = {} + self._remote_destinations: dict[tuple[int, str], str] = {} + + def validate(self) -> None: + original_addresses = self._install_placeholder_addresses() + try: + self._validate_result_contract() + self._validate_cloud_init_templates() + self._validate_app_templates() + self._validate_hooks() + self._validate_connections() + finally: + self._restore_addresses(original_addresses) + + if self.issues: + raise PreflightValidationError(self.issues) + + def _add(self, subject: str, message: str | Exception) -> None: + rendered = str(message) + for secret in self._secret_values: + rendered = rendered.replace(secret, "***") + self.issues.append(PreflightIssue(subject, rendered)) + + def _collect_sensitive_values(self) -> list[str]: + values: set[str] = set() + + def collect(value: Any) -> None: + if isinstance(value, dict): + for item in value.values(): + collect(item) + elif isinstance(value, (list, tuple)): + for item in value: + collect(item) + elif isinstance(value, str) and value: + values.add(value) + + for cluster in self.stand.clusters_app.values(): + collect(cluster.preferences) + registry = cluster.image.registry + collect({"username": registry.username, "password": registry.password}) + for app in cluster.instances_app: + collect(app.preferences) + collect(app.role.preferences) + + return sorted(values, key=len, reverse=True) + + def _install_placeholder_addresses(self) -> dict[int, tuple[bool, Any, bool, Any]]: + originals = {} + for index, node in enumerate(self.stand.nodes.values(), start=1): + had_private = hasattr(node, "private_ip") + had_public = hasattr(node, "public_ip") + originals[id(node)] = ( + had_private, + getattr(node, "private_ip", None), + had_public, + getattr(node, "public_ip", None), + ) + third_octet, fourth_octet = divmod(index - 1, 254) + node.private_ip = f"10.255.{third_octet % 256}.{fourth_octet + 1}" + node.public_ip = f"192.0.2.{fourth_octet + 1}" + return originals + + def _restore_addresses(self, originals: dict[int, tuple[bool, Any, bool, Any]]) -> None: + for node in self.stand.nodes.values(): + had_private, private, had_public, public = originals[id(node)] + if had_private: + node.private_ip = private + else: + del node.private_ip + if had_public: + node.public_ip = public + else: + del node.public_ip + + def _validate_result_contract(self) -> None: + try: + self.stand.validate_result_contract() + except Exception as exc: + self._add("result output", exc) + + def _validate_cloud_init_templates(self) -> None: + seen: set[Path] = set() + for node_name, node in self.stand.nodes.items(): + path = Path(node.cloud_init_template) + if path in seen: + continue + seen.add(path) + subject = f"cloud-init for node {node_name!r} ({path})" + if not path.is_file(): + self._add(subject, "template file does not exist") + continue + try: + rendered = CloudInit.render( + user_admin=self.stand.sudo_user, + ssh_public_key=PLACEHOLDER_SSH_PUBLIC_KEY, + user_app=self.stand.app_user, + template_path=path, + network_ip_range="10.255.0.0/16", + ) + except Exception as exc: + self._add(subject, f"Mako render failed: {type(exc).__name__}: {exc}") + continue + try: + document = yaml.safe_load(rendered) + if not isinstance(document, dict) or not document: + raise ValueError("rendered cloud-init must be a non-empty YAML mapping") + except yaml.YAMLError as exc: + self._add(subject, self._yaml_error(exc)) + except ValueError as exc: + self._add(subject, exc) + + def _validate_app_templates(self) -> None: + for cluster_name, cluster in self.stand.clusters_app.items(): + if "pod" not in cluster.paths_to_templates: + self._add(f"app {cluster_name!r}", "templates must contain required key 'pod'") + + destinations: dict[str, str] = {} + local_names: dict[str, str] = {} + for template_name, config in cluster.paths_to_templates.items(): + subject = f"app {cluster_name!r} template {template_name!r}" + self._validate_upload_metadata(subject, config.dest, config.owner, config.mode) + + previous = destinations.setdefault(config.dest, template_name) + if previous != template_name: + self._add(subject, f"destination {config.dest!r} is also used by template {previous!r}") + local_name = config.paths_to_templates.name.removesuffix(".mako") + previous = local_names.setdefault(local_name, template_name) + if previous != template_name: + self._add(subject, f"configset filename {local_name!r} is also produced by template {previous!r}") + + path = Path(config.paths_to_templates) + if not path.is_file(): + self._add(f"{subject} ({path})", "template file does not exist") + continue + + for app in cluster.instances_app: + instance = self.stand.instance_apps[app.name] + render_subject = f"{subject}, instance {app.name!r} ({path})" + destination_key = (id(instance.node), config.dest) + destination_owner = f"{cluster_name}/{app.name}/{template_name}" + previous_owner = self._remote_destinations.setdefault( + destination_key, destination_owner + ) + if previous_owner != destination_owner: + self._add( + render_subject, + f"destination {config.dest!r} conflicts on the same node with {previous_owner}", + ) + try: + rendered = self.stand.render_app_template(path, instance) + except Exception as exc: + self._add(render_subject, f"Mako render failed: {type(exc).__name__}: {exc}") + continue + if template_name == "pod": + self._validate_pod_yaml(render_subject, rendered, instance) + + def _validate_upload_metadata(self, subject: str, dest: str, owner: str, mode: str) -> None: + if not MODE_PATTERN.fullmatch(mode): + self._add(subject, f"mode {mode!r} must be a 3- or 4-digit octal value") + if not OWNER_PATTERN.fullmatch(owner): + self._add(subject, f"owner {owner!r} is not a valid local account name") + try: + UploadFilesCollector.home_relative_path(dest, f"/home/{self.stand.app_user}") + except ValueError as exc: + self._add(subject, exc) + + def _validate_pod_yaml(self, subject: str, rendered: str, instance: InstanceApp) -> None: + try: + documents = [document for document in yaml.safe_load_all(rendered) if document is not None] + except yaml.YAMLError as exc: + self._add(subject, self._yaml_error(exc)) + return + if not documents: + self._add(subject, "rendered pod template contains no YAML documents") + return + + pods = [] + for index, document in enumerate(documents, start=1): + if not isinstance(document, dict): + self._add(subject, f"YAML document {index} must be a mapping") + continue + for field in ("apiVersion", "kind"): + if not isinstance(document.get(field), str) or not document[field]: + self._add(subject, f"YAML document {index}.{field} must be a non-empty string") + metadata = document.get("metadata") + if not isinstance(metadata, dict) or not isinstance(metadata.get("name"), str) or not metadata["name"]: + self._add(subject, f"YAML document {index}.metadata.name must be a non-empty string") + if document.get("kind") == "Pod": + pods.append(document) + + if not pods: + self._add(subject, "rendered pod template must contain at least one Pod document") + return + for pod in pods: + self._record_host_ports(subject, pod, instance) + + def _record_host_ports(self, subject: str, pod: dict, instance: InstanceApp) -> None: + spec = pod.get("spec") + if not isinstance(spec, dict): + self._add(subject, "Pod.spec must be a mapping") + return + containers = spec.get("containers") + if not isinstance(containers, list) or not containers: + self._add(subject, "Pod.spec.containers must be a non-empty list") + return + for container in containers: + if not isinstance(container, dict): + continue + for port in container.get("ports", []) or []: + if not isinstance(port, dict) or "hostPort" not in port: + continue + host_port = port["hostPort"] + if type(host_port) is not int or not 1 <= host_port <= 65535: + self._add(subject, f"hostPort must be an integer between 1 and 65535, got {host_port!r}") + continue + protocol = str(port.get("protocol", "TCP")).upper() + key = (id(instance.node), host_port, protocol) + current = f"{instance.cluster.name}/{instance.app.name}" + previous = self._host_ports.get(key) + if previous is None: + self._host_ports[key] = current + else: + self._add(subject, f"hostPort {host_port}/{protocol} conflicts on the same node with {previous}") + + def _validate_hooks(self) -> None: + for instance in self.stand.instance_apps.values(): + if instance.app.hook_path is None: + continue + subject = f"hook for {instance.cluster.name}/{instance.app.name}" + try: + files = self.stand._collect_hook_files(instance) + except Exception as exc: + self._add(subject, exc) + continue + for path, relative_path, is_mako in files: + if not is_mako: + continue + try: + self.stand.render_app_template(path, instance) + except Exception as exc: + self._add( + f"{subject} file {relative_path.as_posix()!r} ({path})", + f"Mako render failed: {type(exc).__name__}: {exc}", + ) + + def _validate_connections(self) -> None: + for cluster_name, cluster in self.stand.clusters_app.items(): + path = cluster.connection_template + if path is None: + continue + path = Path(path) + subject = f"connection for app {cluster_name!r} ({path})" + if not path.is_file(): + self._add(subject, "template file does not exist") + continue + try: + self.stand._connection_templates[cluster_name] = Template(filename=str(path)) + self.stand.render_connection(cluster) + except Exception as exc: + self._add(subject, f"validation failed: {type(exc).__name__}: {exc}") + + @staticmethod + def _yaml_error(exc: yaml.YAMLError) -> str: + mark = getattr(exc, "problem_mark", None) + problem = getattr(exc, "problem", None) or type(exc).__name__ + if mark is None: + return f"invalid YAML: {problem}" + return f"invalid YAML at line {mark.line + 1}, column {mark.column + 1}: {problem}" diff --git a/tests/test_preflight.py b/tests/test_preflight.py new file mode 100644 index 0000000..3c584e2 --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,171 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import unittest + +from StandBuilder import build_stand +from StandFramework.preflight import PreflightValidationError + + +POD_TEMPLATE = """apiVersion: v1 +kind: Pod +metadata: + name: ${instance.name} +spec: + containers: + - name: ${instance.name} + image: ${cluster.image.full_name} + ports: + - containerPort: 8080 + hostPort: 8080 + hostIP: ${node.private_ip} +""" + +CLOUD_INIT_TEMPLATE = """#cloud-config +users: + - name: ${user_admin} +network_range: ${network_ip_range} +""" + +CONNECTION_TEMPLATE = """<%! import json %>{ + "endpoint": ${json.dumps(node.private_ip)}, + "port": 8080, + "credentials": {"user": "admin", "password": ${json.dumps(cluster.preferences.password)}} +} +""" + + +class PreflightTests(unittest.TestCase): + def make_stand(self, root: Path, *, second_instance: bool = False): + cloud_init = root / "cloud-init.yml.mako" + pod = root / "pod.yml.mako" + connection = root / "connection.json.mako" + cloud_init.write_text(CLOUD_INIT_TEMPLATE, encoding="utf-8") + pod.write_text(POD_TEMPLATE, encoding="utf-8") + connection.write_text(CONNECTION_TEMPLATE, encoding="utf-8") + + instances = { + "web-1": { + "role": "web", + "cpu": 100, + "ram": 128, + "preferences": {}, + } + } + node_apps = ["web-1"] + if second_instance: + instances["web-2"] = { + "role": "web", + "cpu": 100, + "ram": 128, + "preferences": {}, + } + node_apps.append("web-2") + + data = { + "stand": { + "project": "demo", + "env": "test", + "users": {"sudo": "admin", "app": "app"}, + "ssh": {"key_name_admin": "admin-key"}, + }, + "registries": {"local": {"url": "registry.example.test"}}, + "apps": { + "web": { + "name": "web", + "image": {"registry": "local", "path": "web", "version": "1"}, + "roles": {"web": {"ports": []}}, + "templates": { + "pod": { + "path": str(pod), + "dest": "/home/app/web.yml", + "owner": "app", + "mode": "644", + } + }, + "connection": str(connection), + "connection_instance": "web-1", + "preferences": {"password": "top-secret-value"}, + "instances": instances, + } + }, + "node_profiles": { + "default": { + "location": "hel1", + "type_serv": "cpx11", + "image": "rocky-10", + "network": "test-network", + "cloud-init": str(cloud_init), + } + }, + "nodes": {"node-1": {"apps": node_apps}}, + } + config = SimpleNamespace( + stand=SimpleNamespace( + user="owner", + passphrase="unused-by-local-validation", + path_to_key=root / "key", + path_to_configset=root / "configsets", + ), + output=SimpleNamespace( + console=True, + console_secrets=False, + file=False, + file_path=None, + ), + ) + return build_stand(data, config) + + def test_success_does_not_initialize_cloud_backend_or_leave_placeholder_ips(self): + with TemporaryDirectory() as directory: + stand = self.make_stand(Path(directory)) + + stand.validate_preflight() + + self.assertIsNone(stand.backend) + self.assertIsNone(stand.provision) + self.assertTrue(stand._preflight_validated) + self.assertFalse(hasattr(stand.nodes["node-1"], "private_ip")) + + def test_collects_independent_template_errors_and_redacts_preferences(self): + with TemporaryDirectory() as directory: + root = Path(directory) + stand = self.make_stand(root) + (root / "cloud-init.yml.mako").write_text("users: [\n", encoding="utf-8") + (root / "pod.yml.mako").write_text("${cluster.preferences.password}\n${missing}\n", encoding="utf-8") + (root / "connection.json.mako").write_text("not-json ${cluster.preferences.password}", encoding="utf-8") + + with self.assertRaises(PreflightValidationError) as raised: + stand.validate_preflight() + + message = str(raised.exception) + self.assertGreaterEqual(len(raised.exception.issues), 3) + self.assertIn("cloud-init", message) + self.assertIn("template 'pod'", message) + self.assertIn("connection", message) + self.assertNotIn("top-secret-value", message) + + def test_detects_host_port_conflict_on_same_node(self): + with TemporaryDirectory() as directory: + stand = self.make_stand(Path(directory), second_instance=True) + + with self.assertRaisesRegex(PreflightValidationError, "hostPort 8080/TCP conflicts"): + stand.validate_preflight() + + def test_rejects_upload_metadata_before_archive_build(self): + with TemporaryDirectory() as directory: + stand = self.make_stand(Path(directory)) + config = stand.clusters_app["web"].paths_to_templates["pod"] + config.mode = "99" + config.dest = "/etc/web.yml" + + with self.assertRaises(PreflightValidationError) as raised: + stand.validate_preflight() + + message = str(raised.exception) + self.assertIn("octal", message) + self.assertIn("must be under /home/app", message) + + +if __name__ == "__main__": + unittest.main() From a9de6e9c2b38a7868549682f98ed3ef3819ca44d Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 22:00:55 +0200 Subject: [PATCH 07/10] refactor configuration handling: introduce `load_settings` utility for better error formatting and replace direct --- README.md | 16 ++--- StandFramework/stand/stand.py | 3 +- config/errors.py | 128 ++++++++++++++++++++++++++++++++++ docs/operations.md | 10 +-- docs/stand-manifest.md | 2 +- main.py | 3 +- tests/test_cli.py | 23 ++++++ tests/test_config_errors.py | 124 ++++++++++++++++++++++++++++++++ 8 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 config/errors.py create mode 100644 tests/test_config_errors.py diff --git a/README.md b/README.md index 211e43d..aa81987 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,10 @@ podman build -f Containerfile -t stands-engine:local . ```bash ./stands-engine \ --env-file common.env \ - --env-file stands/dev.env \ + --env-file stands/devBack.env \ --resource project-assets=demo/resources \ create demo/stand/stand.yml -./stands-engine --env-file common.env --env-file stands/dev.env destroy demo/stand/stand.yml +./stands-engine --env-file common.env --env-file stands/devBack.env destroy demo/stand/stand.yml ``` `--env-file` можно повторять. Файлы применяются слева направо, поэтому значения @@ -132,7 +132,7 @@ podman build -f Containerfile -t stands-engine:local . ```bash ./stands-engine \ - --env-file dev.env \ + --env-file devBack.env \ --resource project-assets=/home/user/projects/payment-service/deploy/assets \ create demo/stand/stand.yml ``` @@ -160,7 +160,7 @@ uv run stands-engine \ ./stands-engine \ --runtime docker \ --image registry.example.com/stands-engine:0.1.0 \ - --env-file dev.env \ + --env-file devBack.env \ --resource project-assets=demo/resources \ create demo/stand/stand.yml ``` @@ -189,13 +189,13 @@ STAND__PATH_TO_CONFIGSET=/data/configsets OUTPUT__FILE_PATH=/data/output ``` -Сам `dev.env`, другие `*.env`, приватные ключи, `.git` и локальные результаты исключены из build context и не копируются в image. +Сам `devBack.env`, другие `*.env`, приватные ключи, `.git` и локальные результаты исключены из build context и не копируются в image. ### Запуск без launcher ```bash docker run --rm \ - --env-file dev.env \ + --env-file devBack.env \ -e STAND__PATH_TO_KEY=/data/keys/id_ed25519 \ -e STAND__PATH_TO_CONFIGSET=/data/configsets \ -e OUTPUT__FILE_PATH=/data/output \ @@ -268,7 +268,7 @@ export SECRET_REDIS_ADMIN_PASSWORD='change-me' ```bash set -a -source dev.env +source devBack.env set +a ``` @@ -282,7 +282,7 @@ Redis и MongoDB на трёх серверах и использует публ ```bash set -a -source dev.env +source devBack.env set +a python main.py \ diff --git a/StandFramework/stand/stand.py b/StandFramework/stand/stand.py index da204d7..83869e7 100644 --- a/StandFramework/stand/stand.py +++ b/StandFramework/stand/stand.py @@ -15,6 +15,7 @@ from ShellCollect import ShellCollect, Port, Image, ImageRegistry from App import ClusterApp, App from StandFramework import ConfigBackend, StandState +from config.errors import load_settings @dataclass(kw_only=True) @@ -150,7 +151,7 @@ def ensure_provision(self) -> MetalProvision: if self.provision is not None: return self.provision if self.backend is None: - self.backend = ConfigBackend() + self.backend = load_settings(ConfigBackend) self.provision = MetalProvision( s3_bucket=self.backend.s3.bucket, s3_region=self.backend.s3.region, diff --git a/config/errors.py b/config/errors.py new file mode 100644 index 0000000..4e3a77b --- /dev/null +++ b/config/errors.py @@ -0,0 +1,128 @@ +import re +from typing import TypeVar + +from pydantic import BaseModel, ValidationError +from pydantic_settings import BaseSettings, SettingsError + + +SettingsT = TypeVar("SettingsT", bound=BaseSettings) +FIELD_NAME_PATTERN = re.compile(r'field "(?P[^"]+)"') +ENVIRONMENT_NAME_PATTERN = re.compile(r"\b[A-Z][A-Z0-9]*(?:__[A-Z0-9_]+)+\b") + + +class ConfigurationError(ValueError): + """A settings error formatted for command-line users.""" + + +def load_settings(settings_type: type[SettingsT]) -> SettingsT: + try: + return settings_type() + except (SettingsError, ValidationError) as exc: + raise ConfigurationError( + format_configuration_error(settings_type, exc) + ) from None + + +def format_configuration_error( + settings_type: type[BaseSettings], + exc: SettingsError | ValidationError, +) -> str: + if isinstance(exc, SettingsError): + match = FIELD_NAME_PATTERN.search(str(exc)) + field = match.group("field").upper() if match else "SETTINGS" + issues = [(field, "could not parse the nested setting")] + else: + issues = [] + for error in exc.errors( + include_url=False, + include_context=False, + include_input=False, + ): + location = tuple(str(part) for part in error["loc"]) + locations = [location] + if error["type"] == "missing": + expanded = _expand_required_fields(settings_type, location) + if expanded: + locations = expanded + + message = _format_issue_message(error["type"], error["msg"]) + explicit_name = ENVIRONMENT_NAME_PATTERN.search(message) + if explicit_name: + name = explicit_name.group(0) + issues.append((name, _remove_environment_name(message, name))) + else: + issues.extend((_environment_name(item), message) for item in locations) + + rendered = ["Configuration error:"] + rendered.extend(f"- {name}: {message}" for name, message in _deduplicate(issues)) + return "\n".join(rendered) + + +def _expand_required_fields( + settings_type: type[BaseSettings], + location: tuple[str, ...], +) -> list[tuple[str, ...]]: + model_type: type[BaseModel] = settings_type + field = None + for part in location: + field = model_type.model_fields.get(part) + if field is None: + return [] + annotation = field.annotation + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + model_type = annotation + + if field is None: + return [] + annotation = field.annotation + if not isinstance(annotation, type) or not issubclass(annotation, BaseModel): + return [] + + return _required_leaf_locations(annotation, location) + + +def _required_leaf_locations( + model_type: type[BaseModel], + prefix: tuple[str, ...], +) -> list[tuple[str, ...]]: + locations = [] + for name, field in model_type.model_fields.items(): + if not field.is_required(): + continue + annotation = field.annotation + location = (*prefix, name) + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + locations.extend(_required_leaf_locations(annotation, location)) + else: + locations.append(location) + return locations + + +def _format_issue_message(error_type: str, message: str) -> str: + if error_type == "missing": + return "required" + if error_type == "bool_parsing": + return "expected a valid boolean" + if error_type == "path_type": + return "expected a valid path" + if message.startswith("Value error, "): + return message.removeprefix("Value error, ") + if message.startswith("Input should be "): + return "expected " + message.removeprefix("Input should be ") + return message + + +def _environment_name(location: tuple[str, ...]) -> str: + return "__".join(part.upper() for part in location) + + +def _remove_environment_name(message: str, name: str) -> str: + if message.startswith(f"{name} is "): + return message.removeprefix(f"{name} is ") + if message.startswith(f"{name} "): + return message.removeprefix(f"{name} ") + return message + + +def _deduplicate(issues: list[tuple[str, str]]) -> list[tuple[str, str]]: + return list(dict.fromkeys(issues)) diff --git a/docs/operations.md b/docs/operations.md index bc3cd31..166e470 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -109,7 +109,7 @@ Pulumi project берётся из `stand.project`, stack — из `stand.env`. ```bash set -a -source dev.env +source devBack.env set +a ``` @@ -247,7 +247,7 @@ credentials. Структурные secrets остаются обязатель ```bash set -a -source dev.env +source devBack.env set +a uv run stands-engine \ @@ -309,10 +309,10 @@ stands-engine [--resource NAME=PATH] ```bash ./stands-engine \ --env-file common.env \ - --env-file stands/dev.env \ + --env-file stands/devBack.env \ --resource project-assets=demo/resources \ create demo/stand/stand.yml -./stands-engine --env-file common.env --env-file stands/dev.env destroy demo/stand/stand.yml +./stands-engine --env-file common.env --env-file stands/devBack.env destroy demo/stand/stand.yml ``` `--env-file` можно повторять: файлы загружаются слева направо, и значения из @@ -324,7 +324,7 @@ stands-engine [--resource NAME=PATH] ./stands-engine \ --runtime docker \ --image registry.example.test/stands-engine:0.1.0 \ - --env-file dev.env \ + --env-file devBack.env \ --resource project-assets=demo/resources \ create demo/stand/stand.yml ``` diff --git a/docs/stand-manifest.md b/docs/stand-manifest.md index 7dbf768..032fc8d 100644 --- a/docs/stand-manifest.md +++ b/docs/stand-manifest.md @@ -428,7 +428,7 @@ names, credentials и размеры серверов. ```bash set -a -source dev.env +source devBack.env set +a uv run stands-engine \ diff --git a/main.py b/main.py index 6e494c1..8a9b759 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ import re from config.config import Config +from config.errors import load_settings from ManifestParser import parse_manifest from StandBuilder import build_stand @@ -77,7 +78,7 @@ def main(argv: list[str]) -> int: try: resource_roots = parse_resource_roots(args.resource) - config = Config() + config = load_settings(Config) path_to_key = config.stand.path_to_key operation = "destroy" if is_destroy else "create" stand_data = parse_manifest( diff --git a/tests/test_cli.py b/tests/test_cli.py index e44ed13..4eb1f5e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ import unittest import main +from config.config import Config class CliTests(unittest.TestCase): @@ -70,6 +71,28 @@ def test_runtime_error_returns_1_and_only_writes_stderr(self): self.assertEqual(stdout.getvalue(), "") self.assertEqual(stderr.getvalue(), "configuration failed\n") + def test_configuration_validation_error_is_formatted_for_cli(self): + stdout = StringIO() + stderr = StringIO() + with ( + patch.dict("os.environ", {}, clear=True), + patch.object(main, "Config", Config), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + exit_code = main.main(["stands-engine", "validate", "stand.yml"]) + + self.assertEqual(exit_code, 1) + self.assertEqual(stdout.getvalue(), "") + self.assertEqual( + stderr.getvalue(), + "Configuration error:\n" + "- STAND__USER: required\n" + "- STAND__PASSPHRASE: required\n" + "- STAND__PATH_TO_KEY: required\n" + "- STAND__PATH_TO_CONFIGSET: required\n", + ) + def test_keyboard_interrupt_returns_130(self): stdout = StringIO() stderr = StringIO() diff --git a/tests/test_config_errors.py b/tests/test_config_errors.py new file mode 100644 index 0000000..023189d --- /dev/null +++ b/tests/test_config_errors.py @@ -0,0 +1,124 @@ +import os +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch +import unittest + +from config.config import Config +from config.errors import ConfigurationError, load_settings +from StandFramework.config.config import ConfigBackend + + +VALID_STAND_ENV = { + "STAND__USER": "owner", + "STAND__PASSPHRASE": "passphrase", + "STAND__PATH_TO_KEY": "/tmp/id_ed25519", + "STAND__PATH_TO_CONFIGSET": "/tmp/configsets", +} + + +class ConfigurationErrorTests(unittest.TestCase): + def test_missing_nested_section_expands_required_environment_variables(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(Config) + + self.assertEqual( + str(raised.exception), + "Configuration error:\n" + "- STAND__USER: required\n" + "- STAND__PASSPHRASE: required\n" + "- STAND__PATH_TO_KEY: required\n" + "- STAND__PATH_TO_CONFIGSET: required", + ) + + def test_partial_section_only_lists_missing_variables(self): + with patch.dict(os.environ, {"STAND__USER": "owner"}, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(Config) + + message = str(raised.exception) + self.assertNotIn("STAND__USER", message) + self.assertIn("- STAND__PASSPHRASE: required", message) + self.assertIn("- STAND__PATH_TO_KEY: required", message) + self.assertIn("- STAND__PATH_TO_CONFIGSET: required", message) + + def test_invalid_value_is_not_repeated_in_error_message(self): + invalid_value = "sensitive-invalid-value" + environment = { + **VALID_STAND_ENV, + "OUTPUT__CONSOLE": invalid_value, + } + with patch.dict(os.environ, environment, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(Config) + + self.assertEqual( + str(raised.exception), + "Configuration error:\n" + "- OUTPUT__CONSOLE: expected a valid boolean", + ) + self.assertNotIn(invalid_value, str(raised.exception)) + self.assertNotIn("errors.pydantic.dev", str(raised.exception)) + + def test_model_validator_keeps_actionable_environment_name(self): + environment = {**VALID_STAND_ENV, "OUTPUT__FILE": "true"} + with patch.dict(os.environ, environment, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(Config) + + self.assertEqual( + str(raised.exception), + "Configuration error:\n" + "- OUTPUT__FILE_PATH: required when OUTPUT__FILE=true", + ) + + def test_model_validator_reports_file_path_that_is_not_a_directory(self): + with TemporaryDirectory() as directory: + output_path = Path(directory) / "result.json" + output_path.touch() + environment = { + **VALID_STAND_ENV, + "OUTPUT__FILE": "true", + "OUTPUT__FILE_PATH": str(output_path), + } + with patch.dict(os.environ, environment, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(Config) + + self.assertEqual( + str(raised.exception), + "Configuration error:\n" + "- OUTPUT__FILE_PATH: must point to a directory", + ) + + def test_backend_sections_expand_without_exposing_values(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(ConfigBackend) + + self.assertEqual( + str(raised.exception), + "Configuration error:\n" + "- HCLOUD__TOKEN: required\n" + "- S3__ACCESS_KEY: required\n" + "- S3__SECRET_KEY: required\n" + "- S3__REGION: required\n" + "- S3__ENDPOINT: required\n" + "- S3__BUCKET: required", + ) + + def test_malformed_nested_setting_has_concise_error(self): + with patch.dict(os.environ, {"STAND": "not-json"}, clear=True): + with self.assertRaises(ConfigurationError) as raised: + load_settings(Config) + + self.assertEqual( + str(raised.exception), + "Configuration error:\n" + "- STAND: could not parse the nested setting", + ) + + +if __name__ == "__main__": + unittest.main() From 8c53d35b31ce90a05001bba584b3fa849e20b7b8 Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 22:13:01 +0200 Subject: [PATCH 08/10] update dependencies --- Containerfile | 4 +- pyproject.toml | 8 +- uv.lock | 242 ++++++++++++++++++++++++------------------------- 3 files changed, 126 insertions(+), 128 deletions(-) diff --git a/Containerfile b/Containerfile index 285ef23..bd24bff 100644 --- a/Containerfile +++ b/Containerfile @@ -28,8 +28,8 @@ RUN uv sync --locked --no-dev --no-editable FROM python:3.14-slim-bookworm AS runtime ARG TARGETARCH -ARG PULUMI_VERSION=3.253.0 -ARG PULUMI_HCLOUD_VERSION=1.39.1 +ARG PULUMI_VERSION=3.255.0 +ARG PULUMI_HCLOUD_VERSION=1.41.0 ENV PATH="/opt/stands-engine/.venv/bin:/usr/local/bin:${PATH}" \ PULUMI_HOME=/tmp/.pulumi \ diff --git a/pyproject.toml b/pyproject.toml index 036868d..4bacfa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,12 @@ requires-python = ">=3.14" dependencies = [ "mako==1.3.12", "paramiko==4.0.0", - "pulumi==3.253.0", - "pulumi-hcloud==1.39.1", + "pulumi==3.255.0", + "pulumi-hcloud==1.41.0", "pydantic==2.13.4", "pydantic-settings==2.14.2", "pyyaml==6.0.3", - "pyinfra==3.9.2", + "pyinfra==3.10.0", "python-box==7.4.1", ] @@ -20,7 +20,7 @@ dependencies = [ stands-engine = "main:cli" [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools>=83"] build-backend = "setuptools.build_meta" [tool.setuptools] diff --git a/uv.lock b/uv.lock index e3ef93b..40d22c5 100644 --- a/uv.lock +++ b/uv.lock @@ -4,11 +4,11 @@ requires-python = ">=3.14" [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -144,52 +144,52 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -225,7 +225,7 @@ wheels = [ [[package]] name = "gevent" -version = "26.5.0" +version = "26.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, @@ -233,26 +233,24 @@ dependencies = [ { name = "zope-event" }, { name = "zope-interface" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/cb/98aa3a299e2fc4a2372b5d124863e02965b64579ffc29fe54d0641e65b2f/gevent-26.5.0.tar.gz", hash = "sha256:1655eb04c1e20d71b2aa4a3c7528162dd58ff6cc46a037af1f01f534c80fefba", size = 6712354, upload-time = "2026-05-20T21:22:45.132Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/55/7d98d3888e7bb9ad4656420dec69232ecbbea48792aff9295d0ad7cf8435/gevent-26.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:75a0050e4b87f08ddee7e56f59e6014cd7fcdc3153046c09a847940515d12c85", size = 2968223, upload-time = "2026-05-20T20:13:17.223Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b4/e8e116fcbcb9dc0bf3acc50037f86e1204c217c8ed5defde68be11b3aab6/gevent-26.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:fd1a0b83a04e19378d9466ae0ee2b5937cf1d7fbfdcb916b2aea82179a208574", size = 1793926, upload-time = "2026-05-20T21:17:34.321Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/7b267e9754b661defb93542e97731a4df21f8a40dc0f6c853faa717cf124/gevent-26.5.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:4c964c15076e76391d523ec24202f579a2535f7e301a40efb1656ae046d3eb69", size = 1887632, upload-time = "2026-05-20T21:16:04.158Z" }, - { url = "https://files.pythonhosted.org/packages/5c/50/b47d29e99449bd13b557ffa451401dc13d397a9923f562ef90a4e8514502/gevent-26.5.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:45d5438d1c84da5df7e832434627624709543630977332bb4e2d05ecca362cc9", size = 1838688, upload-time = "2026-05-20T21:30:57.979Z" }, - { url = "https://files.pythonhosted.org/packages/8b/eb/5b54ccff11bc7d7bebd40a24571ccc115d5cdae4f6c32ab457b43b436e42/gevent-26.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:354f35924113abc954819216c2a6ee16751958c615681e0490946e31b437bd2f", size = 2120351, upload-time = "2026-05-20T20:35:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/70/30fd325c30e04b1e5174c61945e17421d53ddb2450366cc52cef234f8c4b/gevent-26.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a47cd2d32f6404212d374ad8014a3491d7477dcf0cc09c5a2308ad6d325fd663", size = 1806684, upload-time = "2026-05-20T21:16:43.87Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e8/fbf911ac3f9524ecfaed174d100fde671904ab8db92ceaf07faaebd13386/gevent-26.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:032157cebdedb84f2f52cdd980f2f5f2623eed6a8f083aadf44b44c47f628642", size = 2146606, upload-time = "2026-05-20T20:43:32.216Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4d/284fcbbfde66fd978c2980c1fbe0eabd586af6e4b728649e9cf459e8b38f/gevent-26.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c414935ba5fc88359110968851d3616f119082c937390d00a1c0f4f59be814f", size = 1722497, upload-time = "2026-05-20T20:16:44.274Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/9f66eb53434704402be0ba733bf3320bf589671a4b76fac52a7d6077e972/gevent-26.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:2a0f5993a04b95a35b3a118b1a58ba272833f9b547b774001dea29f90620882f", size = 1574249, upload-time = "2026-05-20T20:15:50.873Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d5/b4c50adb761878e3c96642b9f79bf44cee3120f3df55cd40876f51d89866/gevent-26.5.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2e117df896a2660c9ebd4e2b5afc02dfd6e2ddf9b495e787e67c72d105432b09", size = 2971993, upload-time = "2026-05-20T20:12:50.845Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/71c2a945e80198422d1d93dbe67355f249fb456b451bf9201199d3ef6a1a/gevent-26.5.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:af5ffe9c11ffb8a39b6bef2e8b722aa2043ae4980977915c6aa8c68b4bc26e46", size = 1796658, upload-time = "2026-05-20T21:17:35.968Z" }, - { url = "https://files.pythonhosted.org/packages/42/96/548ca77aed5cb9a44e855a6c23ebceeb3554a0ea9ca0c01c311878899a3e/gevent-26.5.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:7da34aef7e87c43dd3662e5785e79ed505c01399a7cb42876d2d8925969fd75f", size = 1891473, upload-time = "2026-05-20T21:16:05.657Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4f/f48bd47d5287afb0fbcc56165f3ed47583f1803bad401653fe27e71ade2d/gevent-26.5.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:1c6293a7046bcc6f3d8972a74b19cd7a4cfd02d3881edf0fcf827aa514bd247b", size = 1841429, upload-time = "2026-05-20T21:30:59.907Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/1925215fc720d2561fa3ec8d4af5f098f8d0cbfa76a45fafed6e5ade7718/gevent-26.5.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:d3bde0f140a275b2fa88e4b6516bda85551930e10bc2fd95e18c1b7d11cb780c", size = 2123895, upload-time = "2026-05-20T20:35:34.964Z" }, - { url = "https://files.pythonhosted.org/packages/83/59/0f584f6b1170c9a6abd9b70ccf5e9cc5ead34eabafabc0e21876ef0fe6f7/gevent-26.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e29fb4b17d9958ec8cb7f6339a111b29bc23f2c2efbef86189d1248bb4862d17", size = 1809047, upload-time = "2026-05-20T21:16:45.977Z" }, - { url = "https://files.pythonhosted.org/packages/82/88/61e854bfd98ac22eac78a97fc6db10de0f9ace46514072b435c217168729/gevent-26.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b2239df2f7570efa03736678f3f053bb1bdd22a8a16cd28a2feb7d32ea5f533f", size = 2150764, upload-time = "2026-05-20T20:43:33.781Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f5/af048b97433d7f9a7df7f5510b2c46918b7d073dcfb3bf6d0ef0e5a83dcc/gevent-26.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:aae214952fd38d27a42dc416bb70193962ec932384b63445d29bbb5817a1c042", size = 1722600, upload-time = "2026-05-20T20:19:56.81Z" }, - { url = "https://files.pythonhosted.org/packages/11/95/fb74a2299c6a2d78d9de12deaaac640ab5d2ef96a8e0f97a3ff84b9ca84b/gevent-26.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:f7067564f139e33bf26a31ee3b13d168d76eb99a44b85ced626652b158baa80c", size = 1574406, upload-time = "2026-05-20T20:17:12.125Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" }, + { url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" }, + { url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" }, + { url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" }, + { url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" }, ] [[package]] @@ -316,23 +314,23 @@ wheels = [ [[package]] name = "grpcio" -version = "1.82.1" +version = "1.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, - { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, ] [[package]] @@ -535,11 +533,11 @@ wheels = [ [[package]] name = "parver" -version = "1.0" +version = "1.0.1.post0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/24/6bf5a4462c04edeaed40c74b85bd4f481f634415fe8cdfece1ddabc042f1/parver-1.0.tar.gz", hash = "sha256:e59ad804a0d6ddd532033cb67c5bc26fdbfbfe3d06fe76f2bee48727e2fa5229", size = 104491, upload-time = "2026-05-19T22:30:34.93Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/63/fe1b2d9f19ce47e4a0da6d33ea19a20d7bd1632da432d8e32b20724c741a/parver-1.0.1.post0.tar.gz", hash = "sha256:1bfb9c3f13d5daccaa10eb605679b5dbd4bf2e15b4f5fa1bdf178c5c3f065a2c", size = 105980, upload-time = "2026-07-30T12:32:19.754Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/e4/dcfdaf1fda1883e3fb34a410158cc61e68644c4024c4bdef81e1532cde06/parver-1.0-py3-none-any.whl", hash = "sha256:ba201193c651d5d4de3761ee2709a31fb451280271325607ce1632653617a43e", size = 20803, upload-time = "2026-05-19T22:30:33.343Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/92776e7199caee67944945fb2c3988443060d318782d8c25d9bb9f8d235d/parver-1.0.1.post0-py3-none-any.whl", hash = "sha256:81f5cbe349da9939749ece40b70ef97b94a346f750750ed49f3ebbf1a40f1195", size = 20853, upload-time = "2026-07-30T12:32:18.289Z" }, ] [[package]] @@ -559,7 +557,7 @@ wheels = [ [[package]] name = "pulumi" -version = "3.253.0" +version = "3.255.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "debugpy" }, @@ -575,21 +573,21 @@ dependencies = [ { name = "semver" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/67/122d5eb83ed2ccb897889cbd8ac2110d06b9712efce2db4eb398f49c0ecc/pulumi-3.253.0-py3-none-any.whl", hash = "sha256:3b7557bbf33400b15815f871ce86fee26ba8c4fb85bc800d046122f2eafc0b3e", size = 412679, upload-time = "2026-07-14T11:31:55.379Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1e/9d433d9ec29d268a79cb37e5b052a2d3e34b118f1c3a5b4d7b2ab087b74c/pulumi-3.255.0-py3-none-any.whl", hash = "sha256:46fbdc640a996fa10849ff66d599be4e91d62484e7c5fa32963e9c6d79a171b1", size = 415455, upload-time = "2026-07-28T13:38:19.243Z" }, ] [[package]] name = "pulumi-hcloud" -version = "1.39.1" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parver" }, { name = "pulumi" }, { name = "semver" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/c2/5b067bfbc7fbcbaab0f41bc2cb6f37f0b211f22b11a595e517d83dcd4b02/pulumi_hcloud-1.39.1.tar.gz", hash = "sha256:bbdd5a678b4ef958db1c5a127e742b1ffc8eeb44f796a8194100a7b1e4d76408", size = 109156, upload-time = "2026-07-16T05:09:39.615Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/93/984b4f273079a0ec1f7a030041cd8468f29e9ab83bfe8fd1db98e700645d/pulumi_hcloud-1.41.0.tar.gz", hash = "sha256:4a90f286a96c5606aab917185cdfc948161b09c9985463dba6ffa3ddf34fcddb", size = 109001, upload-time = "2026-07-29T04:36:32.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/7c/361e0cbe2ed187eeff78f05faedb283e834222c0e8e36a0b46c8d7b387b4/pulumi_hcloud-1.39.1-py3-none-any.whl", hash = "sha256:451c11fce8c1fa1b84e32dbdaef20819818fb525c74ea1974fd4e2a2a1151d27", size = 204194, upload-time = "2026-07-16T05:09:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/01d2def75aeee3d964b6c14d61a91e0c9d201b83be379bcf9efe85a86dfd/pulumi_hcloud-1.41.0-py3-none-any.whl", hash = "sha256:d184197469c3f6e1364b276abe9f858475fa0e670df5fcf73dbf3271997bc6ce", size = 203666, upload-time = "2026-07-29T04:36:31.091Z" }, ] [[package]] @@ -673,7 +671,7 @@ wheels = [ [[package]] name = "pyinfra" -version = "3.9.2" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -687,9 +685,9 @@ dependencies = [ { name = "typeguard" }, { name = "types-paramiko" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/02/6ba292a33b6fbd342b031880e618281566c985ad73908127b56c02520495/pyinfra-3.9.2.tar.gz", hash = "sha256:035a94c7d0a78059c86279f9292242ddf8b883959296e033aa0033db7f14e517", size = 619478, upload-time = "2026-06-07T21:05:48.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/af/fc92d96ebfe7f5235977ada99fd5a294e666d771993f2be4ec5eae884920/pyinfra-3.10.0.tar.gz", hash = "sha256:802d22046eb84937ffd2aadb7dd192a3cc2b830b026f9878617349614076810f", size = 650567, upload-time = "2026-07-27T19:50:55.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/d1/530b126c4bf5fa5017039b15982043b2cdeba83eb2234bd0500699a6482c/pyinfra-3.9.2-py3-none-any.whl", hash = "sha256:098190febb48ba9cdf07c24efbd0308da9ca2ad40e08d676f21a374cc38532ba", size = 311346, upload-time = "2026-06-07T21:05:46.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/e3/c2a679d1a951ca4fbd0d3812acaeec17795303c24154a8133e3456c88cda/pyinfra-3.10.0-py3-none-any.whl", hash = "sha256:668268db8df54c23a1e684d260121a6e23330c942f60048138597303227e48fb", size = 325488, upload-time = "2026-07-27T19:50:53.625Z" }, ] [[package]] @@ -823,25 +821,25 @@ dependencies = [ requires-dist = [ { name = "mako", specifier = "==1.3.12" }, { name = "paramiko", specifier = "==4.0.0" }, - { name = "pulumi", specifier = "==3.253.0" }, - { name = "pulumi-hcloud", specifier = "==1.39.1" }, + { name = "pulumi", specifier = "==3.255.0" }, + { name = "pulumi-hcloud", specifier = "==1.41.0" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pydantic-settings", specifier = "==2.14.2" }, - { name = "pyinfra", specifier = "==3.9.2" }, + { name = "pyinfra", specifier = "==3.10.0" }, { name = "python-box", specifier = "==7.4.1" }, { name = "pyyaml", specifier = "==6.0.3" }, ] [[package]] name = "typeguard" -version = "4.5.2" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, ] [[package]] @@ -879,33 +877,33 @@ wheels = [ [[package]] name = "wrapt" -version = "2.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, - { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, - { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, - { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, - { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, - { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, ] [[package]] From 42533368d332dfab95d1f85e03c6e103e020ce94 Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 22:52:37 +0200 Subject: [PATCH 09/10] update deployment order logic in documentation, refine manifest contract details, and add unit tests for validation --- docs/operations.md | 12 ++-- docs/stand-manifest.md | 22 +++++- tests/test_app_deployment_order.py | 105 +++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 tests/test_app_deployment_order.py diff --git a/docs/operations.md b/docs/operations.md index 166e470..acd85bb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -362,11 +362,13 @@ PowerShell: 9. Настройка Podman, firewalld, app user systemd, socket и `app-net`. 10. Registry login, параллельный pull images и logout. 11. Загрузка templates. -12. Генерация Podlet units и запуск user services. -13. Ожидание active service и каждого role port: до 30 попыток с интервалом - 2 секунды. -14. Выполнение post-start hooks. -15. Рендеринг и публикация connection output. +12. Последовательное развёртывание инстансов в порядке верхнеуровневого `apps`, + затем `instances` внутри приложения. Для каждого инстанса движок генерирует + Podlet unit и запускает user service, ожидает active service и каждый role + port (до 30 попыток с интервалом 2 секунды), выполняет post-start hook и + только затем переходит к следующему инстансу. Порядок `nodes..apps` + задаёт размещение и на эту последовательность не влияет. +13. Рендеринг и публикация connection output. Движок проверяет service/listen socket, но не HTTP readiness и не dependency graph. Hooks сложных кластеров должны иметь собственный retry/timeout. diff --git a/docs/stand-manifest.md b/docs/stand-manifest.md index 032fc8d..e1713f0 100644 --- a/docs/stand-manifest.md +++ b/docs/stand-manifest.md @@ -313,7 +313,23 @@ Overrides заменяют соответствующие значения вы - agent-инстанс нельзя размещать вручную; - node должна ссылаться на существующий profile. -Порядок `nodes` и `apps` не является dependency graph приложений. +### Порядок развёртывания + +Порядок развёртывания является частью контракта манифеста. Движок обрабатывает +приложения сверху вниз в порядке ключей верхнеуровневого mapping `apps`, а внутри +каждого приложения — сверху вниз в порядке ключей `instances`. + +Для каждого инстанса движок последовательно генерирует и запускает systemd unit, +дожидается состояния active и прослушивания всех портов роли, затем выполняет +hook, если он задан. Только после успешного завершения этих действий начинается +развёртывание следующего инстанса. Поэтому изменение порядка `apps` или +`instances` в YAML изменяет порядок развёртывания. + +Список `nodes..apps` задаёт только размещение инстансов. Порядок элементов в +нём и порядок самих `nodes` на порядок развёртывания не влияют. Движок не строит +dependency graph и не проверяет прикладную готовность: если следующему приложению +нужна более сильная гарантия, чем active service и открытые порты, её следует +реализовать retry/timeout-логикой приложения или hook. ## 8. `agents` @@ -344,6 +360,10 @@ dozzle--worker Эти имена становятся фактическими `instance.name`, ключами Mako `apps`, service/container names и частями configset. +Agent-инстансы развёртываются на позиции соответствующего приложения в +верхнеуровневом `apps`. Внутри него сгенерированные инстансы идут после обычных +инстансов этого приложения и следуют порядку `nodes`. + Ограничения: - список содержит существующие уникальные имена инстансов; diff --git a/tests/test_app_deployment_order.py b/tests/test_app_deployment_order.py new file mode 100644 index 0000000..8ed6ba0 --- /dev/null +++ b/tests/test_app_deployment_order.py @@ -0,0 +1,105 @@ +from pathlib import Path +from types import SimpleNamespace +import unittest + +from StandBuilder import build_stand + + +class AppDeploymentOrderTests(unittest.TestCase): + def test_launch_order_follows_apps_then_instances_not_node_placement(self): + template = { + "path": "pod.yml.mako", + "dest": "/home/app/pod.yml", + "owner": "app", + "mode": "644", + } + manifest = { + "stand": { + "project": "demo", + "env": "test", + "users": {"sudo": "admin", "app": "app"}, + "ssh": {"key_name_admin": "admin-key"}, + }, + "registries": {"local": {"url": "registry.example.test"}}, + "apps": { + "database": { + "name": "database", + "image": {"registry": "local", "path": "database", "version": "1"}, + "roles": {"server": {}}, + "templates": {"pod": dict(template)}, + "instances": { + "database-2": {"role": "server", "cpu": 500, "ram": 512}, + "database-1": {"role": "server", "cpu": 500, "ram": 512}, + }, + }, + "frontend": { + "name": "frontend", + "image": {"registry": "local", "path": "frontend", "version": "1"}, + "roles": {"web": {}}, + "templates": {"pod": dict(template)}, + "instances": { + "frontend-1": {"role": "web", "cpu": 250, "ram": 256}, + }, + }, + }, + "node_profiles": { + "default": { + "location": "hel1", + "type_serv": "cpx11", + "image": "rocky-10", + "network": "test-network", + "cloud-init": "cloud-init.yml.mako", + } + }, + "nodes": { + "node-1": { + "apps": ["frontend-1", "database-1", "database-2"], + } + }, + } + config = SimpleNamespace( + stand=SimpleNamespace( + user="owner", + passphrase="unused", + path_to_configset=Path("configsets"), + ), + output=SimpleNamespace( + console=False, + console_secrets=False, + file=False, + file_path=None, + ), + ) + stand = build_stand(manifest, config) + + self.assertEqual( + list(stand.instance_apps), + ["database-2", "database-1", "frontend-1"], + ) + + stand.add_app_hook = lambda instance: stand.shell_script.append( + SimpleNamespace(name=f"Run hook {instance.app.name}") + ) + stand.launch_apps() + + self.assertEqual( + [operation.name for operation in stand.shell_script], + [ + "Generate Podman unit database-2", + "Start user container unit database-2", + "Wait user service database-2 active", + "Run hook database-2", + "Generate Podman unit database-1", + "Start user container unit database-1", + "Wait user service database-1 active", + "Run hook database-1", + "Generate Podman unit frontend-1", + "Start user container unit frontend-1", + "Wait user service frontend-1 active", + "Run hook frontend-1", + ], + ) + + +if __name__ == "__main__": + unittest.main() From 080de0f88c3f582664ba8354e36498cb8146f4ff Mon Sep 17 00:00:00 2001 From: AVRybin Date: Fri, 31 Jul 2026 23:20:59 +0200 Subject: [PATCH 10/10] release: prepare v0.2.0 with extensive CLI, automation, and resource management improvements --- .github/release-notes/v0.2.0.md | 44 +++++++++++++++++++++++++++++++++ .trivyignore.yaml | 14 ++--------- tests/test_trivy_ignores.py | 40 +++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 .github/release-notes/v0.2.0.md diff --git a/.github/release-notes/v0.2.0.md b/.github/release-notes/v0.2.0.md new file mode 100644 index 0000000..c1bb447 --- /dev/null +++ b/.github/release-notes/v0.2.0.md @@ -0,0 +1,44 @@ +Версия `0.2.0` делает подготовку стендов безопаснее, упрощает подключение проектных ресурсов и делает CLI предсказуемым для CI/CD и другой автоматизации. + +## Проверка и диагностика + +- Добавлена команда `validate`, которая локально проверяет манифест, шаблоны, hooks, connection output, upload-параметры и конфликты портов. Она не создаёт облачные ресурсы, SSH-ключи, configsets и connection files, поэтому подходит для быстрой проверки в разработке и CI. +- Перед `create` теперь автоматически выполняется тот же preflight. Ошибки в шаблонах и локальных файлах больше не должны приводить к частично созданной инфраструктуре. +- Независимые ошибки preflight собираются в один отчёт, а secret values скрываются. Это сокращает число повторных запусков и не раскрывает чувствительные данные в логах. +- Сообщения об ошибках конфигурации теперь указывают конкретные environment variables и причину ошибки, не выводя их значения. + +## Ресурсы и hooks + +- В CLI и container launcher добавлен повторяемый параметр `--resource NAME=PATH`. Он позволяет подключать к hooks каталоги из прикладных репозиториев через переносимые URI вида `resource://name/path`. +- Расширена конфигурация hooks: к базовому hook можно добавлять внешние assets в заданные подкаталоги. Миграции и другие прикладные данные можно хранить рядом с приложением, не копируя их в Stands Engine. +- В базовом hook рендерятся только файлы с суффиксом `.mako`, а остальные, включая бинарные, копируются без изменений. Это даёт явный контроль над шаблонизацией и не повреждает готовые файлы. +- При `destroy` подключать resources не требуется, поэтому стенд можно удалить без исходного checkout прикладных данных. + +## CLI и автоматизация + +- `--env-file` теперь можно указывать несколько раз. Файлы применяются слева направо, что позволяет отделить общие настройки от окружения конкретного стенда. +- Успешные `validate`, `create` и `destroy` выводят компактные NDJSON-записи в stdout, а диагностика Pulumi, PyInfra и сообщения об ошибках направляются в stderr. Машинно читаемый результат больше не смешивается с логами. +- В результат `create` добавлен `id_stand`, а `destroy` теперь возвращает отдельную запись с идентификатором стенда и статусом. Это упрощает связывание результата с конкретным стендом. +- Закреплены exit codes: `0` для успеха, `1` для ошибок конфигурации или выполнения, `2` для неверного CLI-вызова и `130` для прерывания. CI/CD может однозначно различать исходы запуска. + +## Порядок развёртывания + +- Приложения развёртываются в порядке верхнеуровневого `apps`, а их инстансы — в порядке `instances`. Движок дожидается active service и открытия портов, выполняет hook и только затем переходит к следующему инстансу. Таким образом, YAML-манифест даёт явный и воспроизводимый контроль над очерёдностью запуска. + +## Demo-стенд и зависимости + +- Demo разделено на описание стенда в `demo/stand` и подключаемые данные в `demo/resources`. Пример теперь показывает, как разделять инфраструктурные шаблоны и прикладные migrations. +- Обновлены Pulumi, Pulumi Hetzner provider, PyInfra и сборочные зависимости. + +## Важное при обновлении + +- Формат stdout изменён на NDJSON. Скрипты, которые разбирают результат `create`, необходимо адаптировать к новому однострочному формату и полю `id_stand`. +- Имя connection-приложения `id_stand` теперь зарезервировано для идентификатора стенда. +- Порядок `apps` и `instances` в YAML теперь определяет очерёдность развёртывания. Порядок `nodes` и `nodes..apps` на неё не влияет. +- В каталогах hooks только файлы `*.mako` обрабатываются как шаблоны; остальные файлы передаются без изменений. + +## Контейнерный образ + +```text +ghcr.io/avrybin/stands-engine:0.2.0 +``` diff --git a/.trivyignore.yaml b/.trivyignore.yaml index a33b7cb..2758c36 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -1,18 +1,8 @@ vulnerabilities: - - id: GHSA-hrxh-6v49-42gf - paths: - - usr/local/bin/pulumi - - usr/local/bin/pulumi-language-python - - opt/pulumi/plugins/resource-hcloud-v1.39.1/pulumi-resource-hcloud - expired_at: 2026-08-22 - statement: >- - Pulumi 3.253.0 and hcloud 1.39.1 embed grpc 1.82.0. - Remove after an upstream release containing grpc 1.82.1 or newer. - - id: CVE-2026-39822 paths: - - opt/pulumi/plugins/resource-hcloud-v1.39.1/pulumi-resource-hcloud + - opt/pulumi/plugins/resource-hcloud-v1.41.0/pulumi-resource-hcloud expired_at: 2026-08-22 statement: >- - hcloud 1.39.1 is built with Go 1.26.4. + hcloud 1.41.0 is built with Go 1.26.4. Remove after an upstream release built with Go 1.26.5 or newer. diff --git a/tests/test_trivy_ignores.py b/tests/test_trivy_ignores.py index 21070de..87825f7 100644 --- a/tests/test_trivy_ignores.py +++ b/tests/test_trivy_ignores.py @@ -1,4 +1,5 @@ import datetime as dt +import re import unittest from pathlib import Path @@ -6,8 +7,12 @@ class TrivyIgnorePolicyTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.project_root = Path(__file__).resolve().parents[1] + def test_ignores_are_scoped_documented_and_short_lived(self): - ignore_file = Path(__file__).resolve().parents[1] / ".trivyignore.yaml" + ignore_file = self.project_root / ".trivyignore.yaml" config = yaml.safe_load(ignore_file.read_text(encoding="utf-8")) today = dt.date.today() latest_allowed_expiry = today + dt.timedelta(days=31) @@ -43,6 +48,39 @@ def test_ignores_are_scoped_documented_and_short_lived(self): "Trivy ignores may be granted for at most 31 days", ) + def test_hcloud_ignore_paths_match_container_version(self): + containerfile = (self.project_root / "Containerfile").read_text( + encoding="utf-8", + ) + version_match = re.search( + r"^ARG PULUMI_HCLOUD_VERSION=(\S+)$", + containerfile, + flags=re.MULTILINE, + ) + self.assertIsNotNone( + version_match, + "Containerfile must define PULUMI_HCLOUD_VERSION", + ) + expected_path = ( + "opt/pulumi/plugins/" + f"resource-hcloud-v{version_match.group(1)}/pulumi-resource-hcloud" + ) + + ignore_file = self.project_root / ".trivyignore.yaml" + config = yaml.safe_load(ignore_file.read_text(encoding="utf-8")) + hcloud_paths = [ + path + for entry in config.get("vulnerabilities", []) + for path in entry.get("paths", []) + if "resource-hcloud-v" in path + ] + + self.assertEqual( + hcloud_paths, + [expected_path] * len(hcloud_paths), + "hcloud ignore paths must match PULUMI_HCLOUD_VERSION", + ) + if __name__ == "__main__": unittest.main()