@namewta/speculo 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/kernel.d.ts +23 -2
- package/dist/src/kernel.js +9 -0
- package/dist/src/kernel.js.map +1 -1
- package/dist/src/ops-resources.d.ts +3 -0
- package/dist/src/ops-resources.js +188 -0
- package/dist/src/ops-resources.js.map +1 -0
- package/dist/src/refresh.js +20 -2
- package/dist/src/refresh.js.map +1 -1
- package/dist/src/structured.js +8 -1
- package/dist/src/structured.js.map +1 -1
- package/dist/src/workflows.js +1 -1
- package/dist/src/workflows.js.map +1 -1
- package/package.json +1 -1
- package/template/.speculo/README.md +3 -3
- package/template/.speculo/kernel/README.md +3 -0
- package/template/.speculo/kernel/checkpoint.schema.json +138 -4
- package/template/commands/archive-and-consolidate.md +5 -1
- package/template/commands/status.md +2 -2
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +34 -0
- package/template/workflows/ops/H-host-manage/H-host-manage.md +30 -0
- package/template/workflows/ops/I-initialize/I-initialize.md +28 -0
- package/template/workflows/ops/INDEX.md +15 -17
- package/template/workflows/ops/README.md +59 -87
- package/template/workflows/ops/_state/status.json +15 -3
- package/template/workflows/ops/common/CAPABILITIES.md +23 -0
- package/template/workflows/ops/common/USAGE.md +112 -0
- package/template/workflows/ops/common/examples/README.md +7 -0
- package/template/workflows/ops/common/examples/binding.example.json +12 -0
- package/template/workflows/ops/common/examples/compose-app.example.json +56 -0
- package/template/workflows/ops/common/examples/credential.example.json +9 -0
- package/template/workflows/ops/common/examples/environment-request.example.json +7 -0
- package/template/workflows/ops/common/examples/register.example.json +44 -0
- package/template/workflows/ops/common/examples/shared-allocation.example.json +37 -0
- package/template/workflows/ops/common/rules/activation-and-memory.md +5 -20
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +27 -0
- package/template/workflows/ops/common/rules/recovery.md +15 -0
- package/template/workflows/ops/common/rules/shared-services.md +13 -0
- package/template/workflows/ops/common/schemas/allocation.schema.json +92 -0
- package/template/workflows/ops/common/schemas/approval.schema.json +41 -21
- package/template/workflows/ops/common/schemas/binding.schema.json +73 -0
- package/template/workflows/ops/common/schemas/deployment.schema.json +244 -0
- package/template/workflows/ops/common/schemas/host.schema.json +88 -0
- package/template/workflows/ops/common/schemas/plan.schema.json +1573 -0
- package/template/workflows/ops/common/schemas/project.schema.json +55 -11
- package/template/workflows/ops/common/schemas/spec.schema.json +1032 -0
- package/template/workflows/ops/common/schemas/status.schema.json +758 -17
- package/template/workflows/ops/common/service-profiles/custom.md +5 -0
- package/template/workflows/ops/common/service-profiles/docker-engine.md +11 -0
- package/template/workflows/ops/common/service-profiles/minio.md +7 -0
- package/template/workflows/ops/common/service-profiles/mysql.md +7 -0
- package/template/workflows/ops/common/service-profiles/redis.md +7 -0
- package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +13 -0
- package/template/workflows/ops/common/templates/EXECUTION-PLAN.md +15 -0
- package/template/workflows/ops/common/templates/HOST-README.md +11 -0
- package/template/workflows/ops/common/templates/PROJECT-README.md +15 -0
- package/template/workflows/ops/common/tests/test_ops.py +392 -0
- package/template/workflows/ops/common/tools/bootstrap.ps1 +18 -0
- package/template/workflows/ops/common/tools/bootstrap.sh +27 -0
- package/template/workflows/ops/common/tools/demo-local.py +64 -0
- package/template/workflows/ops/common/tools/ops.py +7 -0
- package/template/workflows/ops/common/tools/opslib/__init__.py +2 -0
- package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
- package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
- package/template/workflows/ops/common/tools/opslib/agent.py +510 -0
- package/template/workflows/ops/common/tools/opslib/cli.py +172 -0
- package/template/workflows/ops/common/tools/opslib/core.py +199 -0
- package/template/workflows/ops/common/tools/opslib/docs.py +199 -0
- package/template/workflows/ops/common/tools/opslib/execution.py +248 -0
- package/template/workflows/ops/common/tools/opslib/host_recipes.py +67 -0
- package/template/workflows/ops/common/tools/opslib/model.py +199 -0
- package/template/workflows/ops/common/tools/opslib/native_windows.py +32 -0
- package/template/workflows/ops/common/tools/opslib/planner.py +497 -0
- package/template/workflows/ops/common/tools/opslib/services.py +47 -0
- package/template/workflows/ops/common/tools/opslib/sources.py +27 -0
- package/template/workflows/ops/common/tools/opslib/transport.py +55 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +32 -991
- package/template/workflows/ops/manifest.json +60 -1
- package/template/workflows/ops/runtime-contract.json +1 -6
- package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +0 -71
- package/template/workflows/ops/A-archive-and-learn/promotion-plan-template.md +0 -41
- package/template/workflows/ops/A-archive-and-learn/retrospective-template.md +0 -53
- package/template/workflows/ops/E-execute-and-stabilize/E-execute-and-stabilize.md +0 -82
- package/template/workflows/ops/E-execute-and-stabilize/attempt-summary-template.md +0 -44
- package/template/workflows/ops/E-execute-and-stabilize/diagnosis-template.md +0 -24
- package/template/workflows/ops/E-execute-and-stabilize/handoff-template.md +0 -32
- package/template/workflows/ops/E-execute-and-stabilize/rollback-template.md +0 -26
- package/template/workflows/ops/E-execute-and-stabilize/verification-state-template.json +0 -30
- package/template/workflows/ops/E-execute-and-stabilize/verification-template.md +0 -55
- package/template/workflows/ops/I-intake-and-assess/I-intake-and-assess.md +0 -73
- package/template/workflows/ops/I-intake-and-assess/change-status-template.json +0 -26
- package/template/workflows/ops/I-intake-and-assess/collector-catalog.md +0 -28
- package/template/workflows/ops/I-intake-and-assess/deployment-dossier-template.md +0 -62
- package/template/workflows/ops/I-intake-and-assess/global-change-status-template.json +0 -26
- package/template/workflows/ops/I-intake-and-assess/project-detection.md +0 -38
- package/template/workflows/ops/I-intake-and-assess/request-template.md +0 -44
- package/template/workflows/ops/I-intake-and-assess/system-report-template.md +0 -34
- package/template/workflows/ops/I-intake-and-assess/target-profile-template.json +0 -24
- package/template/workflows/ops/P-plan-and-approve/P-plan-and-approve.md +0 -72
- package/template/workflows/ops/P-plan-and-approve/plan-review-template.md +0 -78
- package/template/workflows/ops/_state/archive/.gitkeep +0 -1
- package/template/workflows/ops/_state/changes/.gitkeep +0 -1
- package/template/workflows/ops/common/rules/artifact-contract.md +0 -37
- package/template/workflows/ops/common/rules/closure-and-learning.md +0 -25
- package/template/workflows/ops/common/rules/evidence-and-redaction.md +0 -22
- package/template/workflows/ops/common/rules/execution-loop.md +0 -27
- package/template/workflows/ops/common/rules/path-and-scope-contract.md +0 -21
- package/template/workflows/ops/common/rules/plan-and-approval.md +0 -25
- package/template/workflows/ops/common/rules/project-and-change-scope.md +0 -20
- package/template/workflows/ops/common/rules/target-profile-and-release-gates.md +0 -44
- package/template/workflows/ops/common/schemas/attempt.schema.json +0 -42
- package/template/workflows/ops/common/schemas/change-status.schema.json +0 -43
- package/template/workflows/ops/common/schemas/deployment-model.schema.json +0 -26
- package/template/workflows/ops/common/schemas/implementation-plan.schema.json +0 -221
- package/template/workflows/ops/common/schemas/inventory-snapshot.schema.json +0 -31
- package/template/workflows/ops/common/schemas/journal-event.schema.json +0 -22
- package/template/workflows/ops/common/schemas/promotion-approval.schema.json +0 -20
- package/template/workflows/ops/common/schemas/promotion-manifest.schema.json +0 -22
- package/template/workflows/ops/common/schemas/target-profile.schema.json +0 -32
- package/template/workflows/ops/common/schemas/verification-state.schema.json +0 -30
- package/template/workflows/ops/common/tools/close-change.mjs +0 -177
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Linux Docker Engine 准备合同
|
|
2
|
+
|
|
3
|
+
本版不从互联网猜最新安装命令。先读取真实发行版、架构、已有 Engine/Compose、用户和配置。缺失时由用户/Agent提供固定版本的可信安装器与包清单,安装器本地文件哈希进入计划,禁止直接执行未校验 curl 输出。
|
|
4
|
+
|
|
5
|
+
使用 H host_actions:将安装器写入 `_host/installers/<version>/`;install-toolchain 明示系统包文件/服务动作、当前默认版本与验证;write-control 合并 `/etc/docker/daemon.json`,data-root 必须是已登记 `host_root/_runtime/docker`;之后明确 restart/start、info、compose version 和权限检查。已有配置不能直接覆盖成仅含 data-root 的文件;先保存、合并并复核差异。
|
|
6
|
+
|
|
7
|
+
已有数据:生成独立维护计划,列所有容器/消费者,验证一致备份,停机,保留旧目录,显式复制/校验/切换/启动/验证。不能“为了规范”直接删除 /var/lib/docker。Rootless 有不同 daemon/config 权限,需显式适配,不冒充 rootful。
|
|
8
|
+
|
|
9
|
+
Docker Desktop 和隐藏 VM 不能通过在 Windows 创建 C:\Ops\_runtime\docker 就满足真实根;严格模式不接受这类假配置。使用实际 Linux/WSL Engine 主机,或先实施并验证对应平台 adapter。
|
|
10
|
+
|
|
11
|
+
D 会读取 daemon ID/context/endpoint/data-root/Compose 版本,固定实际 daemon,只接受 unix/npipe 本机端点,拒绝 remote context 偷连另一台机器。容器镜像必须 image@sha256,Compose>=2.30,根文件系统只读,全部业务数据显式 bind。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# minio 服务档案
|
|
2
|
+
|
|
3
|
+
提供者独立 minio-main 项目,service_type=minio,service.endpoint 是实际管理员端点;对象数据在 data/minio/objects,实例卷数和恢复方式根据真实拓扑确定,不机械套单盘模板。
|
|
4
|
+
|
|
5
|
+
内置分配使用用户指定并核验版本的 mc client_path,以 MC_HOST_ops 进程环境传管理员凭据;每 APP 一个桶、最小桶策略和应用身份。mc admin user add 的新应用密码位于子进程参数,需 allow_secret_argv=true 显式确认特权进程查看风险;不接受该边界时使用既有资源的实际验证模式。
|
|
6
|
+
|
|
7
|
+
删除 APP 不删除桶或公共实例。桶级恢复需实际版本/复制/版本控制策略,不能把复制 APP 根当作对象数据备份。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# mysql 服务档案
|
|
2
|
+
|
|
3
|
+
提供者是独立 mysql-main 项目,service_type=mysql,method=compose,service.compose_service 必须是实际服务名。image 固定摘要。数据在 data/mysql/instance,容器 /var/lib/mysql 显式 bind;镜像声明的其他 VOLUME 必须根据实际 inspect 映射。创建目录的 uid/gid 与所用镜像一致,不猜所有版本相同。
|
|
4
|
+
|
|
5
|
+
管理员凭据登记 provider.credential_refs;应用库用 Allocation(resource_kind=database),应用账号 username/password 必须在账本中真实匹配。内置分配只允许明确本库权限,默认 SELECT/INSERT/UPDATE/DELETE;迁移用户需要 DDL 时显式授予本库所需权限,不授全局管理员。
|
|
6
|
+
|
|
7
|
+
已存在未知库/用户拒绝覆盖;密码不自动重置。数据备份使用该版本可验证的一致性逻辑/物理工具;单 APP 回滚不得恢复整个实例。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# redis 服务档案
|
|
2
|
+
|
|
3
|
+
提供者独立 redis-main 项目,service_type=redis,service.compose_service 指向实际服务。数据在 data/redis/instance;ACL 文件配置为 data/redis/acl/users.acl(service.acl_persistence 必须完全匹配),容器 ACL 写入路径必须映射到这个项目数据目录,不能将 ACL SAVE 写到只读 config。
|
|
4
|
+
|
|
5
|
+
每个 APP 使用不同 ACL username、prefix 和凭据;禁用/限制危险管理命令,内置分配使用受限命令类别。必须核验真实客户端兼容性、ACL SAVE 和新账号认证。prefix/数字 DB 不等于独立内存/CPU/备份恢复边界;要求强隔离时部署专用实例。
|
|
6
|
+
|
|
7
|
+
持久化 AOF/RDB 属于业务数据不是可随意清理的日志。公共实例恢复影响所有消费者,需要整体维护批准。
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# APP / 服务部署记录
|
|
2
|
+
|
|
3
|
+
此文件是内容合同,不含真实密码;实际生成器依据成功回执填入真实值,不把占位符当交付。
|
|
4
|
+
|
|
5
|
+
## 部署实例、主机、账户、来源与固定构件
|
|
6
|
+
|
|
7
|
+
## observed_version 与首次/最近 UTC 部署时间
|
|
8
|
+
|
|
9
|
+
## 项目根、compose/service、env、config、data/component/purpose、logs、backups
|
|
10
|
+
|
|
11
|
+
## 依赖 provider、逻辑资源、物理路径、网络与备份归属
|
|
12
|
+
|
|
13
|
+
## 启动、停止、查看、更新、迁移、回滚步骤
|
|
14
|
+
|
|
15
|
+
## 最后运行与双边文档回执;默认不写密码
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""OPS safety/contract tests. Adapter simulations are explicitly separate from real local subprocess tests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import base64,copy,importlib.util,json,os,re,stat,sys,tempfile,unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
TOOLS=Path(__file__).resolve().parents[1]/'tools'
|
|
7
|
+
sys.path.insert(0,str(TOOLS))
|
|
8
|
+
from opslib import core,model,planner,execution,agent,transport,docs,services
|
|
9
|
+
from opslib.cli import initialize,analyze
|
|
10
|
+
from opslib.core import OpsError,UnknownResult,digest,write_json,read_json
|
|
11
|
+
|
|
12
|
+
PASSWORD='TEST-ONLY-$literal-quote"-slash\\-not-production'
|
|
13
|
+
|
|
14
|
+
def deployment(project='app-a',did='app-a-prod',root='/srv/ops',method='native',host=None):
|
|
15
|
+
host=host or {'platform':'linux','root':root}
|
|
16
|
+
dep_root=core.target_join(host,project)
|
|
17
|
+
return {'deployment_id':did,'project_id':project,'host_id':'node-a','environment':'prod','instance':'main','layout':'flat','method':method,'version':'v1','observed_version':'v1','root':dep_root,'status':'completed','installed_at':'2026-01-01T00:00:00Z','updated_at':'2026-01-01T00:00:00Z','run_id':'run-old','compose_name':'ops-'+did if method=='compose' else None,'storage':[],'commands':{'start':[],'stop':[],'verify':[]},'credential_refs':[],'backup':'A project-specific backup is required.','recovery':'No automatic shared-database restore.','notes':[],'source':{'type':'local','location':'/source/'+project,'revision':'v1'},'service':{}}
|
|
18
|
+
|
|
19
|
+
def minimal_inventory(host,request,**kwargs):
|
|
20
|
+
return {'identity':host['identity'],'observed_at':core.now(),'platform':host['platform'],'hostname':'fixture','architecture':'fixture','account':'fixture','uid':None,'python':sys.executable,'tools':{},'defaults':{},'diagnostics':{'memory':{},'issues':[],'disks':{}},'docker_control':{'status':'observed','context':'fixture-context','endpoint':'unix:///fixture/docker.sock','id':'fixture-docker-id','data_root':core.target_join(host,'_runtime/docker'),'compose_version':'2.30.0'},'snapshots':{p:(agent.tree_state(p) if p in request.get('deep_paths',[]) else agent.file_state(p)) for p in request.get('paths',[])}}
|
|
21
|
+
|
|
22
|
+
class Fixture(unittest.TestCase):
|
|
23
|
+
def setUp(self):
|
|
24
|
+
self.tmp=tempfile.TemporaryDirectory(prefix='ops-tests-');self.base=Path(self.tmp.name);self.state=self.base/'controller';self.target=self.base/'server'
|
|
25
|
+
with patch('opslib.cli.probe_local',return_value={'identity':agent.fingerprint(),'scope':'mock-initial-inventory-only'}):initialize(self.state,'control-a')
|
|
26
|
+
self.platform='windows' if os.name=='nt' else 'linux'
|
|
27
|
+
self.host={'host_id':'node-a','display_name':'Node A','platform':self.platform,'transport':'local','root':str(self.target),'identity':agent.fingerprint(),'connection':{}}
|
|
28
|
+
self.project={'project_id':'app-a','display_name':'APP A','kind':'app','service_type':None,'source':{'type':'local','location':str(self.base/'source'),'revision':'v1'}}
|
|
29
|
+
model.register(self.state,{'hosts':[self.host],'projects':[self.project]})
|
|
30
|
+
model.put_credential(self.state,{'credential_id':'app-auth','version':1,'purpose':'Synthetic test only','values':{'username':'app-user','password':PASSWORD}})
|
|
31
|
+
def tearDown(self):self.tmp.cleanup()
|
|
32
|
+
def spec(self,version='v1',operation='deploy',program=None):
|
|
33
|
+
if program is None:
|
|
34
|
+
program="import os,json,pathlib\np=pathlib.Path(os.environ['OPS_DATA_ROOT'])/'app/storage/value.json'\nn=json.loads(p.read_text())['count']+1 if p.exists() else 1\nassert os.environ['APP_USERNAME']=='app-user'\nassert os.environ['APP_PASSWORD'].startswith('TEST-ONLY-')\np.write_text(json.dumps({'count':n,'version':"+repr(version)+"}))\n"
|
|
35
|
+
return {'schema_version':1,'worker':'D','operation':operation,'reason':'Disposable isolated test only','rollback_note':'Retain old data; use a new explicitly approved recovery plan.', 'deployments':[{'deployment_id':'app-a-prod','project_id':'app-a','host_id':'node-a','environment':'prod','instance':'main','layout':'flat','method':'native','version':version,'files':[{'path':'artifact/main.py','content':program}],'env':{'app.env':{'APP_USERNAME':'{{credential:app-auth@1:username}}','APP_PASSWORD':'{{credential:app-auth@1:password}}'}},'native':{'supervisor':'oneshot','argv':[sys.executable,'{{artifact}}/main.py']},'credential_refs':['app-auth@1'],'health':[{'type':'file','path':'data/app/storage/value.json'}],'backup':'Back up only after process exit; this fixture has no shared data.','recovery':'A new matching-version plan preserves and verifies data.'}]}
|
|
36
|
+
def plan(self,spec=None):
|
|
37
|
+
f=self.base/'input.json';write_json(f,spec or self.spec())
|
|
38
|
+
with patch('opslib.planner.call',side_effect=minimal_inventory):result=planner.compile_plan(self.state,f)
|
|
39
|
+
return result,read_json(Path(result['plan_path']))
|
|
40
|
+
def approve(self,info):return execution.approval(self.state,info['run_id'],info['plan_digest'],'test-admin','Approve only the exact isolated test plan and its fixture data.')
|
|
41
|
+
def run_spec(self,spec=None):
|
|
42
|
+
info,plan=self.plan(spec);self.approve(info);result=execution.apply(self.state,info['run_id']);return info,plan,result
|
|
43
|
+
def graph(self):
|
|
44
|
+
s=model.load(self.state);s['deployments']['app-a-prod']=deployment(host=self.host);s['deployments']['app-a-prod']['credential_refs']=['app-auth@1']
|
|
45
|
+
p={'project_id':'mysql-main','display_name':'MySQL','kind':'shared-service','service_type':'mysql','source':{'type':'image','location':'mysql@sha256:'+'a'*64,'revision':'digest-pinned'}}
|
|
46
|
+
s['projects']['mysql-main']=p
|
|
47
|
+
d=deployment('mysql-main','mysql-prod',host=self.host,method='compose');d['credential_refs']=['mysql-admin@1'];d['service']={'compose_service':'mysql'};s['deployments']['mysql-prod']=d
|
|
48
|
+
a={'allocation_id':'app-db','provider_deployment_id':'mysql-prod','owner_project_id':'app-a','environment':'prod','data_group':'app-a-prod','resource_kind':'database','resource_name':'app_a_prod','credential_ref':'app-auth@1','shared_owners':[],'status':'active','recovery_scope':'logical','notes':'Fixture logical resource'}
|
|
49
|
+
s['allocations']['app-db']=a
|
|
50
|
+
s['bindings']['app-db-binding']={'binding_id':'app-db-binding','consumer_deployment_id':'app-a-prod','mode':'shared','provider_deployment_id':'mysql-prod','allocation_id':'app-db','endpoint':'mysql-main:3306','credential_ref':'app-auth@1','status':'active','component':'mysql','network':'explicit-intranet'}
|
|
51
|
+
return s
|
|
52
|
+
|
|
53
|
+
class CoreTests(unittest.TestCase):
|
|
54
|
+
def test_canonical_order(self):self.assertEqual(digest({'b':1,'a':2}),digest({'a':2,'b':1}))
|
|
55
|
+
def test_nonfinite_rejected(self):
|
|
56
|
+
with self.assertRaises(ValueError):core.canonical(float('nan'))
|
|
57
|
+
def test_duplicate_json_rejected(self):
|
|
58
|
+
with tempfile.TemporaryDirectory() as d:
|
|
59
|
+
p=Path(d)/'x.json';p.write_text('{"x":1,"x":2}')
|
|
60
|
+
with self.assertRaises(OpsError):read_json(p)
|
|
61
|
+
def test_exclusive_artifact(self):
|
|
62
|
+
with tempfile.TemporaryDirectory() as d:
|
|
63
|
+
p=Path(d)/'a.json';write_json(p,{'x':1},exclusive=True)
|
|
64
|
+
with self.assertRaises(OpsError):write_json(p,{'x':2},exclusive=True)
|
|
65
|
+
self.assertEqual(read_json(p),{'x':1})
|
|
66
|
+
def test_lock_is_not_autobroken(self):
|
|
67
|
+
with tempfile.TemporaryDirectory() as d:
|
|
68
|
+
with core.lock(Path(d)/'lock',{'why':'test'}):
|
|
69
|
+
with self.assertRaises(OpsError):
|
|
70
|
+
with core.lock(Path(d)/'lock',{}):pass
|
|
71
|
+
def test_symlink_rejected(self):
|
|
72
|
+
with tempfile.TemporaryDirectory() as d:
|
|
73
|
+
p=Path(d);(p/'real').mkdir()
|
|
74
|
+
try:(p/'link').symlink_to(p/'real',target_is_directory=True)
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
if os.name=='nt':self.skipTest('Windows symlink privilege is not available')
|
|
77
|
+
raise
|
|
78
|
+
with self.assertRaises(OpsError):core.atomic_write(p/'link/a.txt','x')
|
|
79
|
+
def test_file_permissions(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as d:
|
|
81
|
+
p=Path(d)/'secret';core.atomic_write(p,PASSWORD)
|
|
82
|
+
if os.name!='nt':self.assertEqual(p.stat().st_mode&0o777,0o600)
|
|
83
|
+
def test_credential_raw_values(self):
|
|
84
|
+
ledger={'entries':{'x':{'1':{'values':{'password':PASSWORD}}}}}
|
|
85
|
+
self.assertEqual(core.resolve_secrets('{{credential:x@1:password}}',ledger),PASSWORD)
|
|
86
|
+
def test_credential_missing(self):
|
|
87
|
+
with self.assertRaises(OpsError):core.resolve_secrets('{{credential:x@1:password}}',{'entries':{}})
|
|
88
|
+
def test_redaction(self):self.assertNotIn(PASSWORD,core.redact('pw '+PASSWORD,[PASSWORD]))
|
|
89
|
+
def test_env_dollar_literal(self):self.assertEqual(planner.env_file({'P':'a$$"z'}),'P=a$$"z\n')
|
|
90
|
+
def test_env_newline_rejected(self):
|
|
91
|
+
with self.assertRaises(OpsError):planner.env_file({'P':'a\nb'})
|
|
92
|
+
def test_env_key_rejected(self):
|
|
93
|
+
with self.assertRaises(OpsError):planner.env_file({'P-INVALID':'a'})
|
|
94
|
+
def test_systemd_env_quote(self):self.assertEqual(planner.env_file({'P':'a"\\b'},systemd=True),'P="a\\"\\\\b"\n')
|
|
95
|
+
def test_markdown_secret_backticks(self):
|
|
96
|
+
l={'entries':{'x':{'1':{'purpose':'test','values':{'password':'a```b|c$"'}}}}}
|
|
97
|
+
self.assertIn('a```b|c$"',docs.credentials_text(['x@1'],l))
|
|
98
|
+
def test_windows_native_json_secret(self):
|
|
99
|
+
from opslib.native_windows import task_files
|
|
100
|
+
vals=task_files('app-a',r'C:\Ops\app-a',['python',r'C:\Ops\app-a\main.py'],{'P':'{{credential:x@1:password}}'},{'account':'X\\User'})
|
|
101
|
+
original={'kind':'write','json_values':json.loads(vals['service/task.json']),'content':vals['service/task.json']}
|
|
102
|
+
actual=execution.resolved_op(original,{'entries':{'x':{'1':{'values':{'password':PASSWORD}}}}})
|
|
103
|
+
self.assertEqual(json.loads(base64.b64decode(actual['content_b64']))['environment']['P'],PASSWORD)
|
|
104
|
+
|
|
105
|
+
# Each generated case is a named unittest, not an inflated subtest count.
|
|
106
|
+
for index,value in enumerate(['','../a','a/../b','a//b','/a','a\\b','C:a','a/','./a','a\x00b']):
|
|
107
|
+
def reject(self,value=value):
|
|
108
|
+
with self.assertRaises(OpsError):core.relative(value)
|
|
109
|
+
setattr(CoreTests,f'test_relative_reject_{index:02d}',reject)
|
|
110
|
+
for index,value in enumerate(['app-a','mysql-main','a1','host-22']):
|
|
111
|
+
def valid(self,value=value):self.assertEqual(core.identifier(value),value)
|
|
112
|
+
setattr(CoreTests,f'test_identifier_valid_{index:02d}',valid)
|
|
113
|
+
for index,value in enumerate(['','App','a_b','-a','a-','a/b','con','com1','lpt9','a'*81]):
|
|
114
|
+
def reject(self,value=value):
|
|
115
|
+
with self.assertRaises(OpsError):core.identifier(value)
|
|
116
|
+
setattr(CoreTests,f'test_identifier_reject_{index:02d}',reject)
|
|
117
|
+
for index,value in enumerate(['/','/etc','/var','/tmp','/srv','relative','/srv/../etc']):
|
|
118
|
+
def reject(self,value=value):
|
|
119
|
+
with self.assertRaises(OpsError):core.root_path(value,'linux')
|
|
120
|
+
setattr(CoreTests,f'test_posix_root_reject_{index:02d}',reject)
|
|
121
|
+
for index,value in enumerate(['C:\\','C:\\Windows','C:\\Users',r'\\server\share\ops',r'C:\Ops\..\data',r'C:\Ops:stream']):
|
|
122
|
+
def reject(self,value=value):
|
|
123
|
+
with self.assertRaises(OpsError):core.root_path(value,'windows')
|
|
124
|
+
setattr(CoreTests,f'test_windows_root_reject_{index:02d}',reject)
|
|
125
|
+
|
|
126
|
+
class ResourceTests(Fixture):
|
|
127
|
+
def test_empty_v3_valid(self):model.validate_status(core.empty_status())
|
|
128
|
+
def test_unknown_schema_field(self):
|
|
129
|
+
s=model.load(self.state);s['secret']='bad'
|
|
130
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
131
|
+
def test_legacy_blocked(self):
|
|
132
|
+
with self.assertRaises(OpsError):model.validate_status({'schema_version':2,'active':[],'archived':[]})
|
|
133
|
+
def test_credential_version_immutable(self):
|
|
134
|
+
with self.assertRaises(OpsError):model.put_credential(self.state,{'credential_id':'app-auth','version':1,'purpose':'x','values':{'username':'u','password':'changed'}})
|
|
135
|
+
def test_credential_permissions_rejected(self):
|
|
136
|
+
if os.name=='nt':self.skipTest('POSIX mode test')
|
|
137
|
+
p=self.state/'private/credentials.json';p.chmod(0o644)
|
|
138
|
+
with self.assertRaises(OpsError):model.ledger_load(self.state)
|
|
139
|
+
def test_valid_shared_graph(self):model.validate_status(self.graph())
|
|
140
|
+
def test_duplicate_physical_host(self):
|
|
141
|
+
s=model.load(self.state);s['hosts']['node-b']={**self.host,'host_id':'node-b'}
|
|
142
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
143
|
+
def test_host_root_nested(self):
|
|
144
|
+
s=model.load(self.state);s['hosts']['node-b']={**self.host,'host_id':'node-b','root':str(self.target/'nested')}
|
|
145
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
146
|
+
def test_identity_platform_mismatch(self):
|
|
147
|
+
other='linux' if self.platform=='windows' else 'windows'
|
|
148
|
+
other_root='/srv/ops' if other=='linux' else r'C:\Ops'
|
|
149
|
+
s=model.load(self.state);s['hosts']['node-b']={**self.host,'host_id':'node-b','platform':other,'root':other_root}
|
|
150
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
151
|
+
def test_dedicated_cannot_claim_shared_provider(self):
|
|
152
|
+
s=self.graph();b=s['bindings']['app-db-binding'];b.update(mode='dedicated',allocation_id=None)
|
|
153
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
154
|
+
def test_project_root_cannot_override(self):
|
|
155
|
+
s=self.graph();s['deployments']['app-a-prod']['root']=str(self.target/'elsewhere')
|
|
156
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
157
|
+
def test_native_data_outside_root(self):
|
|
158
|
+
s=self.graph();s['deployments']['app-a-prod']['storage']=[{'component':'app','purpose':'data','path':'/tmp/data'}]
|
|
159
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
160
|
+
def test_flat_instances_overlap(self):
|
|
161
|
+
s=self.graph();d=copy.deepcopy(s['deployments']['app-a-prod']);d.update(deployment_id='app-a-test',layout='instances',environment='test',root=str(self.target/'app-a/instances/test/main'));s['deployments'][d['deployment_id']]=d
|
|
162
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
163
|
+
def test_orphan_provider(self):
|
|
164
|
+
s=self.graph();s['allocations']['app-db']['provider_deployment_id']='missing'
|
|
165
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
166
|
+
def test_duplicate_allocation(self):
|
|
167
|
+
s=self.graph();s['allocations']['app-db-copy']={**s['allocations']['app-db'],'allocation_id':'app-db-copy'}
|
|
168
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
169
|
+
def test_wrong_binding_password_version(self):
|
|
170
|
+
s=self.graph();s['bindings']['app-db-binding']['credential_ref']='app-auth@2'
|
|
171
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
172
|
+
def test_external_cannot_claim_managed_data(self):
|
|
173
|
+
s=self.graph();s['bindings']['app-db-binding']['mode']='external'
|
|
174
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
175
|
+
def test_retired_provider_allocation(self):
|
|
176
|
+
s=self.graph();s['allocations']['app-db']['status']='retired'
|
|
177
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
178
|
+
def test_cross_environment_not_implicit(self):
|
|
179
|
+
s=self.graph();s['allocations']['app-db']['environment']='test'
|
|
180
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
181
|
+
def test_unknown_project_kind(self):
|
|
182
|
+
s=self.graph();s['projects']['mysql-main']['kind']='other'
|
|
183
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
184
|
+
def test_strict_docker_cannot_be_disabled(self):
|
|
185
|
+
s=self.graph();s['policies']['strict_docker_root']=False
|
|
186
|
+
with self.assertRaises(OpsError):model.validate_status(s)
|
|
187
|
+
def test_provider_admin_app_separation(self):
|
|
188
|
+
s=self.graph();dep=s['deployments']['mysql-prod'];a=s['allocations']['app-db'];p={'adapter':'mysql','admin_credential_ref':'app-auth@1','app_username':'app-user'}
|
|
189
|
+
with self.assertRaises(OpsError):services.allocation_operation(s,dep,a,p)
|
|
190
|
+
def test_mysql_no_global_admin_grants(self):
|
|
191
|
+
s=self.graph();p={'adapter':'mysql','admin_credential_ref':'mysql-admin@1','app_username':'app-user','privileges':['SUPER']}
|
|
192
|
+
with self.assertRaises(OpsError):services.allocation_operation(s,s['deployments']['mysql-prod'],s['allocations']['app-db'],p)
|
|
193
|
+
def test_mysql_explicit_scoped_grants(self):
|
|
194
|
+
s=self.graph();p={'adapter':'mysql','admin_credential_ref':'mysql-admin@1','app_username':'app-user','privileges':['SELECT']}
|
|
195
|
+
op=services.allocation_operation(s,s['deployments']['mysql-prod'],s['allocations']['app-db'],p)
|
|
196
|
+
self.assertEqual(op['privileges'],['SELECT']);self.assertEqual(op['kind'],'mysql-allocation')
|
|
197
|
+
def test_source_docs_preserve_deployment_revision(self):
|
|
198
|
+
s=self.graph();s['projects']['app-a']['source']['revision']='newer-project-source';text=docs.deployment_readme(s,s['deployments']['app-a-prod'],'run-test',core.now())
|
|
199
|
+
self.assertNotIn('newer-project-source',text);self.assertIn('v1',text)
|
|
200
|
+
|
|
201
|
+
class PlannerTests(Fixture):
|
|
202
|
+
def test_plan_writes_no_target(self):
|
|
203
|
+
info,plan=self.plan();self.assertFalse(self.target.exists());self.assertNotIn(PASSWORD,Path(info['report']).read_text());self.assertEqual(plan['registry_digest'],digest(model.load(self.state)))
|
|
204
|
+
def test_missing_health_rejected(self):
|
|
205
|
+
s=self.spec();s['deployments'][0]['health']=[]
|
|
206
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
207
|
+
def test_unknown_spec_field_rejected(self):
|
|
208
|
+
s=self.spec();s['do_anything']=True
|
|
209
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
210
|
+
def test_inline_native_code_rejected(self):
|
|
211
|
+
s=self.spec();s['deployments'][0]['native']['argv']=[sys.executable,'-c','print(1)']
|
|
212
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
213
|
+
def test_native_outside_arg_rejected(self):
|
|
214
|
+
s=self.spec();s['deployments'][0]['native']['argv']=[sys.executable,'/tmp/other.py']
|
|
215
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
216
|
+
def test_env_path_escape_rejected(self):
|
|
217
|
+
s=self.spec();s['deployments'][0]['env']['app.env']['APP_DATA_DIR']='/tmp/app-data'
|
|
218
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
219
|
+
def test_env_reserved_root_rejected(self):
|
|
220
|
+
s=self.spec();s['deployments'][0]['env']['app.env']['OPS_DATA_ROOT']='{{data}}'
|
|
221
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
222
|
+
def test_native_multiple_env_requires_order(self):
|
|
223
|
+
s=self.spec();s['deployments'][0]['env']['extra.env']={'EXTRA':'1'}
|
|
224
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
225
|
+
def test_unknown_env_file_rejected(self):
|
|
226
|
+
s=self.spec();s['deployments'][0]['native']['env_files']=['missing.env']
|
|
227
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
228
|
+
def test_env_vars_reach_native_command(self):
|
|
229
|
+
info,p=self.plan();commands=[x for x in p['operations'] if x['kind']=='command'];self.assertIn('APP_PASSWORD',commands[0]['env']);self.assertTrue(commands[0]['env']['HOME'].startswith(str(self.target/'app-a')))
|
|
230
|
+
def test_host_command_requires_writes(self):
|
|
231
|
+
s={'schema_version':1,'worker':'H','operation':'prepare','reason':'test','rollback_note':'test','host_actions':[{'host_id':'node-a','kind':'command','reason':'test','argv':['echo','safe']} ]}
|
|
232
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
233
|
+
def test_host_command_requires_verification(self):
|
|
234
|
+
s={'schema_version':1,'worker':'H','operation':'prepare','reason':'test','rollback_note':'test','host_actions':[{'host_id':'node-a','kind':'command','reason':'test','argv':['echo','safe'],'writes':[str(self.target/'_host/x')]}]}
|
|
235
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
236
|
+
def test_project_file_traversal(self):
|
|
237
|
+
s=self.spec();s['deployments'][0]['files'][0]['path']='../else.py'
|
|
238
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
239
|
+
def test_plaintext_readme_cannot_be_arbitrary_file(self):
|
|
240
|
+
s=self.spec();s['deployments'][0]['files'].append({'path':'README.md','content':'fake success'})
|
|
241
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
242
|
+
def test_resource_update_root_fixed(self):
|
|
243
|
+
s=self.spec();s['resource_updates']={'hosts':[{**self.host,'root':str(self.base/'else')}]}
|
|
244
|
+
with self.assertRaises(OpsError):self.plan(s)
|
|
245
|
+
def test_resource_source_update_planned(self):
|
|
246
|
+
s=self.spec();p=copy.deepcopy(self.project);p['source']['revision']='v2';s['resource_updates']={'projects':[p]};info,plan=self.plan(s)
|
|
247
|
+
self.assertEqual(plan['registry_after']['deployments']['app-a-prod']['source']['revision'],'v2');self.assertEqual(model.load(self.state)['projects']['app-a']['source']['revision'],'v1')
|
|
248
|
+
def test_wrong_approval_digest(self):
|
|
249
|
+
info,p=self.plan()
|
|
250
|
+
with self.assertRaises(OpsError):execution.approval(self.state,info['run_id'],'0'*64,'test','explicit statement')
|
|
251
|
+
def test_state_drift_blocks_approval(self):
|
|
252
|
+
info,p=self.plan();model.register(self.state,{})
|
|
253
|
+
with self.assertRaises(OpsError):self.approve(info)
|
|
254
|
+
def test_approval_is_immutable(self):
|
|
255
|
+
info,p=self.plan();self.approve(info)
|
|
256
|
+
with self.assertRaises(OpsError):self.approve(info)
|
|
257
|
+
def test_plan_tamper_blocks_apply(self):
|
|
258
|
+
info,p=self.plan();self.approve(info);p['reason']='changed';write_json(Path(info['plan_path']),p)
|
|
259
|
+
with self.assertRaises(OpsError):execution.apply(self.state,info['run_id'])
|
|
260
|
+
def test_engine_drift_blocks_apply(self):
|
|
261
|
+
info,p=self.plan();self.approve(info)
|
|
262
|
+
with patch('opslib.execution.engine_digest',return_value='0'*64):
|
|
263
|
+
with self.assertRaises(OpsError):execution.apply(self.state,info['run_id'])
|
|
264
|
+
def test_credential_drift_blocks_apply(self):
|
|
265
|
+
info,p=self.plan();self.approve(info);l=model.ledger_load(self.state);l['entries']['app-auth']['1']['values']['password']='changed';write_json(self.state/'private/credentials.json',l)
|
|
266
|
+
with self.assertRaises(OpsError):execution.apply(self.state,info['run_id'])
|
|
267
|
+
def test_existing_unowned_project_not_overwritten(self):
|
|
268
|
+
(self.target/'app-a').mkdir(parents=True)
|
|
269
|
+
with self.assertRaises(OpsError):self.plan()
|
|
270
|
+
def test_compose_requires_digest(self):
|
|
271
|
+
with self.assertRaises(OpsError):planner.compose_model({'compose':{'services':{'app':{'image':'example:latest'}}}},self.host,str(self.target/'app-a'),'ops-app-a',{}, {})
|
|
272
|
+
def test_compose_rejects_named_volume(self):
|
|
273
|
+
with self.assertRaises(OpsError):planner.compose_model({'compose':{'services':{'app':{'image':'example@sha256:'+'a'*64,'volumes':[{'type':'volume','source':'named','target':'/data'}]}}}},self.host,str(self.target/'app-a'),'ops-app-a',{}, {})
|
|
274
|
+
def test_compose_rejects_cross_app_data(self):
|
|
275
|
+
with self.assertRaises(OpsError):planner.compose_model({'compose':{'services':{'app':{'image':'example@sha256:'+'a'*64,'volumes':[{'type':'bind','source':str(self.target/'app-b/data/a/b'),'target':'/data'}]}}}},self.host,str(self.target/'app-a'),'ops-app-a',{}, {})
|
|
276
|
+
def test_compose_rejects_writable_root(self):
|
|
277
|
+
with self.assertRaises(OpsError):planner.compose_model({'compose':{'services':{'app':{'image':'example@sha256:'+'a'*64,'read_only':False}}}},self.host,str(self.target/'app-a'),'ops-app-a',{}, {})
|
|
278
|
+
def test_compose_safe_persistence_model(self):
|
|
279
|
+
value,mounts=planner.compose_model({'compose':{'services':{'app':{'image':'example@sha256:'+'a'*64,'volumes':[{'type':'bind','source':'data/app/storage','target':'/data'}]}}}},self.host,str(self.target/'app-a'),'ops-app-a',{'app.env':{}},{})
|
|
280
|
+
self.assertTrue(value['services']['app']['read_only']);self.assertFalse(value['services']['app']['volumes'][0]['bind']['create_host_path']);self.assertEqual(value['services']['app']['env_file'][0]['format'],'raw')
|
|
281
|
+
|
|
282
|
+
class LocalExecutionTests(Fixture):
|
|
283
|
+
"""Real local target-agent subprocesses and disk writes. Planning inventory only is simulated to keep tests bounded."""
|
|
284
|
+
def test_real_native_and_dual_docs(self):
|
|
285
|
+
info,p,result=self.run_spec();self.assertEqual(result['status'],'completed',result)
|
|
286
|
+
dep=model.load(self.state)['deployments']['app-a-prod'];self.assertEqual(dep['observed_version'],'v1')
|
|
287
|
+
remote=self.target/'app-a';local=self.state/'hosts/node-a/deployments/app-a-prod'
|
|
288
|
+
self.assertNotIn(PASSWORD,(remote/'README.md').read_text());self.assertIn(PASSWORD,(remote/'OPERATIONS.md').read_text());self.assertIn(PASSWORD,(local/'README.md').read_text())
|
|
289
|
+
self.assertEqual(read_json(local/'docs-receipt.json')['status'],'both-sides-verified');self.assertEqual((remote/'env/app.env').read_bytes(),(local/'server-files/env/app.env').read_bytes())
|
|
290
|
+
self.assertEqual(read_json(remote/'data/app/storage/value.json')['count'],1)
|
|
291
|
+
for f in (Path(info['plan_path']).parent/'journal.jsonl',Path(info['plan_path']).parent/'execution.json'):self.assertNotIn(PASSWORD,f.read_text())
|
|
292
|
+
def test_upgrade_and_rollback_preserve_data(self):
|
|
293
|
+
_,_,r=self.run_spec();self.assertEqual(r['status'],'completed')
|
|
294
|
+
_,_,r=self.run_spec(self.spec('v2','upgrade'));self.assertEqual(r['status'],'completed',r)
|
|
295
|
+
_,_,r=self.run_spec(self.spec('v1','rollback'));self.assertEqual(r['status'],'completed',r)
|
|
296
|
+
value=read_json(self.target/'app-a/data/app/storage/value.json');self.assertEqual(value,{'count':3,'version':'v1'});self.assertEqual(len(list((self.target/'app-a/releases').iterdir())),3)
|
|
297
|
+
def test_retirement_preserves_data_and_credentials(self):
|
|
298
|
+
_,_,r=self.run_spec();self.assertEqual(r['status'],'completed')
|
|
299
|
+
_,p,r=self.run_spec({'schema_version':1,'worker':'D','operation':'uninstall','reason':'Stop only the disposable app','retire_deployments':['app-a-prod'],'rollback_note':'All data and credentials retained.'})
|
|
300
|
+
self.assertEqual(r['status'],'completed',r);self.assertEqual(model.load(self.state)['deployments']['app-a-prod']['status'],'retired');self.assertTrue((self.target/'app-a/data/app/storage/value.json').exists());self.assertTrue((self.target/'app-a/env/app.env').exists())
|
|
301
|
+
self.assertFalse(any(o['kind'] in ('quarantine','purge-quarantine') for o in p['operations']))
|
|
302
|
+
def test_second_apply_blocked_resume_completed_no_repeat(self):
|
|
303
|
+
info,p,r=self.run_spec();self.assertEqual(r['status'],'completed')
|
|
304
|
+
with self.assertRaises(OpsError):execution.apply(self.state,info['run_id'])
|
|
305
|
+
r=execution.apply(self.state,info['run_id'],resume=True);self.assertTrue(r['unchanged']);self.assertEqual(read_json(self.target/'app-a/data/app/storage/value.json')['count'],1)
|
|
306
|
+
def test_docs_retry_does_not_repeat_native_command(self):
|
|
307
|
+
info,p=self.plan();self.approve(info);real=transport.call;failed=False
|
|
308
|
+
def interrupted(host,request,**kw):
|
|
309
|
+
nonlocal failed
|
|
310
|
+
if request.get('action')=='step' and request.get('operation',{}).get('step_id','').startswith('docs-') and not failed:
|
|
311
|
+
failed=True;raise UnknownResult('simulated document transport interruption before dispatch')
|
|
312
|
+
return real(host,request,**kw)
|
|
313
|
+
with patch('opslib.execution.call',side_effect=interrupted):r=execution.apply(self.state,info['run_id'])
|
|
314
|
+
self.assertEqual(r['status'],'docs_pending',r);self.assertTrue((self.state/'hosts/node-a/deployments/app-a-prod/README.md').exists())
|
|
315
|
+
r=execution.apply(self.state,info['run_id'],docs_only=True);self.assertEqual(r['status'],'completed',r);self.assertEqual(read_json(self.target/'app-a/data/app/storage/value.json')['count'],1)
|
|
316
|
+
def test_actual_failed_command_not_replayed(self):
|
|
317
|
+
info,p,r=self.run_spec(self.spec(program='raise SystemExit(7)\n'));self.assertEqual(r['status'],'partial',r)
|
|
318
|
+
dep=model.load(self.state)['deployments']['app-a-prod'];self.assertEqual(dep['status'],'failed');self.assertIsNone(dep['observed_version'])
|
|
319
|
+
again=execution.apply(self.state,info['run_id'],resume=True);self.assertEqual(again['status'],'failed');self.assertIn('not replayed',json.dumps(again))
|
|
320
|
+
def test_unknown_identity_blocks_target_before_data(self):
|
|
321
|
+
info,p=self.plan();self.approve(info)
|
|
322
|
+
# Identity mismatch occurs in real target agent; this simulates an incorrectly registered machine, not SSH execution.
|
|
323
|
+
wrong={**self.host,'identity':'0'*64}
|
|
324
|
+
with self.assertRaises(OpsError):transport.call(wrong,{'action':'probe'})
|
|
325
|
+
self.assertFalse(self.target.exists())
|
|
326
|
+
def test_remote_file_drift_stops_before_overwrite(self):
|
|
327
|
+
info,p=self.plan();self.approve(info);dest=self.target/'app-a/env/app.env';dest.parent.mkdir(parents=True);dest.write_text('UNAPPROVED=1\n')
|
|
328
|
+
# Claiming an unowned root is not silently allowed. The first target lock must block before file mutation.
|
|
329
|
+
r=execution.apply(self.state,info['run_id']);self.assertEqual(r['status'],'failed');self.assertEqual(dest.read_text(),'UNAPPROVED=1\n')
|
|
330
|
+
def test_journal_tamper_detected(self):
|
|
331
|
+
info,p,r=self.run_spec();j=Path(info['plan_path']).parent/'journal.jsonl';rows=j.read_text().splitlines();value=json.loads(rows[0]);value['kind']='tampered';rows[0]=json.dumps(value);j.write_text('\n'.join(rows)+'\n')
|
|
332
|
+
with self.assertRaises(OpsError):execution.verify_journal(j)
|
|
333
|
+
def test_quarantine_then_separate_purge(self):
|
|
334
|
+
hspec={'schema_version':1,'worker':'H','operation':'maintain','hosts':['node-a'],'reason':'Disposable cache fixture','rollback_note':'Keep isolated cache until separate purge is approved.', 'host_actions':[{'host_id':'node-a','kind':'write-file','reason':'Create only fixture cache','path':'_host/cache/demo/cache.txt','content':'Disposable cache'}]}
|
|
335
|
+
_,_,r=self.run_spec(hspec);self.assertEqual(r['status'],'completed',r)
|
|
336
|
+
hspec['host_actions']=[{'host_id':'node-a','kind':'quarantine','reason':'Explicit isolate','path':str(self.target/'_host/cache/demo/cache.txt')}]
|
|
337
|
+
info,plan,r=self.run_spec(hspec);self.assertEqual(r['status'],'completed',r)
|
|
338
|
+
q=self.target/'_host/quarantine'/info['run_id']/'item-0001';self.assertTrue(q.exists())
|
|
339
|
+
hspec['host_actions']=[{'host_id':'node-a','kind':'purge-quarantine','reason':'Separate irreversible deletion of this one approved cache fixture','path':str(q)}]
|
|
340
|
+
_,_,r=self.run_spec(hspec);self.assertEqual(r['status'],'completed',r);self.assertFalse(q.exists())
|
|
341
|
+
|
|
342
|
+
class TargetAgentTests(unittest.TestCase):
|
|
343
|
+
def setUp(self):
|
|
344
|
+
self.t=tempfile.TemporaryDirectory(prefix='ops-agent-test-');self.root=Path(self.t.name)/'target';self.req={'action':'lock','root':str(self.root),'identity':agent.fingerprint(),'host_id':'node-a','run_id':'run-test','controller_id':'control-a','plan_digest':'a'*64,'external_files':[]};agent.main(self.req)
|
|
345
|
+
def tearDown(self):self.t.cleanup()
|
|
346
|
+
def step(self,op):return agent.main({**self.req,'action':'step','operation':op,'operation_digest':digest(op)})
|
|
347
|
+
def test_same_receipt_is_not_reexecuted(self):
|
|
348
|
+
op={'step_id':'step-1','kind':'write','path':str(self.root/'a.txt'),'content_b64':base64.b64encode(b'A').decode(),'expected':{'kind':'absent'}}
|
|
349
|
+
one=self.step(op);two=self.step(op);self.assertEqual(one,two)
|
|
350
|
+
def test_started_only_is_unknown(self):
|
|
351
|
+
p=self.root/'_host/runs/run-test/receipts';p.mkdir(parents=True);(p/'step-1.started.json').write_text('{}')
|
|
352
|
+
result=self.step({'step_id':'step-1','kind':'mkdir','path':str(self.root/'x')});self.assertEqual(result['status'],'unknown');self.assertFalse((self.root/'x').exists())
|
|
353
|
+
def test_target_lock_other_owner_rejected(self):
|
|
354
|
+
with self.assertRaises(agent.Failure):agent.main({**self.req,'run_id':'run-other'})
|
|
355
|
+
def test_write_beyond_root_fails(self):
|
|
356
|
+
result=self.step({'step_id':'step-1','kind':'write','path':str(self.root.parent/'outside'),'content_b64':'WA==','expected':{'kind':'absent'}});self.assertEqual(result['status'],'failed')
|
|
357
|
+
def test_identical_bytes_still_secure_permissions(self):
|
|
358
|
+
p=self.root/'secret';p.write_text('X');p.chmod(0o644)
|
|
359
|
+
result=self.step({'step_id':'step-1','kind':'write','path':str(p),'content_b64':'WA==','expected':agent.file_state(p),'mode':0o600});self.assertEqual(result['status'],'succeeded')
|
|
360
|
+
if os.name!='nt':self.assertEqual(p.stat().st_mode&0o777,0o600)
|
|
361
|
+
def test_quarantine_data_forbidden(self):
|
|
362
|
+
p=self.root/'app-a/data/logs/a';p.parent.mkdir(parents=True);p.write_text('business')
|
|
363
|
+
result=self.step({'step_id':'step-1','kind':'quarantine','path':str(p),'item_id':'item-1','expected':agent.file_state(p)});self.assertEqual(result['status'],'failed');self.assertTrue(p.exists())
|
|
364
|
+
def test_purge_needs_isolation_receipt(self):
|
|
365
|
+
p=self.root/'_host/quarantine/run-old/item-1';p.parent.mkdir(parents=True);p.write_text('unknown')
|
|
366
|
+
result=self.step({'step_id':'step-1','kind':'purge-quarantine','path':str(p),'expected':agent.tree_state(p)});self.assertEqual(result['status'],'failed')
|
|
367
|
+
def test_mirror_requires_https(self):
|
|
368
|
+
with self.assertRaises(agent.Failure):agent.benchmark_mirrors({'candidates':[{'id':'x','ecosystem':'python','url':'http://example.invalid/a','expected_sha256':'0'*64,'trust':'official','approved':True}]})
|
|
369
|
+
def test_mirror_requires_approval(self):
|
|
370
|
+
with self.assertRaises(agent.Failure):agent.benchmark_mirrors({'candidates':[{'id':'x','ecosystem':'python','url':'https://example.invalid/a','expected_sha256':'0'*64,'trust':'official','approved':False}]})
|
|
371
|
+
def test_analyze_does_not_run_repo(self):
|
|
372
|
+
source=self.root/'source';source.mkdir();(source/'package.json').write_text('{"scripts":{"postinstall":"NEVER-RUN"}}');(source/'.env').write_text(PASSWORD)
|
|
373
|
+
result=analyze(source);self.assertEqual(result['detected'],['node']);self.assertNotIn(PASSWORD,json.dumps(result));self.assertEqual(len(result['evidence']),1)
|
|
374
|
+
|
|
375
|
+
class SSHContractTests(unittest.TestCase):
|
|
376
|
+
"""Transport argv/request construction simulation, not a live SSH acceptance test."""
|
|
377
|
+
def test_strict_hostkey_no_secrets_in_argv(self):
|
|
378
|
+
with tempfile.TemporaryDirectory() as d:
|
|
379
|
+
kh=Path(d)/'known_hosts';kh.write_text('fixture pinned key\n')
|
|
380
|
+
h={'host_id':'node-a','platform':'linux','transport':'ssh','identity':'a'*64,'connection':{'hostname':'host.invalid','username':'ops','known_hosts':str(kh),'python':'python3'}}
|
|
381
|
+
captured={}
|
|
382
|
+
def fake(argv,**kw):
|
|
383
|
+
captured.update(argv=argv,**kw)
|
|
384
|
+
return type('Result',(),{'returncode':0,'stdout':b'{"ok":true,"result":{"scope":"simulated"}}','stderr':b''})()
|
|
385
|
+
with patch('opslib.transport.subprocess.run',side_effect=fake):transport.call(h,{'action':'step','secrets':[PASSWORD]})
|
|
386
|
+
self.assertIn('StrictHostKeyChecking=yes',captured['argv']);self.assertIn('BatchMode=yes',captured['argv']);self.assertNotIn(PASSWORD,' '.join(captured['argv']));self.assertIn('UserKnownHostsFile='+str(kh),captured['argv'])
|
|
387
|
+
def test_knownhosts_digest_drift(self):
|
|
388
|
+
with tempfile.TemporaryDirectory() as d:
|
|
389
|
+
kh=Path(d)/'known_hosts';kh.write_text('first')
|
|
390
|
+
h={'transport':'ssh','connection':{'known_hosts':str(kh)}};before=transport.host_transport_digest(h);kh.write_text('second');self.assertNotEqual(before,transport.host_transport_digest(h))
|
|
391
|
+
|
|
392
|
+
if __name__=='__main__':unittest.main(verbosity=2)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
param([switch]$Probe,[string]$Apply,[string]$Sha256,[string]$Approval)
|
|
2
|
+
$ErrorActionPreference='Stop'
|
|
3
|
+
if (-not $Apply) {
|
|
4
|
+
Write-Output 'OPS bootstrap inventory (read-only; no Python prerequisite)'
|
|
5
|
+
Get-CimInstance Win32_OperatingSystem | Select-Object Caption,Version,OSArchitecture,FreePhysicalMemory,TotalVisibleMemorySize
|
|
6
|
+
'ssh','git','python','py','uv','node','npm','java','docker','volta' | ForEach-Object {
|
|
7
|
+
$c=Get-Command $_ -ErrorAction SilentlyContinue
|
|
8
|
+
[pscustomobject]@{Tool=$_;Path=if($c){$c.Source}else{'missing'}}
|
|
9
|
+
}
|
|
10
|
+
exit 0
|
|
11
|
+
}
|
|
12
|
+
if ($Approval -ne 'I-APPROVE-THIS-BOOTSTRAP') { throw 'Explicit bootstrap approval is required' }
|
|
13
|
+
$f=Get-Item -LiteralPath $Apply
|
|
14
|
+
if ($f.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse-point installer is not accepted' }
|
|
15
|
+
if ((Get-FileHash -LiteralPath $Apply -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Sha256.ToLowerInvariant()) { throw 'Installer changed since review' }
|
|
16
|
+
& $f.FullName
|
|
17
|
+
if (-not $?) { throw 'Bootstrap installer failed' }
|
|
18
|
+
if (-not (Get-Command python -ErrorAction SilentlyContinue) -and -not (Get-Command py -ErrorAction SilentlyContinue)) { throw 'Python still unavailable; not completed' }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# First-stage controller/target bootstrap: POSIX shell only, no Python prerequisite.
|
|
3
|
+
set -eu
|
|
4
|
+
case "${1:---probe}" in
|
|
5
|
+
--probe)
|
|
6
|
+
printf 'OPS bootstrap inventory (read-only)\n'
|
|
7
|
+
uname -srm
|
|
8
|
+
for tool in ssh git python3 python uv node npm java docker volta curl wget sha256sum shasum; do
|
|
9
|
+
if command -v "$tool" >/dev/null 2>&1; then printf '%s=%s\n' "$tool" "$(command -v "$tool")"; else printf '%s=missing\n' "$tool"; fi
|
|
10
|
+
done
|
|
11
|
+
printf 'No software installed. Review a version-pinned OS/package-manager installer before --apply.\n'
|
|
12
|
+
;;
|
|
13
|
+
--apply)
|
|
14
|
+
# Explicit file+digest approval, no curl|sh, eval, hidden downloads or "latest" installers.
|
|
15
|
+
[ "$#" -eq 4 ] || { echo 'usage: bootstrap.sh --apply reviewed-script.sh SHA256 I-APPROVE-THIS-BOOTSTRAP' >&2; exit 2; }
|
|
16
|
+
[ "$4" = 'I-APPROVE-THIS-BOOTSTRAP' ] || exit 2
|
|
17
|
+
[ -f "$2" ] && [ ! -L "$2" ] || { echo 'Installer must be a reviewed regular file' >&2; exit 2; }
|
|
18
|
+
if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$2" | cut -d' ' -f1)
|
|
19
|
+
elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$2" | cut -d' ' -f1)
|
|
20
|
+
else echo 'No SHA-256 implementation; bootstrap blocked' >&2; exit 2; fi
|
|
21
|
+
[ "$actual" = "$3" ] || { echo 'Installer changed after review' >&2; exit 2; }
|
|
22
|
+
printf 'Executing explicitly approved bootstrap sha256=%s\n' "$actual"
|
|
23
|
+
/bin/sh "$2"
|
|
24
|
+
command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1 || { echo 'Python still missing; not completed' >&2; exit 2; }
|
|
25
|
+
;;
|
|
26
|
+
*) echo 'usage: bootstrap.sh --probe | --apply FILE SHA256 I-APPROVE-THIS-BOOTSTRAP' >&2; exit 2;;
|
|
27
|
+
esac
|