From de1ee2cff05e7dd9c385dc732782cd0fed404e8c Mon Sep 17 00:00:00 2001 From: knqiufan Date: Fri, 23 Oct 2026 10:39:28 +0800 Subject: [PATCH 1/2] feat(ops): install and repair managed local Server runtimes --- docs/en/docs/operate/local-server-ops.md | 7 +- docs/en/docs/operate/managed-local-server.md | 162 +++++ docs/en/docs/operate/meta.json | 1 + docs/en/rfcs/1455-desktop-control-center.md | 11 +- docs/zh/docs/operate/local-server-ops.md | 3 +- docs/zh/docs/operate/managed-local-server.md | 89 +++ docs/zh/docs/operate/meta.json | 1 + docs/zh/rfcs/1455-desktop-control-center.md | 8 +- src/powercontext/ops/__main__.py | 23 +- src/powercontext/ops/installer.py | 659 ++++++++++++++++++ src/powercontext/ops/local_policy.py | 257 +++++++ src/powercontext/ops/protocol.py | 57 +- src/powercontext/ops/resources.py | 674 +++++++++++++++++++ src/powercontext/ops/runtime.py | 118 +++- src/powercontext/ops/storage.py | 57 +- src/powercontext/service/adapters/launchd.py | 7 +- src/powercontext/service/controller.py | 52 ++ src/powercontext/service/model.py | 15 +- tests/e2e/test_managed_runtime_repair.py | 128 ++++ tests/test_ops_installer.py | 461 +++++++++++++ tests/test_ops_local_policy.py | 230 +++++++ tests/test_ops_resources.py | 379 +++++++++++ tests/test_service_runtime_repair.py | 170 +++++ 23 files changed, 3526 insertions(+), 43 deletions(-) create mode 100644 docs/en/docs/operate/managed-local-server.md create mode 100644 docs/zh/docs/operate/managed-local-server.md create mode 100644 src/powercontext/ops/installer.py create mode 100644 src/powercontext/ops/local_policy.py create mode 100644 src/powercontext/ops/resources.py create mode 100644 tests/e2e/test_managed_runtime_repair.py create mode 100644 tests/test_ops_installer.py create mode 100644 tests/test_ops_local_policy.py create mode 100644 tests/test_ops_resources.py create mode 100644 tests/test_service_runtime_repair.py diff --git a/docs/en/docs/operate/local-server-ops.md b/docs/en/docs/operate/local-server-ops.md index 1e444bece9..b9946524be 100644 --- a/docs/en/docs/operate/local-server-ops.md +++ b/docs/en/docs/operate/local-server-ops.md @@ -10,9 +10,12 @@ current-user service controller used by `powercontext service`; it does not need Its own Python and dependencies must be distributed separately from the Server runtime it manages. A missing Server interpreter therefore does not prevent the tool itself from starting or inspecting the registered service. -This boundary supports prepared Server runtimes. Downloading and repairing the managed runtime, the Desktop native -bridge, and the installation UI are separate capabilities. Existing human-facing CLI commands remain available. +This service boundary supports prepared Server runtimes. For fixed managed resources and the install/repair policy, +see [Managed local Server installation](managed-local-server.md). The Desktop native bridge and installation UI are +separate capabilities. Existing human-facing CLI commands remain available. Ops is not an HTTP API and does not add a daemon. +Managed registrations carrying `isolated_runtime=true` require the bundled Ops or a matching-version service tool; +an older CLI that does not support this definition field rejects it instead of managing the registration. ## Operator binding and invocation diff --git a/docs/en/docs/operate/managed-local-server.md b/docs/en/docs/operate/managed-local-server.md new file mode 100644 index 0000000000..ecbd83b06a --- /dev/null +++ b/docs/en/docs/operate/managed-local-server.md @@ -0,0 +1,162 @@ +--- +title: Managed local Server installation +description: Install or repair a fixed personal Server runtime while preserving configuration and database state. +--- + +# Managed local Server installation + +The independent Ops tool prepares a fixed Server runtime and manages its current-user service. Ops has its own +interpreter and dependencies, so repairing a missing Server interpreter does not require the damaged environment to +run. Desktop integration uses this boundary; a running HTTP connection is not required for installation or repair. + +## Installation and repair plans + +Installation and repair use the explicit `preflight`, `apply`, `operation_status`, `cancel`, and `resume` protocol +described in [Local Server operations](local-server-ops.md). The caller displays the complete plan and obtains approval +before applying it. Retain the operation ID when starting a request; after interruption, inspect the saved attempt and +current facts before approving a recovery plan. + +If a later stop operation supersedes an interrupted repair, approve a new repair plan instead of resuming the +superseded operation. The new plan can continue the original repair guard only after checking its journal, installation +and resource identities, exact service definition, and completed native commands. It does not restart the service or +restore a registration that was subsequently removed. + +The packaged Ops interpreter accepts one JSON request per process. Its trusted host supplies the protected catalog +and the SHA-256 digest pinned by the distribution: + +```text + -I -S -B --catalog-file --catalog-sha256 +``` + +For example, send this single line on standard input to inspect a first installation: + +```json +{"protocol_version":1,"request_id":"inspect-install","action":"preflight","operation":"install","options":{}} +``` + +The response includes the fixed resource, program/configuration/data locations, endpoint, database classification, +required space, and startup effects. Pass the returned complete plan unchanged to `apply`, with a unique operation ID +and explicit authorization for its `plan_id`, as in the shared Ops protocol. To prepare same-version repair, use +`"operation":"repair"`. Managed locations are selected by the host; `--state-dir` belongs to the separate operator-owned +`--target-file` mode and cannot override a managed installation. These commands require a qualified producer catalog; +they do not discover a public release or produce one. + +Only the trusted producer selects resource URLs, executable layout, platform, architecture, package version, and file +digests. The renderer cannot supply a command, download URL, or arbitrary installation path. Resources are downloaded +to staging, checked against the fixed catalog, and verified before publication. A checksum supplied alongside an +untrusted download does not establish trust. A cache hit must pass the same integrity checks as a download. + +Repair uses the same fixed resource identity and version. It preserves the installation identity, existing +configuration, and user data. It does not upgrade to `latest`, reset authentication or models, or delete an existing +database. Repair stops the owned service before replacing resources and completes without restarting it. A stopped +service stays stopped. Repeating installation for a verified current installation reports current +facts without restarting the service or opening its database. + +Managed service definitions explicitly select isolated runtime startup (`isolated_runtime=true`). The target Python +runs with `-I -B`, excluding `PYTHONPATH` and user-site imports and avoiding generated bytecode changes in the verified +resource tree. Manage these definitions with the bundled Ops tool or a matching-version service tool. Older CLI +versions that do not recognize this field reject the definition; they are not a supported way to manage the new +registration. Existing definitions without this field retain their previous startup behavior. + +An independent installation is not automatically adopted. Unknown ownership, damaged control records, mismatched +native service definitions, or unresolved native commands require inspection before modification. Removing a service +registration preserves the environment, configuration, and database; it is not a database deletion operation. + +## New personal configuration + +The new-configuration policy explicitly sets: + +| Setting | New managed installation | +| --- | --- | +| HTTP listener | `127.0.0.1` and the approved port | +| Database | SQLite at an explicit absolute path in the selected user-data directory | +| Dashboard | Enabled | +| Access mode | `disabled` | +| Legacy bearer authentication | Disabled, with no token | +| MCP | Uses the same local access mode as HTTP | +| Models | No generation, embedding, reranking, or decision model is configured | + +This is local access without a token, not a separate anonymous credential. Keep the listener on loopback. The policy +does not change authentication for existing or remote connections, and repair does not overwrite an existing user's +choices. Selecting an authenticated existing installation retains its protected configuration. + +The default data directory follows the platform's `powercontext_data_dir()` location while ignoring an inherited +`POWERCONTEXT_HOME`. New configuration also ignores inherited `POWERCONTEXT_SERVER_*`, `PYTHONPATH`, and `PYTHONHOME` +settings during resolution. It records the chosen data root and SQLite path explicitly. Existing installations retain +their verified registered data directory; program directories and data directories are separate. + +Preflight reads configuration through the protected-file interface and returns only the file identity and necessary +local summary fields. Configuration contents, tokens, and model credentials do not enter plans or operation journals. +Preflight does not create a configuration file, data directory, or database. + +## Database compatibility boundary + +Database inventory does not construct a Server runtime, connect to SQLite, initialize tables, change WAL settings, or +apply migrations. It observes the selected file and recovery sidecars and binds that observation to the exact target +Server version. Ops' own package version cannot prove compatibility with the target Server. + +Program-only repair binds the existing database's file identity and target version, allowing normal writes and WAL +checkpointing while resources are prepared. Replacing the database invalidates the plan. This repair observation does +not authorize startup; installation and startup retain strict database and recovery-sidecar observations. + +| Observation | Installer behavior | +| --- | --- | +| Database and SQLite recovery sidecars are absent | Classify as `new`; the approved target Server may initialize a new database during startup. | +| An existing SQLite file, including an empty file | Classify as `existing_unknown`; do not automatically start or restart against it. | +| An existing file has an unsupported database header | Classify as `incompatible`; preserve it and block startup. | +| Orphan sidecars, a linked or unverified location, an unreadable file, or a changing observation | Classify as `unknown`; preserve the files and block startup. | + +The current public maintenance checks cover individual domains, not complete Server schema, indexes, persisted task +formats, and all configured capabilities. A Dream or processing check, an Atomic Memory verification, or a migration +bundle reporting `ready` therefore cannot lift the installer gate. A `pc_schema_revision` marker does not establish +whole-Server readiness. The installer does not create one for new databases. + +For existing data without a qualified whole-database check for the fixed target version, keep the service stopped and +preserve the database. Same-version program repair can complete while the database remains `existing_unknown`; that +does not authorize startup. There is no general maintenance command that this installer can recommend to make every +such database compatible. Consult [Database migrations](database-migrations.md) for the actual supported bundle +boundary, and wait for a maintenance release supporting the specific database before attempting a data transition. +Do not repeatedly run a partial migration as an automatic repair. + +This also limits normal operation today: after first startup creates a database, subsequent `start`, `restart`, and +`config_apply` with `restart=true` when it would restart an active service are rejected through the managed entry while +whole-database compatibility remains unknown. An already stopped service stays stopped during configuration changes, +including when `restart=true` is supplied. A previous successful startup or the installation manifest does not bypass +this gate. Inspection, stopping, +configuration changes without restart, and same-version program repair remain available. Repeating `install` for an +owned current installation only reports facts. This implementation therefore does not yet provide a complete daily +restart workflow; qualified whole-database checks are still required. The separate prepared-runtime operator binding +retains its existing contract and is not evidence that the managed database is compatible. + +## Startup and connection handoff + +Resource integrity, configuration validity, service registration, process liveness, and readiness are separate facts. +For an approved first startup, the shared service controller performs bounded readiness verification and protects and +stops a newly started owned target when startup fails. A completed installation result is historical evidence; ordinary +status does not rerun readiness checks. Cancellation takes effect at supported safe boundaries and does not promise +rollback. + +The installer returns its actual endpoint and installation/resource identities. These are not Server deployment or +Principal identities. The connection layer must validate the actual endpoint and supported public identity and +compatibility contract before activating a connection or obtaining its default Scope. Installation does not invent a +Scope ID, select an arbitrary Scope, or prove that a Memory operation has succeeded. + +## Release and platform acceptance + +This implementation requires producer-supplied, fixed resources. The formal platform resource catalog and qualified +artifacts belong to [#2112](https://github.com/oceanbase/powercontext/issues/2112); the implementation does not supply a +public production download URL or infer one from a version string. Development fixtures and locally built bundles +verify implementation behavior, not production distribution trust. + +On Windows, preflight checks the producer's file layout against the actual final directory and the fixed staging +budget. Acquisition checks its exact staging directory again before downloading. Ordinary paths must fit the Windows +file and directory limits; unsupported layouts return `resource_path_unsupported`. Ops does not enable system-wide +long paths or assume Task Scheduler supports extended-path executable names. The producer must qualify a layout that +fits the intended user directories. A complete development bundle that starts from a shorter test directory does not +establish that the default installation layout works. + +Release acceptance must use the exact downloaded artifacts on clean standard-user Windows 11 x64, macOS arm64, and +macOS x86_64 machines without preinstalled Python, uv, Git, or build tools. Independently verify signatures and macOS +notarization, independent Ops startup, actual Task Scheduler or LaunchAgent behavior, login/restart handling, and +preservation of configuration and data through repair and removal. Fake service adapters and repository-runtime +SQLite tests do not establish those platform results. diff --git a/docs/en/docs/operate/meta.json b/docs/en/docs/operate/meta.json index 567799bf33..da7ca1e553 100644 --- a/docs/en/docs/operate/meta.json +++ b/docs/en/docs/operate/meta.json @@ -4,6 +4,7 @@ "index", "deploy-server", "local-server-ops", + "managed-local-server", "connect-remote-server", "database-migrations", "artifact-processing-migration", diff --git a/docs/en/rfcs/1455-desktop-control-center.md b/docs/en/rfcs/1455-desktop-control-center.md index 150d6357af..48265ea9af 100644 --- a/docs/en/rfcs/1455-desktop-control-center.md +++ b/docs/en/rfcs/1455-desktop-control-center.md @@ -358,11 +358,12 @@ identity resolution; `multi_principal` alone does not prove a supported login fl Generic `401 unauthorized` means valid credentials are needed, not necessarily expired. Show expiry only when a supported contract supplies it; distinguish `403` denial and `503 authentication_unavailable`. Stop protected loops on rejection. -New managed local setup enables enforced authentication by default; unauthenticated attachment remains an explicit -existing-installation mode. The configuration producer generates the credential in a protected Server environment. After -explicit setup approval, a secure machine interface provisions the desktop OS-store entry and selected hosts through -existing URL-bound authorization adapters, without returning secrets through page data. Server/Agents must work without -the desktop or its unlocked vault. +New managed personal setup explicitly binds loopback, enables Dashboard, and uses `access.mode=disabled` with +`auth.enabled=false`; HTTP and MCP share this local tokenless mode. It does not generate a token or background Principal +and does not configure models. This policy applies to new configuration only: existing and remote authentication choices +remain authoritative, and repair preserves their protected configuration. Authenticated installations continue to use +the OS credential store and existing URL-bound authorization adapters without returning secrets through page data. +Server/Agents must work without the desktop or its unlocked vault. Rotation is a producer plan: check ownership/consumers, stage protected configuration, reconcile the service, replace desktop and selected-host credentials, then verify each consumer. Static Bearer has no assumed old/new overlap; diff --git a/docs/zh/docs/operate/local-server-ops.md b/docs/zh/docs/operate/local-server-ops.md index ba19737b64..34522b3539 100644 --- a/docs/zh/docs/operate/local-server-ops.md +++ b/docs/zh/docs/operate/local-server-ops.md @@ -8,8 +8,9 @@ description: 使用独立 Ops 工具配置和管理已准备好的个人 Server Ops 为个人 Server 提供非交互的配置和服务管理入口,复用 `powercontext service` 使用的当前用户服务控制器,调用时不需要已有的 Server HTTP 会话。 Ops 自带的 Python 与依赖必须和受管 Server 的运行环境分开分发。因此,Server 解释器缺失不会妨碍工具自身启动和检查服务注册。 -此入口管理已准备好的 Server 环境。下载和修复受管环境、Desktop 原生桥接以及安装界面是另外的能力。原有的人用 CLI 命令继续可用。 +此服务入口管理已准备好的 Server 环境。固定受管资源及安装、修复策略见[受管本地 Server 安装](managed-local-server.md)。Desktop 原生桥接和安装界面是另外的能力,原有的人用 CLI 命令继续可用。 Ops 不是 HTTP API,也不新增常驻进程。 +包含 `isolated_runtime=true` 的受管注册需使用随包 Ops 或匹配版本的服务工具;不支持该定义字段的旧 CLI 会拒绝它,不再继续管理该注册。 ## 绑定目标与启动 diff --git a/docs/zh/docs/operate/managed-local-server.md b/docs/zh/docs/operate/managed-local-server.md new file mode 100644 index 0000000000..6b713ceb5f --- /dev/null +++ b/docs/zh/docs/operate/managed-local-server.md @@ -0,0 +1,89 @@ +--- +title: 受管本地 Server 安装 +description: 安装或修复固定版本的个人 Server 运行环境,并保留已有配置与数据库状态。 +--- + +# 受管本地 Server 安装 + +独立 Ops 工具负责准备固定的 Server 运行环境,并管理当前用户的服务。Ops 使用自己的解释器和依赖,因此修复缺失的 Server 解释器不需要先启动损坏的环境。Desktop 集成通过这一入口完成安装和修复,不要求已有 HTTP 连接。 + +## 安装与修复计划 + +安装与修复使用[本地 Server 运维工具](local-server-ops.md)中的 `preflight`、`apply`、`operation_status`、`cancel` 和 `resume` 协议。调用方先展示完整计划,再取得确认并执行。发起请求时保留 operation ID;中断后先查询已保存的尝试和当前事实,再确认恢复计划。 + +如果后续停止操作替代了中断的修复,应确认新的修复计划,不再恢复已被替代的旧操作。新计划只有在核验原操作记录、安装与资源身份、精确服务定义以及原生命令已结束后,才能接续原修复保护。它不会重启服务,也不会恢复随后已移除的注册。 + +随包 Ops 解释器每个进程接收一条 JSON 请求。可信宿主提供受保护的资源目录文件,以及分发包固定的 SHA-256 摘要: + +```text + -I -S -B --catalog-file --catalog-sha256 +``` + +例如,将下面这一行写入标准输入,预检首次安装: + +```json +{"protocol_version":1,"request_id":"inspect-install","action":"preflight","operation":"install","options":{}} +``` + +响应包含固定资源、程序和配置及数据位置、端点、数据库分类、所需空间与启动影响。按共享 Ops 协议,将完整计划原样交给 `apply`,提供唯一 operation ID,并明确授权该 `plan_id`。预检同版本修复时使用 `"operation":"repair"`。受管目录由宿主选择;`--state-dir` 属于独立的 `--target-file` 操作者绑定模式,不能覆盖受管安装位置。这些命令要求已有合格生产者目录,不会自行发现或生成公共发布资源。 + +只有可信资源生产者能够指定下载 URL、可执行文件布局、平台、架构、包版本和文件摘要。渲染层不能提供命令、下载 URL 或任意安装路径。资源先下载到暂存目录,按固定目录校验后才能发布;与不可信下载一起提供的摘要不能建立信任。缓存命中同样需要通过完整性检查。 + +修复保持同一固定资源身份和版本,保留安装身份、已有配置和用户数据。它不升级到 `latest`,不重置认证或模型,也不删除已有数据库。替换资源前先停止自有服务,修复完成后不重启;已停止的服务继续停止。对已核验的当前安装重复执行 install,只返回当前事实,不重启服务、不打开数据库。 + +受管服务定义明确启用隔离运行模式(`isolated_runtime=true`),目标 Python 使用 `-I -B` 启动,排除 `PYTHONPATH` 和用户 site 的导入,并避免生成字节码改变已校验的资源目录。请使用随包 Ops 或匹配版本的服务工具管理这些定义。无法识别该字段的旧版 CLI 会拒绝该定义,不支持用它管理新注册;未包含该字段的已有定义保持原启动行为。 + +独立安装不会被自动接管。归属未知、控制记录损坏、原生服务定义不一致或原生命令尚未确认结束时,需要先检查再修改。移除服务注册会保留运行环境、配置和数据库,并不删除数据。 + +## 新建个人配置 + +新配置明确设置以下选项: + +| 设置 | 新受管安装 | +| --- | --- | +| HTTP 监听 | `127.0.0.1` 和计划确认的端口 | +| 数据库 | 用户数据目录中的 SQLite,使用明确的绝对路径 | +| Dashboard | 启用 | +| Access 模式 | `disabled` | +| 旧式 Bearer 认证 | 禁用,不生成 Token | +| MCP | 与 HTTP 使用相同的本地访问模式 | +| 模型 | 不配置生成、Embedding、重排或决策模型 | + +这是本地免 Token 访问模式,不是一套额外的匿名凭据。监听地址保持 loopback。该策略不改变已有连接或远程连接的认证要求,修复也不会覆盖用户已有选择。选择已有认证安装时,继续引用其受保护配置。 + +默认数据目录按平台的 `powercontext_data_dir()` 规则解析,但忽略继承的 `POWERCONTEXT_HOME`。新配置解析也不继承 `POWERCONTEXT_SERVER_*`、`PYTHONPATH` 和 `PYTHONHOME`,并明确保存选定的数据根和 SQLite 路径。已有安装保留已核验的登记数据目录,程序目录与数据目录分开。 + +预检通过受保护文件接口读取配置,只返回文件身份及必要的本地摘要。配置正文、Token 和模型凭据不进入计划或操作记录。预检不创建配置文件、数据目录或数据库。 + +## 数据库兼容边界 + +数据库检查不构造 Server runtime,不连接 SQLite,不初始化表、不修改 WAL 设置,也不执行迁移。它只观察选定文件及恢复侧文件,并将观察结果绑定到精确目标 Server 版本。Ops 自身的包版本不能证明目标 Server 的数据库兼容性。 + +只修复程序时,计划绑定已有数据库的文件身份和目标版本,允许准备资源期间正常写入及 WAL checkpoint;替换数据库会使计划过期。这种修复观察不授予启动许可,安装和启动仍使用严格的数据库及恢复侧文件观察。 + +| 观察结果 | 安装器处理 | +| --- | --- | +| 数据库及 SQLite 恢复侧文件均不存在 | 分类为 `new`,确认后的目标 Server 可以在启动时初始化新库。 | +| 已有 SQLite 文件,包括空文件 | 分类为 `existing_unknown`,不自动启动或重启。 | +| 已有文件的数据库头不受支持 | 分类为 `incompatible`,保留文件并阻止启动。 | +| 孤立侧文件、链接或未核验位置、不可读文件、观察过程中发生变化 | 分类为 `unknown`,保留文件并阻止启动。 | + +当前公开维护检查只覆盖部分领域,不能证明完整 Server 的 schema、索引、持久任务格式及全部配置能力可用。Dream 或 processing 检查、Atomic Memory 验证、迁移 bundle 的 `ready` 都不能解除安装器的启动阻断。`pc_schema_revision` 标记也不代表整库可用,安装器不会为新库创建该标记。 + +已有数据缺少固定目标版本的合格整库检查时,应保持服务停止并保留数据库。同版本程序修复可以完成,而数据库继续标记为 `existing_unknown`;修好程序不授予启动许可。当前没有一条能让所有这类数据库变得兼容的通用维护命令。请参阅[数据库迁移](database-migrations.md)了解实际支持的 bundle 边界,并等待支持该数据库的维护版本后再执行数据转换。不要把反复执行局部迁移当成自动修复。 + +这也限制了当前的日常操作:首次启动创建数据库后,只要整库兼容性仍为未知,受管入口后续的 `start`、`restart`,以及会实际重启运行中服务的 `config_apply(restart=true)` 都会被拒绝。原本停止的服务在配置修改后仍保持停止,即使传入 `restart=true` 也不会启动。历史启动成功或安装清单不能绕过该检查。检查、停止、不重启的配置修改和同版本程序修复仍可执行。对自有当前安装重复执行 `install` 只返回事实。因此,这一实现尚不具备完整的日常重启流程,仍需补齐合格的整库检查。独立的已备妥 runtime 操作者绑定保留既有契约,也不能用它证明受管数据库兼容。 + +## 启动与连接交接 + +资源完整性、配置有效性、服务注册、进程存活和 readiness 是不同事实。获得首次启动授权后,共享服务控制器执行有界 readiness 验证;本次新启动的自有目标失败时,会恢复保护并确认停止。已完成安装的结果是历史事实,普通状态查询不重新执行 readiness。取消只在支持的安全边界生效,并不承诺回滚。 + +安装器返回实际端点和安装、资源身份,这些信息不是 Server 部署或 Principal 身份。连接负责方仍需根据公开身份和兼容契约核验真实端点,才能激活连接并取得默认 Scope。安装不会自造 Scope ID、任意选择一个 Scope,也不证明 Memory 操作已成功。 + +## 发布与平台验收 + +实现要求生产者提供固定资源。正式平台资源目录及合格产物归 [#2112](https://github.com/oceanbase/powercontext/issues/2112) 负责;实现不提供公共生产下载 URL,也不会根据版本号猜测地址。开发 fixture 和本地构建包验证的是实现行为,不能证明生产分发可信。 + +Windows 预检会根据实际最终目录和固定暂存路径预算检查资源布局,获取资源时还会在下载前核对本次暂存目录。普通路径必须满足 Windows 文件和目录长度限制,不支持的布局返回 `resource_path_unsupported`。Ops 不修改系统长路径设置,也不假定 Task Scheduler 支持扩展路径形式的可执行文件名。生产者需提供适合目标用户目录的合格布局;完整开发包在较短测试目录中启动成功,不能证明默认安装布局可用。 + +发布验收需使用实际下载产物,在未预装 Python、uv、Git 或编译工具的干净普通用户 Windows 11 x64、macOS arm64 和 macOS x86_64 环境执行。分别验证签名与 macOS notarization、独立 Ops 启动、真实 Task Scheduler 或 LaunchAgent 行为、登录与重启,以及修复和移除后的配置与数据保留。Fake 服务适配器和仓库 runtime 的 SQLite 测试不能替代这些平台证据。 diff --git a/docs/zh/docs/operate/meta.json b/docs/zh/docs/operate/meta.json index b903b90adf..0c0d6718a5 100644 --- a/docs/zh/docs/operate/meta.json +++ b/docs/zh/docs/operate/meta.json @@ -4,6 +4,7 @@ "index", "deploy-server", "local-server-ops", + "managed-local-server", "connect-remote-server", "database-migrations", "artifact-processing-migration", diff --git a/docs/zh/rfcs/1455-desktop-control-center.md b/docs/zh/rfcs/1455-desktop-control-center.md index c15b64f911..f548adaeaa 100644 --- a/docs/zh/rfcs/1455-desktop-control-center.md +++ b/docs/zh/rfcs/1455-desktop-control-center.md @@ -317,10 +317,10 @@ CLI 和桌面共用负责方的锁。安装完成不代表宿主加载或健康 判断登录支持。每项操作仍由 Server 授权。通用 `401 unauthorized` 表示需要有效凭据,不一定过期;只有支持的 契约给出过期原因时才显示过期。区分 `403` 拒绝和 `503 authentication_unavailable`;认证拒绝后停止受保护循环。 -新托管本地安装默认启用强制认证;无认证连接只作为明确选择的现有安装模式。配置负责方在受保护 Server 环境中 -生成凭据。用户确认安装后,安全机器接口为桌面凭据库和所选宿主 -配置凭据,复用现有按 URL 绑定的 authorization 适配器,不通过页面数据返回秘密。Server/Agent 不依赖桌面运行 -或桌面 vault 解锁。 +新建受管个人安装明确绑定 loopback、启用 Dashboard,并设置 `access.mode=disabled`、`auth.enabled=false`;HTTP +和 MCP 共用本地免 Token 模式,不生成 Token 或后台 Principal,也不配置模型。此策略仅用于新配置,已有和远程 +安装的认证选择继续生效,修复保留其受保护配置。需要认证的安装继续使用系统凭据库及现有按 URL 绑定的 +authorization 适配器,不通过页面数据返回秘密。Server/Agent 不依赖桌面运行或桌面 vault 解锁。 轮换由负责方计划协调:核验归属/消费者、暂存受保护配置、校准服务、替换桌面与所选宿主凭据、逐个验证。 静态 Bearer 不假设新旧 token 同时有效,需说明中断和部分失败。按负责方状态恢复,不能用一个客户端重连成功推断 diff --git a/src/powercontext/ops/__main__.py b/src/powercontext/ops/__main__.py index c426de75de..e5afc2bd68 100644 --- a/src/powercontext/ops/__main__.py +++ b/src/powercontext/ops/__main__.py @@ -35,9 +35,12 @@ def _emit(message: dict[str, Any]) -> None: def main() -> int: parser = argparse.ArgumentParser(description="Explicitly authorized local PowerContext service operations") - parser.add_argument("--target-file", required=True, type=Path, help="Private operator-owned target binding") + binding = parser.add_mutually_exclusive_group(required=True) + binding.add_argument("--target-file", type=Path, help="Private operator-owned target binding") + binding.add_argument("--catalog-file", type=Path, help="Private producer catalog for fixed managed installation") + parser.add_argument("--catalog-sha256", help="Catalog digest pinned by the trusted distribution") parser.add_argument( - "--state-dir", required=True, type=Path, help="Stable private operation directory for this user" + "--state-dir", type=Path, help="Stable private operation directory for an operator-owned target" ) args = parser.parse_args() request_id = None @@ -49,8 +52,20 @@ def main() -> int: request_id = request["request_id"] from powercontext.ops.runtime import Ops, TargetBinding - target = TargetBinding.read(args.target_file) - result = Ops(target, args.state_dir).handle(request, emit=_emit) + if args.catalog_file is not None: + if args.catalog_sha256 is None or args.state_dir is not None: + raise OpsError("invalid_managed_binding") # noqa: TRY301 + from powercontext.ops.installer import ManagedInstaller, ManagedLayout + from powercontext.ops.resources import ResourceCatalog + + catalog = ResourceCatalog.read(args.catalog_file, args.catalog_sha256) + installer = ManagedInstaller(catalog, catalog.select_current(), ManagedLayout.current_user()) + ops = Ops(installer.target(), installer.layout.state, installer=installer) + else: + if args.state_dir is None or args.catalog_sha256 is not None: + raise OpsError("invalid_target_binding") # noqa: TRY301 + ops = Ops(TargetBinding.read(args.target_file), args.state_dir) + result = ops.handle(request, emit=_emit) except OpsError as error: result = { "protocol_version": 1, diff --git a/src/powercontext/ops/installer.py b/src/powercontext/ops/installer.py new file mode 100644 index 0000000000..5859813666 --- /dev/null +++ b/src/powercontext/ops/installer.py @@ -0,0 +1,659 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fixed managed installation stages, checkpointed by the existing Ops journal.""" + +from __future__ import annotations + +import os +import shutil +import socket +import sys +import time +from collections.abc import Callable +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urlsplit +from uuid import uuid4 + +from powercontext.ops import protocol +from powercontext.ops.local_policy import ( + classify_database, + default_data_dir, + inspect_existing_configuration, + new_configuration, + preview_new_configuration, +) +from powercontext.ops.protocol import OpsError +from powercontext.ops.resources import ( + ResourceCatalog, + ResourceError, + ResourceSpec, + ResourceStore, + validate_destination, + validate_owned_tree, + verify, +) +from powercontext.service.environment import protect_directory, read_protected_file, write_protected_file +from powercontext.service.model import EnvironmentFileIdentity, ServiceDefinition +from powercontext.service.native_command import observe_native_commands + +if TYPE_CHECKING: + from powercontext.ops.runtime import Ops, TargetBinding + + +@dataclass(frozen=True) +class ManagedLayout: + programs: Path + configuration: Path + state: Path + cache: Path + data: Path + + def __post_init__(self) -> None: + paths = [Path(value) for value in asdict(self).values()] + if any(not path.is_absolute() or ".." in path.parts for path in paths) or any( + a == b or a.is_relative_to(b) for i, a in enumerate(paths) for j, b in enumerate(paths) if i != j + ): + raise OpsError("layout_invalid") + + @classmethod + def current_user(cls) -> ManagedLayout: + if sys.platform == "win32": + value = os.environ.get("LOCALAPPDATA") + if not value or not Path(value).is_absolute(): + raise OpsError("layout_invalid") + local = Path(value) + root = local / "PowerContextManaged" + return cls( + local / "Programs/PowerContext/managed", + root / "config", + root / "state", + root / "cache", + default_data_dir(), + ) + if sys.platform == "darwin": + root = Path.home() / "Library/Application Support/PowerContextManaged" + return cls( + root / "programs", + root / "config", + root / "state", + Path.home() / "Library/Caches/PowerContextManaged", + default_data_dir(), + ) + raise OpsError("unsupported_platform") + + def as_dict(self) -> dict[str, str]: + return {key: str(value) for key, value in asdict(self).items()} + + +class ManagedInstaller: + """Installation policy; operation ownership, locking and outcomes remain in Ops.""" + + def __init__(self, catalog: ResourceCatalog, spec: ResourceSpec, layout: ManagedLayout) -> None: + if spec not in catalog.resources: + raise OpsError("resource_untrusted") + self.catalog = catalog + self.spec = spec + self.layout = layout + self.directory = layout.programs / "server" / spec.resource_id + self.environment = layout.configuration / "server.env" + self.resources = ResourceStore(layout.cache) + + def target(self) -> TargetBinding: + from powercontext.ops.runtime import TargetBinding + + return TargetBinding( + f"managed-{self.spec.digest}", + str(self.directory / self.spec.python_relative), + self.spec.server_version, + str(self.environment), + True, + isolated_runtime=True, + ) + + def _installation(self, ops: Ops) -> dict[str, Any] | None: + record = ops.store.read("_installation.json") + if record is None: + return None + expected = { + "format_version", + "installation_id", + "binding_id", + "resource_digest", + "resource_identity", + "configuration_identity", + "definition", + } + if ( + set(record) != expected + or type(record.get("format_version")) is not int + or record["format_version"] != 1 + or record.get("binding_id") != protocol.digest(ops.target.as_dict()) + or record.get("resource_digest") != self.spec.digest + ): + raise OpsError("installation_record_invalid") + protocol.identifier(record["installation_id"]) + if not isinstance(record["resource_identity"], dict) or not isinstance(record["configuration_identity"], dict): + raise OpsError("installation_record_invalid") + definition = ServiceDefinition.from_dict(record["definition"]) + if ( + definition.python_executable != ops.target.python_executable + or definition.package_version != self.spec.server_version + or not definition.isolated_runtime + ): + raise OpsError("installation_record_invalid") + return record + + def _pending(self, ops: Ops, *, missing_registration: bool = False) -> dict[str, Any] | None: + latest = ops.store.read("_installation_owner.json") + if latest is None: + return None + if set(latest) != {"format_version", "operation_id"}: + raise OpsError("installation_record_invalid") + record = ops._record(protocol.identifier(latest.get("operation_id"))) + if missing_registration: + for previous in record["attempts"]: + result = previous.get("result") or {} + components = result.get("components") or {} + state = previous.get("installation") or {} + facts = previous["plan"].get("installation") or {} + if ( + facts.get("registration_present") is True + or state.get("stage") in {"registration_dispatching", "complete"} + or components.get("registration") == "installed" + or previous.get("native_command") is not None + ): + raise OpsError("registration_missing") + attempt = record["attempts"][-1] + result = attempt.get("result") + return attempt.get("installation") if result is None or result.get("outcome") != "succeeded" else None + + def _configuration(self, ops: Ops, snapshot: dict[str, Any], options: dict[str, Any]): + definition = snapshot["registration"].get("definition") + path = ops._configuration_path(snapshot) + if path != self.environment and path.parent != self.layout.state / "configurations": + raise OpsError("configuration_not_owned") + if path.exists() or path.is_symlink(): + summary = inspect_existing_configuration( + path, data_dir=Path(definition["data_dir"]) if definition else self.layout.data + ) + if any( + data.is_relative_to(root) + for data in (summary.data_dir, summary.database_path) + for root in (self.layout.programs, self.layout.configuration, self.layout.state, self.layout.cache) + ): + raise OpsError("configuration_path_unsupported") + if "http_port" in options and urlsplit(summary.endpoint).port != options["http_port"]: + raise OpsError("configuration_conflict") + return summary + if definition is not None: + raise OpsError("configuration_invalid") + return preview_new_configuration(self.layout.data, options.get("http_port", 8000)) + + def _resource_fact( + self, evidence: dict[str, Any] | None, *, cancelled: Callable[[], bool] = lambda: False + ) -> tuple[str, dict[str, int] | None]: + if not self.directory.exists() and not self.directory.is_symlink(): + return "missing", None + identity = validate_owned_tree(self.directory) + prepared = evidence.get("prepared") if evidence else None + owned = evidence is not None and ( + identity == evidence.get("resource_identity") + or (isinstance(prepared, dict) and identity == prepared.get("identity")) + ) + if not owned: + raise OpsError("ownership_conflict") + try: + verify(self.spec, self.directory, cancelled=cancelled) + except ResourceError as error: + if error.code != "resource_corrupt": + raise + return "damaged", identity + return "verified", identity + + def _repair_owner(self, ops: Ops, snapshot: dict[str, Any], evidence: dict[str, Any] | None) -> str | None: + guard = snapshot["maintenance"] + if not isinstance(guard, dict) or guard.get("phase") != "runtime_repair": + return None + owner = protocol.identifier(guard.get("repair_id")) + original = ops._record(owner)["attempts"][-1] + proof = original.get("installation") + if ( + evidence is None + or not isinstance(proof, dict) + or proof.get("repair_owner") != owner + or evidence.get("repair_owner") != owner + or proof["installation_id"] != evidence["installation_id"] + or original["plan"]["operation"] != "repair" + or original["plan"]["installation"]["resource"]["resource_digest"] != self.spec.digest + or guard.get("definition") != snapshot["registration"].get("definition") + or guard.get("registration_checksum") != snapshot["registration"].get("checksum") + ): + raise OpsError("repair_ownership_conflict") + ops._require_native_settled(original) + return owner + + def preflight(self, ops: Ops, operation: str, options: dict[str, Any]) -> dict[str, Any]: # noqa: C901 + validate_destination(self.spec, self.directory) + validate_destination(self.spec, self.layout.programs / "staging" / ("resource-" + "0" * 32)) + snapshot: dict[str, Any] = ops.controller.operation_snapshot() + ops._assert_target(snapshot, operation) + installation = self._installation(ops) + definition = snapshot["registration"].get("definition") + pending = self._pending(ops, missing_registration=definition is None) + evidence = installation or pending + if installation and pending and installation["installation_id"] == pending["installation_id"]: + evidence = {**installation, **pending} + if evidence is None and (definition is not None or self.environment.exists()): + raise OpsError("existing_independent") + if operation == "repair" and installation is None: + raise OpsError("installation_missing") + if installation is not None and definition is None: + raise OpsError("registration_missing") + repair_owner = self._repair_owner(ops, snapshot, evidence) + if repair_owner is not None and operation != "repair": + raise OpsError("repair_required") + resources, identity = self._resource_fact(evidence) + configuration = self._configuration(ops, snapshot, options) + if configuration.identity is not None and evidence is None: + raise OpsError("ownership_conflict") + if installation is None and configuration.identity is not None: + prepared_configuration = pending.get("configuration") if pending else None + if not isinstance(prepared_configuration, dict) or configuration.identity != replace( + EnvironmentFileIdentity(**prepared_configuration["identity"]), path=str(self.environment) + ): + raise OpsError("ownership_conflict") + database = classify_database(configuration, self.spec.server_version, for_repair=operation == "repair") + current = installation is not None and resources == "verified" + if operation == "install" and installation is not None and resources != "verified": + raise OpsError("repair_required") + registering = pending is not None and pending.get("stage") in {"registering", "registration_dispatching"} + verify_running = registering and snapshot["manager"] == "active" and snapshot["maintenance"] is None + maintenance = snapshot["maintenance"] + preserve_stopped = ( + definition is not None + and isinstance(maintenance, dict) + and maintenance.get("phase") in {"manual_stopped", "start_failed"} + ) + may_start = ( + operation == "install" + and not current + and not preserve_stopped + and (database.allowed_to_start or verify_running) + ) + if ( + operation == "install" + and not current + and not preserve_stopped + and not database.allowed_to_start + and not verify_running + ): + raise OpsError( + "database_incompatible" + if database.classification == "incompatible" + else "database_compatibility_unknown" + ) + if definition is None: + endpoint = urlsplit(configuration.endpoint) + with socket.socket() as listener: + try: + listener.bind((endpoint.hostname or "127.0.0.1", endpoint.port or 8000)) + except OSError as error: + raise OpsError("port_conflict") from error + required = ( + 0 + if resources == "verified" + else self.spec.archive_size + self.spec.unpacked_size * (2 if operation == "repair" else 1) + ) + for root in (self.layout.programs, self.layout.configuration, self.layout.state, self.layout.cache): + if root.exists() or root.is_symlink(): + protect_directory(root) + ancestor = root + while not ancestor.exists(): + ancestor = ancestor.parent + if not os.access(ancestor, os.W_OK) or shutil.disk_usage(ancestor).free < required: + raise OpsError("insufficient_space_or_permissions") + login = options.get( + "start_on_login", definition.get("start_on_login", True) if definition else sys.platform != "win32" + ) + if login is False and sys.platform != "win32": + raise OpsError("option_unsupported") + facts = { + "catalog_sha256": self.catalog.sha256, + "resource": self.spec.as_dict(), + "layout": self.layout.as_dict(), + "installation_id": evidence.get("installation_id") if evidence else None, + "classification": "owned_current" + if current + else "owned_repairable" + if installation + else "pending_operation" + if pending + else "missing", + "resources": resources, + "resource_identity": identity, + "configuration": configuration.as_dict(), + "database": database.as_dict(), + "service_before": protocol.digest(snapshot), + "space_required": required, + "verify_running": verify_running, + "preserve_stopped": preserve_stopped, + "repair_owner": repair_owner, + "registration_present": definition is not None, + } + plan = { + "protocol_version": 1, + "target_id": ops.target.target_id, + "binding_id": protocol.digest(ops.target.as_dict()), + "operation": operation, + "options": options, + "before": protocol.digest(facts), + "effects": {"may_start": may_start, "preserves_data": True}, + "target_summary": { + "python_executable": ops.target.python_executable, + "package_version": ops.target.package_version, + "env_file": str(configuration.identity.path if configuration.identity else self.environment), + "endpoint": configuration.endpoint, + "data_dir": str(configuration.data_dir), + "start_on_login": login, + }, + "installation": facts, + } + return {**plan, "plan_id": protocol.digest(plan)} + + def begin( + self, ops: Ops, previous: dict[str, Any] | None, plan: dict[str, Any], operation_id: str + ) -> dict[str, Any]: + if previous is not None and previous.get("installation") is not None: + return dict(previous["installation"]) + pending = self._pending(ops) + if pending is not None and pending["installation_id"] == plan["installation"]["installation_id"]: + return { + **pending, + "repair_owner": plan["installation"]["repair_owner"] + or (operation_id if plan["operation"] == "repair" else None), + } + return { + "installation_id": plan["installation"]["installation_id"] or str(uuid4()), + "stage": "accepted", + "prepared": None, + "configuration": None, + "resource_identity": plan["installation"]["resource_identity"], + "backup_identity": None, + "repair_owner": plan["installation"]["repair_owner"] + or (operation_id if plan["operation"] == "repair" else None), + } + + def execute(self, ops: Ops, record: dict[str, Any], emit: Callable[[dict[str, Any]], None]) -> dict[str, Any]: # noqa: C901 + current = record["attempts"][-1] + plan = current["plan"] + state = current["installation"] + cancelled = lambda: ops.store.cancelled(record["operation_id"], current["attempt"]) + checked_at = 0.0 + cancellation_seen = False + + def resource_cancelled() -> bool: + nonlocal checked_at, cancellation_seen + now = time.monotonic() + if now - checked_at >= 0.5: + cancellation_seen = cancelled() + checked_at = time.monotonic() + return cancellation_seen + + def checkpoint(stage: str) -> None: + state["stage"] = stage + current["phase"] = "executing" + ops.store.save(record) + emit({ + "type": "progress", + "operation_id": record["operation_id"], + "attempt": current["attempt"], + "sequence": { + "preparing": 1, + "prepared": 2, + "publishing": 3, + "resources_published": 4, + "configuration_prepared": 5, + "configured": 6, + "registering": 7, + "complete": 8, + }[stage], + "phase": stage, + }) + + if cancelled(): + return {"outcome": "cancelled", "components": self.components(ops, state)} + if plan["operation"] == "install" and plan["installation"]["classification"] == "owned_current": + return {"outcome": "succeeded", "components": self.components(ops, state)} + ops.store.write("_installation_owner.json", {"operation_id": record["operation_id"]}) + resource_state, _identity = self._resource_fact(state, cancelled=resource_cancelled) + if resource_state != "verified": + prepared = state.get("prepared") + if prepared is None or not Path(prepared["directory"]).exists(): + checkpoint("preparing") + staging = self.layout.programs / "staging" + try: + result = self.resources.prepare(self.spec, staging, cancelled=resource_cancelled) + except ResourceError as error: + if error.code == "resource_cancelled": + return {"outcome": "cancelled", "components": self.components(ops, state)} + raise + state["prepared"] = {"directory": str(result.directory), "identity": result.identity} + checkpoint("prepared") + else: + self._verify_prepared(state["prepared"], cancelled=resource_cancelled) + if cancelled(): + return {"outcome": "cancelled", "components": self.components(ops, state)} + with ops.controller.mutation(timeout=0): + self._revalidate(ops, plan) + if cancelled(): + return {"outcome": "cancelled", "components": self.components(ops, state)} + if plan["operation"] == "repair": + snapshot: dict[str, Any] = ops.controller.operation_snapshot() + definition = ServiceDefinition.from_dict(snapshot["registration"]["definition"]) + with ( + observe_native_commands(lambda event: self._native_checkpoint(ops, record, event)), + ops.controller.runtime_repair(expected_definition=definition, repair_id=state["repair_owner"]), + ): + self._publish(state, record["operation_id"], checkpoint, cancelled=resource_cancelled) + self._commit(ops, state) + else: + self._publish(state, record["operation_id"], checkpoint, cancelled=resource_cancelled) + self._configure(state, plan, record, checkpoint) + if cancelled(): + return {"outcome": "cancelled", "components": self.components(ops, state)} + checkpoint("registering") + if cancelled(): + return {"outcome": "cancelled", "components": self.components(ops, state)} + if plan["effects"]["may_start"] and not plan["installation"]["verify_running"]: + configuration = self._configuration(ops, ops.controller.operation_snapshot(), plan["options"]) + if ( + classify_database(configuration, self.spec.server_version).as_dict() + != plan["installation"]["database"] + ): + raise OpsError("plan_stale") + with observe_native_commands(lambda event: self._native_checkpoint(ops, record, event)): + if plan["installation"]["preserve_stopped"]: + ops.controller.verify_stopped() + elif plan["installation"]["verify_running"]: + ops.controller.verify_running() + else: + state["stage"] = "registration_dispatching" + ops.store.save(record) + if cancelled(): + state["stage"] = "registering" + return {"outcome": "cancelled", "components": self.components(ops, state)} + ops.controller.install( + env_file=self.environment, + start_on_login=plan["target_summary"]["start_on_login"], + require_ready=True, + ) + self._commit(ops, state) + checkpoint("complete") + components = self.components(ops, state) + if plan["operation"] == "install" and plan["effects"]["may_start"]: + components["readiness"] = "verified_during_operation" + return {"outcome": "succeeded", "components": components} + + def _native_checkpoint(self, ops: Ops, record: dict[str, Any], event: dict[str, object]) -> None: + record["attempts"][-1]["native_command"] = event + ops.store.save(record) + + def _revalidate(self, ops: Ops, plan: dict[str, Any]) -> None: + snapshot: dict[str, Any] = ops.controller.operation_snapshot() + if protocol.digest(snapshot) != plan["installation"]["service_before"]: + raise OpsError("plan_stale") + summary = self._configuration(ops, snapshot, plan["options"]) + if ( + summary.as_dict() != plan["installation"]["configuration"] + or classify_database(summary, self.spec.server_version, for_repair=plan["operation"] == "repair").as_dict() + != plan["installation"]["database"] + ): + raise OpsError("plan_stale") + + def _verify_prepared(self, prepared: dict[str, Any], *, cancelled: Callable[[], bool] = lambda: False) -> Path: + path = Path(prepared["directory"]) + if ( + not path.is_relative_to(self.layout.programs / "staging") + or validate_owned_tree(path) != prepared["identity"] + ): + raise OpsError("resource_identity_changed") + verify(self.spec, path, cancelled=cancelled) + return path + + @staticmethod + def _publish_directory(source: Path, target: Path) -> None: + os.replace(source, target) + if sys.platform != "win32": + for parent in {source.parent, target.parent}: + descriptor = os.open(parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + def _publish( + self, + state: dict[str, Any], + operation_id: str, + checkpoint: Callable[[str], None], + *, + cancelled: Callable[[], bool], + ) -> None: + health, identity = self._resource_fact(state, cancelled=cancelled) + if health == "verified": + state["resource_identity"] = identity + return + prepared = state["prepared"] + source = self._verify_prepared(prepared, cancelled=cancelled) + parent = protect_directory(self.directory.parent, create=True) + # Stay within the already-approved staging path budget even for a + # maximum-length operation ID. Recovery derives the same owned path. + backup = parent / f".repair-{protocol.digest(operation_id)[:32]}" + if health == "damaged": + if backup.exists() or backup.is_symlink(): + raise OpsError("resource_identity_changed") + state["backup_identity"] = identity + checkpoint("publishing") + self._publish_directory(self.directory, backup) + else: + if backup.exists() and validate_owned_tree(backup) != state["backup_identity"]: + raise OpsError("resource_identity_changed") + checkpoint("publishing") + if self.directory.exists() or self.directory.is_symlink(): + raise OpsError("resource_identity_changed") + self._publish_directory(source, self.directory) + verify(self.spec, self.directory, cancelled=cancelled) + if validate_owned_tree(self.directory) != prepared["identity"]: + raise OpsError("resource_identity_changed") + state["resource_identity"] = prepared["identity"] + checkpoint("resources_published") + + def _configure( + self, state: dict[str, Any], plan: dict[str, Any], record: dict[str, Any], checkpoint: Callable[[str], None] + ) -> None: + if self.environment.exists(): + expected = state.get("configuration") + if expected is None or read_protected_file(self.environment).identity != replace( + EnvironmentFileIdentity(**expected["identity"]), path=str(self.environment) + ): + raise OpsError("configuration_conflict") + return + prepared: dict[str, Any] | None = state.get("configuration") + if prepared is None: + protect_directory(self.layout.configuration, create=True) + path = ( + self.layout.configuration / f"pending-{record['operation_id']}-{record['attempts'][-1]['attempt']}.env" + ) + if path.exists() or path.is_symlink(): + raise OpsError("configuration_conflict") + identity = write_protected_file( + path, new_configuration(self.layout.data, urlsplit(plan["target_summary"]["endpoint"]).port or 8000) + ) + state["configuration"] = {"directory": str(path), "identity": asdict(identity)} + checkpoint("configuration_prepared") + prepared = cast(dict[str, Any], state["configuration"]) + path = Path(prepared["directory"]) + if path.parent != self.layout.configuration or read_protected_file(path).identity != EnvironmentFileIdentity( + **prepared["identity"] + ): + raise OpsError("configuration_conflict") + self._publish_directory(path, self.environment) + checkpoint("configured") + + def _commit(self, ops: Ops, state: dict[str, Any]) -> None: + snapshot: dict[str, Any] = ops.controller.operation_snapshot() + definition = snapshot["registration"]["definition"] + if definition is None: + raise OpsError("ownership_conflict") + ops.store.write( + "_installation.json", + { + "installation_id": state["installation_id"], + "binding_id": protocol.digest(ops.target.as_dict()), + "resource_digest": self.spec.digest, + "resource_identity": state["resource_identity"], + "configuration_identity": definition["env_file"], + "definition": definition, + }, + ) + + def inspect(self, ops: Ops, snapshot: dict[str, Any]) -> dict[str, Any]: + """Expose identities and database classification without hashing program files.""" + installation = self._installation(ops) + evidence = installation or self._pending(ops) + configuration = self._configuration(ops, snapshot, {}) + return { + "installation_id": evidence["installation_id"] if evidence else None, + "resource_id": self.spec.resource_id, + "resource_digest": self.spec.digest, + "endpoint": configuration.endpoint if configuration.identity is not None else None, + "database": classify_database(configuration, self.spec.server_version).as_dict(), + "connection_compatibility": "not_checked", + } + + def components(self, ops: Ops, state: dict[str, Any] | None = None) -> dict[str, Any]: + installation = self._installation(ops) + evidence = state or installation or self._pending(ops) + return { + **ops.inspect(), + "installation_id": evidence["installation_id"] if evidence else None, + "resource_id": self.spec.resource_id, + "resource_digest": self.spec.digest, + "endpoint": ops.controller.status().endpoint, + "connection_compatibility": "not_checked", + "installation_stage": state["stage"] if state else None, + } diff --git a/src/powercontext/ops/local_policy.py b/src/powercontext/ops/local_policy.py new file mode 100644 index 0000000000..c07d65bce8 --- /dev/null +++ b/src/powercontext/ops/local_policy.py @@ -0,0 +1,257 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Personal configuration policy and conservative, non-mutating database inventory.""" + +from __future__ import annotations + +import os +import shlex +import stat +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Literal + +from sqlalchemy.engine import make_url +from sqlalchemy.exc import ArgumentError + +from powercontext.cli.env_file import environment_context +from powercontext.ops.protocol import OpsError, digest +from powercontext.paths import POWERCONTEXT_HOME_ENV, powercontext_data_dir, sqlite_url +from powercontext.server.configuration import server_settings_context +from powercontext.service.environment import load_protected_environment_file +from powercontext.service.model import EnvironmentFileIdentity +from powercontext.transport import is_loopback_host + + +@dataclass(frozen=True) +class ConfigurationSummary: + """Safe local plan fields; never retain environment contents or credentials.""" + + identity: EnvironmentFileIdentity | None + data_dir: Path + database_path: Path + endpoint: str + local_mode: Literal["tokenless", "authenticated"] + + def as_dict(self) -> dict[str, object]: + return { + "identity": asdict(self.identity) if self.identity is not None else None, + "data_dir": str(self.data_dir), + "database_path": str(self.database_path), + "endpoint": self.endpoint, + "local_mode": self.local_mode, + } + + +@dataclass(frozen=True) +class DatabaseClassification: + """A preflight observation, not a whole-database compatibility certificate.""" + + classification: Literal["new", "existing_unknown", "incompatible", "unknown"] + allowed_to_start: bool + fingerprint: str + reason: str + + def as_dict(self) -> dict[str, object]: + return asdict(self) + + +def _ambient_configuration_names() -> set[str]: + return { + name + for name in os.environ + if name.upper() in {POWERCONTEXT_HOME_ENV, "PYTHONPATH", "PYTHONHOME"} + or name.upper().startswith("POWERCONTEXT_SERVER_") + } + + +def default_data_dir() -> Path: + """Resolve the platform user-data location without an inherited Server override.""" + with environment_context({}, clear=_ambient_configuration_names()): + return powercontext_data_dir().absolute() + + +def _absolute_path(path: Path) -> Path: + if not path.is_absolute() or "\x00" in str(path): + raise OpsError("configuration_path_unsupported") + return Path(os.path.abspath(path)) + + +def _new_values(data_dir: Path, port: int) -> dict[str, str]: + root = _absolute_path(data_dir) + if type(port) is not int or not 1 <= port <= 65535: + raise OpsError("invalid_request") + return { + POWERCONTEXT_HOME_ENV: str(root), + "POWERCONTEXT_SERVER_HTTP_HOST": "127.0.0.1", + "POWERCONTEXT_SERVER_HTTP_PORT": str(port), + "POWERCONTEXT_SERVER_DATABASE_KIND": "sqlite", + "POWERCONTEXT_SERVER_DATABASE_URL": sqlite_url(root / "powercontext.db"), + "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "true", + "POWERCONTEXT_SERVER_ACCESS_MODE": "disabled", + "POWERCONTEXT_SERVER_AUTH_ENABLED": "false", + } + + +def new_configuration(data_dir: Path, port: int = 8000) -> bytes: + """Render validated tokenless local defaults without creating files or directories.""" + values = _new_values(data_dir, port) + _configuration_summary(values, data_dir=data_dir) + return "".join(f"{key}={shlex.quote(value)}\n" for key, value in sorted(values.items())).encode("utf-8") + + +def preview_new_configuration(data_dir: Path, port: int = 8000) -> ConfigurationSummary: + """Preview the same effective settings that ``new_configuration`` will publish.""" + return _configuration_summary(_new_values(data_dir, port), data_dir=data_dir) + + +def inspect_existing_configuration(env_file: Path, *, data_dir: Path | None = None) -> ConfigurationSummary: + """Inspect a protected configuration, preserving the registered data-directory override.""" + try: + loaded = load_protected_environment_file(env_file) + return _configuration_summary(loaded.values, data_dir=data_dir, identity=loaded.identity) + except (OSError, ValueError) as error: + if isinstance(error, OpsError): + raise + raise OpsError("configuration_invalid") from error + + +def _configuration_summary( + values: Mapping[str, str], *, data_dir: Path | None, identity: EnvironmentFileIdentity | None = None +) -> ConfigurationSummary: + configured_home = next((value for key, value in values.items() if key.upper() == POWERCONTEXT_HOME_ENV), None) + root = _absolute_path( + data_dir if data_dir is not None else Path(configured_home) if configured_home else default_data_dir() + ) + try: + with ( + environment_context({}, clear=_ambient_configuration_names()), + server_settings_context(environment=values, data_dir=root) as settings, + ): + if settings.database.kind != "sqlite": + raise OpsError("database_backend_unsupported") + url = make_url(settings.database.url) + if url.query or not url.database or url.database == ":memory:" or url.database.startswith("file:"): + raise OpsError("database_location_unsupported") + database = _absolute_path(Path(url.database)) + if not is_loopback_host(settings.http.host): + raise OpsError("configuration_not_local") + host = f"[{settings.http.host}]" if ":" in settings.http.host else settings.http.host + return ConfigurationSummary( + identity=identity, + data_dir=root, + database_path=database, + endpoint=f"http://{host}:{settings.http.port}", + local_mode="tokenless" + if settings.access.mode == "disabled" and not settings.auth.enabled + else "authenticated", + ) + except (OSError, ValueError, ArgumentError) as error: + if isinstance(error, OpsError): + raise + raise OpsError("configuration_invalid") from error + + +def _stat_identity(value: os.stat_result, *, for_repair: bool = False) -> dict[str, int]: + identity = { + "device": value.st_dev, + "inode": value.st_ino, + "mode": value.st_mode, + } + if not for_repair: + identity.update(size=value.st_size, modified_ns=value.st_mtime_ns) + return identity + + +def _linked(value: os.stat_result) -> bool: + return stat.S_ISLNK(value.st_mode) or bool(getattr(value, "st_file_attributes", 0) & 0x400) + + +def _file_evidence(path: Path, *, for_repair: bool = False) -> dict[str, object]: + try: + observed = path.lstat() + except FileNotFoundError: + return {"state": "absent"} + return { + "state": "linked" if _linked(observed) else "file" if stat.S_ISREG(observed.st_mode) else "not_file", + "identity": _stat_identity(observed, for_repair=for_repair), + } + + +def classify_database( # noqa: C901 + configuration: ConfigurationSummary, target_version: str, *, for_repair: bool = False +) -> DatabaseClassification: + """Inventory without SQLite connection, initialization, migration, or WAL side effects. + + Existing data remains unknown even when the Ops version matches the target. + The current public maintenance checks cover individual domains, not the + complete Server schema, indexes, task formats, and configured capabilities. + Program-only repair binds an existing database's stable file identity, + allowing normal writes and checkpointing before the service is stopped. + Startup observations retain file sizes, times, and all recovery sidecars. + """ + if not isinstance(target_version, str) or not target_version: + raise OpsError("target_invalid") + database = _absolute_path(configuration.database_path) + evidence: dict[str, object] = {"database_path": str(database), "target_version": target_version} + + def result( + classification: Literal["new", "existing_unknown", "incompatible", "unknown"], reason: str + ) -> DatabaseClassification: + return DatabaseClassification(classification, classification == "new", digest(evidence), reason) + + try: + # Do not treat an absent leaf through a link or reparse point as a fresh + # owned database. Inspecting parents never creates the missing tree. + for parent in database.parents: + try: + observed = parent.lstat() + except FileNotFoundError: + continue + if _linked(observed) or not stat.S_ISDIR(observed.st_mode): + evidence["parent"] = _stat_identity(observed) + return result("unknown", "database_location_unverified") + files = {"": _file_evidence(database, for_repair=for_repair)} + if not for_repair or files[""]["state"] != "file": + files.update({ + suffix: _file_evidence(Path(f"{database}{suffix}")) for suffix in ("-wal", "-shm", "-journal") + }) + evidence["files"] = files + main = files[""] + if main["state"] == "absent": + if any(item["state"] != "absent" for item in files.values()): + return result("unknown", "database_sidecars_without_main") + return result("new", "database_absent") + if main["state"] != "file": + return result("unknown", "database_location_unverified") + descriptor = os.open(database, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)) + try: + before = _stat_identity(os.fstat(descriptor), for_repair=for_repair) + header = os.read(descriptor, 16) + if before != main["identity"] or before != _stat_identity(os.fstat(descriptor), for_repair=for_repair): + return result("unknown", "database_changed_during_inspection") + finally: + os.close(descriptor) + if files != { + suffix: _file_evidence(Path(f"{database}{suffix}"), for_repair=for_repair and suffix == "") + for suffix in files + }: + return result("unknown", "database_changed_during_inspection") + if header and header != b"SQLite format 3\x00": + return result("incompatible", "database_format_unsupported") + return result("existing_unknown", "whole_database_compatibility_unavailable") + except OSError: + return result("unknown", "database_inspection_failed") diff --git a/src/powercontext/ops/protocol.py b/src/powercontext/ops/protocol.py index 2b93c85f00..bfae160d4f 100644 --- a/src/powercontext/ops/protocol.py +++ b/src/powercontext/ops/protocol.py @@ -23,7 +23,7 @@ PROTOCOL_VERSION = 1 MAX_MESSAGE_BYTES = 64 * 1024 -OPERATIONS = {"register", "start", "stop", "restart", "remove_registration", "config_apply"} +OPERATIONS = {"register", "start", "stop", "restart", "remove_registration", "config_apply", "install", "repair"} _IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,95}\Z") _REQUEST_FIELDS = { "inspect": set(), @@ -115,6 +115,8 @@ def options(operation: object, value: object) -> dict[str, Any]: allowed = {"start_on_login"} if operation == "register" else set() if operation == "config_apply": allowed = {"http_port", "dashboard_enabled", "start_on_login", "restart"} + elif operation == "install": + allowed = {"http_port", "start_on_login"} if not set(value) <= allowed: raise OpsError("invalid_request") for name, item in value.items(): @@ -127,6 +129,7 @@ def options(operation: object, value: object) -> dict[str, Any]: def plan(value: object) -> dict[str, Any]: + installation = isinstance(value, dict) and value.get("operation") in ("install", "repair") result = object_fields( value, { @@ -139,7 +142,8 @@ def plan(value: object) -> dict[str, Any]: "effects", "target_summary", "plan_id", - }, + } + | ({"installation"} if installation else set()), ) if type(result["protocol_version"]) is not int or result["protocol_version"] != PROTOCOL_VERSION: raise OpsError("unsupported_protocol") @@ -152,11 +156,60 @@ def plan(value: object) -> dict[str, Any]: if type(effects["may_start"]) is not bool or effects["preserves_data"] is not True: raise OpsError("invalid_request") _validate_target_summary(result["target_summary"]) + if installation: + _validate_installation_plan(result["installation"]) if result["plan_id"] != digest({key: item for key, item in result.items() if key != "plan_id"}): raise OpsError("plan_invalid") return result +def _validate_installation_plan(value: object) -> None: + facts = object_fields( + value, + { + "catalog_sha256", + "resource", + "layout", + "installation_id", + "classification", + "resources", + "resource_identity", + "configuration", + "database", + "service_before", + "space_required", + "verify_running", + "preserve_stopped", + "repair_owner", + "registration_present", + }, + ) + for field in ("catalog_sha256", "service_before"): + if not isinstance(facts[field], str) or re.fullmatch(r"[a-f0-9]{64}", facts[field]) is None: + raise OpsError("invalid_request") + if facts["repair_owner"] is not None: + identifier(facts["repair_owner"]) + if facts["installation_id"] is not None: + identifier(facts["installation_id"]) + if facts["classification"] not in ("missing", "owned_current", "owned_repairable", "pending_operation") or facts[ + "resources" + ] not in ("missing", "damaged", "verified"): + raise OpsError("invalid_request") + if ( + type(facts["space_required"]) is not int + or facts["space_required"] < 0 + or type(facts["verify_running"]) is not bool + or type(facts["preserve_stopped"]) is not bool + or type(facts["registration_present"]) is not bool + ): + raise OpsError("invalid_request") + for field in ("resource", "layout", "configuration", "database"): + if not isinstance(facts[field], dict): + raise OpsError("invalid_request") + if facts["resource_identity"] is not None and not isinstance(facts["resource_identity"], dict): + raise OpsError("invalid_request") + + def _validate_target_summary(value: object) -> None: summary = object_fields( value, diff --git a/src/powercontext/ops/resources.py b/src/powercontext/ops/resources.py new file mode 100644 index 0000000000..abb425d557 --- /dev/null +++ b/src/powercontext/ops/resources.py @@ -0,0 +1,674 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fixed producer resources: bounded acquisition, verification, and private staging. + +The installer owns publication and recovery. This module never executes a +resource, resolves dependencies, changes an active tree, or writes a journal. +""" + +# ruff: noqa: TRY301 - boundary normalization preserves stable resource error codes + +from __future__ import annotations + +import gzip +import hashlib +import io +import os +import platform +import re +import shutil +import stat +import sys +import tarfile +import time +import unicodedata +import zipfile +import zlib +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import IO, Any, Literal, Self, cast +from urllib.parse import urljoin, urlsplit +from uuid import uuid4 + +import httpx +from packaging.version import Version +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, model_validator +from typing_extensions import override + +from powercontext.ops.protocol import PROTOCOL_VERSION, OpsError, decode, digest, identifier +from powercontext.service.environment import protect_directory, read_protected_file + +MAX_CATALOG_BYTES = 32 * 1024 * 1024 +MAX_ARCHIVE_BYTES = 2 * 1024**3 +MAX_UNPACKED_BYTES = 8 * 1024**3 +MAX_FILES = 100_000 +_CHUNK = 1024 * 1024 +_HASH = re.compile(r"[0-9a-f]{64}\Z") +_DEVICE = re.compile(r"(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?\Z", re.IGNORECASE) + + +class ResourceError(OpsError): + """Stable resource failure without URLs, credentials, or local paths.""" + + +def _relative(name: str) -> str: + parts = name.split("/") + if ( + not name + or len(name) > 1024 + or any(not part or part in {".", ".."} or part.endswith((".", " ")) for part in parts) + or any(_DEVICE.fullmatch(part) for part in parts) + or any(character in name for character in '\\:<>"|?*') + or any(ord(character) < 32 for character in name) + or unicodedata.normalize("NFC", name) != name + ): + raise ResourceError("resource_invalid") + return name + + +def _origin(url: str) -> str: + try: + parsed = urlsplit(url) + port = parsed.port + except ValueError as error: + raise ResourceError("resource_source_invalid") from error + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or "\\" in url + or any(ord(character) < 33 for character in url) + ): + raise ResourceError("resource_source_invalid") + return f"https://{parsed.hostname.lower()}:{port or 443}" + + +class ResourceFile(BaseModel): + """One regular file, including the executable mode on POSIX.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + path: StrictStr + size: StrictInt = Field(ge=0, le=MAX_UNPACKED_BYTES) + sha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + executable: StrictBool = False + + @model_validator(mode="after") + def safe_path(self) -> Self: + _relative(self.path) + return self + + +class ResourceSpec(BaseModel): + """Producer-attested complete Server bundle; references are not signature proof.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + resource_id: StrictStr + server_version: StrictStr = Field(min_length=1, max_length=128) + python_version: StrictStr = Field(min_length=1, max_length=128) + platform: Literal["windows", "macos"] + architecture: Literal["x86_64", "arm64"] + archive_format: Literal["zip", "tar.gz"] + url: StrictStr = Field(max_length=4096) + allowed_origins: tuple[StrictStr, ...] = Field(min_length=1, max_length=16) + archive_sha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + archive_size: StrictInt = Field(gt=0, le=MAX_ARCHIVE_BYTES) + unpacked_size: StrictInt = Field(gt=0, le=MAX_UNPACKED_BYTES) + python_relative: StrictStr + ops_protocol_min: StrictInt = Field(ge=1) + ops_protocol_max: StrictInt = Field(ge=1) + source_refs: tuple[StrictStr, ...] = Field(min_length=1, max_length=32) + license_refs: tuple[StrictStr, ...] = Field(min_length=1, max_length=32) + qualification_ref: StrictStr = Field(min_length=1, max_length=1024) + files: tuple[ResourceFile, ...] = Field(min_length=1, max_length=MAX_FILES) + + @model_validator(mode="after") + def consistent(self) -> Self: + identifier(self.resource_id) + Version(self.server_version) + if not Version("3.11") <= Version(self.python_version) < Version("4"): + raise ResourceError("resource_invalid") + _relative(self.python_relative) + origins = {_origin(url) for url in self.allowed_origins} + if _origin(self.url) not in origins: + raise ResourceError("resource_source_invalid") + for url in self.allowed_origins: + parsed = urlsplit(url) + if parsed.path not in ("", "/") or parsed.query: + raise ResourceError("resource_source_invalid") + if self.ops_protocol_min > self.ops_protocol_max or sum(file.size for file in self.files) != self.unpacked_size: + raise ResourceError("resource_invalid") + if any(not reference.strip() or len(reference) > 4096 for reference in (*self.source_refs, *self.license_refs)): + raise ResourceError("resource_invalid") + _validate_file_paths(self.files) + executable = next((file for file in self.files if file.path == self.python_relative), None) + if executable is None or not executable.executable: + raise ResourceError("resource_invalid") + if self.platform == "windows": + console = next((file for file in self.files if file.path == self.python_console_relative), None) + if PurePosixPath(self.python_relative).name != "pythonw.exe" or console is None or not console.executable: + raise ResourceError("resource_invalid") + return self + + @property + def python_console_relative(self) -> str: + return ( + str(PurePosixPath(self.python_relative).with_name("python.exe")) + if self.platform == "windows" + else self.python_relative + ) + + @property + def digest(self) -> str: + return digest(self.model_dump(mode="json")) + + def as_dict(self) -> dict[str, object]: + """Bounded plan summary; the full manifest never enters the Ops wire protocol.""" + return { + "resource_id": self.resource_id, + "resource_digest": self.digest, + "server_version": self.server_version, + "python_version": self.python_version, + "platform": self.platform, + "architecture": self.architecture, + "archive_sha256": self.archive_sha256, + "archive_size": self.archive_size, + "unpacked_size": self.unpacked_size, + "python_relative": self.python_relative, + "qualification_ref": self.qualification_ref, + } + + +def _validate_file_paths(manifest: tuple[ResourceFile, ...]) -> None: + files = {file.path.casefold(): file for file in manifest} + if len(files) != len(manifest): + raise ResourceError("resource_invalid") + directories: dict[str, str] = {} + for file in manifest: + for parent in PurePosixPath(file.path).parents: + if parent == PurePosixPath("."): + continue + name = str(parent) + key = name.casefold() + if key in files or (key in directories and directories[key] != name): + raise ResourceError("resource_invalid") + directories[key] = name + + +@dataclass(frozen=True) +class ResourceCatalog: + sha256: str + resources: tuple[ResourceSpec, ...] + + @property + def digest(self) -> str: + return self.sha256 + + @classmethod + def read(cls, path: Path, expected_sha256: str) -> ResourceCatalog: + """Read a producer-controlled catalog pinned by trusted distribution input.""" + if _HASH.fullmatch(expected_sha256) is None: + raise ResourceError("resource_catalog_invalid") + try: + content = read_protected_file(path, max_bytes=MAX_CATALOG_BYTES).content + if hashlib.sha256(content).hexdigest() != expected_sha256: + raise ResourceError("resource_catalog_invalid") + payload = decode(content) + if not isinstance(payload, dict): + raise ResourceError("resource_catalog_invalid") + payload = cast(dict[str, Any], payload) + if ( + set(payload) != {"format_version", "resources"} + or type(payload["format_version"]) is not int + or payload["format_version"] != 1 + or not isinstance(payload["resources"], list) + or not 1 <= len(payload["resources"]) <= 32 + ): + raise ResourceError("resource_catalog_invalid") + resources = tuple(ResourceSpec.model_validate(item) for item in payload["resources"]) + if len({spec.resource_id for spec in resources}) != len(resources): + raise ResourceError("resource_catalog_invalid") + return cls(expected_sha256, resources) + except (OSError, ValueError) as error: + raise ResourceError("resource_catalog_invalid") from error + + def select(self, resource_id: str) -> ResourceSpec: + matches = [resource for resource in self.resources if resource.resource_id == resource_id] + if len(matches) != 1: + raise ResourceError("resource_unavailable") + _require_current(matches[0]) + return matches[0] + + def select_current(self) -> ResourceSpec: + operating_system, architecture = _current_platform() + matches = [ + resource + for resource in self.resources + if resource.platform == operating_system and resource.architecture == architecture + ] + if len(matches) != 1: + raise ResourceError("resource_unavailable") + _require_current(matches[0]) + return matches[0] + + +def _current_platform() -> tuple[str, str]: + operating_system = {"win32": "windows", "darwin": "macos"}.get(sys.platform) + architecture = {"amd64": "x86_64", "x86_64": "x86_64", "arm64": "arm64", "aarch64": "arm64"}.get( + platform.machine().lower() + ) + if operating_system is None or architecture is None: + raise ResourceError("resource_platform_unsupported") + return operating_system, architecture + + +def _require_current(spec: ResourceSpec) -> None: + if (spec.platform, spec.architecture) != _current_platform(): + raise ResourceError("resource_platform_unsupported") + if not spec.ops_protocol_min <= PROTOCOL_VERSION <= spec.ops_protocol_max: + raise ResourceError("resource_protocol_unsupported") + + +def _cancel(cancelled: Callable[[], bool]) -> None: + if cancelled(): + raise ResourceError("resource_cancelled") + + +def _regular(path: Path) -> os.stat_result: + status = path.lstat() + if not stat.S_ISREG(status.st_mode) or status.st_nlink != 1 or getattr(status, "st_file_attributes", 0) & 0x400: + raise ResourceError("resource_unsafe_path") + return status + + +def _tree(path: Path) -> Iterator[tuple[str, Path]]: + def failed(error: OSError) -> None: + raise error + + for parent, directories, filenames in os.walk(path, followlinks=False, onerror=failed): + for name in directories: + directory = Path(parent) / name + status = directory.lstat() + if not stat.S_ISDIR(status.st_mode) or getattr(status, "st_file_attributes", 0) & 0x400: + raise ResourceError("resource_unsafe_path") + for name in filenames: + file = Path(parent) / name + _regular(file) + yield file.relative_to(path).as_posix(), file + + +def validate_owned_tree(path: Path) -> dict[str, int]: + """Validate a private root and link-free regular descendants; never create paths.""" + try: + root = protect_directory(path) + status = root.stat() + for _name, _file in _tree(root): + pass + after = root.stat() + if (status.st_dev, status.st_ino) != (after.st_dev, after.st_ino): + raise ResourceError("resource_unsafe_path") + except (OSError, ValueError) as error: + raise ResourceError("resource_unsafe_path") from error + return {"device": status.st_dev, "inode": status.st_ino} + + +def _file_hash(path: Path, cancelled: Callable[[], bool]) -> str: + before = _regular(path) + hashed = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(_CHUNK): + _cancel(cancelled) + hashed.update(chunk) + opened = os.fstat(stream.fileno()) + after = _regular(path) + identity = lambda value: (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns) + if identity(before) != identity(opened) or identity(before) != identity(after): + raise ResourceError("resource_corrupt") + return hashed.hexdigest() + + +def verify(spec: ResourceSpec, directory: Path, *, cancelled: Callable[[], bool] = lambda: False) -> None: + """Fully verify all files and reject extra content; no imports or writes.""" + try: + protect_directory(directory) + before = directory.stat() + expected = {file.path: file for file in spec.files} + allowed_directories = {str(parent) for file in spec.files for parent in PurePosixPath(file.path).parents} + for parent, directories, _filenames in os.walk(directory, followlinks=False): + for name in directories: + if (Path(parent) / name).relative_to(directory).as_posix() not in allowed_directories: + raise ResourceError("resource_corrupt") + found: set[str] = set() + for name, path in _tree(directory): + _cancel(cancelled) + file = expected.get(name) + if ( + file is None + or path.stat().st_size != file.size + or _file_hash(path, cancelled) != file.sha256 + or (os.name != "nt" and bool(path.stat().st_mode & stat.S_IXUSR) != file.executable) + ): + raise ResourceError("resource_corrupt") + found.add(name) + if found != set(expected): + raise ResourceError("resource_corrupt") + protect_directory(directory) + after = directory.stat() + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise ResourceError("resource_unsafe_path") + except ResourceError: + raise + except (OSError, ValueError) as error: + raise ResourceError("resource_corrupt") from error + + +@dataclass(frozen=True) +class PreparedResource: + directory: Path + spec: ResourceSpec + identity: dict[str, int] + + +def validate_destination(spec: ResourceSpec, root: Path) -> None: + """Reject unsupported ordinary Windows paths before acquisition or writes. + + Native service launch does not promise extended-path support. The producer + must qualify a layout that fits both staging and the final installation. + """ + if sys.platform != "win32": + return + root = Path(os.path.abspath(root)) + if str(root).startswith("\\\\?\\"): + raise ResourceError("resource_path_unsupported") + + def check(path: Path, limit: int) -> None: + if len(str(path).encode("utf-16-le")) // 2 > limit or any( + len(part.encode("utf-16-le")) // 2 > 255 for part in path.parts + ): + raise ResourceError("resource_path_unsupported") + + check(root, 247) # Ordinary CreateDirectory reserves room for an 8.3 name. + directories = {root} + for file in spec.files: + path = root / file.path + check(path, 259) + parent = path.parent + while parent not in directories: + check(parent, 247) + directories.add(parent) + parent = parent.parent + + +class ResourceStore: + """Disposable hash-addressed cache; interrupted partial downloads are never reused.""" + + def __init__(self, cache_dir: Path) -> None: + self.cache_dir = Path(os.path.abspath(cache_dir)) + + def prepare( + self, spec: ResourceSpec, staging_parent: Path, *, cancelled: Callable[[], bool] = lambda: False + ) -> PreparedResource: + _require_current(spec) + _cancel(cancelled) + stage = Path(os.path.abspath(staging_parent)) / ("resource-" + uuid4().hex) + validate_destination(spec, stage) + stage_identity: dict[str, int] | None = None + parent_identity: os.stat_result | None = None + transferred = False + try: + parent = protect_directory(staging_parent, create=True) + parent_identity = parent.stat() + archive = self._archive(spec, cancelled) + protect_directory(parent) + current_parent = parent.stat() + if (parent_identity.st_dev, parent_identity.st_ino) != (current_parent.st_dev, current_parent.st_ino): + raise ResourceError("resource_unsafe_path") + # An unrecorded stage is never implicitly adopted. The caller can + # retain a returned identity in its existing operation checkpoint. + protect_directory(stage, create=True) + stage_identity = _identity(stage.stat()) + self._extract(spec, archive, stage, cancelled) + verify(spec, stage, cancelled=cancelled) + protect_directory(parent) + after = parent.stat() + if (parent_identity.st_dev, parent_identity.st_ino) != (after.st_dev, after.st_ino): + raise ResourceError("resource_unsafe_path") + prepared = PreparedResource(stage, spec, validate_owned_tree(stage)) + except ResourceError: + raise + except ( + OSError, + ValueError, + EOFError, + NotImplementedError, + tarfile.TarError, + zipfile.BadZipFile, + zlib.error, + ) as error: + raise ResourceError("resource_prepare_failed") from error + else: + transferred = True + return prepared + finally: + if not transferred and stage_identity is not None and parent_identity is not None: + _discard_created(stage, _identity(parent_identity), stage_identity, directory=True) + + def _archive(self, spec: ResourceSpec, cancelled: Callable[[], bool]) -> Path: + protect_directory(self.cache_dir, create=True) + cached = self.cache_dir / (spec.archive_sha256 + ".archive") + if cached.exists() or cached.is_symlink(): + if _regular(cached).st_size == spec.archive_size and _file_hash(cached, cancelled) == spec.archive_sha256: + return cached + # Only this exact regular cache object is disposable, not an + # active environment or an arbitrary caller-supplied path. + cached.unlink() + partial = self.cache_dir / (spec.archive_sha256 + "." + uuid4().hex + ".partial") + parent_identity = _identity(self.cache_dir.stat()) + partial_identity = self._download(spec, partial, cancelled) + try: + if _regular(partial).st_size != spec.archive_size or _file_hash(partial, cancelled) != spec.archive_sha256: + raise ResourceError("resource_corrupt") + protect_directory(self.cache_dir) + os.replace(partial, cached) + finally: + _discard_created(partial, parent_identity, partial_identity, directory=False) + return cached + + @staticmethod + def _download(spec: ResourceSpec, destination: Path, cancelled: Callable[[], bool]) -> dict[str, int]: + allowed = {_origin(url) for url in spec.allowed_origins} + url = spec.url + deadline = time.monotonic() + 1800 + try: + with httpx.Client(trust_env=False, follow_redirects=False, timeout=30) as client: + for _redirect in range(6): + _cancel(cancelled) + if _origin(url) not in allowed: + raise ResourceError("resource_source_invalid") + with client.stream("GET", url, headers={"Accept-Encoding": "identity"}) as response: + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + if not location: + raise ResourceError("resource_unavailable") + url = urljoin(url, location) + continue + return _receive(response, destination, spec.archive_size, deadline, cancelled) + raise ResourceError("resource_source_invalid") + except httpx.HTTPError as error: + raise ResourceError("resource_unavailable") from error + + @staticmethod + def _extract(spec: ResourceSpec, archive: Path, stage: Path, cancelled: Callable[[], bool]) -> None: + extraction = _Extraction(spec, stage, cancelled) + if spec.archive_format == "zip": + extraction.zip(archive) + else: + extraction.tar(archive) + + +def _receive( + response: httpx.Response, destination: Path, expected: int, deadline: float, cancelled: Callable[[], bool] +) -> dict[str, int]: + if response.status_code != 200 or response.headers.get("content-encoding", "identity") != "identity": + raise ResourceError("resource_unavailable") + length = response.headers.get("content-length") + if length is not None and length != str(expected): + raise ResourceError("resource_corrupt") + size = 0 + identity: dict[str, int] | None = None + parent_identity = _identity(destination.parent.stat()) + try: + with destination.open("xb") as output: + identity = _identity(os.fstat(output.fileno())) + for chunk in response.iter_raw(): + _cancel(cancelled) + if time.monotonic() > deadline: + raise ResourceError("resource_timeout") + size += len(chunk) + if size > expected: + raise ResourceError("resource_corrupt") + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + if size != expected: + raise ResourceError("resource_corrupt") + except BaseException: + if identity is not None: + _discard_created(destination, parent_identity, identity, directory=False) + raise + return identity + + +def _identity(status: os.stat_result) -> dict[str, int]: + return {"device": status.st_dev, "inode": status.st_ino} + + +def _discard_created(path: Path, parent_identity: dict[str, int], identity: dict[str, int], *, directory: bool) -> None: + """Best-effort removal only while this attempt still owns the exact object.""" + try: + parent = protect_directory(path.parent) + if _identity(parent.stat()) != parent_identity or _identity(path.lstat()) != identity: + return + # Check the absolute resolved target before any recursive removal. + resolved = path.resolve() + if not path.is_absolute() or resolved.parent != parent.resolve() or resolved == parent.resolve(): + return + if directory: + if validate_owned_tree(path) != identity: + return + shutil.rmtree(path) + else: + _regular(path) + path.unlink() + except (OSError, ValueError): + # Replacement, unsafe ownership and permission failures do not + # authorize broader cleanup or hide the original preparation error. + return + + +class _Extraction: + """Enforce the same manifest and byte bounds for both archive formats.""" + + def __init__(self, spec: ResourceSpec, stage: Path, cancelled: Callable[[], bool]) -> None: + self.expected = {file.path: file for file in spec.files} + self.directories = {str(parent) for file in spec.files for parent in PurePosixPath(file.path).parents} + self.seen: set[str] = set() + self.stage = stage + self.cancelled = cancelled + # Include tar headers, padding and bounded producer metadata, without + # permitting an arbitrarily expanding PAX header before a file entry. + self.tar_limit = spec.unpacked_size + len(spec.files) * 4096 + 1024 * 1024 + + def entry(self, name: str, size: int, directory: bool) -> ResourceFile | None: + _cancel(self.cancelled) + name = _relative(name.removesuffix("/") if directory else name) + if len(self.seen) >= MAX_FILES * 2 or name.casefold() in self.seen: + raise ResourceError("resource_archive_invalid") + self.seen.add(name.casefold()) + if directory: + if name not in self.directories or size != 0: + raise ResourceError("resource_archive_invalid") + return None + file = self.expected.get(name) + if file is None or size != file.size: + raise ResourceError("resource_archive_invalid") + return file + + def copy(self, file: ResourceFile, source: IO[bytes]) -> None: + destination = self.stage / file.path + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + total = 0 + hashed = hashlib.sha256() + with destination.open("xb") as output: + while chunk := source.read(min(_CHUNK, file.size - total + 1)): + _cancel(self.cancelled) + total += len(chunk) + if total > file.size: + raise ResourceError("resource_archive_invalid") + hashed.update(chunk) + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + if total != file.size or hashed.hexdigest() != file.sha256: + raise ResourceError("resource_corrupt") + destination.chmod(0o700 if file.executable else 0o600) + + def zip(self, path: Path) -> None: + with zipfile.ZipFile(path) as archive: + if len(archive.infolist()) > MAX_FILES * 2: + raise ResourceError("resource_archive_invalid") + for member in archive.infolist(): + kind = stat.S_IFMT(member.external_attr >> 16) + if kind not in (0, stat.S_IFREG, stat.S_IFDIR) or member.flag_bits & 1 or member.external_attr & 0x400: + raise ResourceError("resource_archive_invalid") + file = self.entry(member.filename, member.file_size, member.is_dir()) + if file is not None: + with archive.open(member) as stream: + self.copy(file, stream) + + def tar(self, path: Path) -> None: + with ( + gzip.open(path, "rb") as compressed, + tarfile.open(fileobj=_BoundedTar(compressed, self.tar_limit, self.cancelled), mode="r|") as archive, + ): + for member in archive: + if not (member.isfile() or member.isdir()) or member.issparse(): + raise ResourceError("resource_archive_invalid") + file = self.entry(member.name, member.size, member.isdir()) + if file is not None: + stream = archive.extractfile(member) + if stream is None: + raise ResourceError("resource_archive_invalid") + with stream: + self.copy(file, stream) + + +class _BoundedTar(io.RawIOBase): + def __init__(self, stream: gzip.GzipFile, limit: int, cancelled: Callable[[], bool]) -> None: + self.stream = stream + self.remaining = limit + self.cancelled = cancelled + + @override + def read(self, size: int = -1) -> bytes: + _cancel(self.cancelled) + requested = self.remaining + 1 if size < 0 else min(size, self.remaining + 1) + content = self.stream.read(requested) + self.remaining -= len(content) + if self.remaining < 0: + raise ResourceError("resource_archive_invalid") + return content diff --git a/src/powercontext/ops/runtime.py b/src/powercontext/ops/runtime.py index 9b3e74ad4f..6e90e6d264 100644 --- a/src/powercontext/ops/runtime.py +++ b/src/powercontext/ops/runtime.py @@ -21,10 +21,12 @@ import subprocess import sys from collections.abc import Callable -from contextlib import suppress +from contextlib import nullcontext, suppress from dataclasses import asdict, dataclass from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast + +from sqlalchemy.exc import ArgumentError from powercontext.ops import protocol from powercontext.ops.protocol import OpsError @@ -41,6 +43,9 @@ from powercontext.service.native_command import native_process_exited, observe_native_commands from powercontext.transport import is_loopback_host +if TYPE_CHECKING: + from powercontext.ops.installer import ManagedInstaller + @dataclass(frozen=True) class TargetBinding: @@ -51,20 +56,33 @@ class TargetBinding: package_version: str env_file: str configuration_owned: bool = False + isolated_runtime: bool = False def __post_init__(self) -> None: protocol.identifier(self.target_id) if not Path(self.python_executable).is_absolute() or not Path(self.env_file).is_absolute(): raise OpsError("target_invalid") - if not self.package_version or type(self.configuration_owned) is not bool: + if ( + not self.package_version + or type(self.configuration_owned) is not bool + or type(self.isolated_runtime) is not bool + ): raise OpsError("target_invalid") + def as_dict(self) -> dict[str, Any]: + payload = asdict(self) + if not self.isolated_runtime: + payload.pop("isolated_runtime") + return payload + @classmethod def read(cls, path: Path) -> TargetBinding: value = protocol.decode(read_protected_file(path).content) + optional = {"isolated_runtime"} if isinstance(value, dict) and "isolated_runtime" in value else set() payload = protocol.object_fields( value, - {"format_version", "target_id", "python_executable", "package_version", "env_file", "configuration_owned"}, + {"format_version", "target_id", "python_executable", "package_version", "env_file", "configuration_owned"} + | optional, ) if type(payload["format_version"]) is not int or payload["format_version"] != 1: raise OpsError("target_unsupported") @@ -84,11 +102,23 @@ class Ops: shown to the user; this same-user process is not a privilege boundary. """ - def __init__(self, target: TargetBinding, state_dir: Path, *, controller: ServiceController | None = None) -> None: + def __init__( + self, + target: TargetBinding, + state_dir: Path, + *, + controller: ServiceController | None = None, + installer: ManagedInstaller | None = None, + ) -> None: self.target = target + self.installer = installer + if installer is not None and (target != installer.target() or Path(state_dir) != installer.layout.state): + raise OpsError("target_conflict") self.store = OperationStore(state_dir) self.controller = controller or ServiceController( - target=ServiceTarget(target.python_executable, target.package_version), + target=ServiceTarget( + target.python_executable, target.package_version, isolated_runtime=target.isolated_runtime + ), ) def handle(self, value: object, *, emit: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]: @@ -145,7 +175,7 @@ def inspect(self) -> dict[str, Any]: configuration = "valid" except (OSError, ValueError): configuration = "missing_or_invalid" - return { + components: dict[str, Any] = { "support": status.support.value, "registration": status.registration.value, "definition": status.definition.value, @@ -153,10 +183,17 @@ def inspect(self) -> dict[str, Any]: "manager": status.manager.value, "liveness": status.server_liveness.value, "readiness": "not_probed", + "endpoint": status.endpoint, "maintenance": maintenance.get("phase") if isinstance(maintenance, dict) else None, "resources": "present" if Path(self.target.python_executable).is_file() else "missing_executable", "configuration": configuration, } + if self.installer is not None: + try: + components.update(self.installer.inspect(self, snapshot)) + except (OSError, ValueError, ServiceError, KeyError, TypeError): + components["installation_observation"] = "unknown" + return components def _configuration_path(self, snapshot: dict[str, Any]) -> Path: definition = snapshot["registration"].get("definition") @@ -170,7 +207,7 @@ def _configuration(self, snapshot: dict[str, Any]) -> dict[str, str]: with server_settings_context(environment=loaded.values) as settings: if not is_loopback_host(settings.http.host): raise OpsError("configuration_invalid") - except ServerConfigurationError as error: + except (ServerConfigurationError, ArgumentError) as error: raise OpsError("configuration_invalid") from error return loaded.values @@ -183,13 +220,21 @@ def _assert_target(self, snapshot: dict[str, Any], operation: str) -> None: if definition is not None and ( os.path.normcase(definition["python_executable"]) != os.path.normcase(self.target.python_executable) or definition["package_version"] != self.target.package_version + or definition.get("isolated_runtime", False) != self.target.isolated_runtime ): raise OpsError("ownership_conflict") - if operation not in {"register", "remove_registration"} and registration["state"] != "installed": + if ( + operation not in {"register", "remove_registration", "install", "repair"} + and registration["state"] != "installed" + ): raise OpsError("registration_missing") - def preflight(self, operation: object, options: object) -> dict[str, Any]: + def preflight(self, operation: object, options: object) -> dict[str, Any]: # noqa: C901 selected = protocol.options(operation, options) + if operation in {"install", "repair"}: + if self.installer is None: + raise OpsError("managed_installation_unavailable") + return self.installer.preflight(self, str(operation), selected) snapshot = self.controller.operation_snapshot() self._assert_target(snapshot, str(operation)) identity = None @@ -215,13 +260,29 @@ def preflight(self, operation: object, options: object) -> dict[str, Any]: may_start = ( selected.get("restart", False) and snapshot["manager"] == "active" and not stopped and not switching ) + database = None + if self.installer is not None and may_start: + from powercontext.ops.local_policy import classify_database + + configuration = self.installer._configuration(self, snapshot, {}) + database = classify_database(configuration, self.target.package_version) + if not database.allowed_to_start: + raise OpsError( + "database_incompatible" + if database.classification == "incompatible" + else "database_compatibility_unknown" + ) result = { "protocol_version": 1, "target_id": self.target.target_id, - "binding_id": protocol.digest(asdict(self.target)), + "binding_id": protocol.digest(self.target.as_dict()), "operation": operation, "options": selected, - "before": protocol.digest({"service": snapshot, "configuration": identity}), + "before": protocol.digest({ + "service": snapshot, + "configuration": identity, + **({"database": database.as_dict()} if database else {}), + }), "effects": {"may_start": may_start, "preserves_data": True}, "target_summary": self._target_summary(snapshot, str(operation), selected), } @@ -249,7 +310,7 @@ def _record(self, operation_id: str) -> dict[str, Any]: record = self.store.operation(operation_id) if record is None: raise OpsError("operation_missing") - if record.get("binding_id") != protocol.digest(asdict(self.target)): + if record.get("binding_id") != protocol.digest(self.target.as_dict()): raise OpsError("target_conflict") return record @@ -317,12 +378,13 @@ def _duplicate(self, request: dict[str, Any], plan: dict[str, Any]) -> dict[str, def _apply(self, request: dict[str, Any], emit: Callable[[dict[str, Any]], None]) -> dict[str, Any]: plan = protocol.plan(request["plan"]) protocol.authorize(request["authorization"], plan) - if plan["binding_id"] != protocol.digest(asdict(self.target)) or plan["target_id"] != self.target.target_id: + if plan["binding_id"] != protocol.digest(self.target.as_dict()) or plan["target_id"] != self.target.target_id: raise OpsError("target_conflict") duplicate = self._duplicate(request, plan) if duplicate is not None: return duplicate - with self.store.mutation(), self.controller.mutation(timeout=0): + managed = plan["operation"] in {"install", "repair"} + with self.store.mutation(), nullcontext() if managed else self.controller.mutation(timeout=0): duplicate = self._duplicate(request, plan) if duplicate is not None: return duplicate @@ -363,10 +425,11 @@ def _apply(self, request: dict[str, Any], emit: Callable[[dict[str, Any]], None] self._execute(record, emit) return self._status(record) - def _new_attempt(self, request: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: + def _new_attempt(self, request: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: # noqa: C901 operation_id = request["operation_id"] recovery = None revision = None + previous = None latest = self.store.read("_latest.json") latest_id = protocol.identifier(latest.get("operation_id")) if latest is not None else None latest_attempt = self._record(latest_id)["attempts"][-1]["attempt"] if latest_id is not None else None @@ -393,8 +456,10 @@ def _new_attempt(self, request: dict[str, Any], plan: dict[str, Any]) -> dict[st raise OpsError("operation_conflict") if previous["result"] is not None and previous["result"]["outcome"] == "succeeded": raise OpsError("operation_completed") - if previous["phase"] == "executing" and ( - previous["result"] is None or previous["result"]["outcome"] == "uncertain" + if ( + plan["operation"] not in {"install", "repair"} + and previous["phase"] == "executing" + and (previous["result"] is None or previous["result"]["outcome"] == "uncertain") ): recovery = self._reconcile(previous, plan) revision = previous.get("configuration_revision") @@ -415,6 +480,10 @@ def _new_attempt(self, request: dict[str, Any], plan: dict[str, Any]) -> dict[st }) if revision is not None: record["attempts"][-1]["configuration_revision"] = revision + if plan["operation"] in {"install", "repair"}: + if self.installer is None: + raise OpsError("managed_installation_unavailable") + record["attempts"][-1]["installation"] = self.installer.begin(self, previous, plan, operation_id) return record def _require_previous_settled(self, plan: dict[str, Any]) -> None: @@ -554,7 +623,11 @@ def native_checkpoint(value: dict[str, object]) -> None: self.store.save(record) try: - if cancelled(): + if plan["operation"] in {"install", "repair"}: + if self.installer is None: + raise OpsError("managed_installation_unavailable") # noqa: TRY301 + current["result"] = self.installer.execute(self, record, emit) + elif cancelled(): current["result"] = {"outcome": "cancelled", "components": self.inspect()} else: recovery = current.get("recovery") @@ -611,6 +684,13 @@ def native_checkpoint(value: dict[str, object]) -> None: if configuration_cancelled: current["result"]["outcome"] = "cancelled" current["phase"] = "finished" + except OpsError as error: + if error.code == "resource_cancelled" and plan["operation"] in {"install", "repair"}: + current["result"] = {"outcome": "cancelled", "components": self.inspect()} + current["phase"] = "finished" + else: + current["result"] = self._failure(current) + current["result"]["error"] = {"code": error.code} except (OSError, ValueError, ServiceError, subprocess.SubprocessError, KeyError, TypeError): current["result"] = self._failure(current) try: diff --git a/src/powercontext/ops/storage.py b/src/powercontext/ops/storage.py index 54490903d6..c674394479 100644 --- a/src/powercontext/ops/storage.py +++ b/src/powercontext/ops/storage.py @@ -140,7 +140,7 @@ def _validate_revision(value: object) -> None: raise OpsError("record_invalid") -def _validate_attempt(value: object, index: int) -> None: +def _validate_attempt(value: object, index: int) -> None: # noqa: C901 if not isinstance(value, dict): raise OpsError("record_invalid") attempt = cast(dict[str, Any], value) @@ -155,12 +155,15 @@ def _validate_attempt(value: object, index: int) -> None: "executor", "coordination_previous_operation_id", "coordination_previous_attempt", + "installation", } ): raise OpsError("record_invalid") if type(attempt["attempt"]) is not int or attempt["attempt"] != index: raise OpsError("record_invalid") - plan(attempt["plan"]) + validated_plan = plan(attempt["plan"]) + if validated_plan["operation"] in {"install", "repair"} and "installation" not in attempt: + raise OpsError("record_invalid") if not isinstance(attempt["phase"], str) or attempt["phase"] not in {"accepted", "executing", "finished"}: raise OpsError("record_invalid") _validate_result(attempt["result"]) @@ -188,11 +191,61 @@ def _validate_attempt(value: object, index: int) -> None: ("executor", _validate_executor), ("coordination_previous_operation_id", _validate_predecessor), ("coordination_previous_attempt", _validate_predecessor_attempt), + ("installation", _validate_installation), ): if field in attempt: validate(attempt[field]) +def _validate_installation(value: object) -> None: + if not isinstance(value, dict): + raise OpsError("record_invalid") + state = cast(dict[str, Any], value) + if set(state) != { + "installation_id", + "stage", + "prepared", + "configuration", + "resource_identity", + "backup_identity", + "repair_owner", + }: + raise OpsError("record_invalid") + identifier(state["installation_id"]) + if state["repair_owner"] is not None: + identifier(state["repair_owner"]) + if state["stage"] not in ( + "accepted", + "preparing", + "prepared", + "publishing", + "resources_published", + "configuration_prepared", + "configured", + "registering", + "registration_dispatching", + "complete", + ): + raise OpsError("record_invalid") + for field in ("prepared", "configuration"): + item = state[field] + if item is not None and ( + not isinstance(item, dict) + or set(item) != {"directory", "identity"} + or not isinstance(item["directory"], str) + or not isinstance(item["identity"], dict) + ): + raise OpsError("record_invalid") + for field in ("resource_identity", "backup_identity"): + item = state[field] + if item is not None and ( + not isinstance(item, dict) + or set(item) != {"device", "inode"} + or any(type(part) is not int or part < 0 for part in item.values()) + ): + raise OpsError("record_invalid") + + def _validate_executor(value: object) -> None: if not isinstance(value, dict): raise OpsError("record_invalid") diff --git a/src/powercontext/service/adapters/launchd.py b/src/powercontext/service/adapters/launchd.py index c99527991f..b3385061d3 100644 --- a/src/powercontext/service/adapters/launchd.py +++ b/src/powercontext/service/adapters/launchd.py @@ -336,9 +336,10 @@ def _clear_retry_files(definition: ServiceDefinition) -> None: def _launcher_arguments(definition: ServiceDefinition) -> list[str]: + launcher = definition.launcher_arguments() + module_position = launcher.index("-m") return [ - definition.python_executable, - "-m", + *launcher[: module_position + 1], "powercontext_service_bootstrap", "--retry-state", str(_failure_state_path(definition)), @@ -349,7 +350,7 @@ def _launcher_arguments(definition: ServiceDefinition) -> list[str]: "--retry-window-seconds", str(_FAILURE_WINDOW_SECONDS), "--", - *definition.launcher_arguments()[3:], + *launcher[module_position + 2 :], ] diff --git a/src/powercontext/service/controller.py b/src/powercontext/service/controller.py index d7d6119bac..bf6e917c66 100644 --- a/src/powercontext/service/controller.py +++ b/src/powercontext/service/controller.py @@ -295,6 +295,8 @@ def start(self) -> ServiceStatus: self._recover_definition_switch() registration, running = self._owned_registration() record = self._maintenance_record() + if record is not None and record.get("phase") == "runtime_repair": + raise ServiceError("resume the owning Ops runtime repair before starting the service") # noqa: TRY003 if record is not None and record.get("phase") not in {"manual_stopped", "database_ready", "start_failed"}: raise ServiceError( # noqa: TRY003 "the database migration has not been verified; complete it before starting the service" @@ -338,6 +340,53 @@ def verify_stopped(self) -> ServiceStatus: self._require_stopped(registration.definition) return self.status() + @contextmanager + def runtime_repair(self, *, expected_definition: ServiceDefinition, repair_id: str) -> Generator[None, None, None]: + """Hold an owned service stopped while its exact runtime is repaired. + + The installer owns resource publication and verification inside this + boundary. A normal exit confirms those steps; an exception or process + exit retains a guard that ordinary start/install cannot clear. Repair + never changes the registered executable, configuration, or version, + and completion leaves the service explicitly stopped. + """ + if ( + not repair_id + or len(repair_id) > 96 + or not all(c.isascii() and (c.isalnum() or c in "-_") for c in repair_id) + ): + raise ServiceError("the runtime repair identity is invalid") # noqa: TRY003 + with self.mutation(): + registration, _running = self._owned_registration() + if registration.definition != expected_definition: + raise ServiceError("the service changed; review a new runtime repair plan") # noqa: TRY003 + guard: dict[str, object] = { + "phase": "runtime_repair", + "repair_id": repair_id, + "definition": expected_definition.as_dict(), + "registration_checksum": hashlib.sha256(registration.content or b"").hexdigest(), + } + record = self._maintenance_record() + if record is not None and record.get("phase") not in {"manual_stopped", "start_failed"} and record != guard: + raise ServiceError("finish the existing service maintenance before repairing its runtime") # noqa: TRY003 + self._write_maintenance_record(guard) + self._adapter.suspend(self.maintenance_path) + self._adapter.stop() + self._require_stopped(expected_definition) + yield + registration, running = self._owned_registration() + if ( + running + or registration.definition != expected_definition + or hashlib.sha256(registration.content or b"").hexdigest() != guard["registration_checksum"] + or self._maintenance_record() != guard + ): + raise ServiceError("the service changed during runtime repair; preserve its recovery guard") # noqa: TRY003 + self._require_stopped(expected_definition) + if not Path(expected_definition.python_executable).is_file(): + raise ServiceError("the repaired Server executable is unavailable") # noqa: TRY003 + self._write_maintenance_record({"phase": "manual_stopped"}) + def stop(self) -> ServiceStatus: """Stop the owned service and persistently suppress native automatic activation.""" @@ -869,6 +918,8 @@ def status(self) -> ServiceStatus: ): if record.get("phase") == "configuration_switching": recovery = "use the owning Ops caller to inspect and resume the interrupted `config_apply` operation" + elif record.get("phase") == "runtime_repair": + recovery = "use the owning Ops caller to inspect and resume the interrupted runtime repair" elif record.get("phase") == "manual_switching": recovery = "run `powercontext service install` to finish the interrupted stopped-registration update" elif record.get("phase") == "manual_stopped" and registration.definition is DefinitionState.CURRENT: @@ -952,6 +1003,7 @@ def preview_definition(self, env_file: Path | None = None, *, start_on_login: bo data_dir=data_dir, env_file=loaded_env.identity if loaded_env is not None else None, start_on_login=start_on_login, + isolated_runtime=target.isolated_runtime, ) def _run_uninstall_stage(self, stage: str, operation: Callable[[], None]) -> None: diff --git a/src/powercontext/service/model.py b/src/powercontext/service/model.py index 908489479b..38b9dda6b7 100644 --- a/src/powercontext/service/model.py +++ b/src/powercontext/service/model.py @@ -33,10 +33,13 @@ class ServiceTarget: python_executable: str package_version: str + isolated_runtime: bool = False def __post_init__(self) -> None: if not Path(self.python_executable).is_absolute() or not self.package_version.strip(): raise ValueError("a service target requires an absolute executable and package version") # noqa: TRY003 + if type(self.isolated_runtime) is not bool: + raise TypeError("isolated_runtime must be a boolean") # noqa: TRY003 class SupportState(StrEnum): @@ -129,12 +132,15 @@ class ServiceDefinition: data_dir: str env_file: EnvironmentFileIdentity | None = None start_on_login: bool = True + isolated_runtime: bool = False def as_dict(self) -> dict[str, Any]: payload = asdict(self) # Omit the default so definitions written before this option was added remain byte-for-byte valid. if self.start_on_login: payload.pop("start_on_login", None) + if not self.isolated_runtime: + payload.pop("isolated_runtime", None) environment = payload.get("env_file") if isinstance(environment, dict) and environment.get("owner_sid") is None: # POSIX identities do not have a Windows owner SID. Keep their metadata compatible with older files. @@ -155,7 +161,7 @@ def from_dict(cls, value: object) -> ServiceDefinition: "data_dir", "env_file", } - if set(payload) not in (expected, expected | {"start_on_login"}): + if set(payload) - {"start_on_login", "isolated_runtime"} != expected: raise ValueError("service definition fields do not match the supported contract") # noqa: TRY003 environment = payload["env_file"] env_file = None @@ -176,6 +182,9 @@ def from_dict(cls, value: object) -> ServiceDefinition: start_on_login = payload.get("start_on_login", True) if not isinstance(start_on_login, bool): raise TypeError("service definition field 'start_on_login' must be a boolean") # noqa: TRY003 + isolated_runtime = payload.get("isolated_runtime", False) + if type(isolated_runtime) is not bool: + raise TypeError("service definition field 'isolated_runtime' must be a boolean") # noqa: TRY003 return cls( ownership=_required_string(payload, "ownership"), definition_version=_required_int(payload, "definition_version"), @@ -185,6 +194,7 @@ def from_dict(cls, value: object) -> ServiceDefinition: data_dir=_required_string(payload, "data_dir"), env_file=env_file, start_on_login=start_on_login, + isolated_runtime=isolated_runtime, ) def launcher_arguments( @@ -195,6 +205,9 @@ def launcher_arguments( ) -> list[str]: arguments = [ self.python_executable, + # Managed resource files are immutable and must not import code + # from the user's site packages, working directory or PYTHONPATH. + *(["-I", "-B"] if self.isolated_runtime else []), "-m", module, "--endpoint", diff --git a/tests/e2e/test_managed_runtime_repair.py b/tests/e2e/test_managed_runtime_repair.py new file mode 100644 index 0000000000..472369fc59 --- /dev/null +++ b/tests/e2e/test_managed_runtime_repair.py @@ -0,0 +1,128 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Real SQLite/public Memory retention; the resource download and OS manager are simulated.""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import socket +from pathlib import Path + +import httpx +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.ops import Ops, resources +from powercontext.ops.installer import ManagedInstaller, ManagedLayout +from powercontext.ops.local_policy import inspect_existing_configuration +from powercontext.ops.resources import ResourceCatalog +from powercontext.paths import sqlite_url +from powercontext.service.controller import ServiceController +from powercontext.service.environment import protect_directory, read_protected_file +from powercontext.service.model import ManagerState, ServiceTarget +from tests.test_ops import _apply_request, _plan +from tests.test_ops_resources import _FILES, _archive, _spec, _transport +from tests.test_service import FakeAdapter, _manager_probe + + +def test_same_version_repair_preserves_public_memory_history_and_user_configuration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + for name in tuple(os.environ): + if name.upper() == "POWERCONTEXT_HOME" or name.upper().startswith("POWERCONTEXT_SERVER_"): + monkeypatch.delenv(name) + archive = _archive() + spec = _spec(archive) + monkeypatch.setattr(resources, "_current_platform", lambda: ("windows", "x86_64")) + _transport(monkeypatch, lambda _request: httpx.Response(200, stream=httpx.ByteStream(archive))) + layout = ManagedLayout(*(tmp_path / name for name in ("programs", "configuration", "state", "cache", "data"))) + installer = ManagedInstaller(ResourceCatalog(hashlib.sha256(b"fixture catalog").hexdigest(), (spec,)), spec, layout) + target = installer.target() + adapter = FakeAdapter(protect_directory(tmp_path / "manager", create=True)) + controller = ServiceController( + adapter, + target=ServiceTarget(target.python_executable, target.package_version, isolated_runtime=True), + probe=_manager_probe(adapter), + readiness_probe=_manager_probe(adapter), + ) + ops = Ops(target, layout.state, controller=controller, installer=installer) + with socket.socket() as available_port: + available_port.bind(("127.0.0.1", 0)) + port = available_port.getsockname()[1] + installed = ops.handle(_apply_request(_plan(ops, "install", http_port=port), "install")) + assert installed["outcome"] == "succeeded", installed + installation_id = installed["result"]["components"]["installation_id"] + assert ops.handle(_apply_request(_plan(ops, "stop"), "stop"))["outcome"] == "succeeded" + configured = ops.handle(_apply_request(_plan(ops, "config_apply", dashboard_enabled=False), "configure")) + assert configured["outcome"] == "succeeded", configured + definition = adapter.definition + assert definition is not None and definition.env_file is not None + selected = Path(definition.env_file.path) + configuration_before = read_protected_file(selected) + summary = inspect_existing_configuration(selected, data_dir=Path(definition.data_dir)) + database = BuiltinConfig(database=SQLiteConfig(url=sqlite_url(summary.database_path))) + scope = "managed-repair-retention" + + async def seed_history(): + async with open_builtin_contexts(database) as contexts: + await contexts.get(scope) + created = await contexts.records.create_artifact( + scope, "atomic-memory", ArtifactWrite(content={"kind": "fact", "text": "Original retained fact."}) + ) + memory = contexts.atomic_memory.for_scope(scope) + original = await memory.get(created.artifact_id) + await contexts.records.replace_artifact( + scope, + "atomic-memory", + original.ref.artifact_id, + f'"revision:{original.ref.revision}"', + ArtifactWrite(content={"kind": "fact", "text": "Revised retained fact."}), + ) + return original, await memory.get(original.ref.artifact_id) + + original, current = asyncio.run(seed_history()) + # Corrupt a file in the owned bundle. The actual resource verifier and + # publisher repair the programs; no Server executable is run by this test. + (installer.directory / "LICENSE").write_bytes(b"damaged program resource") + repair_plan = _plan(ops, "repair") + assert repair_plan["installation"]["database"]["classification"] == "existing_unknown" + assert not repair_plan["effects"]["may_start"] + repaired = ops.handle(_apply_request(repair_plan, "repair")) + assert repaired["outcome"] == "succeeded", repaired + assert repaired["result"]["components"]["installation_id"] == installation_id + assert (installer.directory / "LICENSE").read_bytes() == _FILES["LICENSE"] + assert controller.verify_stopped().manager is ManagerState.INACTIVE + assert adapter.definition == definition + assert read_protected_file(selected) == configuration_before + assert inspect_existing_configuration(selected, data_dir=Path(definition.data_dir)) == summary + + async def read_history() -> None: + # Reopening through the repository runtime verifies retained data. It + # does not certify another packaged Server version's schema compatibility. + async with open_builtin_contexts(database) as contexts: + memory = contexts.atomic_memory.for_scope(scope) + assert await memory.get(current.ref.artifact_id) == current + assert await memory.get(original.ref.artifact_id, revision=original.ref.revision) == original + for record in (original, current): + stored = await contexts.records.get_artifact_revision( + scope, "atomic-memory", record.ref.artifact_id, record.ref.revision + ) + assert stored.content["text"] == record.artifact.content.text + + asyncio.run(read_history()) diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py new file mode 100644 index 0000000000..13c8b5ba41 --- /dev/null +++ b/tests/test_ops_installer.py @@ -0,0 +1,461 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Managed operations use real protected files and a simulated native manager.""" + +from __future__ import annotations + +import os +import socket +import sys +from contextlib import closing +from pathlib import Path +from typing import Any + +import pytest + +from powercontext.ops import Ops +from powercontext.ops.installer import ManagedInstaller, ManagedLayout +from powercontext.ops.resources import PreparedResource, ResourceCatalog, validate_owned_tree, verify +from powercontext.service.controller import ServiceController +from powercontext.service.environment import protect_directory, write_protected_file +from powercontext.service.model import ManagerState, ServiceTarget +from tests.test_ops import _apply_request, _plan, _request +from tests.test_ops_resources import _FILES, _archive, _spec +from tests.test_service import FakeAdapter, _manager_probe + + +def make_managed_ops(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Ops, FakeAdapter, ManagedInstaller]: + for key in tuple(os.environ): + if key == "POWERCONTEXT_HOME" or key.startswith("POWERCONTEXT_SERVER_"): + monkeypatch.delenv(key) + monkeypatch.setattr("powercontext.ops.resources._current_platform", lambda: ("windows", "x86_64")) + root = protect_directory(tmp_path / "managed", create=True) + layout = ManagedLayout(*(root / name for name in ("programs", "config", "state", "cache", "data"))) + spec = _spec(_archive()) + installer = ManagedInstaller(ResourceCatalog("a" * 64, (spec,)), spec, layout) + prepared_count = 0 + + def prepare(spec, staging_parent, *, cancelled): + nonlocal prepared_count + prepared_count += 1 + directory = protect_directory(staging_parent / f"resource-{prepared_count}", create=True) + for name, content in _FILES.items(): + path = directory / name + protect_directory(path.parent, create=True) + path.write_bytes(content) + if sys.platform != "win32": + path.chmod(0o700 if name.endswith(".exe") else 0o600) + verify(spec, directory) + return PreparedResource(directory, spec, validate_owned_tree(directory)) + + monkeypatch.setattr(installer.resources, "prepare", prepare) + target = installer.target() + adapter = FakeAdapter(root) + controller = ServiceController( + adapter, + target=ServiceTarget(target.python_executable, target.package_version, isolated_runtime=True), + probe=_manager_probe(adapter), + readiness_probe=_manager_probe(adapter), + ) + return Ops(target, layout.state, controller=controller, installer=installer), adapter, installer + + +@pytest.fixture +def managed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Ops, FakeAdapter, ManagedInstaller]: + return make_managed_ops(tmp_path, monkeypatch) + + +def _free_port() -> int: + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def install(ops: Ops, port: int | None = None) -> dict[str, Any]: + result = ops.handle(_apply_request(_plan(ops, "install", http_port=port or _free_port()), "install")) + assert result["outcome"] == "succeeded", result + return result + + +def test_install_is_readonly_until_approval_and_repeated_install_does_not_restart(managed): + ops, adapter, installer = managed + port = _free_port() + plan = _plan(ops, "install", http_port=port) + assert plan["installation"]["database"]["classification"] == "new" + assert not installer.layout.programs.exists() + assert not installer.layout.configuration.exists() + assert not installer.layout.state.exists() + result = install(ops, port) + assert adapter.manager == ManagerState.ACTIVE + assert adapter.definition.isolated_runtime + assert result["result"]["components"]["endpoint"] == f"http://127.0.0.1:{port}" + before = list(adapter.events) + again = ops.handle(_apply_request(_plan(ops, "install"), "again")) + assert again["outcome"] == "succeeded", again + assert again["result"]["components"]["installation_id"] == result["result"]["components"]["installation_id"] + assert adapter.events == before + + +def test_repair_missing_python_keeps_manual_stop_and_configuration_revision(managed): + ops, adapter, installer = managed + first = install(ops) + stopped = ops.handle(_apply_request(_plan(ops, "stop"), "stop")) + assert stopped["outcome"] == "succeeded", stopped + changed = ops.handle( + _apply_request(_plan(ops, "config_apply", http_port=_free_port(), dashboard_enabled=False), "configure") + ) + assert changed["outcome"] == "succeeded", changed + definition = adapter.definition + Path(ops.target.python_executable).unlink() + result = ops.handle(_apply_request(_plan(ops, "repair"), "repair")) + assert result["outcome"] == "succeeded", result + assert adapter.manager == ManagerState.INACTIVE + assert adapter.definition == definition + assert result["result"]["components"]["installation_id"] == first["result"]["components"]["installation_id"] + verify(installer.spec, installer.directory) + + +@pytest.mark.parametrize("phase", ["resources_published", "configuration_prepared", "configured", "registering"]) +def test_interrupted_install_resumes_existing_identity_and_configuration(managed, phase): + ops, adapter, installer = managed + port = _free_port() + request = _apply_request(_plan(ops, "install", http_port=port), "install") + + def interrupted(message): + if message.get("phase") == phase: + raise SystemExit(99) + + with pytest.raises(SystemExit): + ops.handle(request, emit=interrupted) + previous = ops.store.operation("install") + assert previous is not None + identity = previous["attempts"][-1]["installation"]["installation_id"] + plan = _plan(ops, "install", http_port=port) + resumed = ops.handle({**_apply_request(plan, "install"), "action": "resume", "expected_attempt": 1}) + assert resumed["outcome"] == "succeeded", resumed + assert resumed["result"]["components"]["installation_id"] == identity + assert sum(event.startswith("start:") for event in adapter.events) == 1 + assert installer.environment.exists() + + +def test_cancel_after_download_resumes_without_replacing_prepared_tree(managed): + ops, adapter, installer = managed + port = _free_port() + + def cancel(message): + if message.get("phase") == "prepared": + response = ops.handle(_request("cancel", operation_id="install", attempt=1)) + assert response["cancel_requested"] + + first = ops.handle(_apply_request(_plan(ops, "install", http_port=port), "install"), emit=cancel) + assert first["outcome"] == "cancelled", first + assert adapter.definition is None + record = ops.store.operation("install") + assert record is not None + prepared = record["attempts"][-1]["installation"]["prepared"]["identity"] + result = ops.handle({ + **_apply_request(_plan(ops, "install", http_port=port), "install"), + "action": "resume", + "expected_attempt": 1, + }) + assert result["outcome"] == "succeeded", result + assert validate_owned_tree(installer.directory) == prepared + + +def test_existing_unknown_database_blocks_start_but_allows_repair(managed): + ops, adapter, installer = managed + install(ops) + stopped = ops.handle(_apply_request(_plan(ops, "stop"), "stop")) + assert stopped["outcome"] == "succeeded", stopped + protect_directory(installer.layout.data, create=True) + import sqlite3 + + with sqlite3.connect(installer.layout.data / "powercontext.db") as database: + database.execute("CREATE TABLE user_owned(value TEXT)") + database.execute("INSERT INTO user_owned VALUES ('retained')") + denied = ops.handle(_request("preflight", operation="start", options={})) + assert denied["error"]["code"] == "database_compatibility_unknown", denied + Path(ops.target.python_executable).unlink() + result = ops.handle(_apply_request(_plan(ops, "repair"), "repair")) + assert result["outcome"] == "succeeded", result + assert adapter.manager == ManagerState.INACTIVE + with sqlite3.connect(installer.layout.data / "powercontext.db") as database: + assert database.execute("SELECT value FROM user_owned").fetchall() == [("retained",)] + + +def test_readiness_failure_then_stop_retains_installation_ownership(managed): + from powercontext.service.model import ProbeResult, ProbeState + + ops, adapter, installer = managed + port = _free_port() + ops.controller = ServiceController( + adapter, + target=ServiceTarget(ops.target.python_executable, ops.target.package_version, isolated_runtime=True), + probe=_manager_probe(adapter), + readiness_probe=lambda _: ProbeResult(ProbeState.CONFLICT, "test readiness conflict"), + ) + failed = ops.handle(_apply_request(_plan(ops, "install", http_port=port), "install")) + assert failed["outcome"] in {"partial_failure", "uncertain"}, failed + stopped = ops.handle(_apply_request(_plan(ops, "stop"), "stop")) + assert stopped["outcome"] == "succeeded", stopped + plan = _plan(ops, "install", http_port=port) + assert not plan["effects"]["may_start"] + events = list(adapter.events) + result = ops.handle(_apply_request(plan, "settle")) + assert result["outcome"] == "succeeded", result + assert adapter.manager == ManagerState.INACTIVE + assert adapter.events == events + assert installer.environment.exists() + + +def test_missing_registered_configuration_is_not_reset_or_guessed(managed): + ops, adapter, installer = managed + install(ops) + endpoint = adapter.definition.endpoint + installer.environment.unlink() + denied = ops.handle(_request("preflight", operation="repair", options={})) + assert denied["error"]["code"] == "configuration_invalid", denied + observed = ops.handle(_request("inspect")) + assert observed["components"]["endpoint"] == endpoint + assert observed["components"]["configuration"] == "missing_or_invalid" + assert not installer.environment.exists() + + +@pytest.mark.parametrize("after_stop", [False, True]) +def test_repair_resource_publication_interruption_preserves_identity(managed, monkeypatch, after_stop): + import powercontext.ops.installer as module + + ops, adapter, installer = managed + original = install(ops) + Path(ops.target.python_executable).unlink() + replace = module.os.replace + crashed = False + + def interrupted(source, target): + nonlocal crashed + replace(source, target) + if Path(target) == installer.directory and not crashed: + crashed = True + raise SystemExit(99) + + monkeypatch.setattr(module.os, "replace", interrupted) + with pytest.raises(SystemExit): + ops.handle(_apply_request(_plan(ops, "repair"), "repair")) + assert adapter.manager == ManagerState.INACTIVE + published = validate_owned_tree(installer.directory) + if after_stop: + stopped = ops.handle(_apply_request(_plan(ops, "stop"), "intervening-stop")) + assert stopped["outcome"] == "succeeded", stopped + plan = _plan(ops, "repair") + assert plan["installation"]["repair_owner"] == "repair" + if after_stop: + superseded = ops.handle({**_apply_request(plan, "repair"), "action": "resume", "expected_attempt": 1}) + assert superseded["error"]["code"] == "operation_superseded", superseded + result = ops.handle(_apply_request(plan, "continued-repair")) + else: + result = ops.handle({**_apply_request(plan, "repair"), "action": "resume", "expected_attempt": 1}) + assert result["outcome"] == "succeeded", result + assert validate_owned_tree(installer.directory) == published + assert result["result"]["components"]["installation_id"] == original["result"]["components"]["installation_id"] + assert adapter.manager == ManagerState.INACTIVE + + +def test_explicit_registration_removal_does_not_reinstall(managed): + ops, adapter, _installer = managed + install(ops) + removed = ops.handle(_apply_request(_plan(ops, "remove_registration"), "remove")) + assert removed["outcome"] == "succeeded", removed + denied = ops.handle(_request("preflight", operation="install", options={})) + assert denied["error"]["code"] == "registration_missing", denied + assert adapter.definition is None + + +def test_healthy_repair_stops_without_republishing_resources(managed): + ops, adapter, installer = managed + install(ops) + identity = validate_owned_tree(installer.directory) + result = ops.handle(_apply_request(_plan(ops, "repair"), "repair")) + assert result["outcome"] == "succeeded", result + assert adapter.manager == ManagerState.INACTIVE + assert validate_owned_tree(installer.directory) == identity + ops.controller.verify_stopped() + + +def test_cancel_at_registration_progress_never_starts(managed): + ops, adapter, _installer = managed + + def cancel(message): + if message.get("phase") == "registering": + response = ops.handle(_request("cancel", operation_id="install", attempt=1)) + assert response["cancel_requested"] + + result = ops.handle(_apply_request(_plan(ops, "install", http_port=_free_port()), "install"), emit=cancel) + assert result["outcome"] == "cancelled", result + assert adapter.definition is None + assert not any(event.startswith("start:") for event in adapter.events) + + +@pytest.mark.parametrize("replace_database", [False, True]) +def test_repair_tolerates_committed_database_writes_but_rejects_replacement(managed, replace_database): + import sqlite3 + + ops, adapter, installer = managed + install(ops) + protect_directory(installer.layout.data, create=True) + path = installer.layout.data / "powercontext.db" + with closing(sqlite3.connect(path)) as database, database: + database.execute("CREATE TABLE user_owned(value TEXT)") + database.execute("INSERT INTO user_owned VALUES ('before')") + writer = sqlite3.connect(path) + writer.execute("PRAGMA journal_mode=WAL") + writer.execute("INSERT INTO user_owned VALUES ('in-wal')") + writer.commit() + Path(ops.target.python_executable).unlink() + plan = _plan(ops, "repair") + # A live Server can commit and checkpoint between approval and repair. + writer.execute("INSERT INTO user_owned VALUES ('after-plan')") + writer.commit() + writer.close() + if replace_database: + replacement = path.with_name("replacement.db") + with closing(sqlite3.connect(replacement)) as database, database: + database.execute("CREATE TABLE replacement(value TEXT)") + os.replace(replacement, path) + events = list(adapter.events) + result = ops.handle(_apply_request(plan, "repair")) + if replace_database: + assert result["outcome"] == "rejected", result + assert result["error"]["code"] == "plan_stale", result + assert adapter.events == events + assert not Path(ops.target.python_executable).exists() + else: + assert result["outcome"] == "succeeded", result + assert adapter.manager == ManagerState.INACTIVE + with closing(sqlite3.connect(path)) as database: + assert database.execute("SELECT value FROM user_owned ORDER BY rowid").fetchall() == [ + ("before",), + ("in-wal",), + ("after-plan",), + ] + verify(installer.spec, installer.directory) + + +def test_malformed_database_url_returns_bounded_configuration_errors(managed): + ops, adapter, installer = managed + install(ops) + write_protected_file(installer.environment, b"POWERCONTEXT_SERVER_DATABASE_URL=bad\n") + for request in ( + _request("preflight", operation="repair", options={}), + _request("preflight", operation="start", options={}), + _request("config_validate"), + ): + rejected = ops.handle(request) + assert rejected["outcome"] == "rejected", rejected + assert rejected["error"]["code"] == "configuration_invalid", rejected + assert "bad" not in str(rejected) + observed = ops.handle(_request("inspect")) + assert observed["outcome"] == "succeeded", observed + assert observed["components"]["configuration"] == "missing_or_invalid" + assert adapter.manager == ManagerState.ACTIVE + + +def test_repair_rejects_database_inside_managed_program_tree(managed): + import shlex + import sqlite3 + + from powercontext.paths import sqlite_url + + ops, adapter, installer = managed + install(ops) + path = installer.directory / "user.db" + with sqlite3.connect(path) as database: + database.execute("CREATE TABLE user_owned(value TEXT)") + database.execute("INSERT INTO user_owned VALUES ('retained')") + lines = [ + line + for line in installer.environment.read_text(encoding="utf-8").splitlines() + if not line.startswith("POWERCONTEXT_SERVER_DATABASE_URL=") + ] + lines.append(f"POWERCONTEXT_SERVER_DATABASE_URL={shlex.quote(sqlite_url(path))}") + write_protected_file(installer.environment, ("\n".join(lines) + "\n").encode()) + events = list(adapter.events) + denied = ops.handle(_request("preflight", operation="repair", options={})) + assert denied["error"]["code"] == "configuration_path_unsupported", denied + assert adapter.events == events + with sqlite3.connect(path) as database: + assert database.execute("SELECT value FROM user_owned").fetchall() == [("retained",)] + + +@pytest.mark.parametrize("checkpoint", ["failed", "dispatched", "continued"]) +def test_uninstalled_partial_install_cannot_be_revived_by_old_recovery(managed, monkeypatch, checkpoint): + ops, adapter, _installer = managed + request = _apply_request(_plan(ops, "install", http_port=_free_port()), "install") + operation_id = "install" + if checkpoint == "dispatched": + install_target = ops.controller.install + + def interrupted_install(**kwargs): + install_target(**kwargs) + raise SystemExit(99) + + monkeypatch.setattr(ops.controller, "install", interrupted_install) + with pytest.raises(SystemExit): + ops.handle(request) + else: + adapter.fail_start = True + failed = ops.handle(request) + assert failed["outcome"] == "partial_failure", failed + adapter.fail_start = False + if checkpoint == "continued": + request = _apply_request(_plan(ops, "install"), "continued") + operation_id = "continued" + + def interrupted(message): + if message.get("phase") == "registering": + raise SystemExit(99) + + with pytest.raises(SystemExit): + ops.handle(request, emit=interrupted) + # The shared CLI path does not update Ops' latest-operation pointer. + ops.controller.uninstall() + events = list(adapter.events) + denied = ops.handle(_request("preflight", operation="install", options=request["plan"]["options"])) + assert denied["error"]["code"] == "registration_missing", denied + resumed = ops.handle({**request, "action": "resume", "expected_attempt": 1, "operation_id": operation_id}) + assert resumed["error"]["code"] == "registration_missing", resumed + assert adapter.definition is None + assert adapter.events == events + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows ordinary-path budget") +def test_unsupported_managed_path_rejects_preflight_without_creating_resources(managed): + from dataclasses import replace + + _ops, adapter, installer = managed + layout = replace(installer.layout, programs=installer.layout.programs.parent / ("p" * 230)) + installer = ManagedInstaller(installer.catalog, installer.spec, layout) + target = installer.target() + controller = ServiceController( + adapter, + target=ServiceTarget(target.python_executable, target.package_version, isolated_runtime=True), + probe=_manager_probe(adapter), + readiness_probe=_manager_probe(adapter), + ) + ops = Ops(target, layout.state, controller=controller, installer=installer) + result = ops.handle(_request("preflight", operation="install", options={})) + assert result["outcome"] == "rejected", result + assert result["error"]["code"] == "resource_path_unsupported", result + assert not any(path.exists() for path in (layout.programs, layout.configuration, layout.state, layout.cache)) + assert adapter.events == [] diff --git a/tests/test_ops_local_policy.py b/tests/test_ops_local_policy.py new file mode 100644 index 0000000000..26be14645f --- /dev/null +++ b/tests/test_ops_local_policy.py @@ -0,0 +1,230 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +import os +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest + +from powercontext.cli.env_file import parse_environment +from powercontext.ops.local_policy import ( + classify_database, + default_data_dir, + inspect_existing_configuration, + new_configuration, + preview_new_configuration, +) +from powercontext.ops.protocol import OpsError +from powercontext.server.configuration import server_settings_context +from powercontext.service.environment import write_protected_file + + +def test_new_configuration_ignores_parent_server_settings_and_does_not_create_data( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + expected_default = default_data_dir() + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "inherited-home")) + monkeypatch.setenv("PYTHONPATH", str(tmp_path / "inherited-imports")) + monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_HOST", "0.0.0.0") # noqa: S104 + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_ENABLED", "true") + monkeypatch.setenv("POWERCONTEXT_SERVER_AUTH_TOKEN", "inherited-secret") + monkeypatch.setenv("POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL", "inherited:model") + assert default_data_dir() == expected_default + root = tmp_path / "new-data" + content = new_configuration(root, 8999) + summary = preview_new_configuration(root, 8999) + values = parse_environment(content.decode("utf-8")) + with server_settings_context(environment=values, data_dir=root) as settings: + assert settings.http.host == "127.0.0.1" + assert settings.http.port == 8999 + assert settings.database.kind == "sqlite" + assert settings.dashboard.enabled + assert settings.mcp.enabled + assert settings.access.mode == "disabled" + assert settings.auth.enabled is False + assert settings.auth.token is None + assert settings.access.background_principal_id is None + assert settings.inference.generation_model is None + assert settings.inference.embedding_model is None + assert summary.identity is None + assert summary.endpoint == "http://127.0.0.1:8999" + assert summary.database_path == root / "powercontext.db" + assert summary.local_mode == "tokenless" + assert classify_database(summary, "exact-server-version").classification == "new" + assert not root.exists() + assert os.environ["POWERCONTEXT_HOME"] == str(tmp_path / "inherited-home") + assert os.environ["PYTHONPATH"] == str(tmp_path / "inherited-imports") + assert b"inherited" not in content + + +def test_existing_configuration_preserves_explicit_settings_and_redacts_secrets(tmp_path: Path) -> None: + root = tmp_path / "existing-data" + content = new_configuration(root, 8991).replace( + b"POWERCONTEXT_SERVER_AUTH_ENABLED=false", b"POWERCONTEXT_SERVER_AUTH_ENABLED=true" + ) + content += b"POWERCONTEXT_SERVER_AUTH_TOKEN='private credential'\nPOWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL='chosen:model'\n" + path = tmp_path / "config" / "server.env" + write_protected_file(path, content) + before = path.read_bytes() + summary = inspect_existing_configuration(path) + assert summary.identity is not None + assert summary.data_dir == root + assert summary.database_path == root / "powercontext.db" + assert summary.endpoint == "http://127.0.0.1:8991" + assert summary.local_mode == "authenticated" + assert "private credential" not in json.dumps(summary.as_dict()) + assert "chosen:model" not in json.dumps(summary.as_dict()) + assert path.read_bytes() == before + assert not root.exists() + + +def test_existing_configuration_uses_registered_data_root_without_rewriting_file(tmp_path: Path) -> None: + path = tmp_path / "config" / "server.env" + write_protected_file(path, b"POWERCONTEXT_SERVER_HTTP_PORT=8992\n") + registered_root = tmp_path / "registered-data" + summary = inspect_existing_configuration(path, data_dir=registered_root) + assert summary.data_dir == registered_root + assert summary.database_path == registered_root / "powercontext.db" + assert path.read_bytes() == b"POWERCONTEXT_SERVER_HTTP_PORT=8992\n" + + +def test_malformed_database_url_returns_stable_configuration_error(tmp_path: Path) -> None: + path = tmp_path / "config" / "server.env" + content = b"POWERCONTEXT_SERVER_DATABASE_URL=bad\n" + write_protected_file(path, content) + with pytest.raises(OpsError, match="configuration_invalid"): + inspect_existing_configuration(path) + assert path.read_bytes() == content + + +@pytest.mark.parametrize( + "url", + ["sqlite+aiosqlite:///:memory:", "sqlite+aiosqlite:///relative.db", "sqlite+aiosqlite:///file:db?uri=true&mode=ro"], +) +def test_database_locations_are_not_guessed_from_unsupported_urls(tmp_path: Path, url: str) -> None: + path = tmp_path / "config" / "server.env" + write_protected_file(path, f"POWERCONTEXT_SERVER_DATABASE_URL='{url}'\n".encode()) + with pytest.raises(OpsError, match=r"database_location_unsupported|configuration_path_unsupported"): + inspect_existing_configuration(path) + + +@pytest.mark.parametrize("marker", [None, "pc_schema_revision", "pc_artifact_processing_intents", "pc_candidate_heads"]) +def test_existing_sqlite_and_partial_migration_markers_never_prove_compatibility( + tmp_path: Path, marker: str | None +) -> None: + summary = preview_new_configuration(tmp_path, 8993) + with closing(sqlite3.connect(summary.database_path)) as connection: + connection.execute("CREATE TABLE preserved (value TEXT NOT NULL)") + connection.execute("INSERT INTO preserved VALUES (?)", ("user memory",)) + if marker is not None: + connection.execute(f'CREATE TABLE "{marker}" (state TEXT)') + connection.execute(f'INSERT INTO "{marker}" VALUES (?)', ("ready",)) # noqa: S608 + connection.commit() + before = summary.database_path.read_bytes() + observed = classify_database(summary, "server-not-ops-version") + assert observed.classification == "existing_unknown" + assert not observed.allowed_to_start + assert observed.reason == "whole_database_compatibility_unavailable" + assert summary.database_path.read_bytes() == before + with closing(sqlite3.connect(summary.database_path)) as connection: + assert connection.execute("SELECT value FROM preserved").fetchall() == [("user memory",)] + assert classify_database(summary, "another-server-version").fingerprint != observed.fingerprint + + +def test_existing_empty_file_is_not_a_new_database(tmp_path: Path) -> None: + summary = preview_new_configuration(tmp_path, 8994) + summary.database_path.touch() + observed = classify_database(summary, "target") + assert observed.classification == "existing_unknown" + assert not observed.allowed_to_start + assert summary.database_path.stat().st_size == 0 + + +def test_wal_database_inventory_does_not_open_sqlite_or_checkpoint_user_data(tmp_path: Path) -> None: + summary = preview_new_configuration(tmp_path, 8995) + with closing(sqlite3.connect(summary.database_path)) as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("CREATE TABLE preserved (value TEXT)") + connection.execute("INSERT INTO preserved VALUES ('committed in WAL')") + connection.commit() + paths = [Path(f"{summary.database_path}{suffix}") for suffix in ("", "-wal", "-shm")] + before = {path: (path.read_bytes(), path.stat().st_mtime_ns) for path in paths} + observed = classify_database(summary, "target") + assert observed.classification == "existing_unknown" + assert not observed.allowed_to_start + assert {path: (path.read_bytes(), path.stat().st_mtime_ns) for path in paths} == before + assert connection.execute("SELECT value FROM preserved").fetchall() == [("committed in WAL",)] + + +@pytest.mark.parametrize("suffix", ["-wal", "-shm", "-journal"]) +def test_orphan_sidecars_block_new_database_classification(tmp_path: Path, suffix: str) -> None: + summary = preview_new_configuration(tmp_path, 8996) + Path(f"{summary.database_path}{suffix}").write_bytes(b"existing recovery evidence") + observed = classify_database(summary, "target") + assert observed.classification == "unknown" + assert not observed.allowed_to_start + assert not summary.database_path.exists() + + +def test_non_sqlite_content_is_preserved_and_blocks_start(tmp_path: Path) -> None: + summary = preview_new_configuration(tmp_path, 8997) + summary.database_path.write_bytes(b"not an sqlite database") + observed = classify_database(summary, "target") + assert observed.classification == "incompatible" + assert not observed.allowed_to_start + assert summary.database_path.read_bytes() == b"not an sqlite database" + + +def test_database_creation_invalidates_new_database_fingerprint(tmp_path: Path) -> None: + summary = preview_new_configuration(tmp_path, 8998) + before = classify_database(summary, "target") + summary.database_path.touch() + after = classify_database(summary, "target") + assert before.allowed_to_start + assert not after.allowed_to_start + assert before.fingerprint != after.fingerprint + + +@pytest.mark.parametrize("journal_mode", ["WAL", "DELETE"]) +def test_repair_observation_allows_database_writes_but_detects_replacement(tmp_path: Path, journal_mode: str) -> None: + summary = preview_new_configuration(tmp_path, 8999) + with closing(sqlite3.connect(summary.database_path)) as connection: + connection.execute(f"PRAGMA journal_mode={journal_mode}") + connection.execute("CREATE TABLE preserved (value TEXT)") + connection.execute("INSERT INTO preserved VALUES ('before repair plan')") + connection.commit() + strict = classify_database(summary, "target") + planned = classify_database(summary, "target", for_repair=True) + connection.execute("INSERT INTO preserved VALUES (?)", ("committed while preparing repair " * 1024,)) + connection.commit() + assert classify_database(summary, "target", for_repair=True) == planned + assert classify_database(summary, "target").fingerprint != strict.fingerprint + if journal_mode == "WAL": + connection.execute("PRAGMA wal_checkpoint(TRUNCATE)") + assert classify_database(summary, "target", for_repair=True) == planned + assert connection.execute("SELECT count(*) FROM preserved").fetchone() == (2,) + # Closing the last WAL connection removes sidecars, while retaining the + # same database identity that a program-only repair must preserve. + assert classify_database(summary, "target", for_repair=True) == planned + assert not planned.allowed_to_start + assert classify_database(summary, "another-target", for_repair=True).fingerprint != planned.fingerprint + replacement = tmp_path / "replacement.db" + replacement.write_bytes(summary.database_path.read_bytes()) + os.replace(replacement, summary.database_path) + assert classify_database(summary, "target", for_repair=True).fingerprint != planned.fingerprint diff --git a/tests/test_ops_resources.py b/tests/test_ops_resources.py new file mode 100644 index 0000000000..a7bd69cd17 --- /dev/null +++ b/tests/test_ops_resources.py @@ -0,0 +1,379 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Small real archives/filesystem; HTTPS responses are simulated, never release qualification.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import stat +import sys +import tarfile +import zipfile +from collections.abc import Callable, Iterator +from pathlib import Path + +import httpx +import pytest +from pydantic import ValidationError + +from powercontext.ops import resources +from powercontext.ops.resources import ResourceCatalog, ResourceError, ResourceSpec, ResourceStore, verify +from powercontext.service.environment import protect_directory, write_protected_file + +_FILES = { + "python/pythonw.exe": b"windowed fixture", + "python/python.exe": b"console fixture", + "LICENSE": b"fixture license", +} + + +def _archive(files: dict[str, bytes] | None = None, *, archive_format: str = "zip") -> bytes: + output = io.BytesIO() + if archive_format == "zip": + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, content in (files or _FILES).items(): + archive.writestr(name, content) + else: + with tarfile.open(fileobj=output, mode="w:gz") as archive: + for name, content in (files or _FILES).items(): + member = tarfile.TarInfo(name) + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + return output.getvalue() + + +def _spec(archive: bytes, **overrides: object) -> ResourceSpec: + values = { + "resource_id": "fixture-windows-x64", + "server_version": "1.0.0", + "python_version": "3.12.13", + "platform": "windows", + "architecture": "x86_64", + "archive_format": "zip", + "url": "https://producer.example/server.zip", + "allowed_origins": ["https://producer.example", "https://cdn.example"], + "archive_sha256": hashlib.sha256(archive).hexdigest(), + "archive_size": len(archive), + "unpacked_size": sum(map(len, _FILES.values())), + "python_relative": "python/pythonw.exe", + "ops_protocol_min": 1, + "ops_protocol_max": 1, + "source_refs": ["test fixture; not a release"], + "license_refs": ["LICENSE"], + "qualification_ref": "test-only-unqualified", + "files": [ + { + "path": name, + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "executable": name.endswith(".exe"), + } + for name, content in _FILES.items() + ], + } + values.update(overrides) + return ResourceSpec.model_validate(values) + + +@pytest.fixture(autouse=True) +def platform_fixture(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(resources, "_current_platform", lambda: ("windows", "x86_64")) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows native path budget") +def test_unsupported_destination_is_rejected_before_download_or_directory_creation(tmp_path: Path) -> None: + destination = tmp_path / ("a" * 100) / ("b" * 100) + cache = tmp_path / "cache" + with pytest.raises(ResourceError, match="resource_path_unsupported"): + ResourceStore(cache).prepare(_spec(_archive()), destination) + assert not cache.exists() + assert not destination.parent.exists() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows native path budget") +def test_destination_budget_counts_utf16_and_checks_nested_directory_creation(tmp_path: Path) -> None: + spec = _spec(_archive()) + resources.validate_destination(spec, tmp_path / "supported") + # Non-BMP characters require two Windows WCHARs, even though Python len is one. + with pytest.raises(ResourceError, match="resource_path_unsupported"): + resources.validate_destination(spec, tmp_path / ("\U0001f4e6" * 128)) + nested = spec.model_copy( + update={ + "files": ( + resources.ResourceFile( + path="a" * 240 + "/x", + size=1, + sha256="0" * 64, + ), + ) + } + ) + with pytest.raises(ResourceError, match="resource_path_unsupported"): + resources.validate_destination(nested, tmp_path) + + +def _transport(monkeypatch: pytest.MonkeyPatch, handler: Callable[[httpx.Request], httpx.Response]) -> None: + client = httpx.Client + monkeypatch.setattr( + resources.httpx, "Client", lambda **kwargs: client(transport=httpx.MockTransport(handler), **kwargs) + ) + + +@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +def test_verified_cache_and_complete_tree_are_reusable_without_fetching( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, archive_format: str +) -> None: + archive = _archive(archive_format=archive_format) + spec = _spec(archive, archive_format=archive_format) + fetched: list[str] = [] + + def fetch(request: httpx.Request) -> httpx.Response: + fetched.append(str(request.url)) + return httpx.Response(200, stream=httpx.ByteStream(archive)) + + _transport(monkeypatch, fetch) + store = ResourceStore(tmp_path / "cache") + prepared = store.prepare(spec, tmp_path / "staging") + assert prepared.spec == spec + assert prepared.identity["inode"] == prepared.directory.stat().st_ino + assert {name: (prepared.directory / name).read_bytes() for name in _FILES} == _FILES + verify(spec, prepared.directory) + second = store.prepare(spec, tmp_path / "staging") + assert second.directory != prepared.directory + assert len(fetched) == 1 + (tmp_path / "cache" / (spec.archive_sha256 + ".archive")).write_bytes(b"damaged cache") + store.prepare(spec, tmp_path / "staging") + assert len(fetched) == 2 + (prepared.directory / "LICENSE").write_bytes(b"modified") + with pytest.raises(ResourceError, match="resource_corrupt"): + verify(spec, prepared.directory) + + +@pytest.mark.parametrize( + "target", ["http://producer.example/file", "https://foreign.example/file", "https://user:pass@cdn.example/file"] +) +def test_redirect_is_rejected_before_contacting_unauthorized_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str +) -> None: + requests: list[str] = [] + + def fetch(request: httpx.Request) -> httpx.Response: + requests.append(str(request.url)) + return httpx.Response(302, headers={"location": target}) + + _transport(monkeypatch, fetch) + with pytest.raises(ResourceError, match="resource_source_invalid"): + ResourceStore(tmp_path / "cache").prepare(_spec(_archive()), tmp_path / "stage") + assert requests == ["https://producer.example/server.zip"] + assert not list((tmp_path / "stage").iterdir()) + + +def test_authorized_redirect_is_followed_and_bytes_must_match(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + archive = _archive() + + def fetch(request: httpx.Request) -> httpx.Response: + if request.url.host == "producer.example": + return httpx.Response(307, headers={"location": "https://cdn.example/fixed.zip"}) + return httpx.Response(200, stream=httpx.ByteStream(archive)) + + _transport(monkeypatch, fetch) + prepared = ResourceStore(tmp_path / "cache").prepare(_spec(archive), tmp_path / "stage") + verify(_spec(archive), prepared.directory) + + +@pytest.mark.parametrize("failure", ["digest", "too_long", "truncated"]) +def test_bad_download_never_becomes_a_cache_or_extracted_resource( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + archive = _archive() + content = { + "digest": archive[:-1] + bytes([archive[-1] ^ 1]), + "too_long": archive + b"x", + "truncated": archive[:-1], + }[failure] + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(content))) + with pytest.raises(ResourceError, match="resource_corrupt"): + ResourceStore(tmp_path / "cache").prepare(_spec(archive), tmp_path / "stage") + assert not list((tmp_path / "cache").glob("*.archive")) + assert not list((tmp_path / "cache").glob("*.partial")) + assert not list((tmp_path / "stage").iterdir()) + + +@pytest.mark.parametrize( + "member", + ["../escaped", "/absolute", "C:/escaped", "python/../escape", "python/name:stream", "python/NUL", "python/name. "], +) +def test_archive_paths_never_escape_private_stage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, member: str) -> None: + archive = _archive({**_FILES, member: b"outside"}) + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(archive))) + with pytest.raises(ResourceError): + ResourceStore(tmp_path / "cache").prepare(_spec(archive), tmp_path / "stage") + assert not list((tmp_path / "stage").iterdir()) + assert not (tmp_path / "escaped").exists() + assert not (tmp_path / "escape").exists() + + +@pytest.mark.parametrize("kind", ["symlink", "hardlink", "fifo"]) +def test_tar_links_and_special_files_are_not_materialized( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + member = tarfile.TarInfo("python/pythonw.exe") + member.type = {"symlink": tarfile.SYMTYPE, "hardlink": tarfile.LNKTYPE, "fifo": tarfile.FIFOTYPE}[kind] + member.linkname = str(tmp_path / "outside") + archive.addfile(member) + payload = output.getvalue() + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(payload))) + with pytest.raises(ResourceError, match="resource_archive_invalid"): + ResourceStore(tmp_path / "cache").prepare(_spec(payload, archive_format="tar.gz"), tmp_path / "stage") + assert not (tmp_path / "outside").exists() + + +def test_zip_symlink_is_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + member = zipfile.ZipInfo("python/pythonw.exe") + member.create_system = 3 + member.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(member, "../../outside") + payload = output.getvalue() + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(payload))) + with pytest.raises(ResourceError, match="resource_archive_invalid"): + ResourceStore(tmp_path / "cache").prepare(_spec(payload), tmp_path / "stage") + + +def test_tar_metadata_expansion_is_bounded_before_materializing_a_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz", format=tarfile.PAX_FORMAT) as archive: + member = tarfile.TarInfo("python/pythonw.exe") + member.size = len(_FILES[member.name]) + member.pax_headers = {"comment": "x" * (2 * 1024 * 1024)} + archive.addfile(member, io.BytesIO(_FILES[member.name])) + payload = output.getvalue() + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(payload))) + with pytest.raises(ResourceError, match="resource_archive_invalid"): + ResourceStore(tmp_path / "cache").prepare(_spec(payload, archive_format="tar.gz"), tmp_path / "stage") + assert not list((tmp_path / "stage").rglob("pythonw.exe")) + + +def test_interrupted_download_can_restart_without_reusing_partial_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + archive = _archive() + cancellation = [False] + + class Interrupted(httpx.SyncByteStream): + def __iter__(self) -> Iterator[bytes]: + yield archive[:10] + cancellation[0] = True + yield archive[10:] + + responses = iter([httpx.Response(200, stream=Interrupted()), httpx.Response(200, stream=httpx.ByteStream(archive))]) + _transport(monkeypatch, lambda request: next(responses)) + store = ResourceStore(tmp_path / "cache") + with pytest.raises(ResourceError, match="resource_cancelled"): + store.prepare(_spec(archive), tmp_path / "stage", cancelled=lambda: cancellation[0]) + assert not list((tmp_path / "cache").glob("*.archive")) + assert not list((tmp_path / "cache").glob("*.partial")) + cancellation[0] = False + prepared = store.prepare(_spec(archive), tmp_path / "stage") + verify(_spec(archive), prepared.directory) + + +def test_cancelled_extraction_discards_only_its_own_stage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + archive = _archive() + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(archive))) + store = ResourceStore(tmp_path / "cache") + retained = store.prepare(_spec(archive), tmp_path / "stage") + + def cancel_after_first_file() -> bool: + return any( + stage != retained.directory and (stage / "python/pythonw.exe").exists() + for stage in (tmp_path / "stage").glob("resource-*") + ) + + with pytest.raises(ResourceError, match="resource_cancelled"): + store.prepare(_spec(archive), tmp_path / "stage", cancelled=cancel_after_first_file) + assert list((tmp_path / "stage").iterdir()) == [retained.directory] + verify(_spec(archive), retained.directory) + assert len(list((tmp_path / "cache").glob("*.archive"))) == 1 + + +def test_failure_cleanup_does_not_remove_a_replaced_stage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + archive = _archive() + _transport(monkeypatch, lambda request: httpx.Response(200, stream=httpx.ByteStream(archive))) + replacement: list[Path] = [] + + def replace_and_cancel() -> bool: + for stage in (tmp_path / "stage").glob("resource-*"): + stage.rename(stage.with_name("moved-stage")) + protect_directory(stage, create=True) + (stage / "unrelated.txt").write_text("preserve this replacement", encoding="utf-8") + replacement.append(stage) + return True + return False + + with pytest.raises(ResourceError, match="resource_cancelled"): + ResourceStore(tmp_path / "cache").prepare(_spec(archive), tmp_path / "stage", cancelled=replace_and_cancel) + assert len(replacement) == 1 + assert (replacement[0] / "unrelated.txt").read_text(encoding="utf-8") == "preserve this replacement" + assert (tmp_path / "stage/moved-stage").is_dir() + + +def test_catalog_is_pinned_private_and_read_only(tmp_path: Path) -> None: + private = protect_directory(tmp_path / "private", create=True) + content = json.dumps({"format_version": 1, "resources": [_spec(_archive()).model_dump(mode="json")]}).encode() + path = private / "catalog.json" + write_protected_file(path, content) + catalog = ResourceCatalog.read(path, hashlib.sha256(content).hexdigest()) + assert catalog.select_current().python_console_relative == "python/python.exe" + assert catalog.select_current().digest == catalog.select("fixture-windows-x64").digest + assert "files" not in catalog.select_current().as_dict() + with pytest.raises(ResourceError, match="resource_catalog_invalid"): + ResourceCatalog.read(path, "0" * 64) + with pytest.raises(ResourceError, match="resource_catalog_invalid"): + ResourceCatalog.read(tmp_path / "missing" / "catalog.json", hashlib.sha256(content).hexdigest()) + assert not (tmp_path / "missing").exists() + + +@pytest.mark.parametrize("name", ["../escape", "python/CON.txt", "python/name.", "python/file:stream"]) +def test_manifest_cannot_authorize_unsafe_paths(name: str) -> None: + file = {"path": name, "size": 1, "sha256": "0" * 64} + with pytest.raises(ValidationError): + _spec(_archive(), files=[file]) + + +def test_foreign_platform_is_rejected_before_creating_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + spec = _spec(_archive()) + monkeypatch.setattr(resources, "_current_platform", lambda: ("macos", "arm64")) + with pytest.raises(ResourceError, match="resource_platform_unsupported"): + ResourceStore(tmp_path / "cache").prepare(spec, tmp_path / "stage") + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.skipif( + os.name == "nt", reason="POSIX symlink creation; Windows reparse rejection uses the same lstat check" +) +def test_existing_tree_symlink_is_rejected_without_reading_target(tmp_path: Path) -> None: + root = protect_directory(tmp_path / "private", create=True) + (root / "outside").symlink_to(tmp_path / "missing") + with pytest.raises(ResourceError, match="resource_unsafe_path"): + resources.validate_owned_tree(root) diff --git a/tests/test_service_runtime_repair.py b/tests/test_service_runtime_repair.py new file mode 100644 index 0000000000..c72d07f6fe --- /dev/null +++ b/tests/test_service_runtime_repair.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +from powercontext.service.controller import ServiceController +from powercontext.service.model import ManagerState, RegistrationState, ServiceDefinition, ServiceError, ServiceTarget +from tests.test_service import _manager_probe +from tests.test_service_ops_boundary import _controller + + +def test_runtime_repair_guards_interruption_and_resumes_without_starting(tmp_path: Path) -> None: + controller, adapter, target = _controller(tmp_path) + controller.install(require_ready=True) + definition = adapter.definition + assert definition is not None + with ( + pytest.raises(RuntimeError, match="interrupted"), + controller.runtime_repair(expected_definition=definition, repair_id="repair-1"), + ): + assert adapter.manager is ManagerState.INACTIVE + Path(target.python_executable).unlink() + raise RuntimeError("interrupted") + assert "runtime repair" in (controller.status().recovery_action or "") + with pytest.raises(ServiceError, match="owning Ops runtime repair"): + controller.start() + controller.stop() + assert json.loads(controller.maintenance_path.read_text())["phase"] == "runtime_repair" + with ( + pytest.raises(ServiceError, match="existing service maintenance"), + controller.runtime_repair(expected_definition=definition, repair_id="another-operation"), + ): + pytest.fail("a different operation cannot clear the repair guard") + with controller.runtime_repair(expected_definition=definition, repair_id="repair-1"): + Path(target.python_executable).touch() + assert controller.verify_stopped().manager is ManagerState.INACTIVE + assert adapter.definition == definition + assert controller.start().ok + + +def test_runtime_repair_rejects_registration_changes_and_preserves_guard(tmp_path: Path) -> None: + controller, adapter, _target = _controller(tmp_path) + controller.install(require_ready=True) + definition = adapter.definition + assert definition is not None + with ( + pytest.raises(ServiceError, match="changed; review"), + controller.runtime_repair(expected_definition=replace(definition, package_version="other"), repair_id="r"), + ): + pytest.fail("the approved definition no longer matches") + assert adapter.manager is ManagerState.ACTIVE + with ( + pytest.raises(ServiceError, match="changed during runtime repair"), + controller.runtime_repair(expected_definition=definition, repair_id="r"), + ): + adapter.write(adapter.render(replace(definition, package_version="other"))) + assert json.loads(controller.maintenance_path.read_text())["phase"] == "runtime_repair" + + +def test_runtime_repair_does_not_clear_database_maintenance(tmp_path: Path) -> None: + controller, adapter, _target = _controller(tmp_path) + controller.install(require_ready=True) + definition = adapter.definition + assert definition is not None + summary = controller.maintenance_summary() + with controller.maintenance(expected_fingerprint=summary.fingerprint): + pass + before = controller.maintenance_path.read_bytes() + with ( + pytest.raises(ServiceError, match="existing service maintenance"), + controller.runtime_repair(expected_definition=definition, repair_id="r"), + ): + pytest.fail("database maintenance belongs to its existing owner") + assert controller.maintenance_path.read_bytes() == before + + +def test_remove_during_interrupted_runtime_repair_allows_fresh_installation(tmp_path: Path) -> None: + controller, adapter, _target = _controller(tmp_path) + controller.install(require_ready=True) + definition = adapter.definition + assert definition is not None + with pytest.raises(RuntimeError), controller.runtime_repair(expected_definition=definition, repair_id="r"): + raise RuntimeError + assert controller.uninstall().registration is RegistrationState.NOT_INSTALLED + assert not controller.maintenance_path.exists() + with ( + pytest.raises(ServiceError, match="no owned personal service"), + controller.runtime_repair(expected_definition=definition, repair_id="r"), + ): + pytest.fail("recovery cannot recreate a subsequently removed service") + assert controller.install(require_ready=True).ok + + +def test_runtime_repair_cannot_complete_with_missing_executable(tmp_path: Path) -> None: + controller, adapter, target = _controller(tmp_path) + controller.install(require_ready=True) + controller.stop() + definition = adapter.definition + assert definition is not None + with ( + pytest.raises(ServiceError, match="repaired Server executable is unavailable"), + controller.runtime_repair(expected_definition=definition, repair_id="r"), + ): + Path(target.python_executable).unlink() + assert json.loads(controller.maintenance_path.read_text())["phase"] == "runtime_repair" + assert adapter.manager is ManagerState.INACTIVE + + +def test_managed_definition_preserves_isolation_and_legacy_serialization(tmp_path: Path) -> None: + _old_controller, adapter, target = _controller(tmp_path) + managed_target = ServiceTarget(target.python_executable, target.package_version, isolated_runtime=True) + probe = _manager_probe(adapter) + controller = ServiceController(adapter, target=managed_target, probe=probe, readiness_probe=probe) + controller.install(require_ready=True) + definition = adapter.definition + assert definition is not None and definition.isolated_runtime + assert ServiceDefinition.from_dict(definition.as_dict()) == definition + legacy = replace(definition, isolated_runtime=False) + assert "isolated_runtime" not in legacy.as_dict() + assert ServiceDefinition.from_dict(legacy.as_dict()) == legacy + with controller.runtime_repair(expected_definition=definition, repair_id="r"): + pass + assert adapter.definition == definition + + +def test_managed_launcher_ignores_injected_python_module(tmp_path: Path) -> None: + controller, _adapter, _target = _controller(tmp_path) + definition = replace(controller.preview_definition(), python_executable=sys.executable, isolated_runtime=True) + injected = tmp_path / "injected" + injected.mkdir() + (injected / "powercontext_injected.py").write_text("print('injected module executed')\n", encoding="utf-8") + environment = {**os.environ, "PYTHONPATH": str(injected)} + command = definition.launcher_arguments(module="powercontext_injected") + result = subprocess.run(command, cwd=injected, env=environment, capture_output=True, text=True, timeout=60) + assert result.returncode != 0 + assert "injected module executed" not in result.stdout + assert not (injected / "__pycache__").exists() + # The interpreter actually receives no-bytecode mode, even when the + # native launcher is used with a trusted module in its installed package. + module_index = command.index("-m") + flags = subprocess.run( + [*command[:module_index], "-c", "import sys; print(sys.dont_write_bytecode, sys.flags.isolated)"], + cwd=injected, + env=environment, + capture_output=True, + text=True, + timeout=60, + check=True, + ) + assert flags.stdout.strip() == "True 1" From 37036d1f0da623b0cc7c618cc47f7d6c2e124ab0 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Sat, 24 Oct 2026 19:52:19 +0800 Subject: [PATCH 2/2] fix(ops): recover incomplete managed installations --- docs/en/docs/operate/managed-local-server.md | 11 ++ docs/zh/docs/operate/managed-local-server.md | 4 + src/powercontext/ops/installer.py | 41 ++++++-- src/powercontext/ops/protocol.py | 10 +- src/powercontext/service/controller.py | 42 +++++++- tests/test_ops_installer.py | 100 ++++++++++++++++++- 6 files changed, 192 insertions(+), 16 deletions(-) diff --git a/docs/en/docs/operate/managed-local-server.md b/docs/en/docs/operate/managed-local-server.md index ecbd83b06a..1842df2e17 100644 --- a/docs/en/docs/operate/managed-local-server.md +++ b/docs/en/docs/operate/managed-local-server.md @@ -136,6 +136,17 @@ stops a newly started owned target when startup fails. A completed installation status does not rerun readiness checks. Cancellation takes effect at supported safe boundaries and does not promise rollback. +If first registration fails before creating a service, release the conflicting port or resolve the reported failure, +then confirm a fresh install plan and resume the recorded operation. The installation identity and owned configuration +are retained. A dispatch checkpoint alone does not establish that registration existed. Matching-version CLI and Ops +removal persist a private removal identity before native changes; an older install operation cannot undo that removal, +even when no registration existed yet. Pending records without this identity remain readable, but a legacy dispatch +checkpoint without registration evidence requires inspection rather than automatic replay. + +A cancelled installation can reuse verified staging. If regular staged files are corrupt, resume or a new approved +install operation verifies the recorded directory ownership, discards that exact staging tree and prepares the fixed +resource again. Replaced directories, links and unsafe ownership are rejected and preserved. + The installer returns its actual endpoint and installation/resource identities. These are not Server deployment or Principal identities. The connection layer must validate the actual endpoint and supported public identity and compatibility contract before activating a connection or obtaining its default Scope. Installation does not invent a diff --git a/docs/zh/docs/operate/managed-local-server.md b/docs/zh/docs/operate/managed-local-server.md index 6b713ceb5f..c966e0d718 100644 --- a/docs/zh/docs/operate/managed-local-server.md +++ b/docs/zh/docs/operate/managed-local-server.md @@ -78,6 +78,10 @@ description: 安装或修复固定版本的个人 Server 运行环境,并保 资源完整性、配置有效性、服务注册、进程存活和 readiness 是不同事实。获得首次启动授权后,共享服务控制器执行有界 readiness 验证;本次新启动的自有目标失败时,会恢复保护并确认停止。已完成安装的结果是历史事实,普通状态查询不重新执行 readiness。取消只在支持的安全边界生效,并不承诺回滚。 +如果首次注册在创建服务前失败,释放冲突端口或解决报告的问题后,确认新的 install 计划并恢复原操作,安装身份和自有配置会保留。派发检查点本身不能证明注册已经存在。匹配版本的 CLI 和 Ops 在修改原生注册前保存受保护的移除身份,旧安装操作不能撤销之后的明确移除,即使当时尚未创建注册。不含该身份的旧记录仍可读取,但缺少注册证据的旧派发检查点需要先检查,不会自动重放。 + +取消后的安装可复用已核验暂存资源。如果普通暂存文件损坏,resume 或新批准的 install 操作会核对已记录的目录归属,只清理对应暂存树,再次准备同一固定资源。目录被替换、包含链接或归属不安全时,拒绝操作并保留现场。 + 安装器返回实际端点和安装、资源身份,这些信息不是 Server 部署或 Principal 身份。连接负责方仍需根据公开身份和兼容契约核验真实端点,才能激活连接并取得默认 Scope。安装不会自造 Scope ID、任意选择一个 Scope,也不证明 Memory 操作已成功。 ## 发布与平台验收 diff --git a/src/powercontext/ops/installer.py b/src/powercontext/ops/installer.py index 5859813666..743056c219 100644 --- a/src/powercontext/ops/installer.py +++ b/src/powercontext/ops/installer.py @@ -157,14 +157,15 @@ def _installation(self, ops: Ops) -> dict[str, Any] | None: raise OpsError("installation_record_invalid") return record - def _pending(self, ops: Ops, *, missing_registration: bool = False) -> dict[str, Any] | None: + def _pending(self, ops: Ops, *, snapshot: dict[str, Any] | None = None) -> dict[str, Any] | None: latest = ops.store.read("_installation_owner.json") if latest is None: return None if set(latest) != {"format_version", "operation_id"}: raise OpsError("installation_record_invalid") record = ops._record(protocol.identifier(latest.get("operation_id"))) - if missing_registration: + if snapshot is not None and snapshot["registration"].get("definition") is None: + removal = snapshot["registration_removal"] for previous in record["attempts"]: result = previous.get("result") or {} components = result.get("components") or {} @@ -172,9 +173,16 @@ def _pending(self, ops: Ops, *, missing_registration: bool = False) -> dict[str, facts = previous["plan"].get("installation") or {} if ( facts.get("registration_present") is True - or state.get("stage") in {"registration_dispatching", "complete"} + or state.get("stage") == "complete" or components.get("registration") == "installed" - or previous.get("native_command") is not None + or facts.get("registration_removal") != removal + or ( + "registration_removal" not in facts + and ( + state.get("stage") == "registration_dispatching" + or previous.get("native_command") is not None + ) + ) ): raise OpsError("registration_missing") attempt = record["attempts"][-1] @@ -253,7 +261,7 @@ def preflight(self, ops: Ops, operation: str, options: dict[str, Any]) -> dict[s ops._assert_target(snapshot, operation) installation = self._installation(ops) definition = snapshot["registration"].get("definition") - pending = self._pending(ops, missing_registration=definition is None) + pending = self._pending(ops, snapshot=snapshot) evidence = installation or pending if installation and pending and installation["installation_id"] == pending["installation_id"]: evidence = {**installation, **pending} @@ -353,6 +361,7 @@ def preflight(self, ops: Ops, operation: str, options: dict[str, Any]) -> dict[s "preserve_stopped": preserve_stopped, "repair_owner": repair_owner, "registration_present": definition is not None, + "registration_removal": snapshot["registration_removal"], } plan = { "protocol_version": 1, @@ -442,7 +451,27 @@ def checkpoint(stage: str) -> None: resource_state, _identity = self._resource_fact(state, cancelled=resource_cancelled) if resource_state != "verified": prepared = state.get("prepared") + if prepared is not None and ( + Path(prepared["directory"]).exists() or Path(prepared["directory"]).is_symlink() + ): + try: + self._verify_prepared(prepared, cancelled=resource_cancelled) + except ResourceError as error: + if error.code != "resource_corrupt": + raise + path = Path(prepared["directory"]) + # Only a private, link-free tree with the recorded identity + # directly inside our staging root is disposable. + if ( + not path.is_absolute() + or path.resolve().parent != (self.layout.programs / "staging").resolve() + or validate_owned_tree(path) != prepared["identity"] + ): + raise OpsError("resource_identity_changed") from error + shutil.rmtree(path) + prepared = None if prepared is None or not Path(prepared["directory"]).exists(): + state["prepared"] = None checkpoint("preparing") staging = self.layout.programs / "staging" try: @@ -453,8 +482,6 @@ def checkpoint(stage: str) -> None: raise state["prepared"] = {"directory": str(result.directory), "identity": result.identity} checkpoint("prepared") - else: - self._verify_prepared(state["prepared"], cancelled=resource_cancelled) if cancelled(): return {"outcome": "cancelled", "components": self.components(ops, state)} with ops.controller.mutation(timeout=0): diff --git a/src/powercontext/ops/protocol.py b/src/powercontext/ops/protocol.py index bfae160d4f..e439b856b6 100644 --- a/src/powercontext/ops/protocol.py +++ b/src/powercontext/ops/protocol.py @@ -182,15 +182,15 @@ def _validate_installation_plan(value: object) -> None: "preserve_stopped", "repair_owner", "registration_present", - }, + } + | ({"registration_removal"} if isinstance(value, dict) and "registration_removal" in value else set()), ) for field in ("catalog_sha256", "service_before"): if not isinstance(facts[field], str) or re.fullmatch(r"[a-f0-9]{64}", facts[field]) is None: raise OpsError("invalid_request") - if facts["repair_owner"] is not None: - identifier(facts["repair_owner"]) - if facts["installation_id"] is not None: - identifier(facts["installation_id"]) + for field in ("repair_owner", "installation_id", "registration_removal"): + if facts.get(field) is not None: + identifier(facts[field]) if facts["classification"] not in ("missing", "owned_current", "owned_repairable", "pending_operation") or facts[ "resources" ] not in ("missing", "damaged", "verified"): diff --git a/src/powercontext/service/controller.py b/src/powercontext/service/controller.py index bf6e917c66..0c349f1fab 100644 --- a/src/powercontext/service/controller.py +++ b/src/powercontext/service/controller.py @@ -30,13 +30,19 @@ from pathlib import Path from typing import cast from urllib.parse import urlsplit +from uuid import UUID, uuid4 from powercontext.cli.env_file import environment_context from powercontext.paths import POWERCONTEXT_HOME_ENV, powercontext_data_dir from powercontext.server.configuration import ServerConfigurationError, server_settings_context from powercontext.service.adapters import NativeServiceAdapter, native_service_adapter from powercontext.service.adapters.base import atomic_write, definition_state, service_python_executable -from powercontext.service.environment import ProtectedEnvironmentFileError, load_protected_environment_file +from powercontext.service.environment import ( + ProtectedEnvironmentFileError, + load_protected_environment_file, + read_protected_file, + write_protected_file, +) from powercontext.service.model import ( DEFINITION_VERSION, OWNERSHIP_MARKER, @@ -288,6 +294,29 @@ def maintenance_path(self) -> Path: def native_command_path(self) -> Path: return self._adapter.lock_path.with_name(f"{self._adapter.lock_path.name}.native-command") / "state.json" + @property + def registration_removal_path(self) -> Path: + return self._adapter.lock_path.with_name(f"{self._adapter.lock_path.name}.registration-removal") / "state.json" + + def _registration_removal(self) -> str | None: + path = self.registration_removal_path + if not path.exists() and not path.is_symlink(): + return None + try: + record = json.loads(read_protected_file(path).content) + if ( + not isinstance(record, dict) + or set(record) != {"format_version", "removal_id"} + or type(record["format_version"]) is not int + or record["format_version"] != 1 + or not isinstance(record["removal_id"], str) + or str(UUID(record["removal_id"])) != record["removal_id"] + ): + raise ValueError("invalid registration removal evidence") # noqa: TRY003, TRY301 + return record["removal_id"] + except (OSError, ValueError) as error: + raise ServiceError("the registration removal evidence cannot be verified") from error # noqa: TRY003 + def start(self) -> ServiceStatus: """Start the registered executable without reinstalling or changing configuration.""" @@ -442,6 +471,7 @@ def operation_snapshot(self) -> dict[str, object]: "manager": self._adapter.manager_state().value, "maintenance": self._maintenance_record(), "native_command": native_command_evidence(self.native_command_path), + "registration_removal": self._registration_removal(), } def configure( # noqa: C901 @@ -941,6 +971,16 @@ def uninstall(self) -> ServiceStatus: self._require_mutable_registration(registration) loaded = self._adapter.loaded_registration() self._require_mutable_manager_registration(loaded) + # Persist explicit removal intent before touching the registration. + # A first install interrupted before registration is retryable, but + # its recovery must never undo a later CLI or Ops removal. + try: + write_protected_file( + self.registration_removal_path, + json.dumps({"format_version": 1, "removal_id": str(uuid4())}).encode("utf-8"), + ) + except (OSError, ValueError) as error: + raise ServiceError("registration removal intent could not be recorded") from error # noqa: TRY003 if ( registration.state is RegistrationState.NOT_INSTALLED and loaded.state is ManagerOwnershipState.NOT_LOADED diff --git a/tests/test_ops_installer.py b/tests/test_ops_installer.py index 13c8b5ba41..56defab476 100644 --- a/tests/test_ops_installer.py +++ b/tests/test_ops_installer.py @@ -174,6 +174,99 @@ def cancel(message): assert validate_owned_tree(installer.directory) == prepared +@pytest.mark.parametrize("failure", ["port_conflict", "dispatching_interruption"]) +def test_first_registration_failure_can_resume(managed, monkeypatch, failure): + from powercontext.service.probe import probe_server + + ops, adapter, _installer = managed + port = _free_port() + request = _apply_request(_plan(ops, "install", http_port=port), "install") + with socket.socket() as listener, monkeypatch.context() as fault: + if failure == "port_conflict": + fault.setattr(ops.controller, "_probe", lambda endpoint: probe_server(endpoint, timeout=0.1)) + + def occupy_port(message): + if message.get("phase") == "registering": + listener.bind(("127.0.0.1", port)) + listener.listen() + + failed = ops.handle(request, emit=occupy_port) + assert failed["outcome"] == "uncertain", failed + else: + + def interrupted(**kwargs): + raise SystemExit(99) + + fault.setattr(ops.controller, "install", interrupted) + with pytest.raises(SystemExit): + ops.handle(request) + assert adapter.definition is None + assert adapter.events == [] + record = ops.store.operation("install") + identity = record["attempts"][-1]["installation"]["installation_id"] + result = ops.handle({ + **_apply_request(_plan(ops, "install", http_port=port), "install"), + "action": "resume", + "expected_attempt": 1, + }) + assert result["outcome"] == "succeeded", result + assert result["result"]["components"]["installation_id"] == identity + assert adapter.manager == ManagerState.ACTIVE + + +@pytest.mark.parametrize("resume", [True, False]) +def test_corrupt_owned_staging_can_be_prepared_again(managed, resume): + ops, adapter, installer = managed + port = _free_port() + + def cancel(message): + if message.get("phase") == "prepared": + ops.handle(_request("cancel", operation_id="install", attempt=1)) + + first = ops.handle(_apply_request(_plan(ops, "install", http_port=port), "install"), emit=cancel) + assert first["outcome"] == "cancelled", first + record = ops.store.operation("install") + state = record["attempts"][-1]["installation"] + damaged = Path(state["prepared"]["directory"]) + (damaged / next(iter(_FILES))).write_bytes(b"corrupt") + request = _apply_request(_plan(ops, "install", http_port=port), "install" if resume else "retry") + if resume: + request.update(action="resume", expected_attempt=1) + result = ops.handle(request) + assert result["outcome"] == "succeeded", result + assert result["result"]["components"]["installation_id"] == state["installation_id"] + assert adapter.manager == ManagerState.ACTIVE + assert not damaged.exists() + verify(installer.spec, installer.directory) + + +def test_replaced_staging_is_preserved_and_not_reacquired(managed): + ops, adapter, _installer = managed + port = _free_port() + + def cancel(message): + if message.get("phase") == "prepared": + ops.handle(_request("cancel", operation_id="install", attempt=1)) + + first = ops.handle(_apply_request(_plan(ops, "install", http_port=port), "install"), emit=cancel) + assert first["outcome"] == "cancelled", first + record = ops.store.operation("install") + directory = Path(record["attempts"][-1]["installation"]["prepared"]["directory"]) + directory.rename(directory.with_name("previous")) + protect_directory(directory, create=True) + foreign = directory / "user-owned" + foreign.write_bytes(b"retain") + result = ops.handle({ + **_apply_request(_plan(ops, "install", http_port=port), "install"), + "action": "resume", + "expected_attempt": 1, + }) + assert result["outcome"] == "failed", result + assert result["result"]["error"]["code"] == "resource_identity_changed", result + assert foreign.read_bytes() == b"retain" + assert adapter.definition is None + + def test_existing_unknown_database_blocks_start_but_allows_repair(managed): ops, adapter, installer = managed install(ops) @@ -398,16 +491,17 @@ def test_repair_rejects_database_inside_managed_program_tree(managed): assert database.execute("SELECT value FROM user_owned").fetchall() == [("retained",)] -@pytest.mark.parametrize("checkpoint", ["failed", "dispatched", "continued"]) +@pytest.mark.parametrize("checkpoint", ["failed", "dispatched", "continued", "before_registration"]) def test_uninstalled_partial_install_cannot_be_revived_by_old_recovery(managed, monkeypatch, checkpoint): ops, adapter, _installer = managed request = _apply_request(_plan(ops, "install", http_port=_free_port()), "install") operation_id = "install" - if checkpoint == "dispatched": + if checkpoint in {"dispatched", "before_registration"}: install_target = ops.controller.install def interrupted_install(**kwargs): - install_target(**kwargs) + if checkpoint == "dispatched": + install_target(**kwargs) raise SystemExit(99) monkeypatch.setattr(ops.controller, "install", interrupted_install)