From c2c0f5ad940ff177ee613374de8215955e6fb9f9 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Sun, 4 Oct 2026 06:01:14 +0900 Subject: [PATCH 1/4] feat(v4.5): complete Determa 0.2 migration --- config/defaults.yaml | 2 +- core/fsm/instance.yaml | 8 +- core/fsm/package.yaml | 48 +++-- core/schema/observed-state.schema.json | 6 +- core/schema/plugin-manifest.schema.json | 14 ++ docs/state.md | 29 +-- docs/v4.5-fsm-design.md | 65 ++++--- eve_sdk/config.py | 23 ++- eve_sdk/dispatch.py | 72 ++++++- eve_sdk/fsm.py | 57 +++--- eve_sdk/instance_fsm.py | 180 +++++++++++++++--- eve_sdk/package_dispatch.py | 143 ++++++++++++-- eve_sdk/plugin_manifest.py | 26 +++ eve_sdk/provider_command.py | 65 +++++-- eve_sdk/state.py | 112 ++++++++++- poetry.lock | 13 +- pyproject.toml | 2 +- scripts/instance-provision | 49 ++--- scripts/instance-state | 13 +- scripts/package-action | 115 ++++++++--- scripts/package-provision | 72 +++++-- scripts/package-provision-windows | 49 ++++- scripts/providers-status | 27 ++- scripts/provision | 161 +++++++++++----- scripts/test | 8 + .../commands/test_package_action_launch.py | 109 +++++++++-- .../python/commands/test_provision_secrets.py | 62 ++++++ .../python/sdk/test_config_provider_paths.py | 137 ++++++++++++- .../sdk/test_plugin_manifest_contract.py | 52 +++++ tests/python/sdk/test_state.py | 69 +++++++ tests/test_fsm_machines.py | 27 ++- tests/test_fsm_platform.py | 99 +++++++++- 32 files changed, 1582 insertions(+), 332 deletions(-) create mode 100644 tests/python/commands/test_provision_secrets.py diff --git a/config/defaults.yaml b/config/defaults.yaml index 9edd1497..f029c7fe 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -11,7 +11,7 @@ display: fps: - resolution: + resolution: 1920x1080 global: my_ip: diff --git a/core/fsm/instance.yaml b/core/fsm/instance.yaml index 98a470b8..0478c49b 100644 --- a/core/fsm/instance.yaml +++ b/core/fsm/instance.yaml @@ -112,18 +112,18 @@ machines: - machine_id: instance_provision root: type: composite - initial: { transition_to: unprovisioned } + initial: { transition_to: unknown } states: - unprovisioned: + unknown: on_events: provision: { transition_to: provisioning } provisioning: on_events: provision_ok: { transition_to: provisioned } - provision_fail: { transition_to: provision_error } + provision_fail: { transition_to: error } provisioned: on_events: provision: { transition_to: provisioning } # re-converge - provision_error: + error: on_events: provision: { transition_to: provisioning } # retry diff --git a/core/fsm/package.yaml b/core/fsm/package.yaml index bb7e34f4..3df4a9ca 100644 --- a/core/fsm/package.yaml +++ b/core/fsm/package.yaml @@ -6,38 +6,44 @@ format: 1 namespace: eve.core events: - found: { direction: input } # observe: the package is present on the instance absent: { direction: input } # observe: not present + found: { direction: input } # observe: the package is present on the instance install: { direction: input } - install_ok: { direction: input } install_fail: { direction: input } + install_ok: { direction: input } + observe_failed: { direction: input } + observe_unknown: { direction: input } remove: { direction: input } + remove_fail: { direction: input } + remove_ok: { direction: input } machines: - machine_id: package root: type: composite - initial: { transition_to: unknown } + initial: { transition_to: idle } + on_events: + absent: { transition_to: idle.missing } + found: { transition_to: idle.installed } + observe_failed: { transition_to: idle.failed } + observe_unknown: { transition_to: idle.unknown } states: - unknown: + idle: + type: composite + initial: { transition_to: unknown } on_events: - found: { transition_to: installed } - absent: { transition_to: missing } install: { transition_to: installing } - missing: - on_events: - install: { transition_to: installing } - found: { transition_to: installed } # drift: appeared out of band + remove: { transition_to: removing } + states: + failed: {} + installed: {} + missing: {} + removed: {} + unknown: {} installing: on_events: - install_ok: { transition_to: installed } - install_fail: { transition_to: failed } - installed: - on_events: - remove: { transition_to: removed } - absent: { transition_to: missing } # drift: disappeared - failed: + install_fail: { transition_to: idle.failed } + install_ok: { transition_to: idle.installed } + removing: on_events: - install: { transition_to: installing } # retry - removed: - on_events: - install: { transition_to: installing } + remove_fail: { transition_to: idle.failed } + remove_ok: { transition_to: idle.removed } diff --git a/core/schema/observed-state.schema.json b/core/schema/observed-state.schema.json index d10b1fa6..8b9b814a 100644 --- a/core/schema/observed-state.schema.json +++ b/core/schema/observed-state.schema.json @@ -131,7 +131,7 @@ "properties": { "status": { "type": "string", - "enum": ["unknown", "installing", "installed", "missing", "failed", "removed"] + "enum": ["failed", "installed", "installing", "missing", "removed", "removing", "unknown"] }, "updated_at": { "type": "string", @@ -154,6 +154,10 @@ "type": "string", "description": "Dotted operation name (e.g. provider.up, package.install)." }, + "package": { + "type": "string", + "description": "Package id for a package operation." + }, "type": { "type": "string", "description": "Operation category (provider, provision, package).", diff --git a/core/schema/plugin-manifest.schema.json b/core/schema/plugin-manifest.schema.json index e24e27e8..1021b001 100644 --- a/core/schema/plugin-manifest.schema.json +++ b/core/schema/plugin-manifest.schema.json @@ -193,6 +193,20 @@ "$ref": "#/$defs/field_spec" } }, + "required_any": { + "description": "Provider-only alternative complete credential/configuration groups. At least one group must have every referenced field set. References use config. or secrets..", + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^(config|secrets)\\.[a-z][a-z0-9_]*$" + }, + "uniqueItems": true + } + }, "secrets": { "type": "object", "additionalProperties": { diff --git a/docs/state.md b/docs/state.md index 163e0b42..18905ca4 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,4 +1,4 @@ -# v3 Instance State +# v4.5 Instance State Concrete instance state lives in `.eve/state/instances/.json` and is updated through `scripts/instance-state`. Core dispatchers should not write @@ -7,22 +7,25 @@ these JSON files directly. ## Top-Level States - `desired_state`: `unknown`, `running`, `stopped`, `absent` -- `provider_state`: `unknown`, `initializing`, `initialized`, `planned`, - `changing`, `running`, `stopped`, `absent`, `error` +- `provider_state`: `unknown`, `creating`, `starting`, `stopping`, `destroying`, + `running`, `stopped`, `absent`, `error` - `provision_state`: `unknown`, `provisioning`, `provisioned`, `error` ## Package States Package entries under `package_state` use: -- `unknown` +- `failed` - `installed` +- `installing` - `missing` -- `failed` - `removed` -- `reinstalled` +- `removing` +- `unknown` -Package state updates must pass both `--package` and `--package-state`. +Dispatchers derive package state by firing operation and observation events into +`core/fsm/package.yaml`. The `--package-state` pair remains available only for explicit +compatibility writes. ## Observed State Cache @@ -52,13 +55,14 @@ commands, so they do not append lifecycle operations to `operation_history`. Every write records `last_operation` and appends to `operation_history`. Operation entries include: -- `id`: monotonic per retained history window +- `id`: monotonic even after the retained history window is trimmed - `name`: full operation name, such as `provider.up`, `package.status`, or `provision` - `type`: operation prefix, such as `provider`, `package`, or `provision` - `status`: `running`, `succeeded`, `failed`, or `skipped` - `at`: UTC timestamp - `error`: present only for failed or error-bearing entries +- `package`: package id, present on package operations The history keeps the latest 50 entries by default. @@ -79,8 +83,7 @@ the local state before retrying: eve instance recover --instance ``` -This marks the last running operation as `failed`, records a recovery error, and -sets `provider_state` or `provision_state` to `error` when the interrupted -operation belongs to that surface. It does not destroy or change remote -resources. After recovery, run `eve instance status --instance ` and retry -the relevant lifecycle/provision/package command. +This marks the last running operation as `failed`, records a recovery error, and fires +the matching failure event into the provider, provision, or package machine. It does not +destroy or change remote resources. After recovery, run +`eve instance status --instance ` and retry the relevant command. diff --git a/docs/v4.5-fsm-design.md b/docs/v4.5-fsm-design.md index 4e95d5b5..da669699 100644 --- a/docs/v4.5-fsm-design.md +++ b/docs/v4.5-fsm-design.md @@ -5,12 +5,12 @@ FSM context). Engine lives in core; **every machine is declared in YAML by the plugin that owns it**. The interface is kept thin so the engine can later be replaced by a cns-based implementation (ref: github.com/fruwe/cns_statemachine). -> **Status (v4.5):** the engine is now real — [**harel**](https://github.com/fruwehq/harel) -> (spec) + [harel-python](https://github.com/fruwehq/harel-python) (reference impl, -> passing the conformance suite). The original `state_machine:`/`transitions:` sketch -> below is **superseded by harel's actual YAML** (`top`/`esvs`/`events`/`on_events`/ -> `transition_to`) — see **"Reconciliation with harel"** at the end. The eve↔harel seam -> is `eve_sdk/fsm.py` (`EveFsm`); increment 1 (platform + a proving test) has landed. +> **Status (v4.5):** Eve tracks **Determa State 0.2.x** +> ([spec](https://github.com/fruwehq/determa-state-spec) and +> [Python reference implementation](https://github.com/fruwehq/determa-state-python)). +> The original `state_machine:`/`transitions:` sketch below is superseded by +> Determa's format-1 bundles; see **"Reconciliation with Determa State"** below. +> The Eve↔Determa seam is `eve_sdk/fsm.py` (`EveFsm`). ## Why @@ -188,8 +188,8 @@ keep persistence, but behind `FSM`'s storage seam. The engine is real now, so this supersedes the speculative `state_machine:` schema above. (History: the engine started as **harel**, was renamed **determa-state**, and -at spec **v0.1.0** pivoted to *format-1 bundles* + a *pure-functional interface* — -eve tracks 0.1.0.) +at spec **v0.1.0** pivoted to *format-1 bundles* + a *pure-functional interface*. +Eve now tracks the compatible 0.2.x line.) - **Machines are format-1 bundles.** A plugin ships a Determa State bundle: `format: 1`, a `namespace`, shared `events` (host-delivered events are `direction: input`), and @@ -225,29 +225,31 @@ eve tracks 0.1.0.) - ☑ **Increment 1** — `EveFsm` + a synthetic provider machine (`tests/fsm_fixtures/provider.yaml`) + `tests/test_fsm_platform.py` proving configured/reachable are FSM-computed from injected context and re-derive on a settings - change. The engine is editable-installed for dev; the test skips if neither - `determa_state` nor `harel` is installed. + change. Determa State is a required project dependency; the FSM tests do not skip. - ☑ **Real resolver** — `provider_context_resolver()` in `eve_sdk/fsm.py`: resolves each - declared external esv (named as its eve env var) from the one path dispatch uses - (`os.environ` + `ConfigEnv.environment()` + `_inject_secrets`), hermetically tested. So - a provider's FSM context ≡ what dispatch runs with (bug M). Systemic follow-up: drop - ambient `os.environ` from both this and dispatch. + declared external variable (named as its eve env var) through + `resolved_provider_environment()`, the same builder used by dispatch and connectivity. + Required fields are read from the manifest's actual `config_schema.config` and + `config_schema.secrets` blocks. Provider and package commands receive operation-local + environments: explicit process transport/routing variables, core configuration, and + only that target plugin's declared config and secrets. Ambient or sibling-plugin + credentials cannot cross the command boundary. - ☑ **Core provider machine** — `core/fsm/provider.yaml`: one shared provider lifecycle (unconfigured → configured → reachable) for *every* provider. `is_configured` is derived by `provider_configured_resolver()` from the plugin's `config_schema` - `required` secrets/settings (via the shared path), so "configured" is eve-resolved, not - a plugin's ambient self-report. The lifecycle is identical across providers, so it lives - in `core/`, not per-provider. Tested end-to-end. -- ☑ **Dependency** — `determa-state (>=0.1.0,<0.2.0)` from PyPI, locked; the FSM tests + universally `required` secrets/config fields plus the optional provider-only + `required_any` alternative groups (via the shared path), so "configured" is + eve-resolved, not a plugin's ambient self-report. The lifecycle is identical across + providers, so it lives in `core/`, not per-provider. Tested end-to-end. +- ☑ **Dependency** — `determa-state (>=0.2.0,<0.3.0)` from PyPI, locked; the FSM tests run in CI. - ☑ **Rewire `providers-status`** — `configured` is now FSM-derived (`core/fsm/provider.yaml` + `provider_configured_resolver`); the `connectivity` command runs **only for `reachable`** (its self-reported `configured` is ignored) and only when configured. Live-verified (vultr yes/yes, truenas no, docker/qemu yes); full suite green. - - *AWS follow-up (eve-providers):* AWS declares only a non-required `config_file`, so - it reads `configured=yes / reachable=no` (readiness surfaces as reachability + note). - If AWS should read `configured=no` until eve holds an auth profile, mark an auth - setting `required` or model AWS's configured as probe-derived — a per-provider call. + - AWS uses `required_any` to express a profile **or** a complete static access-key pair; + all static credential fields map to their AWS environment names. A partial key pair + remains unconfigured, while connectivity remains a separate live probe. - ☑ **Instance & package machines (declarative)** — `core/fsm/instance.yaml` (two machines in one bundle: `instance_provider` + `instance_provision`, since format 1 has no orthogonal regions; `desired` as external context) + `core/fsm/package.yaml`, @@ -263,7 +265,7 @@ eve tracks 0.1.0.) write the machine vocabulary (`creating/starting/stopping/destroying`, `init`/`plan`→ `creating`, package `reinstall`→`installed`); `state.py`'s state sets, the `observed-state`/`command-io` schemas, the `state_machine`/`sdk/test_state*` tests, the - `valid-pending` fixture, and `test-plugins` all follow. Full suite green (496 passed). + `valid-pending` fixture, and `test-plugins` all follow. Full suite green. - ☑ **FSM-driven status (write path):** `provider_state` is no longer a hand-maintained lookup table. `eve_sdk/instance_fsm.py` is the single lifecycle authority: dispatch maps a `(command, op_status)` to a machine event (`dispatch_event`) and @@ -275,6 +277,10 @@ eve tracks 0.1.0.) `provider_command.py` + `scripts/provider-dispatch` fire events. Live-validated on a real local-qemu instance (create→running, stop→stopped, start→running, destroy→absent, failure→error) + `scripts/test-lifecycle` green + a new `test_state.py` case. + - ☑ **FSM-driven package state:** install, reinstall, removal, live status, interrupted + operation recovery, and full-provision observations all fire events into + `core/fsm/package.yaml`. Root-level observation transitions let authoritative probes + reconcile an interrupted `installing` or `removing` state. - *Machine change:* every concrete provider state now accepts every operator command (routing to its transient), so a command's terminal outcome holds from any state (start recovers `absent`→`running`) — the provider command, not the machine, decides @@ -285,8 +291,11 @@ eve tracks 0.1.0.) precedence), and `aggregate_summary` (counting) are **display/aggregation policy, not a second lifecycle definition** — there is now exactly one transition authority (the machine), so no divergence risk remains. They stay as a thin status-render adapter. - (Provision state still uses the persisted `unknown`/`error` vocabulary; wiring the - `instance_provision` machine the same way is the next increment.) + - ☑ **FSM-driven provision state:** `State.record_operation` maps provision operation + statuses to `provision`/`provision_ok`/`provision_fail` and fires them through the + `instance_provision` machine atomically. The machine uses the persisted + `unknown`/`provisioning`/`provisioned`/`error` vocabulary; interrupted provider and + provision operations also recover through machine events. - ☑ **Engine 0.1.0 port** — determa-state's v0.1.0 pivoted to format-1 bundles + the pure-functional §8 interface (`create`/`dispatch` over plain aggregate-state dicts; the old `Host`/`Instance` object API is gone). All three core machines rewritten as @@ -296,6 +305,12 @@ eve tracks 0.1.0.) provider leaf once by replaying a short event prefix and dispatches against the cached aggregate (dispatch is pure, the cache stays valid). The harel fallback import is gone. The engine's IntType leak (old #40) is moot — 0.1.0 returns native values. +- ☑ **Engine 0.2.0 upgrade** — 0.2.0 preserves the format-1 grammar and pure + `load_bundle`/`create`/`dispatch` contract used by Eve. Its optional `ExecutionHost` + and checkpoint stores are intentionally not adopted here: Eve already commits its + operation metadata and lifecycle fields atomically in one locked state document. + Seed-by-replay remains limited to the current context-free lifecycle graphs; future + stateful machines must persist and restore full aggregates. ### Open decisions — resolved 1. Engine repo/name → **Determa State** (`fruwehq/determa-state-spec` spec, diff --git a/eve_sdk/config.py b/eve_sdk/config.py index 61d1e20b..6517c925 100644 --- a/eve_sdk/config.py +++ b/eve_sdk/config.py @@ -212,13 +212,15 @@ def bootstrap_sudo_password(cls, provider_id: str) -> str: provider declares none or the value is unset. """ from eve_sdk.plugin_manifest import PluginManifest + from eve_sdk.provider_command import resolved_provider_environment for plugin in PluginManifest.load_all("provider"): if plugin.get("id") != provider_id: continue bootstrap = plugin.get("bootstrap") if isinstance(bootstrap, dict) and bootstrap.get("sudo_password_env"): - return os.environ.get(str(bootstrap["sudo_password_env"]), "") + env = resolved_provider_environment(provider_id, plugin) + return env.get(str(bootstrap["sudo_password_env"]), "") return "" @classmethod @@ -256,7 +258,12 @@ def plugin_provision_env_names( return sorted(names) @classmethod - def provision_env_payload(cls, windows_password: str = "") -> dict[str, str]: + def provision_env_payload( + cls, + windows_password: str = "", + environment: dict[str, str] | None = None, + package_env_names: list[str] | None = None, + ) -> dict[str, str]: """Build the generic provision ``env.json`` payload (v4.4 §15). Core keys (``windows_password``, ``display_resolution``) plus every @@ -265,12 +272,18 @@ def provision_env_payload(cls, windows_password: str = "") -> dict[str, str]: provision steps read their keys (e.g. a streaming host's version, a remote …) by construction — core names no package. Unset vars are omitted. """ + source = os.environ if environment is None else environment payload: dict[str, str] = { "windows_password": windows_password, - "display_resolution": os.environ.get("EPHEMERAL_DISPLAY_RESOLUTION", ""), + "display_resolution": source.get("EPHEMERAL_DISPLAY_RESOLUTION", ""), } - for _path, env_var, _is_path in cls._plugin_mappings(kinds=("package",)): - value = os.environ.get(env_var, "") + env_names = ( + cls.plugin_provision_env_names(kinds=("package",)) + if package_env_names is None + else sorted(set(package_env_names)) + ) + for env_var in env_names: + value = source.get(env_var, "") if not value: continue key = env_var.lower().removeprefix("ephemeral_") diff --git a/eve_sdk/dispatch.py b/eve_sdk/dispatch.py index 5310adc1..5a184e21 100644 --- a/eve_sdk/dispatch.py +++ b/eve_sdk/dispatch.py @@ -21,12 +21,80 @@ class DispatchError(Exception): pass +_PROCESS_ENV_NAMES = { + "COLORTERM", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "EVE_ALLOW_UNPINNED_PLUGINS", + "EVE_CONFIG_PATH", + "EVE_DISABLE_STATE", + "EVE_HOME", + "EVE_INSTANCE_REGISTRY", + "EVE_INSTANCE_WORKDIR", + "EVE_PACKAGE_DOWN_SSH", + "EVE_PACKAGE_STATUS_SSH", + "EVE_PLUGIN_ALLOW_OVERRIDE", + "EVE_PLUGIN_DRY_RUN", + "EVE_PLUGIN_ROOTS", + "EVE_PLUGIN_ROOTS_EXCLUSIVE", + "EVE_PLUGIN_SOURCES", + "EVE_PROVIDER_DRY_RUN", + "EVE_PROVISION_DRY_RUN", + "EVE_PROVISION_SCRIPT", + "EVE_PROVISION_WAIT_POLL_INTERVAL", + "EVE_SECRETS_DIR", + "EVE_SSH_RETRY_ATTEMPTS", + "EVE_SSH_RETRY_DELAY", + "EVE_STATE_DIR", + "EVE_TF_DATA_BASE", + "EVE_TF_ENV_JSON", + "EVE_TF_PRINT", + "EVE_TF_STATE_BASE", + "EVE_TM_READ_FLAGS", + "EVE_UPLOAD_DIR", + "EVE_WAIT_FOR_PROVISION_PROBE_SCRIPT", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LANG", + "LANGUAGE", + "LOGNAME", + "NO_PROXY", + "PATH", + "REQUESTS_CA_BUNDLE", + "SHELL", + "SSH_AGENT_PID", + "SSH_AUTH_SOCK", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TERM", + "TMP", + "TMPDIR", + "USER", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "http_proxy", + "https_proxy", + "no_proxy", +} + + +def process_environment() -> dict[str, str]: + """Explicit host transport/routing environment for an outer command boundary.""" + return { + name: value + for name, value in os.environ.items() + if name in _PROCESS_ENV_NAMES or name.startswith("LC_") + } + + def command_env(extra: dict[str, str] | None = None) -> dict[str, str]: """Env for invoking plugin/script commands. Ensures the eve repo root is on PYTHONPATH so external (synced) plugin command scripts can `import eve_sdk` regardless of where they live on disk (their own `parents[N]` path assumes the in-repo layout and is wrong once extracted).""" - env = os.environ | (extra or {}) + env = dict(os.environ) if extra is None else dict(extra) root = str(Workdir.repo_root()) existing = env.get("PYTHONPATH", "") env["PYTHONPATH"] = root + (os.pathsep + existing if existing else "") @@ -163,6 +231,7 @@ def record_package_state( command: str, status: str, package_id: str, + package_event: str | None = None, package_state: str | None = None, error: str | None = None, ) -> None: @@ -173,6 +242,7 @@ def record_package_state( f"package.{command}", status, package=package_id, + package_event=package_event, package_state=package_state, error=error, ) diff --git a/eve_sdk/fsm.py b/eve_sdk/fsm.py index f8546aad..62df7850 100644 --- a/eve_sdk/fsm.py +++ b/eve_sdk/fsm.py @@ -169,25 +169,19 @@ def provider_context_resolver() -> ContextResolver: """The real :data:`ContextResolver` for provider machines. Each declared external variable is named as its eve env var (e.g. ``VULTR_API_KEY``) - and resolved from the *one* path dispatch uses — ``os.environ`` + - ``ConfigEnv.environment()`` + injected secrets — so a provider's FSM context and what - dispatch runs with are identical (v4.5 bug M: status ≡ dispatch). ``key`` is the - provider id. + and resolved from the one environment builder dispatch uses, so a provider's + FSM context and what dispatch runs with are identical (v4.5 bug M: status ≡ + dispatch). ``key`` is the provider id. (Systemic follow-up: drop ambient ``os.environ`` from *both* this resolver and dispatch so no config reaches a plugin behind eve's back; kept here for now to preserve exact parity.) """ - import os - - from eve_sdk.config import ConfigEnv - from eve_sdk.provider_command import _inject_secrets, _load_public_plugin + from eve_sdk.provider_command import _load_public_plugin, resolved_provider_environment def resolve(scope: str, key: str, names: list[str]) -> dict[str, Any]: - env = dict(os.environ) - env.update(ConfigEnv.environment()) plugin = _load_public_plugin("provider", key) - env = _inject_secrets(key, plugin, env) + env = resolved_provider_environment(key, plugin) return {name: env.get(name, "") for name in names} return resolve @@ -196,26 +190,21 @@ def resolve(scope: str, key: str, names: list[str]) -> dict[str, Any]: def provider_configured_resolver() -> ContextResolver: """The :data:`ContextResolver` for the core provider machine (``core/fsm/provider.yaml``). - Derives ``is_configured`` = True iff every ``required`` config_schema secret/setting - of the provider resolves to a non-empty value via the one shared path (``os.environ`` - + ``ConfigEnv.environment()`` + injected secrets). So "configured" is eve-resolved, - never a plugin's ambient self-report (the AWS-off-``~/.aws`` bug is structurally - impossible). ``key`` is the provider id; ``names`` is typically ``["is_configured"]``. + Derives ``is_configured`` from both universally ``required`` fields and the + optional ``required_any`` alternatives. Every universal field must be set, + and at least one alternative group must be complete. So "configured" is + eve-resolved, never a plugin's connectivity self-report. ``key`` is the + provider id; ``names`` is typically ``["is_configured"]``. """ - import os - - from eve_sdk.config import ConfigEnv - from eve_sdk.provider_command import _inject_secrets, _load_public_plugin + from eve_sdk.provider_command import _load_public_plugin, resolved_provider_environment def resolve(scope: str, key: str, names: list[str]) -> dict[str, Any]: plugin = _load_public_plugin("provider", key) - env = dict(os.environ) - env.update(ConfigEnv.environment()) - env = _inject_secrets(key, plugin, env) + env = resolved_provider_environment(key, plugin) schema = plugin.get("config_schema") or {} required = [ (field, spec) - for section in ("secrets", "settings") + for section in ("config", "secrets") for field, spec in (schema.get(section) or {}).items() if isinstance(spec, Mapping) and spec.get("required") ] @@ -223,11 +212,27 @@ def resolve(scope: str, key: str, names: list[str]) -> dict[str, Any]: def present(field: str, spec: Mapping[str, Any]) -> bool: candidates = [field] env_var = spec.get("env_var") - if env_var: + if isinstance(env_var, list): + candidates.extend(str(name) for name in env_var) + elif env_var: candidates.append(str(env_var)) return any(env.get(candidate) for candidate in candidates) - values: dict[str, Any] = {"is_configured": all(present(f, s) for f, s in required)} + alternatives = schema.get("required_any") or [] + + def referenced_present(reference: str) -> bool: + section, field = reference.split(".", 1) + spec = (schema.get(section) or {}).get(field) + return isinstance(spec, Mapping) and present(field, spec) + + required_ok = all(present(field, spec) for field, spec in required) + alternatives_ok = not alternatives or any( + all(referenced_present(str(reference)) for reference in group) + for group in alternatives + ) + values: dict[str, Any] = { + "is_configured": required_ok and alternatives_ok + } return {name: values.get(name, "") for name in names} return resolve diff --git a/eve_sdk/instance_fsm.py b/eve_sdk/instance_fsm.py index b6e6f989..f3261575 100644 --- a/eve_sdk/instance_fsm.py +++ b/eve_sdk/instance_fsm.py @@ -1,20 +1,19 @@ """Instance-lifecycle transitions, driven by the core instance machine. -The single authority for how a provider operation moves an instance's -``provider_state``: dispatch computes the next state by **firing an event into the -``instance_provider`` machine of ``core/fsm/instance.yaml``** seeded from the -persisted state, not from a second, hand-maintained transition table. The machine -bundle *is* the lifecycle; this module is the thin, fast adapter eve calls (and the -persisted ``provider_state`` a status read sees is therefore exactly what the -machine produced — bug N). +The single authority for how an instance operation moves its ``provider_state``, +``provision_state``, or per-package state: dispatch computes the next state by +**firing an event into the corresponding core machine** seeded from the persisted +state, not from a second, hand-maintained transition table. The machine bundle *is* +the lifecycle; this module is the thin, fast adapter Eve calls (and the persisted +state a status read sees is therefore exactly what the machine produced — bug N). The engine is pure-functional (``create``/``dispatch`` over plain aggregate-state -dicts), so "seeding from the persisted state" is: reach each provider leaf once from -a fresh aggregate by replaying a short event prefix, cache that aggregate per leaf, -then dispatch the real event against it — dispatch never mutates its input, so the -cache stays valid. Only the provider machine is driven here; the persisted -``provision_state`` field keeps its own vocabulary until the ``instance_provision`` -machine is wired the same way. +dicts), so "seeding from the persisted state" is: reach each lifecycle leaf once +from a fresh aggregate by replaying a short event prefix, cache that aggregate per +leaf, then dispatch the real event against it — dispatch never mutates its input, so +the cache stays valid. This is deliberately limited to these context-free finite +graphs; a future stateful machine must persist and restore its full aggregate instead +of extending seed-by-replay. """ from __future__ import annotations @@ -42,7 +41,7 @@ # The shortest event prefix that reaches each leaf from a fresh aggregate; every # concrete state accepts every operator command, so each transient is one event away # and `error` is two. -_SEED_EVENTS: dict[str, list[str]] = { +_PROVIDER_SEED_EVENTS: dict[str, list[str]] = { "unknown": [], "creating": ["create"], "starting": ["start"], @@ -54,14 +53,52 @@ "error": ["create", "op_fail"], } -_MACHINE_ID = "instance_provider" +PROVISION_LEAVES = {"unknown", "provisioning", "provisioned", "error"} -_bundle: Any = None -_seeded: dict[str, dict[str, Any]] = {} +_PROVISION_SEED_EVENTS: dict[str, list[str]] = { + "unknown": [], + "provisioning": ["provision"], + "provisioned": ["provision", "provision_ok"], + "error": ["provision", "provision_fail"], +} + +PACKAGE_LEAVES = { + "failed", + "installed", + "installing", + "missing", + "removed", + "removing", + "unknown", +} + +_PACKAGE_SEED_EVENTS: dict[str, list[str]] = { + "failed": ["observe_failed"], + "installed": ["found"], + "installing": ["install"], + "missing": ["absent"], + "removed": ["remove", "remove_ok"], + "removing": ["remove"], + "unknown": [], +} + +_bundles: dict[str, Any] = {} +_seeded: dict[tuple[str, str, str], dict[str, Any]] = {} _sequence = 0 -def _dispatch(state: dict[str, Any], event: str) -> dict[str, Any]: +def _bundle(filename: str) -> Any: + bundle = _bundles.get(filename) + if bundle is None: + bundle_yaml = (Workdir.repo_root() / "core" / "fsm" / filename).read_text( + encoding="utf-8" + ) + bundle = engine.load_bundle(bundle_yaml) + _bundles[filename] = bundle + return bundle + + +def _dispatch(bundle: Any, state: dict[str, Any], event: str) -> dict[str, Any]: """One input-event RTC step; returns the (new) aggregate state.""" global _sequence _sequence += 1 @@ -76,24 +113,26 @@ def _dispatch(state: dict[str, Any], event: str) -> dict[str, Any]: }, "payload": {}, } - result = engine.dispatch(_bundle, state, {"input": envelope}) + result = engine.dispatch(bundle, state, {"input": envelope}) new_state: dict[str, Any] = result["state"] return new_state -def _seeded_aggregate(leaf: str) -> dict[str, Any]: - """The cached aggregate whose provider machine sits at ``leaf``.""" - global _bundle - if _bundle is None: - bundle_yaml = (Workdir.repo_root() / "core" / "fsm" / "instance.yaml").read_text(encoding="utf-8") - _bundle = engine.load_bundle(bundle_yaml) - state = _seeded.get(leaf) +def _seeded_aggregate( + filename: str, machine_id: str, leaf: str, seed_events: dict[str, list[str]] +) -> dict[str, Any]: + """The cached aggregate whose selected lifecycle machine sits at ``leaf``.""" + bundle = _bundle(filename) + cache_key = (filename, machine_id, leaf) + state = _seeded.get(cache_key) if state is None: - state = engine.create(_bundle, _MACHINE_ID, leaf, f"eve-seed-{leaf}", None)["state"] - for event in _SEED_EVENTS[leaf]: - state = _dispatch(state, event) + state = engine.create( + bundle, machine_id, leaf, f"eve-seed-{machine_id}-{leaf}", None + )["state"] + for event in seed_events[leaf]: + state = _dispatch(bundle, state, event) assert leaf_names(state) == [leaf] - _seeded[leaf] = state + _seeded[cache_key] = state return state @@ -134,5 +173,84 @@ def provider_state_after(current: str, event: str | None) -> str | None: if event is None: return None leaf = current if current in PROVIDER_LEAVES else "unknown" - moved = leaf_names(_dispatch(_seeded_aggregate(leaf), event)) + bundle = _bundle("instance.yaml") + moved = leaf_names( + _dispatch( + bundle, + _seeded_aggregate( + "instance.yaml", "instance_provider", leaf, _PROVIDER_SEED_EVENTS + ), + event, + ) + ) + return moved[0] if moved else leaf + + +def provision_event(op_status: str) -> str | None: + """Map a provision operation status to an instance-provision event.""" + return { + "running": "provision", + "succeeded": "provision_ok", + "failed": "provision_fail", + }.get(op_status) + + +def provision_state_after(current: str, event: str | None) -> str | None: + """The provision_state after firing ``event`` from ``current``.""" + if event is None: + return None + leaf = current if current in PROVISION_LEAVES else "unknown" + bundle = _bundle("instance.yaml") + moved = leaf_names( + _dispatch( + bundle, + _seeded_aggregate( + "instance.yaml", "instance_provision", leaf, _PROVISION_SEED_EVENTS + ), + event, + ) + ) + return moved[0] if moved else leaf + + +def package_operation_event(command: str, op_status: str) -> str | None: + """Map a package operation status to a package-machine event.""" + if command in {"install", "reinstall"}: + return { + "failed": "install_fail", + "running": "install", + "succeeded": "install_ok", + }.get(op_status) + if command == "down": + return { + "failed": "remove_fail", + "running": "remove", + "succeeded": "remove_ok", + }.get(op_status) + return None + + +def package_observation_event(status: str) -> str | None: + """Map a validated package status result to an observation event.""" + return { + "failed": "observe_failed", + "installed": "found", + "missing": "absent", + "unknown": "observe_unknown", + }.get(status) + + +def package_state_after(current: str, event: str | None) -> str | None: + """The package state after firing ``event`` from ``current``.""" + if event is None: + return None + leaf = current if current in PACKAGE_LEAVES else "unknown" + bundle = _bundle("package.yaml") + moved = leaf_names( + _dispatch( + bundle, + _seeded_aggregate("package.yaml", "package", leaf, _PACKAGE_SEED_EVENTS), + event, + ) + ) return moved[0] if moved else leaf diff --git a/eve_sdk/package_dispatch.py b/eve_sdk/package_dispatch.py index dc0606b3..320f416d 100644 --- a/eve_sdk/package_dispatch.py +++ b/eve_sdk/package_dispatch.py @@ -22,16 +22,20 @@ from pathlib import Path from typing import Any +from eve_sdk.config import ConfigEnv from eve_sdk.dispatch import ( DispatchError, command_vector, package_status_from_output, prepare_overlay, + process_environment, record_package_state, stream_command, validate_package_support, ) +from eve_sdk.instance_fsm import package_observation_event from eve_sdk.plugin_manifest import PluginManifest +from eve_sdk.provider_command import _inject_secrets from eve_sdk.resolve import resolve_instance from eve_sdk.schema import SchemaValidationError, validate_input @@ -211,6 +215,7 @@ def _run_plugin( resolved: dict[str, Any], dry_run: bool, on_output: Callable[[str], None] | None = None, + operation_env: dict[str, str] | None = None, ) -> str: commands = plugin.get("commands") if not isinstance(commands, dict): @@ -226,12 +231,13 @@ def _run_plugin( os_paths_raw = plugin.get("os_paths") os_paths = os_paths_raw if isinstance(os_paths_raw, dict) else {} manifest_path = os_paths.get(os_family) or plugin["path"] - env = os.environ | { + env = resolved_package_environment(plugin, resolved, operation_env) + env.update({ "EVE_INSTANCE_NAME": resolved["instance"]["name"], "EVE_PACKAGE_PLUGIN": str(plugin["id"]), "EVE_PACKAGE_PLUGIN_ROOT": str(Path(str(manifest_path)).parent), "EVE_RESOLVED_JSON": json.dumps(resolved, separators=(",", ":")), - } + }) if dry_run: env["EVE_PLUGIN_DRY_RUN"] = "1" @@ -246,6 +252,85 @@ def _run_plugin( return output +def resolved_package_environment( + plugin: dict[str, Any], + resolved: dict[str, Any], + overrides: dict[str, str] | None = None, +) -> dict[str, str]: + """Build an operation-local environment for one package plugin.""" + package_id = str(plugin["id"]) + all_package_config = resolved.get("package_config") or {} + package_config = ( + {package_id: all_package_config[package_id]} + if package_id in all_package_config + else {} + ) + return resolved_package_set_environment( + [plugin], package_config, overrides=overrides + ) + + +def resolved_package_set_environment( + plugins: list[dict[str, Any]], + package_config: dict[str, Any], + *, + overrides: dict[str, str] | None = None, +) -> dict[str, str]: + """Build a trusted provision environment for an applicable package set.""" + declared_names = { + str(name) + for plugin in plugins + for section in ("config", "secrets") + for field in ((plugin.get("config_schema") or {}).get(section) or {}).values() + if isinstance(field, dict) and field.get("env_var") + for name in ( + field["env_var"] + if isinstance(field["env_var"], list) + else [field["env_var"]] + ) + } + core_names = {name for _path, name, _is_path in ConfigEnv.MAPPINGS} + configured = ConfigEnv.environment() + env = process_environment() + env.update( + { + name: value + for name, value in configured.items() + if name in core_names or name in declared_names + } + ) + env.update(ConfigEnv.instance_package_env(package_config)) + for plugin in plugins: + env = _inject_secrets(str(plugin["id"]), plugin, env) + env.update(overrides or {}) + return env + + +def package_environment_names( + plugins: list[dict[str, Any]], *, path_config_only: bool = False +) -> list[str]: + """Declared environment names for an applicable package set.""" + sections = ("config",) if path_config_only else ("config", "secrets") + return sorted( + { + str(name) + for plugin in plugins + for section in sections + for field in ( + (plugin.get("config_schema") or {}).get(section) or {} + ).values() + if isinstance(field, dict) + and field.get("env_var") + and (not path_config_only or field.get("type") == "path") + for name in ( + field["env_var"] + if isinstance(field["env_var"], list) + else [field["env_var"]] + ) + } + ) + + def dispatch_package( instance_name: str, package_id: str, @@ -290,9 +375,9 @@ def dispatch_package( ) overlay = prepare_overlay(instance_name, registry_path) - os.environ["EVE_CATALOG_LOCAL"] = overlay + operation_env = {"EVE_CATALOG_LOCAL": overlay} if registry_path: - os.environ["EVE_INSTANCE_REGISTRY"] = registry_path + operation_env["EVE_INSTANCE_REGISTRY"] = registry_path # Record "running" only after lookup/validation/overlay prep succeeded — # so a missing-package or unsupported-version failure records only the @@ -301,30 +386,54 @@ def dispatch_package( record_package_state(instance_name, command, "running", package_id) if command == "reinstall" and "reinstall" not in plugin.get("commands", {}): - _run_plugin(plugin, "down", resolved, dry_run, on_output) - output = _run_plugin(plugin, "install", resolved, dry_run, on_output) + _run_plugin( + plugin, "down", resolved, dry_run, on_output, operation_env + ) + output = _run_plugin( + plugin, "install", resolved, dry_run, on_output, operation_env + ) else: - output = _run_plugin(plugin, command, resolved, dry_run, on_output) + output = _run_plugin( + plugin, command, resolved, dry_run, on_output, operation_env + ) except Exception as error: if not dry_run: try: record_package_state( - instance_name, command, "failed", package_id, "failed", str(error) + instance_name, command, "failed", package_id, error=str(error) ) except Exception as state_error: print(f"package-dispatch: failed to record failure state: {state_error}", file=sys.stderr) print(f"package-dispatch: {error}", file=sys.stderr) return 1 - package_state = { - "install": "installed", - "down": "removed", - "reinstall": "installed", # cleaner vocabulary: a reinstall lands in installed - }.get(command) - if package_state is None: - package_state = package_status_from_output(output) or "unknown" - if not dry_run: - record_package_state(instance_name, command, "succeeded", package_id, package_state) + try: + package_event = None + if command == "status": + package_event = package_observation_event( + package_status_from_output(output) or "unknown" + ) + if not dry_run: + record_package_state( + instance_name, + command, + "succeeded", + package_id, + package_event=package_event, + ) + except Exception as error: + if not dry_run: + try: + record_package_state( + instance_name, command, "failed", package_id, error=str(error) + ) + except Exception as state_error: + print( + f"package-dispatch: failed to record failure state: {state_error}", + file=sys.stderr, + ) + print(f"package-dispatch: {error}", file=sys.stderr) + return 1 return 0 diff --git a/eve_sdk/plugin_manifest.py b/eve_sdk/plugin_manifest.py index 5d81e6bb..381a16e6 100644 --- a/eve_sdk/plugin_manifest.py +++ b/eve_sdk/plugin_manifest.py @@ -282,8 +282,34 @@ def validate(cls, plugin: dict[str, Any]) -> None: if not isinstance(config_schema, dict): raise ValueError(f"{path}: config_schema must be a map") validate_json_schema_fragment(config_schema, f"{path}: config_schema") + cls._validate_config_required_any(config_schema, path, str(kind)) cls._validate_requires(plugin, path) + @staticmethod + def _validate_config_required_any( + config_schema: dict[str, Any], path: str, kind: str + ) -> None: + alternatives = config_schema.get("required_any") or [] + if alternatives and kind != "provider": + raise ValueError( + f"{path}: config_schema.required_any is only valid on provider plugins" + ) + for group in alternatives: + for reference in group: + section, field = str(reference).split(".", 1) + fields = config_schema.get(section) or {} + if field not in fields: + raise ValueError( + f"{path}: config_schema.required_any references " + f"undeclared field {reference}" + ) + spec = fields[field] + if not isinstance(spec, dict) or not spec.get("env_var"): + raise ValueError( + f"{path}: config_schema.required_any field {reference} " + "must declare env_var" + ) + @staticmethod def public(plugin: dict[str, Any]) -> dict[str, Any]: output = {key: value for key, value in plugin.items() if not key.startswith("_")} diff --git a/eve_sdk/provider_command.py b/eve_sdk/provider_command.py index c2ee8fe4..50e48aae 100644 --- a/eve_sdk/provider_command.py +++ b/eve_sdk/provider_command.py @@ -14,6 +14,7 @@ exec_cmd, interactive_provider_command, prepare_overlay, + process_environment, read_resolved_from_env_or_stdin, record_provider_state, stream_command, @@ -128,15 +129,49 @@ def _inject_secrets( return env schema_secrets = (plugin.get("config_schema") or {}).get("secrets") or {} for secret_key, value in secrets.items(): - env[secret_key] = value schema_entry = schema_secrets.get(secret_key) - if isinstance(schema_entry, dict): - mapped = schema_entry.get("env_var") - if mapped: - env[mapped] = value + if not isinstance(schema_entry, dict): + continue + mapped = schema_entry.get("env_var") + if mapped: + for name in mapped if isinstance(mapped, list) else [mapped]: + env[str(name)] = value return env +def resolved_provider_environment( + provider_name: str, + plugin: dict[str, Any], + overrides: dict[str, str] | None = None, +) -> dict[str, str]: + """Build the one provider environment used by status and dispatch.""" + from eve_sdk.config import ConfigEnv + + declared_names = { + str(name) + for section in ("config", "secrets") + for field in ((plugin.get("config_schema") or {}).get(section) or {}).values() + if isinstance(field, dict) and field.get("env_var") + for name in ( + field["env_var"] + if isinstance(field["env_var"], list) + else [field["env_var"]] + ) + } + core_names = {name for _path, name, _is_path in ConfigEnv.MAPPINGS} + configured = ConfigEnv.environment() + env = process_environment() + env.update( + { + name: value + for name, value in configured.items() + if name in core_names or name in declared_names + } + ) + env.update(overrides or {}) + return _inject_secrets(provider_name, plugin, env) + + def _select_plugin(plugins: list[dict[str, Any]], kind: str, plugin_id: str) -> dict[str, Any]: for plugin in plugins: if ( @@ -179,7 +214,9 @@ def dispatch_provider_command( if not isinstance(spec, dict): raise DispatchError(f"provider plugin {provider_name} has no command: {command}") cmd = command_vector(plugin, spec) + list(extra_args) - env = _inject_secrets(provider_name, plugin, dict(os.environ) | {"EVE_PROVIDER_PLUGIN": provider_name}) + env = resolved_provider_environment( + provider_name, plugin, {"EVE_PROVIDER_PLUGIN": provider_name} + ) if dry_run: env["EVE_PLUGIN_DRY_RUN"] = "1" print(json.dumps({"kind": "provider", "provider": provider_name, "command": command, "dry_run": True})) @@ -232,12 +269,16 @@ def dispatch_instance_command( return 0 overlay = prepare_overlay(instance_name, registry_path) - env = _inject_secrets(provider, plugin, dict(os.environ) | { - "EVE_CATALOG_LOCAL": overlay, - "EVE_INSTANCE_NAME": instance_name, - "EVE_PROVIDER_PLUGIN": provider, - "EVE_RESOLVED_JSON": json.dumps(resolved, separators=(",", ":")), - }) + env = resolved_provider_environment( + provider, + plugin, + { + "EVE_CATALOG_LOCAL": overlay, + "EVE_INSTANCE_NAME": instance_name, + "EVE_PROVIDER_PLUGIN": provider, + "EVE_RESOLVED_JSON": json.dumps(resolved, separators=(",", ":")), + }, + ) if registry_path: env["EVE_INSTANCE_REGISTRY"] = registry_path diff --git a/eve_sdk/state.py b/eve_sdk/state.py index 77e2135a..618da481 100644 --- a/eve_sdk/state.py +++ b/eve_sdk/state.py @@ -37,7 +37,15 @@ class State: "error", } PROVISION_STATES: ClassVar[set[str]] = {"unknown", "provisioning", "provisioned", "error"} - PACKAGE_STATES: ClassVar[set[str]] = {"unknown", "installing", "installed", "missing", "failed", "removed"} + PACKAGE_STATES: ClassVar[set[str]] = { + "failed", + "installed", + "installing", + "missing", + "removed", + "removing", + "unknown", + } DEFAULT_HISTORY_LIMIT = 50 @classmethod @@ -133,6 +141,8 @@ def record_operation( provider_event: str | None = None, provision_state: str | None = None, package: str | None = None, + package_event: str | None = None, + package_observations: dict[str, str] | None = None, package_state: str | None = None, ) -> dict[str, Any]: cls._validate_enum(status, cls.OPERATION_STATUSES, "status") @@ -144,6 +154,12 @@ def record_operation( cls._validate_enum(provision_state, cls.PROVISION_STATES, "provision_state") if package_state: cls._validate_enum(package_state, cls.PACKAGE_STATES, "package_state") + for observed_status in (package_observations or {}).values(): + cls._validate_enum( + observed_status, + {"failed", "installed", "missing", "unknown"}, + "package_observation", + ) now = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -152,12 +168,22 @@ def update(state: dict[str, Any]) -> dict[str, Any]: state["updated_at"] = now history = state.setdefault("operation_history", []) entry = { - "id": len(history) + 1, + "id": max( + ( + int(item.get("id", 0)) + for item in history + if isinstance(item, dict) + ), + default=0, + ) + + 1, "name": operation, "type": operation.split(".", 1)[0], "status": status, "at": now, } + if package is not None: + entry["package"] = package state["last_operation"] = entry history_entry = dict(entry) if error is not None: @@ -182,8 +208,54 @@ def update(state: dict[str, Any]) -> dict[str, Any]: state["provider_state"] = new_provider if provision_state: state["provision_state"] = provision_state + elif operation == "provision": + from eve_sdk import instance_fsm + + new_provision = instance_fsm.provision_state_after( + str(state.get("provision_state", "unknown")), + instance_fsm.provision_event(status), + ) + if new_provision is not None: + state["provision_state"] = new_provision + if package and package_state: - state.setdefault("package_state", {})[package] = {"status": package_state, "updated_at": now} + state.setdefault("package_state", {})[package] = { + "status": package_state, + "updated_at": now, + } + elif package: + from eve_sdk import instance_fsm + + command = operation.split(".", 1)[1] if "." in operation else "" + event = package_event or instance_fsm.package_operation_event(command, status) + current = str( + state.setdefault("package_state", {}) + .get(package, {}) + .get("status", "unknown") + ) + new_package = instance_fsm.package_state_after(current, event) + if new_package is not None: + state["package_state"][package] = { + "status": new_package, + "updated_at": now, + } + if package_observations: + from eve_sdk import instance_fsm + + package_states = state.setdefault("package_state", {}) + for package_id, observed_status in sorted(package_observations.items()): + current = str( + package_states.get(package_id, {}).get("status", "unknown") + ) + new_package = instance_fsm.package_state_after( + current, + instance_fsm.package_observation_event(observed_status), + ) + if new_package is not None: + package_states[package_id] = { + "status": new_package, + "updated_at": now, + } return state return cls.modify(instance_name, update) @@ -223,9 +295,39 @@ def update(state: dict[str, Any]) -> dict[str, Any]: "provider.ip", "provider.ssh", }: - state["provider_state"] = "error" + from eve_sdk import instance_fsm + + recovered_provider = instance_fsm.provider_state_after( + str(state.get("provider_state", "unknown")), "op_fail" + ) + if recovered_provider is not None: + state["provider_state"] = recovered_provider if recovered.get("type") == "provision": - state["provision_state"] = "error" + from eve_sdk import instance_fsm + + recovered_provision = instance_fsm.provision_state_after( + str(state.get("provision_state", "unknown")), "provision_fail" + ) + if recovered_provision is not None: + state["provision_state"] = recovered_provision + if recovered.get("type") == "package" and recovered.get("package"): + from eve_sdk import instance_fsm + + package_id = str(recovered["package"]) + command = str(recovered.get("name", "")).split(".", 1)[-1] + package_states = state.setdefault("package_state", {}) + current = str( + package_states.get(package_id, {}).get("status", "unknown") + ) + recovered_package = instance_fsm.package_state_after( + current, + instance_fsm.package_operation_event(command, "failed"), + ) + if recovered_package is not None: + package_states[package_id] = { + "status": recovered_package, + "updated_at": now, + } return state return cls.modify(instance_name, update) diff --git a/poetry.lock b/poetry.lock index 4c221db4..4bfacfbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. [[package]] name = "attrs" @@ -49,14 +49,14 @@ files = [ [[package]] name = "determa-state" -version = "0.1.0" +version = "0.2.0" description = "Determa State — Python reference implementation of the Determa statechart engine" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "determa_state-0.1.0-py3-none-any.whl", hash = "sha256:c1ddde17d4dadb9797d727fce75186cb3609d479c902098f12ddb49625bc7709"}, - {file = "determa_state-0.1.0.tar.gz", hash = "sha256:7adeda9877e1698262918ca36bf38ea420aa6ae7820abd442f5034cad9fbd5ab"}, + {file = "determa_state-0.2.0-py3-none-any.whl", hash = "sha256:8aee5f6cac1086fd3731b39dc7f946eda0a56b30e6966384c7246588846b0e21"}, + {file = "determa_state-0.2.0.tar.gz", hash = "sha256:ecc6269dfb0938d09f887da6a874f26121329b528086302616ef6493ba64f1e7"}, ] [package.dependencies] @@ -66,7 +66,8 @@ pyyaml = ">=6" rfc8785 = ">=0.1.4" [package.extras] -dev = ["mypy", "pytest (>=8)", "ruff", "types-jsonschema", "types-pyyaml"] +dev = ["build (>=1)", "hatchling", "mypy", "pytest (>=8)", "ruff", "types-jsonschema", "types-pyyaml"] +postgresql = ["psycopg[binary] (>=3.1,<4)"] [[package]] name = "google-re2" @@ -1034,4 +1035,4 @@ test = ["coverage", "pytest", "pytest-cov"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "07004ccc761288f7f2999f1b91ae759c9b6eb9ea211cafb5cf3845c75e4a8b8f" +content-hash = "3c9a2848f4515bf72a3272263e3520cb0ae3ef239f13d0a93898031b19436de3" diff --git a/pyproject.toml b/pyproject.toml index 4c542743..2afa26e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ license = "MIT" readme = "README.md" requires-python = ">=3.12,<4.0" dependencies = [ - "determa-state (>=0.1.0,<0.2.0)", + "determa-state (>=0.2.0,<0.3.0)", "jsonschema (>=4.25.1,<5.0.0)", "pyyaml (>=6.0.3,<7.0.0)", "textual (>=8.2.5,<9.0.0)" diff --git a/scripts/instance-provision b/scripts/instance-provision index c0a3bc6f..42fb9fb5 100755 --- a/scripts/instance-provision +++ b/scripts/instance-provision @@ -76,7 +76,11 @@ def main(argv: list[str]) -> int: state_disabled = os.environ.get("EVE_DISABLE_STATE", "0") == "1" or dry_run - def record_state(status: str, provision_state: str, error: str = "") -> None: + def record_state( + status: str, + error: str = "", + package_observations: dict[str, str] | None = None, + ) -> None: if state_disabled: return cmd = [ @@ -84,10 +88,14 @@ def main(argv: list[str]) -> int: "--instance", instance_name, "--operation", "provision", "--status", status, - "--provision-state", provision_state, ] if error: cmd += ["--error", error] + if package_observations: + cmd += [ + "--package-observations-json", + json.dumps(package_observations, separators=(",", ":")), + ] # set -e: a failing instance-state call is fatal with its exit status. state_rc = subprocess.run( cmd, cwd=root, stdout=subprocess.DEVNULL, check=False, @@ -95,9 +103,9 @@ def main(argv: list[str]) -> int: if state_rc != 0: raise SystemExit(state_rc) - def mark_selected_packages_installed() -> None: + def selected_package_observations() -> dict[str, str]: if state_disabled: - return + return {} # stdout captured (replaces the `package-list | jq` pipeline); stderr of # package-list passes through. Under pipefail a failure is fatal. result = subprocess.run( @@ -108,24 +116,14 @@ def main(argv: list[str]) -> int: raise SystemExit(result.returncode) doc = json.loads(result.stdout) packages = doc.get("packages", []) if isinstance(doc, dict) else [] + observations: dict[str, str] = {} for package in packages: if not isinstance(package, dict): continue if package.get("selected") is True and package.get("installable") is True: package_id = str(package.get("id")) - state_rc = subprocess.run( - [ - str(root / "scripts/instance-state"), - "--instance", instance_name, - "--operation", "package.install", - "--status", "succeeded", - "--package", package_id, - "--package-state", "installed", - ], - cwd=root, stdout=subprocess.DEVNULL, check=False, - ).returncode - if state_rc != 0: - raise SystemExit(state_rc) + observations[package_id] = "installed" + return observations if dry_run: emit_json({ @@ -137,7 +135,7 @@ def main(argv: list[str]) -> int: }) return 0 - record_state("running", "provisioning") + record_state("running") if force: clear_rc = subprocess.run( @@ -145,7 +143,7 @@ def main(argv: list[str]) -> int: cwd=root, check=False, ).returncode if clear_rc != 0: - record_state("failed", "error", "provision.clear-state failed") + record_state("failed", "provision.clear-state failed") return 1 provision_script = os.environ.get("EVE_PROVISION_SCRIPT", str(root / "scripts/provision")) @@ -161,6 +159,7 @@ def main(argv: list[str]) -> int: resolve_cmd, cwd=root, text=True, stdout=subprocess.PIPE, check=False, ) if resolved.returncode != 0: + record_state("failed", "instance resolution failed") return resolved.returncode os.environ["EVE_RESOLVED_JSON"] = resolved.stdout.rstrip("\n") @@ -168,11 +167,17 @@ def main(argv: list[str]) -> int: [provision_script, instance_name], cwd=root, check=False, ).returncode if exit_code != 0: - record_state("failed", "error", f"exit {exit_code}") + record_state("failed", f"exit {exit_code}") return exit_code - mark_selected_packages_installed() - record_state("succeeded", "provisioned") + try: + package_observations = selected_package_observations() + except (json.JSONDecodeError, OSError, SystemExit) as error: + record_state("failed", f"package state recording failed: {error}") + if isinstance(error, SystemExit) and isinstance(error.code, int): + return error.code + return 1 + record_state("succeeded", package_observations=package_observations) return 0 diff --git a/scripts/instance-state b/scripts/instance-state index bcfd353e..112746a6 100755 --- a/scripts/instance-state +++ b/scripts/instance-state @@ -24,7 +24,8 @@ def parser() -> argparse.ArgumentParser: " scripts/instance-state --instance --observed-json \n" " scripts/instance-state --instance --operation --status " "[--desired-state ] [--provider-state ] [--provision-state ] " - "[--package --package-state ] [--error ]" + "[--package --package-state ] " + "[--package-observations-json ] [--error ]" ), add_help=True, ) @@ -37,6 +38,7 @@ def parser() -> argparse.ArgumentParser: arg_parser.add_argument("--provider-state") arg_parser.add_argument("--provision-state") arg_parser.add_argument("--package") + arg_parser.add_argument("--package-observations-json") arg_parser.add_argument("--package-state") arg_parser.add_argument("--observed-json") arg_parser.add_argument("--error") @@ -61,6 +63,14 @@ def main() -> int: return 2 if (args.package is None) != (args.package_state is None): raise ValueError("package and package_state must be provided together") + package_observations = None + if args.package_observations_json: + package_observations = json.loads(args.package_observations_json) + if not isinstance(package_observations, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in package_observations.items() + ): + raise ValueError("package_observations_json must be an object of strings") state = State.record_operation( args.instance, args.operation, @@ -70,6 +80,7 @@ def main() -> int: provider_state=args.provider_state, provision_state=args.provision_state, package=args.package, + package_observations=package_observations, package_state=args.package_state, ) print(json.dumps(state, indent=2)) diff --git a/scripts/package-action b/scripts/package-action index 68835d82..f11368ac 100755 --- a/scripts/package-action +++ b/scripts/package-action @@ -2,7 +2,6 @@ from __future__ import annotations import json -import os import sys from pathlib import Path from typing import Any @@ -10,9 +9,17 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from eve_sdk.dispatch import DispatchError, exec_cmd, prepare_overlay, run_json # noqa: E402 -from eve_sdk.instance_dispatch import load_runtime_env # noqa: E402 -from eve_sdk.remote_launch import instance_ip, resolve_env, resolve_private_key # noqa: E402 +from eve_sdk.dispatch import ( # noqa: E402 + DispatchError, + exec_cmd, + prepare_overlay, + process_environment, + run_json, +) +from eve_sdk.package_dispatch import resolved_package_environment # noqa: E402 +from eve_sdk.plugin_manifest import PluginManifest # noqa: E402 +from eve_sdk.remote_launch import instance_ip, resolve_env # noqa: E402 +from eve_sdk.resolve import resolve_instance # noqa: E402 from eve_sdk.workdir import Workdir # noqa: E402 @@ -52,8 +59,13 @@ def parse_args(argv: list[str]) -> tuple[str, str, str, bool]: # ---- generic launcher path (v4.4 §8) ------------------------------------- # -def export_remote_context(root: Path, instance_name: str, target: str) -> None: - """Resolve and export the shared launcher context for a package exec. +def export_remote_context( + root: Path, + instance_name: str, + target: str, + base_env: dict[str, str], +) -> dict[str, str]: + """Resolve the shared launcher context for a package exec. The documented stable contract is the ``EVE_REMOTE_*`` set below; the full ``profile-resolve`` env (incl. ``BUNDLE_PACKAGES``, ``OS_FAMILY``, @@ -63,12 +75,17 @@ def export_remote_context(root: Path, instance_name: str, target: str) -> None: """ profile_env = resolve_env(root, instance_name) ip = instance_ip(root, instance_name) - os.environ.update(profile_env) - os.environ.update( + env = dict(base_env) + env.update(profile_env) + key_file = env.get("SSH_PUBLIC_KEY_FILE", "") + private_key = key_file[:-4] if key_file.endswith(".pub") else env.get( + "SSH_PRIVATE_KEY_FILE", "" + ) + env.update( { "EVE_REMOTE_IP": ip, "EVE_REMOTE_USER": profile_env.get("VM_USER_NAME") or profile_env.get("SSH_USER", ""), - "EVE_REMOTE_KEY": resolve_private_key(), + "EVE_REMOTE_KEY": private_key, "EVE_REMOTE_OS_FAMILY": profile_env.get("OS_FAMILY", ""), "EVE_REMOTE_ENGINE": profile_env.get("ENGINE", ""), "EVE_REMOTE_ACTION": target, @@ -79,13 +96,18 @@ def export_remote_context(root: Path, instance_name: str, target: str) -> None: "EVE_REMOTE_INSTANCE": instance_name, } ) + return env -def exec_launcher(exec_rel: str, package: dict[str, Any], target: str) -> None: +def exec_launcher( + exec_rel: str, + package: dict[str, Any], + target: str, + env: dict[str, str], +) -> None: """Resolve an action ``exec`` (relative to the plugin dir) and run it. - Inherits os.environ, so the ``EVE_REMOTE_*`` context exported by - :func:`export_remote_context` is visible to the launcher. + Receives only the operation-local package and ``EVE_REMOTE_*`` environment. """ plugin_dir = Path(str(package.get("path", ""))).resolve().parent exec_path = Path(exec_rel) @@ -93,7 +115,7 @@ def exec_launcher(exec_rel: str, package: dict[str, Any], target: str) -> None: exec_path = (plugin_dir / exec_rel).resolve() if not exec_path.is_file(): raise DispatchError(f"launcher exec not found for action {target}: {exec_rel}") - exec_cmd([str(exec_path)]) + exec_cmd([str(exec_path)], env) def run_action_target( @@ -102,6 +124,8 @@ def run_action_target( instance_name: str, target_map: dict[str, dict[str, Any]], root: Path, + resolved: dict[str, Any], + operation_env: dict[str, str], ) -> None: """Dispatch a package action to its declared launcher command. @@ -117,28 +141,46 @@ def run_action_target( wf = target_map.get(wait_for) if wf is None: raise DispatchError(f"wait_for target not found: {wait_for}") - run_action_target(wf["action"], wf["package"], instance_name, target_map, root) + run_action_target( + wf["action"], + wf["package"], + instance_name, + target_map, + root, + resolved, + operation_env, + ) exec_rel = action.get("exec") if not exec_rel: raise DispatchError( f"action {target} has no 'exec' launcher; the owning package must declare one" ) - export_remote_context(root, instance_name, target) - exec_launcher(exec_rel, package, target) + package_env = resolved_package_environment(package, resolved, operation_env) + remote_env = export_remote_context(root, instance_name, target, package_env) + exec_launcher(exec_rel, package, target, remote_env) def main(argv: list[str]) -> int: instance_name, package_id, action_id, dry_run = parse_args(argv) - # Package launchers read config-derived env (e.g. VM_USER_NAME). The old - # `make` layer injected config-env before every recipe; load it here like - # instance-run does. - load_runtime_env(Workdir.repo_root()) try: + # Registry selection is a transport concern, not package configuration. + inherited = process_environment() + registry_path = inherited.get("EVE_INSTANCE_REGISTRY") + resolved = resolve_instance(instance_name, registry_path) doc = run_json(str(Workdir.repo_root() / "scripts/package-list"), "--instance", instance_name, "--json") - package = next((entry for entry in doc["packages"] if entry["id"] == package_id), None) - if package is None: + package_view = next( + (entry for entry in doc["packages"] if entry["id"] == package_id), None + ) + if package_view is None: raise DispatchError(f"unknown package: {package_id}") + installed_packages = { + str(plugin["id"]): PluginManifest.public(plugin) + for plugin in PluginManifest.load_all("package") + } + package = installed_packages.get(package_id) + if package is None: + raise DispatchError(f"package plugin not found: {package_id}") action = next((entry for entry in package.get("actions", []) if entry["id"] == action_id), None) if action is None: raise DispatchError(f"unknown action {action_id} for package {package_id}") @@ -160,15 +202,30 @@ def main(argv: list[str]) -> int: ) ) return 0 - overlay = prepare_overlay(instance_name, os.environ.get("EVE_INSTANCE_REGISTRY")) - os.environ["EVE_CATALOG_LOCAL"] = overlay - os.environ["EVE_INSTANCE_NAME"] = instance_name + overlay = prepare_overlay(instance_name, registry_path) + operation_env = {"EVE_CATALOG_LOCAL": overlay, "EVE_INSTANCE_NAME": instance_name} + if registry_path: + operation_env["EVE_INSTANCE_REGISTRY"] = registry_path # target -> {package, action} for wait_for resolution. target_map: dict[str, dict[str, Any]] = {} - for pkg in doc["packages"]: - for act in pkg.get("actions", []): - target_map[act["target"]] = {"package": pkg, "action": act} - run_action_target(action, package, instance_name, target_map, Workdir.repo_root()) + for pkg_view in doc["packages"]: + full_package = installed_packages.get(str(pkg_view["id"])) + if full_package is None: + continue + for act in full_package.get("actions", []): + target_map[act["target"]] = { + "action": act, + "package": full_package, + } + run_action_target( + action, + package, + instance_name, + target_map, + Workdir.repo_root(), + resolved, + operation_env, + ) return 0 except SystemExit as error: raise error diff --git a/scripts/package-provision b/scripts/package-provision index a55463a3..1853d596 100755 --- a/scripts/package-provision +++ b/scripts/package-provision @@ -14,6 +14,7 @@ from __future__ import annotations import json import os +import shlex import shutil import subprocess import sys @@ -24,7 +25,11 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from eve_sdk.config import ConfigEnv # noqa: E402 -from eve_sdk.package_dispatch import emit_json # noqa: E402 +from eve_sdk.package_dispatch import ( # noqa: E402 + emit_json, + package_environment_names, + resolved_package_set_environment, +) from eve_sdk.resolve import instance_package_config # noqa: E402 from eve_sdk.tf_dispatch import repo_root, resolve_env # noqa: E402 @@ -86,7 +91,12 @@ def _ssh_retry(label: str, cmd: list[str]) -> int: def _env_line(key: str, value: str) -> str: - return f'export {key}="{value}"' + return f"export {key}={shlex.quote(value)}" + + +def _write_private_text(path: Path, value: str) -> None: + path.write_text(value, encoding="utf-8") + path.chmod(0o600) def main(argv: list[str]) -> int: @@ -174,6 +184,25 @@ def main(argv: list[str]) -> int: print(f"package-provision: no install chain for {package}", file=sys.stderr) return 2 + chain_ids = {str(plugin["id"]) for plugin in chain} + applicable_ids = chain_ids + applicable_plugins = [ + plugin for plugin in plugins if str(plugin["id"]) in applicable_ids + ] + all_package_config = instance_package_config(instance_name) + applicable_config = { + package_id: value + for package_id, value in all_package_config.items() + if package_id in applicable_ids + } + package_env = resolved_package_set_environment( + applicable_plugins, applicable_config + ) + applicable_env_names = package_environment_names(applicable_plugins) + stage_env_names = package_environment_names( + applicable_plugins, path_config_only=True + ) + steps: list[str] = [] step_roots: list[str] = [] package_markers: list[str] = [] @@ -280,7 +309,7 @@ def main(argv: list[str]) -> int: bundle_packages.replace(",", "\n") + "\n" ) - host_tz = os.environ.get("TIMEZONE", "") + host_tz = package_env.get("TIMEZONE", "") if not host_tz: tz_result = subprocess.run( [str(ROOT / "scripts/host-timezone")], @@ -292,15 +321,15 @@ def main(argv: list[str]) -> int: # the remote and rewrite each env var to its remote path — generic, so # core names no package. (local_path, remote_rel_dir); uploaded below. stage_files: list[tuple[str, str]] = [] - for stage_var in ConfigEnv.package_stage_env_names(): - local_path = os.environ.get(stage_var, "") + for stage_var in stage_env_names: + local_path = package_env.get(stage_var, "") if not local_path: continue if not Path(local_path).exists(): print(f"package-provision: {stage_var} does not exist: {local_path}", file=sys.stderr) return 2 remote_rel = f"provision/downloads/{stage_var.lower()}" - os.environ[stage_var] = f"$HOME/{remote_rel}/{Path(local_path).name}" + package_env[stage_var] = f"$HOME/{remote_rel}/{Path(local_path).name}" stage_files.append((local_path, remote_rel)) # Core (non-plugin) provision env; plugin env appended generically below @@ -315,22 +344,17 @@ def main(argv: list[str]) -> int: _env_line("PROVISION_USER_NAME", resolved.get("PROVISION_USER_NAME", "")), _env_line("VM_USER_NAME", resolved.get("VM_USER_NAME", "")), _env_line("VM_USER_PASSWORD", os.environ.get("VM_USER_PASSWORD", "")), - _env_line("EPHEMERAL_DISPLAY_RESOLUTION", os.environ.get("EPHEMERAL_DISPLAY_RESOLUTION", "")), - _env_line("EPHEMERAL_DISPLAY_FPS", os.environ.get("EPHEMERAL_DISPLAY_FPS", "")), + _env_line("EPHEMERAL_DISPLAY_RESOLUTION", package_env.get("EPHEMERAL_DISPLAY_RESOLUTION", "")), + _env_line("EPHEMERAL_DISPLAY_FPS", package_env.get("EPHEMERAL_DISPLAY_FPS", "")), _env_line("PROVIDER", provider), _env_line("TIMEZONE", host_tz), - _env_line("VIRTUAL_MONITOR_WIDTH", os.environ.get("VIRTUAL_MONITOR_WIDTH", "1920")), - _env_line("VIRTUAL_MONITOR_HEIGHT", os.environ.get("VIRTUAL_MONITOR_HEIGHT", "1080")), ] - # Per-instance package option overrides (§16): instance scope wins over - # the user-scope config-env in os.environ before the generic plugin loop - # reads it. Field→env_var mapping comes from each package manifest. - os.environ.update(ConfigEnv.instance_package_env(instance_package_config(instance_name))) env_lines += [ - _env_line(_name, os.environ.get(_name, "")) - for _name in ConfigEnv.plugin_provision_env_names() + _env_line(name, package_env.get(name, "")) + for name in applicable_env_names ] - (stage / "state" / "env").write_text("\n".join(env_lines) + "\n") + env_path = stage / "state" / "env" + _write_private_text(env_path, "\n".join(env_lines) + "\n") print(f"[package-provision] uploading {os_provision_dir} scripts for package={package}") uploads: list[tuple[str, list[str]]] = [( @@ -363,10 +387,22 @@ def main(argv: list[str]) -> int: if status != 0: return status + status = _ssh_retry( + "protect linux package environment", + [ssh, instance_name, "--", "chmod 0600 ~/provision/state/env"], + ) + if status != 0: + _ssh_retry( + "remove unprotected linux package environment", + [ssh, instance_name, "--", "rm -f ~/provision/state/env"], + ) + return status + remote_steps = "".join(f" ~/provision/scripts/steps/{Path(s).name}" for s in steps) print(f"[package-provision] running package={package} steps:{remote_steps}") remote_cmd = ( - 'set -e; export PROVISION_ROOT="$HOME/provision"; ' + 'set -e; trap \'rm -f "$HOME/provision/state/env"\' EXIT; ' + 'export PROVISION_ROOT="$HOME/provision"; ' '. "$HOME/provision/state/env"; ' "chmod +x ~/provision/scripts/steps/*.sh ~/provision/scripts/lib/*.sh; " f"for step in{remote_steps}; do " diff --git a/scripts/package-provision-windows b/scripts/package-provision-windows index 961e2f0b..d0d0b558 100755 --- a/scripts/package-provision-windows +++ b/scripts/package-provision-windows @@ -24,7 +24,12 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from eve_sdk.package_dispatch import emit_json # noqa: E402 +from eve_sdk.package_dispatch import ( # noqa: E402 + emit_json, + package_environment_names, + resolved_package_set_environment, +) +from eve_sdk.resolve import instance_package_config # noqa: E402 from eve_sdk.tf_dispatch import repo_root, resolve_env # noqa: E402 _USAGE = ( @@ -210,7 +215,15 @@ def main(argv: list[str]) -> int: windows_password = pw_result.stdout.strip() if pw_result.returncode == 0 else "" from eve_sdk.config import ConfigEnv - env_payload = ConfigEnv.provision_env_payload(windows_password) + all_config = instance_package_config(instance_name) + package_config = ( + {package: all_config[package]} if package in all_config else {} + ) + package_env = resolved_package_set_environment([entry], package_config) + env_names = package_environment_names([entry]) + env_payload = ConfigEnv.provision_env_payload( + windows_password, package_env, env_names + ) (stage / "state").mkdir(parents=True, exist_ok=True) (stage / "state" / "env.json").write_text( json.dumps(env_payload, indent=2, ensure_ascii=False) + "\n", @@ -253,16 +266,42 @@ def main(argv: list[str]) -> int: status = _ssh_retry(label, cmd) if status != 0: return status + if needs_env: + status = _instance_ssh( + instance_name, + "--", + f"icacls '{_REMOTE_STATE_DIR}\\env.json' /inheritance:r " + '/grant:r "Administrator:F" "SYSTEM:F" | Out-Null', + ) + if status != 0: + _instance_ssh( + instance_name, + "--", + f"Remove-Item -Force -ErrorAction SilentlyContinue '{_REMOTE_STATE_DIR}\\env.json'", + ) + return status + step_status = 0 for step in steps: step_file = Path(step).name print(f"[package-provision-windows] running step={step_file} package={package}") - status = _instance_ssh( + step_status = _instance_ssh( instance_name, "--", f"& 'C:\\Users\\Administrator\\provision\\scripts-package\\steps\\{step_file}'", ) - if status != 0: - return status + if step_status != 0: + break + cleanup_status = 0 + if needs_env: + cleanup_status = _instance_ssh( + instance_name, + "--", + f"Remove-Item -Force -ErrorAction SilentlyContinue '{_REMOTE_STATE_DIR}\\env.json'", + ) + if step_status != 0: + return step_status + if cleanup_status != 0: + return cleanup_status finally: shutil.rmtree(stage, ignore_errors=True) diff --git a/scripts/providers-status b/scripts/providers-status index 164c35b0..c58e2a32 100755 --- a/scripts/providers-status +++ b/scripts/providers-status @@ -18,7 +18,6 @@ command's own ``configured`` field is ignored (it only needs to answer reachabil from __future__ import annotations import json -import os import subprocess import sys from pathlib import Path @@ -27,11 +26,10 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from eve_sdk.config import ConfigEnv # noqa: E402 -from eve_sdk.dispatch import command_vector # noqa: E402 +from eve_sdk.dispatch import command_env, command_vector # noqa: E402 from eve_sdk.fsm import EveFsm, provider_configured_resolver # noqa: E402 from eve_sdk.plugin_manifest import PluginManifest # noqa: E402 -from eve_sdk.provider_command import _inject_secrets # noqa: E402 +from eve_sdk.provider_command import resolved_provider_environment # noqa: E402 CORE_PROVIDER_MACHINE = ROOT / "core" / "fsm" / "provider.yaml" @@ -55,17 +53,16 @@ def _run_connectivity(plugin: dict[str, Any]) -> tuple[str, str, str]: """Run a provider's connectivity command, parse JSON → (configured, reachable, notes).""" spec = plugin["commands"]["connectivity"] cmd = command_vector(plugin, spec) - # Resolve the SAME env the real dispatch path gives a provider command: - # global config-env plus the provider's injected secrets. Without this the - # probe ran with a bare environment, so a provider whose "configured" gate is - # a secret (e.g. Vultr's VULTR_API_KEY, which lives only in the secrets file) - # was reported unconfigured even though dispatch could create instances with - # it. Status and dispatch must resolve config/secrets identically. (v4.5: this - # injection should become the one shared helper the FSM platform provides.) - env = dict(os.environ) - env.update(ConfigEnv.environment()) - env = _inject_secrets(str(plugin["id"]), plugin, env) - result = subprocess.run(cmd, env=env, capture_output=True, text=True, check=False) + # Use the same shared environment builder as provider dispatch, including + # ConfigEnv settings and injected secrets (v4.5 M/O: status == dispatch). + env = resolved_provider_environment(str(plugin["id"]), plugin) + result = subprocess.run( + cmd, + env=command_env(env), + capture_output=True, + text=True, + check=False, + ) if result.returncode != 0: return "no", "no", f"connectivity check failed (exit {result.returncode})" try: diff --git a/scripts/provision b/scripts/provision index d4858972..03d24387 100755 --- a/scripts/provision +++ b/scripts/provision @@ -13,6 +13,7 @@ from __future__ import annotations import hashlib import json import os +import shlex import subprocess import sys import tempfile @@ -25,6 +26,10 @@ sys.path.insert(0, str(ROOT)) from eve_sdk.config import ConfigEnv # noqa: E402 from eve_sdk.instance_dispatch import load_runtime_env # noqa: E402 +from eve_sdk.package_dispatch import ( # noqa: E402 + package_environment_names, + resolved_package_set_environment, +) from eve_sdk.plugin_manifest import PluginManifest # noqa: E402 from eve_sdk.resolve import instance_package_config # noqa: E402 @@ -66,10 +71,39 @@ def _parse_env(output: str) -> dict[str, str]: return env +def _env_line(key: str, value: str) -> str: + return f"export {key}={shlex.quote(value)}" + + +def _write_private_text(path: Path, value: str) -> None: + path.write_text(value, encoding="utf-8") + path.chmod(0o600) + + def _selected_packages(bundle_packages: str) -> list[str]: return [pkg for pkg in bundle_packages.split(",") if pkg] +def _selected_package_environment( + plugin_doc: dict, + bundle_packages: str, + profile: str, +) -> tuple[list[dict], dict[str, str]]: + selected = set(_selected_packages(bundle_packages)) + plugins = [ + plugin + for plugin in plugin_doc.get("plugins") or [] + if isinstance(plugin, dict) and str(plugin.get("id")) in selected + ] + all_config = instance_package_config(profile) + selected_config = { + package_id: value + for package_id, value in all_config.items() + if package_id in selected + } + return plugins, resolved_package_set_environment(plugins, selected_config) + + def _plugin_ubuntu_steps(plugin_doc: dict, bundle_packages: str) -> list[tuple[str, str]]: """Return [(plugin_path, step_name)] in selection order from BUNDLE_PACKAGES.""" selected = _selected_packages(bundle_packages) @@ -201,6 +235,9 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: [str(root / "scripts/plugin-list"), "--kind", "package", "--json"], cwd=root, capture_output=True, text=True, check=False, ).stdout) + selected_plugins, package_env = _selected_package_environment( + plugin_doc, bundle_packages, profile + ) for plugin_path, step in _plugin_ubuntu_steps(plugin_doc, bundle_packages): if not plugin_path: continue @@ -251,18 +288,20 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: # (local_path, remote_rel_dir) and uploaded after the provision dir is # prepared, below. stage_files: list[tuple[str, str]] = [] - for stage_var in ConfigEnv.package_stage_env_names(): - local_path = os.environ.get(stage_var, "") + for stage_var in package_environment_names( + selected_plugins, path_config_only=True + ): + local_path = package_env.get(stage_var, "") if not local_path: continue if not Path(local_path).exists(): print(f"[provision] {stage_var} does not exist: {local_path}", file=sys.stderr) return 2 remote_rel = f"provision/downloads/{stage_var.lower()}" - os.environ[stage_var] = f"$HOME/{remote_rel}/{os.path.basename(local_path)}" + package_env[stage_var] = f"$HOME/{remote_rel}/{os.path.basename(local_path)}" stage_files.append((local_path, remote_rel)) - host_tz = os.environ.get("TIMEZONE", "") + host_tz = package_env.get("TIMEZONE", "") if not host_tz: tz_result = subprocess.run( [str(root / "scripts/host-timezone")], cwd=root, @@ -274,34 +313,24 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: # Core (non-plugin) provision env. Plugin env is appended generically # below so core names no provider or package (v4.4 §15.5). env_lines = [ - f'export ACCESS_BOOTSTRAP_USER="{env.get("ACCESS_BOOTSTRAP_USER", "")}"', - f'export ACCESS_HUMAN_USER="{env.get("ACCESS_HUMAN_USER", "")}"', - f'export ACCESS_PROVISION_USER="{env.get("ACCESS_PROVISION_USER", "")}"', - f'export OS_FAMILY="{os_family}"', - f'export PROFILE_NAME="{profile_name}"', - f'export HUMAN_USER_NAME="{env.get("HUMAN_USER_NAME", "")}"', - f'export PROVISION_USER_NAME="{env.get("PROVISION_USER_NAME", "")}"', - f'export VM_USER_NAME="{env.get("VM_USER_NAME", "")}"', - f'export VM_USER_PASSWORD="{os.environ.get("VM_USER_PASSWORD", "")}"', - f'export EPHEMERAL_DISPLAY_RESOLUTION="{os.environ.get("EPHEMERAL_DISPLAY_RESOLUTION", "")}"', - f'export EPHEMERAL_DISPLAY_FPS="{os.environ.get("EPHEMERAL_DISPLAY_FPS", "")}"', - f'export PROVIDER="{provider}"', - f'export TIMEZONE="{host_tz}"', - f'export VIRTUAL_MONITOR_WIDTH="{os.environ.get("VIRTUAL_MONITOR_WIDTH", "1920")}"', - f'export VIRTUAL_MONITOR_HEIGHT="{os.environ.get("VIRTUAL_MONITOR_HEIGHT", "1080")}"', + _env_line("ACCESS_BOOTSTRAP_USER", env.get("ACCESS_BOOTSTRAP_USER", "")), + _env_line("ACCESS_HUMAN_USER", env.get("ACCESS_HUMAN_USER", "")), + _env_line("ACCESS_PROVISION_USER", env.get("ACCESS_PROVISION_USER", "")), + _env_line("OS_FAMILY", os_family), + _env_line("PROFILE_NAME", profile_name), + _env_line("HUMAN_USER_NAME", env.get("HUMAN_USER_NAME", "")), + _env_line("PROVISION_USER_NAME", env.get("PROVISION_USER_NAME", "")), + _env_line("VM_USER_NAME", env.get("VM_USER_NAME", "")), + _env_line("VM_USER_PASSWORD", os.environ.get("VM_USER_PASSWORD", "")), + _env_line("EPHEMERAL_DISPLAY_RESOLUTION", package_env.get("EPHEMERAL_DISPLAY_RESOLUTION", "")), + _env_line("EPHEMERAL_DISPLAY_FPS", package_env.get("EPHEMERAL_DISPLAY_FPS", "")), + _env_line("PROVIDER", provider), + _env_line("TIMEZONE", host_tz), ] - # Per-instance package option overrides (§16): instance scope wins over - # the user-scope config-env already in os.environ, before the generic - # plugin loop reads it. Core names no package — the field→env_var mapping - # comes from each package manifest. - os.environ.update(ConfigEnv.instance_package_env(instance_package_config(profile))) - # Plugin-declared env (config + secrets) from installed provider/package - # manifests — generic, so no plugin id is named in core. Staged package - # files (above) were rewritten in os.environ to their remote paths, so - # they emit here with the remote value. - for _name in ConfigEnv.plugin_provision_env_names(): - env_lines.append(f'export {_name}="{os.environ.get(_name, "")}"') - (stage / "state" / "env").write_text("\n".join(env_lines) + "\n", encoding="utf-8") + for name in package_environment_names(selected_plugins): + env_lines.append(_env_line(name, package_env.get(name, ""))) + env_path = stage / "state" / "env" + _write_private_text(env_path, "\n".join(env_lines) + "\n") print(f"[provision] uploading {_provision_dir_display(os_provision_dir, root)}/ to remote") rc = _ssh_retry(profile, "prepare linux provision directory", @@ -334,14 +363,24 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: str(stage / "state" / "env"), "provision/state/") if rc: return rc + rc = _ssh_retry(profile, "protect linux provision environment", + "chmod 0600 ~/provision/state/env") + if rc: + _ssh_retry(profile, "remove unprotected linux provision environment", + "rm -f ~/provision/state/env") + return rc rc = _ssh_retry_scp(profile, "upload linux step list", str(stage / "state" / "steps.list"), "provision/state/") if rc: + _ssh_retry(profile, "remove linux provision environment after upload failure", + "rm -f ~/provision/state/env") return rc manifest_path = str(stage / "state" / "provision-manifest.json") rc = _ssh_retry_scp(profile, "upload linux provision manifest", manifest_path, "provision/state/") if rc: + _ssh_retry(profile, "remove linux provision environment after upload failure", + "rm -f ~/provision/state/env") return rc print("[provision] running bootstrap.sh") rc = _ssh_retry(profile, "start linux provisioning runner", @@ -349,11 +388,15 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: " ~/provision/scripts/steps/*.sh ~/provision/scripts/lib/*.sh" " && ~/provision/scripts/bootstrap.sh") if rc: + _ssh_retry(profile, "remove linux provision environment after bootstrap failure", + "rm -f ~/provision/state/env") return rc poll_interval = os.environ.get("EVE_PROVISION_WAIT_POLL_INTERVAL", "5") print(f"[provision] waiting for linux provisioning to finish (polling remote log every {poll_interval}s)") rc = _run([str(root / "scripts/wait-for-provision"), profile]).returncode - return rc + cleanup_rc = _ssh_retry(profile, "remove linux provision environment", + "rm -f ~/provision/state/env") + return rc if rc else cleanup_rc finally: import shutil shutil.rmtree(stage, ignore_errors=True) @@ -385,20 +428,26 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: ) return 1 - tmp_dir = root / "tmp" - tmp_dir.mkdir(parents=True, exist_ok=True) - env_json = ConfigEnv.provision_env_payload(windows_password) - - (tmp_dir / "env.json").write_text(json.dumps(env_json, indent=2) + "\n", encoding="utf-8") - + plugin_doc = json.loads(subprocess.run( + [str(root / "scripts/plugin-list"), "--kind", "package", "--json"], + cwd=root, capture_output=True, text=True, check=False, + ).stdout) + _selected_plugins, package_env = _selected_package_environment( + plugin_doc, bundle_packages, profile + ) stage = Path(tempfile.mkdtemp()) try: copytree(os_provision_dir, stage / "provision") + env_names = package_environment_names(_selected_plugins) + env_json = ConfigEnv.provision_env_payload( + windows_password, package_env, env_names + ) + state_dir = stage / "provision" / "state" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "env.json").write_text( + json.dumps(env_json, indent=2) + "\n", encoding="utf-8" + ) - plugin_doc = json.loads(subprocess.run( - [str(root / "scripts/plugin-list"), "--kind", "package", "--json"], - cwd=root, capture_output=True, text=True, check=False, - ).stdout) for plugin_path, step in _plugin_windows_steps(plugin_doc, bundle_packages): if not plugin_path or not step: continue @@ -449,9 +498,19 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: str(stage / "provision"), "/C:/Users/Administrator/") if rc: return rc - rc = _ssh_retry_scp(profile, "upload windows env.json", - str(tmp_dir / "env.json"), "/C:/Users/Administrator/provision/state/env.json") + rc = _ssh_retry( + profile, + "protect windows provision environment", + 'icacls "C:\\Users\\Administrator\\provision\\state\\env.json" ' + '/inheritance:r /grant:r "Administrator:F" "SYSTEM:F" | Out-Null', + ) if rc: + _ssh_retry( + profile, + "remove unprotected windows provision environment", + 'Remove-Item -Force -ErrorAction SilentlyContinue ' + '"C:\\Users\\Administrator\\provision\\state\\env.json"', + ) return rc print("[provision] running bootstrap.ps1") instance_ssh = str(root / "scripts/instance-ssh") @@ -460,11 +519,23 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: cwd=root, check=False, ) if result.returncode != 0 and result.returncode != 255: + _ssh_retry( + profile, + "remove windows provision environment after bootstrap failure", + 'Remove-Item -Force -ErrorAction SilentlyContinue ' + '"C:\\Users\\Administrator\\provision\\state\\env.json"', + ) return result.returncode poll_interval = os.environ.get("EVE_PROVISION_WAIT_POLL_INTERVAL", "5") print(f"[provision] waiting for windows provisioning to finish (polling remote log every {poll_interval}s)") rc = _run([str(root / "scripts/wait-for-provision"), profile]).returncode - return rc + cleanup_rc = _ssh_retry( + profile, + "remove windows provision environment", + 'Remove-Item -Force -ErrorAction SilentlyContinue ' + '"C:\\Users\\Administrator\\provision\\state\\env.json"', + ) + return rc if rc else cleanup_rc finally: import shutil shutil.rmtree(stage, ignore_errors=True) diff --git a/scripts/test b/scripts/test index c6447f61..155c5e3c 100755 --- a/scripts/test +++ b/scripts/test @@ -46,6 +46,14 @@ SUITE_SCRIPTS: dict[str, list[str]] = { def main(argv: list[str]) -> int: + # Match bin/eve: child scripts use the project environment, so repository + # tests never depend on or mutate a separate host-Python installation. + venv_bin = ROOT / ".venv" / "bin" + if not (venv_bin / "python").is_file(): + print("scripts/test: project environment missing; run poetry install", file=sys.stderr) + return 1 + os.environ["PATH"] = f"{venv_bin}{os.pathsep}{os.environ['PATH']}" + # Set hermetic plugin discovery for all test subprocesses. os.environ["EVE_PLUGIN_ROOTS"] = str(ROOT / "tests/fixtures/hermetic") os.environ["EVE_PLUGIN_ROOTS_EXCLUSIVE"] = "1" diff --git a/tests/python/commands/test_package_action_launch.py b/tests/python/commands/test_package_action_launch.py index 0919dde3..93fc1f42 100644 --- a/tests/python/commands/test_package_action_launch.py +++ b/tests/python/commands/test_package_action_launch.py @@ -4,7 +4,6 @@ import importlib.machinery import importlib.util -import os from pathlib import Path from typing import Any @@ -13,16 +12,6 @@ ROOT = Path(__file__).resolve().parents[3] -@pytest.fixture(autouse=True) -def _restore_environ() -> Any: - """export_remote_context mutates os.environ; restore it between tests so the - EVE_REMOTE_* leak does not break byte-identical env tests elsewhere.""" - snapshot = dict(os.environ) - yield - os.environ.clear() - os.environ.update(snapshot) - - def _load_package_action() -> Any: path = ROOT / "scripts" / "package-action" loader = importlib.machinery.SourceFileLoader("package_action_v44", str(path)) @@ -39,14 +28,22 @@ def pa() -> Any: return _load_package_action() -def _stub_spine(monkeypatch: pytest.MonkeyPatch, pa: Any, ip: str = "1.2.3.4") -> dict[str, list[list[str]]]: - monkeypatch.setattr(pa, "resolve_env", lambda root, inst: {"VM_USER_NAME": "alice", "OS_FAMILY": "ubuntu", "ENGINE": "terraform"}) +def _stub_spine(monkeypatch: pytest.MonkeyPatch, pa: Any, ip: str = "1.2.3.4") -> dict[str, Any]: + monkeypatch.setattr( + pa, + "resolve_env", + lambda root, inst: { + "ENGINE": "terraform", + "OS_FAMILY": "ubuntu", + "VM_USER_NAME": "alice", + }, + ) monkeypatch.setattr(pa, "instance_ip", lambda root, inst: ip) - monkeypatch.setattr(pa, "resolve_private_key", lambda: "/key") - captured: dict[str, list[list[str]]] = {"cmds": []} + captured: dict[str, Any] = {"cmds": [], "envs": []} - def fake_exec(cmd: list[str]) -> None: + def fake_exec(cmd: list[str], env: dict[str, str]) -> None: captured["cmds"].append(cmd) + captured["envs"].append(env) monkeypatch.setattr(pa, "exec_cmd", fake_exec) return captured @@ -61,10 +58,14 @@ def test_exec_action_exports_remote_context_and_runs( action = {"id": "open", "label": "Open", "target": "pkg.open", "exec": "remote-open"} captured = _stub_spine(monkeypatch, pa) - pa.run_action_target(action, package, "inst", {}, tmp_path) + resolved = {"instance": {"name": "inst"}, "package_config": {}} + operation_env = {"SSH_PUBLIC_KEY_FILE": "/key.pub"} + pa.run_action_target( + action, package, "inst", {}, tmp_path, resolved, operation_env + ) assert captured["cmds"] == [[str(tmp_path / "remote-open")]] - env = os.environ + env = captured["envs"][0] assert env["EVE_REMOTE_IP"] == "1.2.3.4" assert env["EVE_REMOTE_USER"] == "alice" assert env["EVE_REMOTE_OS_FAMILY"] == "ubuntu" @@ -83,7 +84,10 @@ def test_exec_action_wait_for_runs_target_first(pa: Any, monkeypatch: pytest.Mon target_map = {"pkg.wait": {"package": package, "action": wait_action}} captured = _stub_spine(monkeypatch, pa) - pa.run_action_target(open_action, package, "inst", target_map, tmp_path) + resolved = {"instance": {"name": "inst"}, "package_config": {}} + pa.run_action_target( + open_action, package, "inst", target_map, tmp_path, resolved, {} + ) assert captured["cmds"] == [[str(tmp_path / "wait")], [str(tmp_path / "open")]] @@ -93,4 +97,69 @@ def test_exec_action_missing_exec_fails_loudly(pa: Any, monkeypatch: pytest.Monk action = {"id": "open", "label": "Open", "target": "pkg.open", "exec": "no-such-file"} _stub_spine(monkeypatch, pa) with pytest.raises(pa.DispatchError, match="launcher exec not found"): - pa.run_action_target(action, package, "inst", {}, tmp_path) + pa.run_action_target( + action, + package, + "inst", + {}, + tmp_path, + {"instance": {"name": "inst"}, "package_config": {}}, + {}, + ) + + +def test_main_uses_full_manifest_for_action_environment( + pa: Any, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + action = { + "exec": "remote-open", + "id": "open", + "label": "Open", + "target": "pkg.open", + } + manifest = { + "_path": str(tmp_path / "eve-plugin.yaml"), + "_source": "test", + "actions": [action], + "config_schema": { + "secrets": {"token": {"env_var": "PKG_TOKEN"}}, + }, + "id": "pkg", + } + monkeypatch.setattr(pa, "process_environment", lambda: {}) + monkeypatch.setattr( + pa, + "resolve_instance", + lambda instance, registry: { + "instance": {"name": instance}, + "package_config": {}, + }, + ) + monkeypatch.setattr( + pa, + "run_json", + lambda *args: { + "packages": [{"actions": [action], "id": "pkg", "path": "reduced"}] + }, + ) + monkeypatch.setattr( + pa.PluginManifest, + "load_all", + classmethod(lambda cls, kind: [manifest]), + ) + monkeypatch.setattr(pa, "prepare_overlay", lambda instance, registry: "/overlay") + captured: dict[str, Any] = {} + + def fake_run( + selected_action: dict[str, Any], + package: dict[str, Any], + *args: Any, + ) -> None: + captured["action"] = selected_action + captured["package"] = package + + monkeypatch.setattr(pa, "run_action_target", fake_run) + + assert pa.main(["--instance", "inst", "--package", "pkg", "--action", "open"]) == 0 + assert captured["action"] == action + assert captured["package"]["config_schema"] == manifest["config_schema"] diff --git a/tests/python/commands/test_provision_secrets.py b/tests/python/commands/test_provision_secrets.py new file mode 100644 index 00000000..6793fc7a --- /dev/null +++ b/tests/python/commands/test_provision_secrets.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import importlib.machinery +import importlib.util +import stat +import subprocess +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + + +def _load_script(name: str) -> ModuleType: + path = ROOT / "scripts" / name + loader = importlib.machinery.SourceFileLoader(name.replace("-", "_"), str(path)) + spec = importlib.util.spec_from_loader(loader.name, loader) + assert spec is not None + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +@pytest.mark.parametrize("script", ["package-provision", "provision"]) +def test_shell_environment_values_round_trip_without_expansion(script: str) -> None: + module = _load_script(script) + hostile = 'cash$HOME $(printf injected) `printf injected` "quote" \\ slash' + source = module._env_line("TEST_SECRET", hostile) + + result = subprocess.run( + ["/bin/sh", "-c", f"{source}; printf %s \"$TEST_SECRET\""], + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout == hostile + + +@pytest.mark.parametrize("script", ["package-provision", "provision"]) +def test_secret_environment_file_is_owner_only(script: str, tmp_path: Path) -> None: + module = _load_script(script) + path = tmp_path / "env" + + module._write_private_text(path, "secret") + + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_provision_scripts_remove_remote_secret_files() -> None: + linux = (ROOT / "scripts" / "package-provision").read_text(encoding="utf-8") + full = (ROOT / "scripts" / "provision").read_text(encoding="utf-8") + windows = (ROOT / "scripts" / "package-provision-windows").read_text( + encoding="utf-8" + ) + + assert "trap \\'rm -f" in linux + assert "$HOME/provision/state/env" in linux + assert "remove linux provision environment" in full + assert "remove windows provision environment" in full + assert "Remove-Item -Force -ErrorAction SilentlyContinue" in windows diff --git a/tests/python/sdk/test_config_provider_paths.py b/tests/python/sdk/test_config_provider_paths.py index 02fdea24..e603532b 100644 --- a/tests/python/sdk/test_config_provider_paths.py +++ b/tests/python/sdk/test_config_provider_paths.py @@ -1,6 +1,9 @@ from __future__ import annotations + from pathlib import Path + import pytest + from eve_sdk.config import ConfigEnv @@ -22,6 +25,7 @@ def test_config_env_expands_provider_path_field(monkeypatch: pytest.MonkeyPatch, def test_config_env_has_no_provider_env_names_hardcoded() -> None: """Core no longer carries provider env-var literals (they live in manifests).""" import inspect + from eve_sdk import config as cfg src = inspect.getsource(cfg) for provider_env in ( @@ -39,18 +43,27 @@ def test_bootstrap_sudo_password_reads_declared_env(monkeypatch: pytest.MonkeyPa from eve_sdk import plugin_manifest fake = [ - {"id": "p1", "bootstrap": {"sudo_password_env": "P1_PW"}}, + { + "id": "p1", + "bootstrap": {"sudo_password_env": "P1_PW"}, + "config_schema": { + "secrets": { + "password": {"env_var": "P1_PW", "type": "string"} + } + }, + }, {"id": "p2"}, # declares no bootstrap ] monkeypatch.setattr( plugin_manifest.PluginManifest, "load_all", classmethod(lambda cls, kind=None: fake), ) - monkeypatch.setenv("P1_PW", "s3cret") + monkeypatch.setattr( + "eve_sdk.provider_command.Secrets.read", + staticmethod(lambda name: {"password": "s3cret"} if name == "p1" else {}), + ) assert ConfigEnv.bootstrap_sudo_password("p1") == "s3cret" # declared + set - monkeypatch.delenv("P1_PW") - assert ConfigEnv.bootstrap_sudo_password("p1") == "" # declared, unset assert ConfigEnv.bootstrap_sudo_password("p2") == "" # no bootstrap block assert ConfigEnv.bootstrap_sudo_password("missing") == "" # unknown provider @@ -84,6 +97,24 @@ def test_plugin_provision_env_names_aggregates_config_and_secrets( assert names == ["PKG_KEY_FILE", "PKG_PASSWORD", "PKG_VERSION"] +def test_provision_env_payload_includes_only_selected_package_names() -> None: + environment = { + "EPHEMERAL_DISPLAY_RESOLUTION": "1920x1080", + "SELECTED_PASSWORD": "secret-one", + "UNRELATED_PASSWORD": "secret-two", + } + + payload = ConfigEnv.provision_env_payload( + "windows-secret", environment, ["SELECTED_PASSWORD"] + ) + + assert payload == { + "windows_password": "windows-secret", + "display_resolution": "1920x1080", + "selected_password": "secret-one", + } + + def test_package_stage_env_names_only_package_type_path( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -138,3 +169,101 @@ def test_instance_package_env_maps_overrides_via_manifest( }) assert out == {"STREAMER_VERSION": "9.9", "STREAMER_BITRATE": "20000"} assert ConfigEnv.instance_package_env({}) == {} + + +def test_package_environment_is_operation_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from eve_sdk import plugin_manifest + from eve_sdk.package_dispatch import resolved_package_environment + + plugin = { + "id": "streamer", + "config_schema": { + "config": {"version": {"env_var": "STREAMER_VERSION"}}, + "secrets": {"token": {"env_var": "STREAMER_TOKEN"}}, + }, + } + other_plugin = { + "id": "other", + "config_schema": { + "config": {"version": {"env_var": "OTHER_VERSION"}}, + }, + } + monkeypatch.setattr( + plugin_manifest.PluginManifest, + "load_all", + classmethod( + lambda cls, kind=None: [plugin, other_plugin] + if kind == "package" + else [] + ), + ) + monkeypatch.setattr( + ConfigEnv, + "environment", + classmethod( + lambda cls, *a, **k: { + "OTHER_PLUGIN_TOKEN": "must-not-leak", + "STREAMER_VERSION": "1.0", + } + ), + ) + monkeypatch.setattr( + "eve_sdk.provider_command.Secrets.read", + staticmethod(lambda name: {"token": "secret"}), + ) + monkeypatch.setenv("UNDECLARED_AMBIENT_TOKEN", "must-not-leak") + + env = resolved_package_environment( + plugin, + { + "instance": {"name": "test"}, + "package_config": { + "other": {"version": "must-not-leak"}, + "streamer": {"version": "2.0"}, + }, + }, + ) + + assert env["STREAMER_TOKEN"] == "secret" + assert env["STREAMER_VERSION"] == "2.0" + assert "OTHER_PLUGIN_TOKEN" not in env + assert "OTHER_VERSION" not in env + assert "UNDECLARED_AMBIENT_TOKEN" not in env + + +def test_provider_environment_is_operation_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from eve_sdk.provider_command import resolved_provider_environment + + plugin = { + "id": "cloud", + "config_schema": { + "config": {"region": {"env_var": "CLOUD_REGION"}}, + "secrets": {"token": {"env_var": "CLOUD_TOKEN"}}, + }, + } + monkeypatch.setattr( + ConfigEnv, + "environment", + classmethod( + lambda cls, *a, **k: { + "CLOUD_REGION": "r1", + "OTHER_PROVIDER_TOKEN": "must-not-leak", + } + ), + ) + monkeypatch.setattr( + "eve_sdk.provider_command.Secrets.read", + staticmethod(lambda name: {"token": "secret"}), + ) + monkeypatch.setenv("UNDECLARED_AMBIENT_TOKEN", "must-not-leak") + + env = resolved_provider_environment("cloud", plugin) + + assert env["CLOUD_REGION"] == "r1" + assert env["CLOUD_TOKEN"] == "secret" + assert "OTHER_PROVIDER_TOKEN" not in env + assert "UNDECLARED_AMBIENT_TOKEN" not in env diff --git a/tests/python/sdk/test_plugin_manifest_contract.py b/tests/python/sdk/test_plugin_manifest_contract.py index b66401e3..6a64c43c 100644 --- a/tests/python/sdk/test_plugin_manifest_contract.py +++ b/tests/python/sdk/test_plugin_manifest_contract.py @@ -100,6 +100,58 @@ def test_core_version_is_4_0(self) -> None: # --------------------------------------------------------------------------- class TestPackageConfigSchema: + def test_required_any_accepts_declared_alternatives(self, tmp_path: Path) -> None: + manifest = _provider_manifest( + tmp_path, + config_schema={ + "config": {"profile": {"env_var": "AWS_PROFILE", "type": "string"}}, + "required_any": [ + ["config.profile"], + ["secrets.access_key", "secrets.secret_key"], + ], + "secrets": { + "access_key": {"env_var": "AWS_ACCESS_KEY_ID", "type": "string"}, + "secret_key": {"env_var": "AWS_SECRET_ACCESS_KEY", "type": "string"}, + }, + }, + ) + PluginManifest.validate(manifest) + + def test_required_any_rejects_undeclared_reference(self, tmp_path: Path) -> None: + manifest = _provider_manifest( + tmp_path, + config_schema={ + "config": {"profile": {"env_var": "AWS_PROFILE", "type": "string"}}, + "required_any": [["secrets.missing"]], + }, + ) + with pytest.raises(ValueError, match=r"undeclared field secrets\.missing"): + PluginManifest.validate(manifest) + + def test_required_any_rejects_field_without_env_var(self, tmp_path: Path) -> None: + manifest = _provider_manifest( + tmp_path, + config_schema={ + "config": {"profile": {"type": "string"}}, + "required_any": [["config.profile"]], + }, + ) + with pytest.raises(ValueError, match=r"config\.profile must declare env_var"): + PluginManifest.validate(manifest) + + def test_required_any_rejects_package_manifest(self, tmp_path: Path) -> None: + manifest = _package_manifest( + tmp_path, + config_schema={ + "config": { + "profile": {"env_var": "PROFILE", "type": "string"} + }, + "required_any": [["config.profile"]], + }, + ) + with pytest.raises(ValueError, match="only valid on provider plugins"): + PluginManifest.validate(manifest) + def test_package_config_schema_valid(self, tmp_path: Path) -> None: manifest = _package_manifest( tmp_path, diff --git a/tests/python/sdk/test_state.py b/tests/python/sdk/test_state.py index bfd264dc..78cd70a5 100644 --- a/tests/python/sdk/test_state.py +++ b/tests/python/sdk/test_state.py @@ -86,6 +86,75 @@ def test_provider_event_drives_state_through_the_machine( assert State.read("demo")["provider_state"] == "error" +def test_provision_operation_drives_state_through_the_machine( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("EVE_STATE_DIR", str(tmp_path)) + + assert ( + State.record_operation("demo", "provision", "running")["provision_state"] + == "provisioning" + ) + recovered = State.recover_running("demo") + assert recovered["provision_state"] == "error" + assert recovered["last_operation"]["status"] == "failed" + assert State.record_operation("demo", "provision", "failed")["provision_state"] == "error" + assert ( + State.record_operation("demo", "provision", "running")["provision_state"] + == "provisioning" + ) + assert ( + State.record_operation("demo", "provision", "succeeded")["provision_state"] + == "provisioned" + ) + assert ( + State.record_operation("demo", "provision", "running")["provision_state"] + == "provisioning" + ) + + +def test_package_operation_and_provision_observations_are_machine_driven( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("EVE_STATE_DIR", str(tmp_path)) + + installing = State.record_operation( + "demo", "package.install", "running", package="mock-app" + ) + assert installing["package_state"]["mock-app"]["status"] == "installing" + assert installing["last_operation"]["package"] == "mock-app" + + recovered = State.recover_running("demo") + assert recovered["package_state"]["mock-app"]["status"] == "failed" + + State.record_operation("demo", "provision", "running") + completed = State.record_operation( + "demo", + "provision", + "succeeded", + package_observations={"mock-app": "installed", "mock-tool": "installed"}, + ) + assert completed["provision_state"] == "provisioned" + assert completed["package_state"]["mock-app"]["status"] == "installed" + assert completed["package_state"]["mock-tool"]["status"] == "installed" + assert completed["last_operation"]["name"] == "provision" + + +def test_operation_ids_remain_unique_after_history_is_trimmed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("EVE_STATE_DIR", str(tmp_path)) + + for _ in range(State.DEFAULT_HISTORY_LIMIT + 5): + State.record_operation("demo", "provider.status", "succeeded") + + history = State.read("demo")["operation_history"] + ids = [entry["id"] for entry in history] + assert len(ids) == State.DEFAULT_HISTORY_LIMIT + assert len(ids) == len(set(ids)) + assert ids[-1] == State.DEFAULT_HISTORY_LIMIT + 5 + + def test_state_concurrent_writers_preserve_history(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("EVE_STATE_DIR", str(tmp_path)) State.write("concurrency-test", State.default_state("concurrency-test", "2026-01-01T00:00:00Z")) diff --git a/tests/test_fsm_machines.py b/tests/test_fsm_machines.py index 190e6e93..7a8cbf50 100644 --- a/tests/test_fsm_machines.py +++ b/tests/test_fsm_machines.py @@ -38,7 +38,28 @@ def test_package_lifecycle() -> None: fsm.fire("package", "x", "install_ok") assert fsm.leaves("package", "x") == ["installed"] - fsm.fire("package", "x", "absent") # drift: disappeared out of band + fsm.fire("package", "x", "install") # reinstall + assert fsm.leaves("package", "x") == ["installing"] + fsm.fire("package", "x", "install_ok") + + fsm.fire("package", "x", "remove") + assert fsm.leaves("package", "x") == ["removing"] + fsm.fire("package", "x", "remove_fail") + assert fsm.leaves("package", "x") == ["failed"] + + fsm.fire("package", "x", "remove") # retry + fsm.fire("package", "x", "remove_ok") + assert fsm.leaves("package", "x") == ["removed"] + + fsm.fire("package", "x", "found") # appeared out of band + assert fsm.leaves("package", "x") == ["installed"] + + fsm.fire("package", "x", "install") + fsm.fire("package", "x", "found") # authoritative observation + assert fsm.leaves("package", "x") == ["installed"] + + fsm.fire("package", "x", "remove") + fsm.fire("package", "x", "absent") # authoritative observation assert fsm.leaves("package", "x") == ["missing"] @@ -68,7 +89,7 @@ def test_instance_provider_lifecycle() -> None: def test_instance_provision_lifecycle() -> None: fsm = _fsm("instance", machine_id="instance_provision") - assert fsm.leaves("instance", "x") == ["unprovisioned"] + assert fsm.leaves("instance", "x") == ["unknown"] fsm.fire("instance", "x", "provision") fsm.fire("instance", "x", "provision_ok") @@ -76,7 +97,7 @@ def test_instance_provision_lifecycle() -> None: fsm.fire("instance", "x", "provision") # re-converge fsm.fire("instance", "x", "provision_fail") - assert fsm.leaves("instance", "x") == ["provision_error"] + assert fsm.leaves("instance", "x") == ["error"] fsm.fire("instance", "x", "provision") # retry fsm.fire("instance", "x", "provision_ok") diff --git a/tests/test_fsm_platform.py b/tests/test_fsm_platform.py index c3607e6e..e95cbfb6 100644 --- a/tests/test_fsm_platform.py +++ b/tests/test_fsm_platform.py @@ -77,7 +77,10 @@ def test_provider_context_resolver_uses_shared_config_secret_path(monkeypatch: A lambda kind, plugin_id: { "id": plugin_id, "kind": "provider", - "config_schema": {"secrets": {"api_key": {"env_var": "VULTR_API_KEY"}}}, + "config_schema": { + "config": {"region": {"env_var": "VULTR_REGION"}}, + "secrets": {"api_key": {"env_var": "VULTR_API_KEY"}}, + }, }, ) monkeypatch.setattr("eve_sdk.provider_command.Secrets.read", staticmethod(lambda name: {"api_key": "SECRET"})) @@ -121,3 +124,97 @@ def test_core_provider_machine_configured_from_required_secret(monkeypatch: Any) # the connectivity probe succeeds -> reachable fsm.fire("provider", "vultr", "probe_ok") assert _leaves(fsm) == ["reachable"] + + +def test_core_provider_machine_honors_required_config(monkeypatch: Any) -> None: + from eve_sdk.fsm import EveFsm, provider_configured_resolver + + monkeypatch.delenv("AWS_PROFILE", raising=False) + box = {"env": {}} + monkeypatch.setattr( + "eve_sdk.config.ConfigEnv.environment", + classmethod(lambda cls, *a, **k: dict(box["env"])), + ) + monkeypatch.setattr( + "eve_sdk.provider_command._load_public_plugin", + lambda kind, plugin_id: { + "id": plugin_id, + "kind": "provider", + "config_schema": { + "config": {"profile": {"required": True, "env_var": "AWS_PROFILE"}} + }, + }, + ) + monkeypatch.setattr( + "eve_sdk.provider_command.Secrets.read", staticmethod(lambda name: {}) + ) + + fsm = EveFsm(provider_configured_resolver()) + core = (Path(__file__).resolve().parents[1] / "core" / "fsm" / "provider.yaml").read_text() + fsm.load("provider", core) + fsm.fire("provider", "aws", "resolve") + assert fsm.state("provider", "aws") == "unconfigured" + + box["env"] = {"AWS_PROFILE": "eve"} + fsm.refresh("provider", "aws") + fsm.fire("provider", "aws", "resolve") + assert fsm.state("provider", "aws") == "unreachable" + + +def test_core_provider_machine_honors_required_any(monkeypatch: Any) -> None: + from eve_sdk.fsm import EveFsm, provider_configured_resolver + + box = {"env": {}} + monkeypatch.setattr( + "eve_sdk.config.ConfigEnv.environment", + classmethod(lambda cls, *a, **k: dict(box["env"])), + ) + plugin = { + "id": "aws", + "kind": "provider", + "config_schema": { + "config": {"profile": {"env_var": "AWS_PROFILE"}}, + "required_any": [ + ["config.profile"], + ["secrets.access_key", "secrets.secret_key"], + ], + "secrets": { + "access_key": {"env_var": "AWS_ACCESS_KEY_ID"}, + "secret_key": {"env_var": "AWS_SECRET_ACCESS_KEY"}, + }, + }, + } + monkeypatch.setattr( + "eve_sdk.provider_command._load_public_plugin", + lambda kind, plugin_id: plugin, + ) + box_secrets: dict[str, str] = {} + monkeypatch.setattr( + "eve_sdk.provider_command.Secrets.read", + staticmethod(lambda name: dict(box_secrets)), + ) + + fsm = EveFsm(provider_configured_resolver()) + core = ( + Path(__file__).resolve().parents[1] / "core" / "fsm" / "provider.yaml" + ).read_text() + fsm.load("provider", core) + + fsm.fire("provider", "aws", "resolve") + assert fsm.state("provider", "aws") == "unconfigured" + + box_secrets["access_key"] = "ACCESS" + fsm.refresh("provider", "aws") + fsm.fire("provider", "aws", "resolve") + assert fsm.state("provider", "aws") == "unconfigured" + + box_secrets["secret_key"] = "SECRET" + fsm.refresh("provider", "aws") + fsm.fire("provider", "aws", "resolve") + assert fsm.state("provider", "aws") == "unreachable" + + box_secrets.clear() + box["env"] = {"AWS_PROFILE": "eve"} + fsm.refresh("provider", "aws") + fsm.fire("provider", "aws", "resolve") + assert fsm.state("provider", "aws") == "unreachable" From b63712fe629b7ca3f2e226a77b3f023cdc271364 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Sat, 3 Oct 2026 21:33:12 +0000 Subject: [PATCH 2/4] fix(v4.5): close recovery validation and secret handling gaps --- .github/workflows/test.yml | 1 + docs/recovery-cloud-validation.md | 126 ++++++++++++++++++ eve_sdk/dispatch.py | 26 ++-- eve_sdk/fsm.py | 2 +- eve_sdk/secrets.py | 17 +++ scripts/package-provision | 30 ++++- scripts/package-provision-windows | 22 ++- scripts/provision | 48 ++++--- scripts/test-cross-repo | 75 +++++++++++ scripts/test-provision-runner | 16 ++- scripts/test-python | 2 +- .../python/commands/test_provision_secrets.py | 123 ++++++++++++++++- tests/python/sdk/test_command_observations.py | 24 ++++ 13 files changed, 460 insertions(+), 52 deletions(-) create mode 100644 docs/recovery-cloud-validation.md create mode 100755 scripts/test-cross-repo create mode 100644 tests/python/sdk/test_command_observations.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9246cbd..9b45ef3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + types: [opened, ready_for_review, reopened, synchronize] schedule: - cron: "0 9 * * *" workflow_dispatch: diff --git a/docs/recovery-cloud-validation.md b/docs/recovery-cloud-validation.md new file mode 100644 index 00000000..3d679b59 --- /dev/null +++ b/docs/recovery-cloud-validation.md @@ -0,0 +1,126 @@ +# v4.5 recovery validation in Cloud + +Validated on 2026-10-04 from fresh GitHub recovery checkouts. No backup files or +ignored personal configuration were used. All five initial heads matched the +recovery request: + +| Repository | Recovery PR | Initial head | +| --- | --- | --- | +| eve | #56 | c2c0f5a | +| eve-providers | #11 | 16ef4ef | +| eve-packages-linux | #7 | 5f9bb7c | +| eve-packages-windows | #5 | 5b4e261 | +| eve-plugins-ai | #3 | 1557a55 | + +## Determa API and version + +The [Python upstream release](https://github.com/fruwehq/determa-state-python/releases/tag/v0.2.0), +[specification release](https://github.com/fruwehq/determa-state-spec/releases/tag/v0.2.0), +and PyPI release metadata all identify 0.2.0 as the latest release. No newer +compatible or breaking release was found. Retain `>=0.2.0,<0.3.0` and the existing +0.2.0 lockfile entry. + +Eve uses the documented public `load_bundle`, `create`, and `dispatch` functions, +format-1 machine definitions, root-targeted event envelopes, external bindings, +and reserved `env` refresh events. Aggregate `runtimes`, active paths, and root +scopes are documented public state data. Side effects remain host-owned. +`ExecutionHost` is optional; its checkpoint stores are not required for pure +foreground transitions. Eve's small seed-by-replay adapter is restricted to its +context-free finite lifecycle graphs. A future machine with context or history +must persist and restore the complete aggregate, as the adapter documents. +No Determa design defect requiring an upstream issue was reproduced. + +## Fresh validation + +The Cloud environment used Python 3.12.14, dependencies installed from +`poetry.lock`, Terraform 1.14.9, Terramate 0.17.0, ShellCheck 0.11.0, and portable +PowerShell 7.5.4. PowerShell's writable XDG directories were isolated for testing. + +- The full `scripts/test` passed. Final validation of all Python tests passed + 528 tests with one intentional skip (an empty-registry instance comparison). +- The standalone FSM tests now run with the default Python suite, rather than + being omitted by its old `tests/python` selection. +- Linux launcher tests: 23 passed with `python -m pytest -q tests`. +- Providers: all seven provider conformance checks, host-resolver checks, five + AWS connectivity tests, five status-error tests, and environment contracts passed. +- Every tracked manifest across all four companion repositories passed + conformance, including nested bundle and OS manifests. +- The combined catalog loaded 47 plugins: seven providers, 25 packages, eleven + bundles, and four OS plugins. Shared Linux/Windows package configuration + schemas match; standalone bundle membership resolves. +- Actual OS runners passed failure, success, resume, manifest, and structured + status checks. PowerShell additionally passed explicit exit, thrown-error, + and informational native-tool exit cases. +- ShellCheck and Ruff passed for provider and Linux scripts. All 22 tracked + provider/Windows PowerShell files parsed successfully. +- External provider stack staging, Terramate generation, Terraform formatting, + Terramate formatting, and the core boundary check with real plugins passed. +- Linux and Windows integration plans, including all applicable packages, were + generated from synthetic instances. Fake-provider lifecycle tests exercised + create/status/IP/stop/start/provision/destroy without live infrastructure. + +Reproduce cross-repository checks from the core checkout: + +```sh +scripts/test-cross-repo \ + --plugin-root ../eve-providers \ + --plugin-root ../eve-packages-linux \ + --plugin-root ../eve-packages-windows \ + --plugin-root ../eve-plugins-ai +scripts/test-provision-runner --plugin-roots ../eve-providers +``` + +Run with the core project environment on `PATH`. Add `pwsh` to `PATH` for Windows +runner checks. The ordinary core suite stays hermetic; cross-repository runners +accept explicit roots and never need live providers. + +## Audit findings and fixes + +Determa remains the transition authority for provider, provisioning, package, +and interrupted-operation recovery. Operation IDs are allocated under the state +lock and remain monotonic when history is trimmed. Authentication evaluates all +universal requirements plus at least one complete alternative group +(`required_any` is OR-of-AND). Provider and package command environments are +operation-local and inject only declared target credentials. + +Fresh checks identified and fixed these additional problems: + +- Nested package observations could supply a root status through line scanning. + JSON extraction now skips nested objects and reads the last matching root. +- Secret payload files are created with mode 0600 before bytes are written, + including Windows JSON. Remote state directories are restricted before upload. + Partial secret uploads trigger cleanup. Failed Windows deletions and native + ACL errors propagate instead of silently succeeding. +- Literal shell quoting had prevented staged installer paths from expanding + `$HOME`. Only that known prefix expands; filenames and secrets stay quoted. +- The Windows runner treated an explicit `exit 42` as success. Each step now + executes in the current PowerShell runtime's separate `-File` process. +- Terraform status helpers suppressed backend failures. Missing explicit local + state remains absence; generation, backend, malformed JSON, and AWS API + failures now propagate. No alternate backend is queried for missing explicit + instance state. +- Linux contained tracked bytecode and lint findings. Bytecode was removed, + generated Python artifacts ignored, and lint findings fixed. +- Core contained a concrete provider identifier in an FSM comment that failed + the real-plugin core boundary check. The example is now generic. + +## GitHub and remaining rollout dependency + +Core recovery CI skipped because its PR was a draft; the cross-repository job is +scheduled/manual only. Provider and Linux/Windows recovery CI was absent because +those workflows filtered PR bases to `main`. Companion workflows now include +`v4.5`, select the corresponding core ref, and validate nested manifests. Core +and companion workflows explicitly handle `ready_for_review`. Provider CI now +runs status-error and OS-runner tests, and Linux CI runs its launcher suite. + +No open issues or unresolved review threads were found on the affected PRs. +The only additional open PR found was Eve #46, a TUI portrait-blink change +outside this migration. Its `tui/app.py` overlap should be reviewed separately; +its code was not incorporated into recovery. + +Offline readiness does not establish live Windows GPU usability. The existing +v4.5 roadmap still requires a Vultr `vultr-vcg-a40-2c` Windows gaming instance +with the VDD as the sole active display and working Sunshine streaming. Cloud +currently has no provider secret or outbound identity bindings. Main-facing PRs +must state this remaining live dependency accurately; do not claim that demo or +GPU validation has occurred. No billable resources were created. diff --git a/eve_sdk/dispatch.py b/eve_sdk/dispatch.py index 5a184e21..820abccc 100644 --- a/eve_sdk/dispatch.py +++ b/eve_sdk/dispatch.py @@ -273,26 +273,20 @@ def interactive_provider_command(plugin: dict[str, Any], command: str) -> bool: def last_json_object(output: str, keys: set[str]) -> dict[str, Any] | None: - for index in range(len(output) - 1, -1, -1): - if output[index] != "{": - continue - try: - parsed = json.loads(output[index:].strip()) - except json.JSONDecodeError: - continue - if isinstance(parsed, dict) and keys.intersection(parsed): - return parsed - for line in reversed(output.splitlines()): - line = line.strip() - if not line: - continue + """Find the last matching top-level object, skipping its nested values.""" + decoder = json.JSONDecoder() + result = None + offset = 0 + while (index := output.find("{", offset)) != -1: try: - parsed = json.loads(line) + parsed, end = decoder.raw_decode(output, index) except json.JSONDecodeError: + offset = index + 1 continue + offset = end if isinstance(parsed, dict) and keys.intersection(parsed): - return parsed - return None + result = parsed + return result def validate_provider_output(output: str) -> None: diff --git a/eve_sdk/fsm.py b/eve_sdk/fsm.py index 62df7850..09d6a673 100644 --- a/eve_sdk/fsm.py +++ b/eve_sdk/fsm.py @@ -168,7 +168,7 @@ def view(self, scope: str, key: str) -> dict[str, Any]: def provider_context_resolver() -> ContextResolver: """The real :data:`ContextResolver` for provider machines. - Each declared external variable is named as its eve env var (e.g. ``VULTR_API_KEY``) + Each declared external variable is named as its eve env var (e.g. ``PROVIDER_API_KEY``) and resolved from the one environment builder dispatch uses, so a provider's FSM context and what dispatch runs with are identical (v4.5 bug M: status ≡ dispatch). ``key`` is the provider id. diff --git a/eve_sdk/secrets.py b/eve_sdk/secrets.py index fce3ac40..be68f3d7 100644 --- a/eve_sdk/secrets.py +++ b/eve_sdk/secrets.py @@ -11,6 +11,23 @@ from eve_sdk.workdir import Workdir +def write_private_text(path: Path, value: str) -> None: + """Create secret payloads owner-only before writing any bytes.""" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + os.fchmod(handle.fileno(), 0o600) + handle.write(value) + + +def windows_secret_cleanup(path: str) -> str: + """Allow an absent payload while propagating failures to delete one.""" + quoted = "'" + path.replace("'", "''") + "'" + return ( + f"if (Test-Path -LiteralPath {quoted}) {{ " + f"Remove-Item -LiteralPath {quoted} -Force -ErrorAction Stop }}" + ) + + class SecretsError(Exception): pass diff --git a/scripts/package-provision b/scripts/package-provision index 1853d596..aa65bca1 100755 --- a/scripts/package-provision +++ b/scripts/package-provision @@ -31,6 +31,7 @@ from eve_sdk.package_dispatch import ( # noqa: E402 resolved_package_set_environment, ) from eve_sdk.resolve import instance_package_config # noqa: E402 +from eve_sdk.secrets import write_private_text # noqa: E402 from eve_sdk.tf_dispatch import repo_root, resolve_env # noqa: E402 _USAGE = ( @@ -90,13 +91,14 @@ def _ssh_retry(label: str, cmd: list[str]) -> int: return subprocess.run([str(ROOT / "scripts/ssh-retry"), label, *cmd]).returncode -def _env_line(key: str, value: str) -> str: +def _env_line(key: str, value: str, *, remote_home: bool = False) -> str: + if remote_home: + return f'export {key}="$HOME"/{shlex.quote(value.removeprefix("$HOME/"))}' return f"export {key}={shlex.quote(value)}" def _write_private_text(path: Path, value: str) -> None: - path.write_text(value, encoding="utf-8") - path.chmod(0o600) + write_private_text(path, value) def main(argv: list[str]) -> int: @@ -350,7 +352,10 @@ def main(argv: list[str]) -> int: _env_line("TIMEZONE", host_tz), ] env_lines += [ - _env_line(name, package_env.get(name, "")) + _env_line( + name, package_env.get(name, ""), + remote_home=name in stage_env_names and bool(package_env.get(name)), + ) for name in applicable_env_names ] env_path = stage / "state" / "env" @@ -360,7 +365,8 @@ def main(argv: list[str]) -> int: uploads: list[tuple[str, list[str]]] = [( "prepare linux package provision directory", [ssh, instance_name, "--", - "rm -rf ~/provision/scripts && mkdir -p ~/provision/state ~/provision/logs ~/provision/downloads"], + "rm -rf ~/provision/scripts && mkdir -p ~/provision/state ~/provision/logs ~/provision/downloads" + " && chmod 0700 ~/provision/state"], )] for local_path, remote_rel in stage_files: uploads.append(( @@ -385,6 +391,11 @@ def main(argv: list[str]) -> int: for label, cmd in uploads: status = _ssh_retry(label, cmd) if status != 0: + if label == "upload linux package environment": + _ssh_retry( + "remove linux package environment after upload failure", + [ssh, instance_name, "--", "rm -f ~/provision/state/env"], + ) return status status = _ssh_retry( @@ -409,7 +420,14 @@ def main(argv: list[str]) -> int: 'echo "[package-provision] running $(basename "$step")"; ' '/usr/bin/env bash "$step"; done' ) - return subprocess.run([ssh, instance_name, "--", remote_cmd]).returncode + try: + status = subprocess.run([ssh, instance_name, "--", remote_cmd]).returncode + finally: + cleanup_status = _ssh_retry( + "remove linux package environment", + [ssh, instance_name, "--", "rm -f ~/provision/state/env"], + ) + return status if status else cleanup_status finally: shutil.rmtree(stage, ignore_errors=True) diff --git a/scripts/package-provision-windows b/scripts/package-provision-windows index d0d0b558..373ab363 100755 --- a/scripts/package-provision-windows +++ b/scripts/package-provision-windows @@ -30,6 +30,7 @@ from eve_sdk.package_dispatch import ( # noqa: E402 resolved_package_set_environment, ) from eve_sdk.resolve import instance_package_config # noqa: E402 +from eve_sdk.secrets import windows_secret_cleanup, write_private_text # noqa: E402 from eve_sdk.tf_dispatch import repo_root, resolve_env # noqa: E402 _USAGE = ( @@ -225,9 +226,9 @@ def main(argv: list[str]) -> int: windows_password, package_env, env_names ) (stage / "state").mkdir(parents=True, exist_ok=True) - (stage / "state" / "env.json").write_text( + write_private_text( + stage / "state" / "env.json", json.dumps(env_payload, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", ) print(f"[package-provision-windows] uploading windows step files for package={package}") @@ -241,6 +242,11 @@ def main(argv: list[str]) -> int: ) if needs_env: reset_cmd += f" ; New-Item -ItemType Directory -Force '{_REMOTE_STATE_DIR}' | Out-Null" + reset_cmd += ( + f" ; icacls '{_REMOTE_STATE_DIR}' /inheritance:r " + '/grant:r "Administrator:(OI)(CI)F" "SYSTEM:(OI)(CI)F" | Out-Null; ' + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }' + ) uploads: list[tuple[str, list[str]]] = [ ("prepare windows package provision directory", [ssh, instance_name, "--", reset_cmd]), @@ -265,19 +271,25 @@ def main(argv: list[str]) -> int: for label, cmd in uploads: status = _ssh_retry(label, cmd) if status != 0: + if label == "upload windows package env.json": + _instance_ssh( + instance_name, "--", + windows_secret_cleanup(_REMOTE_STATE_DIR + r"\env.json"), + ) return status if needs_env: status = _instance_ssh( instance_name, "--", f"icacls '{_REMOTE_STATE_DIR}\\env.json' /inheritance:r " - '/grant:r "Administrator:F" "SYSTEM:F" | Out-Null', + '/grant:r "Administrator:F" "SYSTEM:F" | Out-Null; ' + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', ) if status != 0: _instance_ssh( instance_name, "--", - f"Remove-Item -Force -ErrorAction SilentlyContinue '{_REMOTE_STATE_DIR}\\env.json'", + windows_secret_cleanup(_REMOTE_STATE_DIR + r"\env.json"), ) return status @@ -296,7 +308,7 @@ def main(argv: list[str]) -> int: cleanup_status = _instance_ssh( instance_name, "--", - f"Remove-Item -Force -ErrorAction SilentlyContinue '{_REMOTE_STATE_DIR}\\env.json'", + windows_secret_cleanup(_REMOTE_STATE_DIR + r"\env.json"), ) if step_status != 0: return step_status diff --git a/scripts/provision b/scripts/provision index 03d24387..50b8da73 100755 --- a/scripts/provision +++ b/scripts/provision @@ -32,6 +32,7 @@ from eve_sdk.package_dispatch import ( # noqa: E402 ) from eve_sdk.plugin_manifest import PluginManifest # noqa: E402 from eve_sdk.resolve import instance_package_config # noqa: E402 +from eve_sdk.secrets import windows_secret_cleanup, write_private_text # noqa: E402 def _provision_dir_display(path: Path, root: Path) -> str: @@ -71,13 +72,14 @@ def _parse_env(output: str) -> dict[str, str]: return env -def _env_line(key: str, value: str) -> str: +def _env_line(key: str, value: str, *, remote_home: bool = False) -> str: + if remote_home: + return f'export {key}="$HOME"/{shlex.quote(value.removeprefix("$HOME/"))}' return f"export {key}={shlex.quote(value)}" def _write_private_text(path: Path, value: str) -> None: - path.write_text(value, encoding="utf-8") - path.chmod(0o600) + write_private_text(path, value) def _selected_packages(bundle_packages: str) -> list[str]: @@ -288,9 +290,8 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: # (local_path, remote_rel_dir) and uploaded after the provision dir is # prepared, below. stage_files: list[tuple[str, str]] = [] - for stage_var in package_environment_names( - selected_plugins, path_config_only=True - ): + stage_env_names = package_environment_names(selected_plugins, path_config_only=True) + for stage_var in stage_env_names: local_path = package_env.get(stage_var, "") if not local_path: continue @@ -328,13 +329,17 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: _env_line("TIMEZONE", host_tz), ] for name in package_environment_names(selected_plugins): - env_lines.append(_env_line(name, package_env.get(name, ""))) + env_lines.append(_env_line( + name, package_env.get(name, ""), + remote_home=name in stage_env_names and bool(package_env.get(name)), + )) env_path = stage / "state" / "env" _write_private_text(env_path, "\n".join(env_lines) + "\n") print(f"[provision] uploading {_provision_dir_display(os_provision_dir, root)}/ to remote") rc = _ssh_retry(profile, "prepare linux provision directory", - "rm -rf ~/provision/state ~/provision/scripts ~/provision/logs && mkdir -p ~/provision/state") + "rm -rf ~/provision/state ~/provision/scripts ~/provision/logs && mkdir -p ~/provision/state" + " && chmod 0700 ~/provision/state") if rc: return rc for local_path, remote_rel in stage_files: @@ -362,6 +367,8 @@ def provision_ubuntu(profile: str, env: dict[str, str], root: Path) -> int: rc = _ssh_retry_scp(profile, "upload linux environment", str(stage / "state" / "env"), "provision/state/") if rc: + _ssh_retry(profile, "remove linux provision environment after upload failure", + "rm -f ~/provision/state/env") return rc rc = _ssh_retry(profile, "protect linux provision environment", "chmod 0600 ~/provision/state/env") @@ -444,8 +451,8 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: ) state_dir = stage / "provision" / "state" state_dir.mkdir(parents=True, exist_ok=True) - (state_dir / "env.json").write_text( - json.dumps(env_json, indent=2) + "\n", encoding="utf-8" + _write_private_text( + state_dir / "env.json", json.dumps(env_json, indent=2) + "\n" ) for plugin_path, step in _plugin_windows_steps(plugin_doc, bundle_packages): @@ -491,25 +498,32 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: 'Remove-Item -Recurse -Force "C:\\Users\\Administrator\\provision\\state" }; ' 'if (Test-Path "C:\\Users\\Administrator\\provision\\logs") { ' 'Remove-Item -Recurse -Force "C:\\Users\\Administrator\\provision\\logs" }; ' - 'New-Item -ItemType Directory -Force C:\\Users\\Administrator\\provision\\state | Out-Null') + 'New-Item -ItemType Directory -Force C:\\Users\\Administrator\\provision\\state | Out-Null; ' + 'icacls "C:\\Users\\Administrator\\provision\\state" /inheritance:r ' + '/grant:r "Administrator:(OI)(CI)F" "SYSTEM:(OI)(CI)F" | Out-Null; ' + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') if rc: return rc rc = _ssh_retry_scp(profile, "upload windows provision tree", str(stage / "provision"), "/C:/Users/Administrator/") if rc: + _ssh_retry( + profile, "remove windows provision environment after upload failure", + windows_secret_cleanup(r"C:\Users\Administrator\provision\state\env.json"), + ) return rc rc = _ssh_retry( profile, "protect windows provision environment", 'icacls "C:\\Users\\Administrator\\provision\\state\\env.json" ' - '/inheritance:r /grant:r "Administrator:F" "SYSTEM:F" | Out-Null', + '/inheritance:r /grant:r "Administrator:F" "SYSTEM:F" | Out-Null; ' + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', ) if rc: _ssh_retry( profile, "remove unprotected windows provision environment", - 'Remove-Item -Force -ErrorAction SilentlyContinue ' - '"C:\\Users\\Administrator\\provision\\state\\env.json"', + windows_secret_cleanup(r"C:\Users\Administrator\provision\state\env.json"), ) return rc print("[provision] running bootstrap.ps1") @@ -522,8 +536,7 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: _ssh_retry( profile, "remove windows provision environment after bootstrap failure", - 'Remove-Item -Force -ErrorAction SilentlyContinue ' - '"C:\\Users\\Administrator\\provision\\state\\env.json"', + windows_secret_cleanup(r"C:\Users\Administrator\provision\state\env.json"), ) return result.returncode poll_interval = os.environ.get("EVE_PROVISION_WAIT_POLL_INTERVAL", "5") @@ -532,8 +545,7 @@ def provision_windows(profile: str, env: dict[str, str], root: Path) -> int: cleanup_rc = _ssh_retry( profile, "remove windows provision environment", - 'Remove-Item -Force -ErrorAction SilentlyContinue ' - '"C:\\Users\\Administrator\\provision\\state\\env.json"', + windows_secret_cleanup(r"C:\Users\Administrator\provision\state\env.json"), ) return rc if rc else cleanup_rc finally: diff --git a/scripts/test-cross-repo b/scripts/test-cross-repo new file mode 100755 index 00000000..151434fe --- /dev/null +++ b/scripts/test-cross-repo @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Validate tracked sibling manifests, dual-OS schemas, and the combined catalog.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from eve_sdk.catalog import load_catalog # noqa: E402 +from eve_sdk.plugin_manifest import PluginManifest # noqa: E402 +from eve_sdk.plugin_test import run_plugin_test # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plugin-root", action="append", required=True, type=Path) + args = parser.parse_args() + roots = [path.resolve() for path in args.plugin_root] + os.environ["EVE_PLUGIN_ROOTS"] = os.pathsep.join(map(str, roots)) + os.environ["EVE_PLUGIN_ROOTS_EXCLUSIVE"] = "1" + with tempfile.TemporaryDirectory(prefix="eve-cross-repo.") as home: + os.environ["EVE_HOME"] = home + halves: dict[str, list[dict[str, Any]]] = defaultdict(list) + failures = 0 + for root in roots: + tracked = subprocess.run( + ["git", "ls-files", "-z", "*eve-plugin.yaml"], + cwd=root, check=True, capture_output=True, text=True, + ).stdout + for name in sorted(filter(None, tracked.split("\0"))): + path = root / name + result = run_plugin_test(path) + print(f"{'PASS' if result.passed else 'FAIL'} {root.name}/{name}") + for check in result.failures: + print(f" {check.name}: {check.message}") + failures += not result.passed + plugin = PluginManifest.load(path) + if plugin.get("kind") == "package": + halves[str(plugin["id"])].append(plugin) + for package_id, plugins in sorted(halves.items()): + if len(plugins) > 1: + schemas = [plugin.get("config_schema") or {} for plugin in plugins] + if any(schema != schemas[0] for schema in schemas[1:]): + print(f"FAIL dual-OS configuration schema parity: {package_id}") + failures += 1 + plugins = PluginManifest.load_all() + for plugin in plugins: + PluginManifest.validate(plugin) + catalog = load_catalog(plugins=plugins) + available = {package["id"] for package in catalog["packages"]} + for bundle in catalog["bundles"]: + missing = set(bundle.get("packages") or []) - available + if missing: + print(f"FAIL bundle {bundle['id']} has missing packages: {sorted(missing)}") + failures += 1 + print(json.dumps({ + "plugins": len(plugins), "kinds": dict(sorted(Counter(p["kind"] for p in plugins).items())), + "catalog": {key: len(value) for key, value in catalog.items()}, + "failures": failures, + }, indent=2)) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-provision-runner b/scripts/test-provision-runner index b60d827b..af3d4e56 100755 --- a/scripts/test-provision-runner +++ b/scripts/test-provision-runner @@ -3,6 +3,7 @@ from __future__ import annotations +import argparse import json import os import shutil @@ -12,7 +13,12 @@ import tempfile from pathlib import Path from typing import Any -os.environ["EVE_PLUGIN_ROOTS"] = str(Path(__file__).resolve().parents[1] / "tests/fixtures/hermetic") +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--plugin-roots", help="Explicit OS plugin roots for cross-repository runner tests") +args = parser.parse_args() +os.environ["EVE_PLUGIN_ROOTS"] = args.plugin_roots or str( + Path(__file__).resolve().parents[1] / "tests/fixtures/hermetic" +) os.environ["EVE_PLUGIN_ROOTS_EXCLUSIVE"] = "1" os.environ.setdefault("EVE_HOME", tempfile.mkdtemp(prefix="eve-prov.")) @@ -237,8 +243,10 @@ print("\n== provision-runner: windows (powershell) ==") pwsh = shutil.which("pwsh") if pwsh: for name, body, expected_status in [ - ("ok", "Write-Host 'ok'; exit 0", "done"), ("fail", "Write-Error 'fail'; exit 42", "failed"), + ("native-info", "& (Get-Process -Id $PID).Path -NoProfile -Command 'exit 7'", "done"), + ("ok", "Write-Host 'ok'; exit 0", "done"), + ("throw", "throw 'failed step'", "failed"), ]: with tempfile.TemporaryDirectory(prefix=f"eve-runner-pwsh-{name}-") as tmp: root = Path(tmp) @@ -248,7 +256,7 @@ if pwsh: logs_dir = prov_dir / "logs" for path in [steps_dir, state_dir, logs_dir]: path.mkdir(parents=True, exist_ok=True) - step_name = f"00_{name}.ps1" if name == "ok" else "00_fail.ps1" + step_name = f"00_{name}.ps1" (steps_dir / step_name).write_text(body, encoding="utf-8") (state_dir / "state.json").write_text('{"currentStep":0}', encoding="utf-8") (state_dir / "steps.list").write_text(step_name + "\n", encoding="utf-8") @@ -269,7 +277,7 @@ if pwsh: else: actual_status = status.get("status") if status else "nil" fail_check(f"Windows runner status: expected={expected_status} actual={actual_status}") - if expected_status == "failed": + if name == "fail": actual_exit = status["steps"][0]["exit_code"] if status else None pass_check("Windows runner captures exit_code=42") if actual_exit == 42 else fail_check( f"Windows runner exit_code: expected=42 actual={actual_exit}" diff --git a/scripts/test-python b/scripts/test-python index a24e7883..819c9441 100755 --- a/scripts/test-python +++ b/scripts/test-python @@ -37,7 +37,7 @@ def main() -> int: return code code = subprocess.run( - ["poetry", "run", "pytest", "tests/python"], + ["poetry", "run", "pytest", "tests"], cwd=str(ROOT), check=False, ).returncode if code != 0: diff --git a/tests/python/commands/test_provision_secrets.py b/tests/python/commands/test_provision_secrets.py index 6793fc7a..a300711f 100644 --- a/tests/python/commands/test_provision_secrets.py +++ b/tests/python/commands/test_provision_secrets.py @@ -2,6 +2,8 @@ import importlib.machinery import importlib.util +import json +import os import stat import subprocess from pathlib import Path @@ -48,6 +50,125 @@ def test_secret_environment_file_is_owner_only(script: str, tmp_path: Path) -> N assert stat.S_IMODE(path.stat().st_mode) == 0o600 +@pytest.mark.parametrize("script", ["package-provision", "provision"]) +def test_staged_path_expands_only_remote_home(script: str) -> None: + module = _load_script(script) + filename = 'installer $(printf injected) `printf injected` "quote".deb' + relative = "provision/downloads/installer/" + filename + source = module._env_line("INSTALLER", "$HOME/" + relative, remote_home=True) + result = subprocess.run( + ["/bin/sh", "-c", source + '; printf %s "$INSTALLER"'], + env={"HOME": "/remote/home"}, text=True, capture_output=True, check=True, + ) + assert result.stdout == "/remote/home/" + relative + + +@pytest.mark.parametrize("existing_mode", [None, 0o644]) +def test_private_payload_permissions_are_set_before_writing( + existing_mode: int | None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + from eve_sdk.secrets import write_private_text + + path = tmp_path / "env.json" + if existing_mode is not None: + path.write_text("old") + path.chmod(existing_mode) + original_fdopen = os.fdopen + + def checked_fdopen(fd: int, *args: object, **kwargs: object) -> object: + # Existing files are secured inside the writer before handle.write. + handle = original_fdopen(fd, *args, **kwargs) + + class CheckedWriter: + def __enter__(self) -> object: + return self + + def __exit__(self, *exc: object) -> None: + handle.close() + + def fileno(self) -> int: + return handle.fileno() + + def write(self, text: str) -> int: + assert stat.S_IMODE(os.fstat(fd).st_mode) == 0o600 + return handle.write(text) + + return CheckedWriter() + + monkeypatch.setattr(os, "fdopen", checked_fdopen) + write_private_text(path, '{"secret":"fake"}') + assert path.read_text() == '{"secret":"fake"}' + + +def test_windows_partial_package_payload_upload_is_cleaned( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + module = _load_script("package-provision-windows") + tree = tmp_path / "oses" / "test-windows" / "provision" + (tree / "scripts" / "lib").mkdir(parents=True) + (tree / "scripts" / "steps").mkdir() + (tree / "scripts" / "steps" / "install.ps1").write_text("exit 0") + entry = { + "id": "test-package", "path": str(tmp_path / "eve-plugin.yaml"), + "install": {"windows": {"steps": ["install.ps1"], "state_files": ["env.json"]}}, + "config_schema": {"secrets": {"password": {"env_var": "TEST_PASSWORD"}}}, + } + monkeypatch.setattr(module, "repo_root", lambda: tmp_path) + monkeypatch.setattr(module, "resolve_env", lambda *args: {"OS_FAMILY": "windows", "OS_ID": "test-windows"}) + monkeypatch.setattr(module, "_plugin_list_json", lambda: {"plugins": [entry]}) + monkeypatch.setattr(module, "instance_package_config", lambda *args: {}) + monkeypatch.setattr(module, "resolved_package_set_environment", lambda *args: {"TEST_PASSWORD": "fake-secret"}) + monkeypatch.setenv("EPHEMERAL_WINDOWS_PASSWORD", "fake-login") + monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 0)) + cleanups: list[str] = [] + monkeypatch.setattr(module, "_instance_ssh", lambda *args: cleanups.append(args[-1]) or 0) + + def upload(label: str, cmd: list[str]) -> int: + if label == "upload windows package env.json": + payload = Path(cmd[-2]) + assert stat.S_IMODE(payload.stat().st_mode) == 0o600 + assert "fake-secret" in json.dumps(json.loads(payload.read_text())) + return 23 + return 0 + + monkeypatch.setattr(module, "_ssh_retry", upload) + assert module.main(["--instance", "test", "--package", "test-package"]) == 23 + assert any("Remove-Item" in cmd and "env.json" in cmd for cmd in cleanups) + + +@pytest.mark.parametrize("family", ["ubuntu", "windows"]) +def test_full_provision_cleans_partial_secret_uploads( + family: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + module = _load_script("provision") + tree = tmp_path / "provision" + steps = tree / "scripts" / "steps" + steps.mkdir(parents=True) + for name in ["base.sh", "finish.sh", "human-user.sh", "timezone.sh"]: + (steps / name).write_text("exit 0") + monkeypatch.setattr(module.PluginManifest, "os_provision_dir", lambda *args: tree) + monkeypatch.setattr(module, "_selected_package_environment", lambda *args: ([], {"TIMEZONE": "UTC"})) + monkeypatch.setenv("EPHEMERAL_WINDOWS_PASSWORD", "fake-login") + monkeypatch.setattr( + module.subprocess, "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, stdout='{"plugins": []}'), + ) + cleanups: list[str] = [] + monkeypatch.setattr(module, "_ssh_retry", lambda *args: cleanups.append(args[1]) or 0) + + def upload(profile: str, label: str, local: str, remote: str) -> int: + if label in {"upload linux environment", "upload windows provision tree"}: + path = Path(local) if family == "ubuntu" else Path(local) / "state" / "env.json" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + return 23 + return 0 + + monkeypatch.setattr(module, "_ssh_retry_scp", upload) + action = module.provision_ubuntu if family == "ubuntu" else module.provision_windows + assert action("test", {"OS_FAMILY": family, "OS_ID": "test-os"}, tmp_path) == 23 + assert any("environment after upload failure" in label for label in cleanups) + + def test_provision_scripts_remove_remote_secret_files() -> None: linux = (ROOT / "scripts" / "package-provision").read_text(encoding="utf-8") full = (ROOT / "scripts" / "provision").read_text(encoding="utf-8") @@ -59,4 +180,4 @@ def test_provision_scripts_remove_remote_secret_files() -> None: assert "$HOME/provision/state/env" in linux assert "remove linux provision environment" in full assert "remove windows provision environment" in full - assert "Remove-Item -Force -ErrorAction SilentlyContinue" in windows + assert "windows_secret_cleanup" in windows diff --git a/tests/python/sdk/test_command_observations.py b/tests/python/sdk/test_command_observations.py new file mode 100644 index 00000000..46cf9447 --- /dev/null +++ b/tests/python/sdk/test_command_observations.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json + +import pytest + +from eve_sdk.dispatch import package_status_from_output + + +@pytest.mark.parametrize("indent", [None, 2]) +def test_root_package_status_wins_over_nested_observations(indent: int | None) -> None: + output = json.dumps({"status": "missing", "packages": [{"status": "installed"}]}, indent=indent) + assert package_status_from_output(output) == "missing" + + +@pytest.mark.parametrize("indent", [None, 2]) +def test_nested_observations_do_not_supply_a_missing_root_status(indent: int | None) -> None: + output = json.dumps({"packages": [{"status": "installed"}]}, indent=indent) + assert package_status_from_output(output) is None + + +def test_last_root_status_survives_surrounding_command_logs() -> None: + output = 'starting\n{"status":"missing"}\n{"status":"installed"}\nfinished\n' + assert package_status_from_output(output) == "installed" From cb0a6daf10cfa960a76361f2d967181205a58bde Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Sat, 3 Oct 2026 21:39:21 +0000 Subject: [PATCH 3/4] fix(ci): create project environment before test runners --- .github/workflows/test.yml | 3 +++ docs/recovery-cloud-validation.md | 5 +++++ scripts/test-ci-pipeline | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b45ef3e..df203c06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ on: - cron: "0 9 * * *" workflow_dispatch: +env: + POETRY_VIRTUALENVS_IN_PROJECT: "true" + jobs: test: if: github.event_name != 'pull_request' || github.event.pull_request.draft == false diff --git a/docs/recovery-cloud-validation.md b/docs/recovery-cloud-validation.md index 3d679b59..e04af6b8 100644 --- a/docs/recovery-cloud-validation.md +++ b/docs/recovery-cloud-validation.md @@ -113,6 +113,11 @@ those workflows filtered PR bases to `main`. Companion workflows now include and companion workflows explicitly handle `ready_for_review`. Provider CI now runs status-error and OS-runner tests, and Linux CI runs its launcher suite. +Enabling core CI exposed a pre-existing environment mismatch: Poetry created a +cached environment while the test runners require `.venv/bin/python`. The +workflow now explicitly creates its environment inside the project, as used in +fresh Cloud validation, and the pipeline contract check enforces this setting. + No open issues or unresolved review threads were found on the affected PRs. The only additional open PR found was Eve #46, a TUI portrait-blink change outside this migration. Its `tui/app.py` overlap should be reviewed separately; diff --git a/scripts/test-ci-pipeline b/scripts/test-ci-pipeline index f37f5d44..8a9d8398 100755 --- a/scripts/test-ci-pipeline +++ b/scripts/test-ci-pipeline @@ -110,6 +110,12 @@ def main() -> int: print("\n== ci-pipeline workflow contract ==") + workflow_env = workflow.get("env", {}) + checks.assert_check( + "Poetry creates the project environment required by scripts/test", + isinstance(workflow_env, dict) and workflow_env.get("POETRY_VIRTUALENVS_IN_PROJECT") == "true", + ) + triggers = workflow.get("on", workflow.get(True)) checks.assert_check( "workflow runs on pull_request", From ea9367c9dff3c55816624c1bebcff802e4420c12 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Sat, 3 Oct 2026 21:41:07 +0000 Subject: [PATCH 4/4] docs: use UTC date for Cloud validation --- docs/recovery-cloud-validation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/recovery-cloud-validation.md b/docs/recovery-cloud-validation.md index e04af6b8..7e19b9d3 100644 --- a/docs/recovery-cloud-validation.md +++ b/docs/recovery-cloud-validation.md @@ -1,6 +1,6 @@ # v4.5 recovery validation in Cloud -Validated on 2026-10-04 from fresh GitHub recovery checkouts. No backup files or +Validated on 2026-10-03 UTC from fresh GitHub recovery checkouts. No backup files or ignored personal configuration were used. All five initial heads matched the recovery request: