Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/release-notes/v0.2.0.md
Original file line number Diff line number Diff line change
@@ -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.<node>.apps` на неё не влияет.
- В каталогах hooks только файлы `*.mako` обрабатываются как шаблоны; остальные файлы передаются без изменений.

## Контейнерный образ

```text
ghcr.io/avrybin/stands-engine:0.2.0
```
14 changes: 2 additions & 12 deletions .trivyignore.yaml
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion App/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
5 changes: 3 additions & 2 deletions InfraBaseLib/SShExecutor/diagnostic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import datetime
import sys
from time import perf_counter
from typing import Any

Expand Down Expand Up @@ -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")
Expand All @@ -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


Expand Down
5 changes: 5 additions & 0 deletions InfraBaseLib/SShExecutor/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
21 changes: 11 additions & 10 deletions InfraBaseLib/metal_provision/provision.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -86,7 +87,7 @@ def _log_resource_event(self, metadata, prefix: str = "", respect_ignored: bool
op = getattr(metadata, "op", "<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)



Expand All @@ -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)
Expand All @@ -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()
Expand Down
142 changes: 132 additions & 10 deletions ManifestParser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<name>[A-Za-z][A-Za-z0-9_-]*)(?:/(?P<path>.*))?$"
)


class SecretReference(str):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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())
Expand All @@ -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)
Loading
Loading