@namewta/speculo 1.0.6 → 1.0.8

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.
Files changed (57) hide show
  1. package/dist/src/ops-resources.js +1 -1
  2. package/dist/src/ops-resources.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  5. package/template/workflows/ops/H-host-manage/H-host-manage.md +5 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +8 -5
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +25 -25
  10. package/template/workflows/ops/common/examples/README.md +1 -1
  11. package/template/workflows/ops/common/examples/register.example.json +1 -1
  12. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  13. package/template/workflows/ops/common/schemas/host.schema.json +65 -2
  14. package/template/workflows/ops/common/schemas/plan.schema.json +315 -3
  15. package/template/workflows/ops/common/schemas/spec.schema.json +137 -1
  16. package/template/workflows/ops/common/schemas/status.schema.json +143 -1
  17. package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
  18. package/template/workflows/ops/common/service-profiles/redis.md +4 -0
  19. package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
  20. package/template/workflows/ops/common/templates/HOST-README.md +12 -2
  21. package/template/workflows/ops/common/tests/test_ops.mjs +982 -0
  22. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  23. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  24. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  25. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  26. package/template/workflows/ops/common/tools/opslib/agent.mjs +904 -0
  27. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  28. package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
  29. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  30. package/template/workflows/ops/common/tools/opslib/docs.mjs +436 -0
  31. package/template/workflows/ops/common/tools/opslib/execution.mjs +396 -0
  32. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  33. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  34. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  35. package/template/workflows/ops/common/tools/opslib/planner.mjs +781 -0
  36. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  37. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  38. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  39. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  40. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  41. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  42. package/template/workflows/ops/common/tools/ops.py +0 -7
  43. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  44. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  45. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  46. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  47. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  48. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  49. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  50. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  51. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  52. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  53. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  54. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  55. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  56. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  57. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -1,392 +0,0 @@
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)
@@ -1,64 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Real disposable local deployment. No network, system installation, or existing-root writes."""
3
- from __future__ import annotations
4
- import argparse,json,os,sys
5
- from pathlib import Path
6
- from opslib.cli import initialize
7
- from opslib.core import write_json,OpsError,read_json
8
- from opslib.model import register,put_credential,load
9
- from opslib.transport import probe_local
10
- from opslib.planner import compile_plan
11
- from opslib.execution import approval,apply
12
-
13
- def run(output:Path)->dict:
14
- output=output.absolute()
15
- if output.exists() and any(output.iterdir()):raise OpsError("demo output must be a new/empty directory, never your real state or target root")
16
- output.mkdir(parents=True,exist_ok=True)
17
- state=output/'controller';target=output/'server'
18
- initialize(state,'demo-controller')
19
- inventory=probe_local()
20
- platform=inventory['platform']
21
- if platform not in ('linux','darwin','windows'):raise OpsError('unsupported platform')
22
- register(state,{'hosts':[{'host_id':'demo-local','display_name':'Disposable local demo','platform':platform,'transport':'local','connection':{},'root':str(target),'identity':inventory['identity']}],
23
- 'projects':[{'project_id':'app-a','display_name':'Demo APP A','kind':'app','service_type':None,'source':{'type':'local','location':str(output/'fixture-source'),'revision':'demo-v1'}}]})
24
- password='DEMO-ONLY-$literal-quote\"-not-a-real-password'
25
- put_credential(state,{'credential_id':'demo-auth','version':1,'purpose':'Disposable example only; never use in production','values':{'username':'demo-admin','password':password}})
26
- program="""import os,json,pathlib
27
- root=pathlib.Path(os.environ['OPS_DATA_ROOT'])/'app/storage'
28
- root.mkdir(parents=True,exist_ok=True)
29
- assert os.environ['APP_USERNAME']=='demo-admin'
30
- assert os.environ['APP_PASSWORD'].startswith('DEMO-ONLY-')
31
- p=root/'record.json'
32
- p.write_text(json.dumps({'version':'demo-v1','count':1,'environment_injected':True}))
33
- """
34
- spec={'schema_version':1,'worker':'D','operation':'deploy','reason':'Explicit disposable local demo; only this new output tree is written. No network or system install.',
35
- 'rollback_note':'All files belong to the demo output. Keep evidence; no user data or shared service is touched.',
36
- 'deployments':[{'deployment_id':'app-a-demo','project_id':'app-a','host_id':'demo-local','environment':'demo','instance':'main','layout':'flat','method':'native','version':'demo-v1',
37
- 'files':[{'path':'artifact/main.py','content':program}], 'native':{'supervisor':'oneshot','argv':[sys.executable,'{{artifact}}/main.py']},
38
- 'env':{'app.env':{'APP_USERNAME':'{{credential:demo-auth@1:username}}','APP_PASSWORD':'{{credential:demo-auth@1:password}}'}},'credential_refs':['demo-auth@1'],
39
- 'health':[{'type':'file','path':'data/app/storage/record.json'}],
40
- 'backup':'The finite process exits before backup. Demo has no shared dependencies. Actual backup must be a separately approved action.',
41
- 'recovery':'Keep data/app/storage. Rerunning a different version requires a new approved plan.'}]}
42
- write_json(output/'demo-spec.json',spec)
43
- plan=compile_plan(state,output/'demo-spec.json')
44
- # This script is an explicit opt-in demo limited to a fresh user-designated directory, not production approval automation.
45
- approval(state,plan['run_id'],plan['plan_digest'],'demo-user','I authorize only this fresh disposable demo directory and the exact generated fixture plan.')
46
- result=apply(state,plan['run_id'])
47
- if result['status']!='completed':raise OpsError('demo did not fully complete: '+json.dumps(result))
48
- local=state/'hosts/demo-local/deployments/app-a-demo'
49
- readme=(target/'app-a/README.md').read_text()
50
- assert password not in readme and 'demo-v1' in readme and str(target/'app-a/data/app/storage') in readme
51
- assert password in (local/'README.md').read_text()
52
- assert password in (target/'app-a/OPERATIONS.md').read_text()
53
- assert read_json(local/'docs-receipt.json')['status']=='both-sides-verified'
54
- assert read_json(target/'app-a/data/app/storage/record.json')['environment_injected']
55
- report={**result,'output':str(output),'target_readme':str(target/'app-a/README.md'),'controller_record':str(local/'README.md'),
56
- 'checks':{'runtime_data_in_project':True,'native_env_injection':True,'server_readme_no_password':True,'controller_plaintext_exact':True,'server_operations_plaintext_exact':True,'both_sides_verified':True},
57
- 'scope':'real local filesystem and subprocess fixture; not a Docker/SSH/Windows-service production acceptance test'}
58
- write_json(output/'DEMO-RESULT.json',report)
59
- return report
60
-
61
- if __name__=='__main__':
62
- parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',required=True)
63
- try:print(json.dumps(run(Path(parser.parse_args().output)),ensure_ascii=False,indent=2))
64
- except (OpsError,OSError,AssertionError) as exc:print(str(exc),file=sys.stderr);sys.exit(2)
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env python3
2
- """OPS entry point: Python >=3.10, no third-party packages."""
3
- import sys
4
- if sys.version_info < (3,10):
5
- raise SystemExit("OPS requires Python >=3.10; run bootstrap.sh or bootstrap.ps1 --probe first.")
6
- from opslib.cli import main
7
- if __name__=="__main__":raise SystemExit(main())
@@ -1,2 +0,0 @@
1
- """OPS 2.2 resource-centric runtime."""
2
- __version__="2.2.0"