@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,64 @@
|
|
|
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)
|
|
@@ -0,0 +1,7 @@
|
|
|
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())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Ephemeral local/SSH target agent. No third-party modules or target installation.
|
|
2
|
+
Receives the trusted code and a JSON request on stdin; never executes repository text implicitly.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import base64, csv, datetime, hashlib, io, json, os, pathlib, platform, re, shutil, socket
|
|
6
|
+
import stat, subprocess, sys, tempfile, time, urllib.request, urllib.error, urllib.parse, signal
|
|
7
|
+
|
|
8
|
+
class Failure(Exception): pass
|
|
9
|
+
|
|
10
|
+
def stamp(): return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
11
|
+
def packed(v): return json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
12
|
+
def sha(v): return hashlib.sha256(v if isinstance(v, bytes) else packed(v)).hexdigest()
|
|
13
|
+
def check_id(v):
|
|
14
|
+
if not isinstance(v, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", v): raise Failure("invalid resource id")
|
|
15
|
+
|
|
16
|
+
def no_links(path):
|
|
17
|
+
path = pathlib.Path(path).absolute()
|
|
18
|
+
for q in [*reversed(path.parents), path]:
|
|
19
|
+
try: s = q.lstat()
|
|
20
|
+
except FileNotFoundError: continue
|
|
21
|
+
if stat.S_ISLNK(s.st_mode) or getattr(s, "st_file_attributes", 0) & 0x400: raise Failure("symlink/reparse point: " + str(q))
|
|
22
|
+
|
|
23
|
+
def secure_file(path):
|
|
24
|
+
if os.name != "nt": os.chmod(path, 0o600); return
|
|
25
|
+
p = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True, text=True, check=True)
|
|
26
|
+
sid = next(csv.reader([p.stdout.strip()]))[1]
|
|
27
|
+
subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"*{sid}:F", "*S-1-5-18:F"],
|
|
28
|
+
capture_output=True, check=True)
|
|
29
|
+
|
|
30
|
+
def mkdir(path, mode=0o750):
|
|
31
|
+
no_links(path)
|
|
32
|
+
missing=[]; p=pathlib.Path(path)
|
|
33
|
+
while not p.exists(): missing.append(p); p=p.parent
|
|
34
|
+
for p in reversed(missing): p.mkdir(mode=mode)
|
|
35
|
+
|
|
36
|
+
def atomic(path, data, mode=0o600):
|
|
37
|
+
no_links(path); mkdir(path.parent)
|
|
38
|
+
fd, name = tempfile.mkstemp(prefix=".ops-", dir=path.parent)
|
|
39
|
+
tmp = pathlib.Path(name)
|
|
40
|
+
try:
|
|
41
|
+
if hasattr(os, "fchmod"): os.fchmod(fd, mode)
|
|
42
|
+
with os.fdopen(fd, "wb") as f: f.write(data); f.flush(); os.fsync(f.fileno())
|
|
43
|
+
if os.name == "nt": secure_file(tmp)
|
|
44
|
+
os.replace(tmp, path)
|
|
45
|
+
if os.name != "nt": os.chmod(path, mode)
|
|
46
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
47
|
+
d=os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
48
|
+
try: os.fsync(d)
|
|
49
|
+
finally: os.close(d)
|
|
50
|
+
finally: tmp.unlink(missing_ok=True)
|
|
51
|
+
|
|
52
|
+
def wj(p, v): atomic(p, json.dumps(v, ensure_ascii=False, indent=2).encode()+b"\n")
|
|
53
|
+
def rj(p): no_links(p); return json.loads(p.read_text(encoding="utf-8"))
|
|
54
|
+
def fingerprint():
|
|
55
|
+
stable = ""
|
|
56
|
+
for p in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
|
57
|
+
try: stable=pathlib.Path(p).read_text().strip(); break
|
|
58
|
+
except OSError: pass
|
|
59
|
+
if os.name == "nt":
|
|
60
|
+
try:
|
|
61
|
+
import winreg
|
|
62
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as k:
|
|
63
|
+
stable=winreg.QueryValueEx(k,"MachineGuid")[0]
|
|
64
|
+
except OSError: pass
|
|
65
|
+
if platform.system() == "Darwin" and not stable:
|
|
66
|
+
p=subprocess.run(["ioreg","-rd1","-c","IOPlatformExpertDevice"],capture_output=True,text=True,timeout=5)
|
|
67
|
+
m=re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"',p.stdout)
|
|
68
|
+
if m: stable=m.group(1)
|
|
69
|
+
if not stable: raise Failure("stable machine identity unavailable; explicit identity adapter required")
|
|
70
|
+
return sha({"machine":stable,"platform":platform.system()})
|
|
71
|
+
|
|
72
|
+
def file_state(path, limit=16*1024*1024):
|
|
73
|
+
path=pathlib.Path(path); no_links(path)
|
|
74
|
+
if not path.exists(): return {"kind":"absent"}
|
|
75
|
+
s=path.stat()
|
|
76
|
+
if path.is_file():
|
|
77
|
+
if s.st_size>limit: raise Failure("snapshot exceeds bounded file size: " + str(path))
|
|
78
|
+
result={"kind":"file","sha256":sha(path.read_bytes()),"size":s.st_size,"mode":stat.S_IMODE(s.st_mode)}
|
|
79
|
+
if path.name in (".ops-project.json",".ops-host.json"):result["owner"]=rj(path)
|
|
80
|
+
return result
|
|
81
|
+
if path.is_dir():
|
|
82
|
+
entries=sorted(p.name for p in path.iterdir())
|
|
83
|
+
if len(entries)>10000: raise Failure("directory snapshot exceeds 10000 entries")
|
|
84
|
+
return {"kind":"directory","entries_digest":sha(entries),"entry_count":len(entries)}
|
|
85
|
+
raise Failure("unsupported file type: "+str(path))
|
|
86
|
+
|
|
87
|
+
def tree_state(path):
|
|
88
|
+
root=pathlib.Path(path);no_links(root)
|
|
89
|
+
if not root.exists():raise Failure("quarantine item no longer exists")
|
|
90
|
+
rows=[];total=0
|
|
91
|
+
for p in ([root] if root.is_file() else [root,*root.rglob("*")]):
|
|
92
|
+
no_links(p)
|
|
93
|
+
if len(rows)>=10000:raise Failure("purge manifest exceeds 10000 entries; split a reviewed cleanup")
|
|
94
|
+
info=file_state(p)
|
|
95
|
+
if info["kind"]=="file":total+=info["size"]
|
|
96
|
+
if total>256*1024*1024:raise Failure("purge manifest exceeds 256 MiB; split or use a separately reviewed cleanup adapter")
|
|
97
|
+
rows.append({"path":str(p.relative_to(root)),"state":info})
|
|
98
|
+
return {"kind":"tree","manifest_sha256":sha(sorted(rows,key=lambda x:x["path"])),"logical_bytes":total,"entries":len(rows)}
|
|
99
|
+
|
|
100
|
+
def inventory(req):
|
|
101
|
+
tools={}
|
|
102
|
+
for name,args in (("python3",["--version"]),("python",["--version"]),("uv",["--version"]),
|
|
103
|
+
("java",["-version"]),("node",["--version"]),("npm",["--version"]),
|
|
104
|
+
("volta",["--version"]),("docker",["--version"]),("git",["--version"]),("ssh",["-V"])):
|
|
105
|
+
path=shutil.which(name)
|
|
106
|
+
item={"path":path,"status":"missing"}
|
|
107
|
+
if path:
|
|
108
|
+
try:
|
|
109
|
+
p=subprocess.run([path,*args],capture_output=True,text=True,timeout=8)
|
|
110
|
+
item.update(status="observed" if p.returncode==0 else "failed",version=(p.stdout+p.stderr).strip()[:2000])
|
|
111
|
+
except (OSError,subprocess.TimeoutExpired): item["status"]="unavailable"
|
|
112
|
+
tools[name]=item
|
|
113
|
+
docker_control=None
|
|
114
|
+
if req.get("include_docker"):
|
|
115
|
+
try:
|
|
116
|
+
context=subprocess.run(["docker","context","show"],capture_output=True,text=True,check=True,timeout=20).stdout.strip()
|
|
117
|
+
endpoint=json.loads(subprocess.run(["docker","context","inspect",context,"--format","{{json .Endpoints.docker.Host}}"],capture_output=True,text=True,check=True,timeout=20).stdout.strip())
|
|
118
|
+
info=json.loads(subprocess.run(["docker","--context",context,"info","--format","{{json .}}"],capture_output=True,text=True,check=True,timeout=30).stdout.strip())
|
|
119
|
+
compose=subprocess.run(["docker","--context",context,"compose","version","--short"],capture_output=True,text=True,check=True,timeout=20).stdout.strip()
|
|
120
|
+
docker_control={"status":"observed","context":context,"endpoint":endpoint,"id":info["ID"],"data_root":info["DockerRootDir"],"os_type":info.get("OSType"),"compose_version":compose}
|
|
121
|
+
except (OSError,ValueError,KeyError,subprocess.SubprocessError):docker_control={"status":"unavailable"}
|
|
122
|
+
diagnostics={"issues":[],"memory":{},"disks":{}}
|
|
123
|
+
if pathlib.Path("/proc/meminfo").exists():
|
|
124
|
+
mem={}
|
|
125
|
+
for line in pathlib.Path("/proc/meminfo").read_text().splitlines():
|
|
126
|
+
k,v=line.split(":",1);mem[k]=int(v.strip().split()[0])*1024
|
|
127
|
+
diagnostics["memory"]={k:mem.get(k) for k in ("MemTotal","MemAvailable","Cached","SwapTotal","SwapFree")}
|
|
128
|
+
if mem.get("MemTotal",0) and mem.get("MemAvailable",0)/mem["MemTotal"]<0.05:diagnostics["issues"].append("low-memory-available: diagnose pressure before any cleanup")
|
|
129
|
+
for candidate in req.get("disk_roots",[str(pathlib.Path.home())]):
|
|
130
|
+
p=pathlib.Path(candidate)
|
|
131
|
+
while not p.exists() and p!=p.parent:p=p.parent
|
|
132
|
+
usage=shutil.disk_usage(p);diagnostics["disks"][candidate]={"observed_path":str(p),"total":usage.total,"free":usage.free}
|
|
133
|
+
if usage.free/max(usage.total,1)<0.1:diagnostics["issues"].append("low-disk-free:"+candidate)
|
|
134
|
+
return {"identity":fingerprint(),"observed_at":stamp(),"diagnostics":diagnostics,"docker_control":docker_control,"platform":platform.system().lower(),
|
|
135
|
+
"architecture":platform.machine(),"hostname":socket.gethostname(),"account":os.environ.get("USERNAME",os.environ.get("USER","unknown")),
|
|
136
|
+
"uid":os.geteuid() if hasattr(os,"geteuid") else None,"python":sys.executable,
|
|
137
|
+
"tools":tools,"defaults":{"JAVA_HOME":os.environ.get("JAVA_HOME"),"PATH":os.environ.get("PATH"),
|
|
138
|
+
"SDKMAN_DIR":os.environ.get("SDKMAN_DIR"),"VOLTA_HOME":os.environ.get("VOLTA_HOME")},
|
|
139
|
+
"snapshots":{p:(tree_state(p) if p in req.get("deep_paths",[]) else file_state(p)) for p in req.get("paths",[])}}
|
|
140
|
+
|
|
141
|
+
def under(path, root, allow_root=False):
|
|
142
|
+
p=pathlib.Path(path); r=pathlib.Path(root)
|
|
143
|
+
if not p.is_absolute() or ".." in p.parts: raise Failure("nonabsolute/traversing path")
|
|
144
|
+
try: p.relative_to(r)
|
|
145
|
+
except ValueError: raise Failure("path outside approved root: " + str(p))
|
|
146
|
+
if p==r and not allow_root: raise Failure("operation may not target entire host root")
|
|
147
|
+
no_links(p); return p
|
|
148
|
+
|
|
149
|
+
def checked_path(path, req, *, allow_root=False):
|
|
150
|
+
if path in req.get("external_files",[]):
|
|
151
|
+
if path not in ("/etc/docker/daemon.json",) and not re.fullmatch(r"/etc/systemd/system/ops-[a-z0-9-]+\.service",path):
|
|
152
|
+
raise Failure("unrecognized external control file")
|
|
153
|
+
p=pathlib.Path(path); no_links(p); return p
|
|
154
|
+
return under(path,req["root"],allow_root)
|
|
155
|
+
|
|
156
|
+
def strip_secrets(text, values):
|
|
157
|
+
for v in sorted(set(values),key=len,reverse=True):
|
|
158
|
+
if v: text=text.replace(v,"[REDACTED]")
|
|
159
|
+
return re.sub(r"(?i)(password|passwd|token|secret|access_key)(\s*[=:]\s*)[^\s,;]+",r"\1\2[REDACTED]",text)
|
|
160
|
+
|
|
161
|
+
def command(argv, *, cwd, env=None, stdin=None, timeout=300, secrets=None, success_codes=None):
|
|
162
|
+
if not isinstance(argv,list) or not argv or not all(isinstance(x,str) and "\x00" not in x for x in argv): raise Failure("argv must be a nonempty string array")
|
|
163
|
+
values=secrets or []
|
|
164
|
+
# Temporary files bound output memory. They live under the approved run directory, not OS /tmp.
|
|
165
|
+
run_tmp=pathlib.Path(cwd)/".ops-command-tmp"
|
|
166
|
+
mkdir(run_tmp,0o700)
|
|
167
|
+
out=tempfile.TemporaryFile(dir=run_tmp); err=tempfile.TemporaryFile(dir=run_tmp)
|
|
168
|
+
try:
|
|
169
|
+
p=subprocess.Popen(argv,cwd=cwd,env={**os.environ,**(env or {}),"TMPDIR":str(run_tmp),"TMP":str(run_tmp),"TEMP":str(run_tmp)},
|
|
170
|
+
stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,stdout=out,stderr=err)
|
|
171
|
+
try: p.communicate(input=stdin.encode() if stdin is not None else None,timeout=timeout)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
p.kill(); p.wait(); raise Failure("command timed out; side effects may have occurred, inspect before replanning")
|
|
174
|
+
out.seek(0); err.seek(0)
|
|
175
|
+
raw_out=out.read(2*1024*1024); raw_err=err.read(2*1024*1024)
|
|
176
|
+
result={"exit_code":p.returncode,"stdout":strip_secrets(raw_out.decode("utf-8","replace"),values),
|
|
177
|
+
"stderr":strip_secrets(raw_err.decode("utf-8","replace"),values),"output_sha256":sha(raw_out+raw_err)}
|
|
178
|
+
if p.returncode not in (success_codes or [0]):
|
|
179
|
+
raise Failure("command failed with exit="+str(p.returncode)+"; output_sha256="+result["output_sha256"])
|
|
180
|
+
return result
|
|
181
|
+
finally:
|
|
182
|
+
out.close();err.close()
|
|
183
|
+
try:run_tmp.rmdir()
|
|
184
|
+
except OSError:pass
|
|
185
|
+
|
|
186
|
+
def docker_base(op):
|
|
187
|
+
cmd=[op.get("docker","docker")]
|
|
188
|
+
if op.get("context"): cmd += ["--context",op["context"]]
|
|
189
|
+
return cmd
|
|
190
|
+
|
|
191
|
+
def compose_base(op):
|
|
192
|
+
return docker_base(op)+["compose","--project-name",op["compose_name"],"--project-directory",op["project_root"],
|
|
193
|
+
"--file",str(pathlib.Path(op["project_root"])/"compose/compose.yaml")]
|
|
194
|
+
|
|
195
|
+
def compose_up(op,req):
|
|
196
|
+
root=checked_path(op["project_root"],req); base=compose_base(op)
|
|
197
|
+
docker=docker_base(op)
|
|
198
|
+
info=command(docker+["info","--format","{{json .}}"],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))
|
|
199
|
+
daemon=json.loads(info["stdout"].strip())
|
|
200
|
+
if daemon["ID"]!=op["expected_docker_id"]:raise Failure("Docker daemon identity changed since approval")
|
|
201
|
+
actual=daemon["DockerRootDir"]
|
|
202
|
+
if req.get("strict_docker_root",True):
|
|
203
|
+
under(actual,req["root"])
|
|
204
|
+
if pathlib.Path(actual)!=pathlib.Path(req["root"])/"_runtime/docker": raise Failure("Docker data-root must be registered host_root/_runtime/docker; existing engine migration needs a separate approved host plan")
|
|
205
|
+
command(base+["config","--quiet"],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))
|
|
206
|
+
model=json.loads((root/"compose/compose.yaml").read_text(encoding="utf-8"))
|
|
207
|
+
if any("build" in s for s in model["services"].values()):
|
|
208
|
+
command(base+["build","--pull=false"],cwd=str(root),timeout=op.get("timeout",1200),secrets=req.get("secrets",[]))
|
|
209
|
+
command(base+["pull","--ignore-buildable"],cwd=str(root),timeout=op.get("timeout",1200),secrets=req.get("secrets",[]))
|
|
210
|
+
for name,service in model["services"].items():
|
|
211
|
+
image=service.get("image",op["compose_name"]+"-"+name)
|
|
212
|
+
conf=command(docker+["image","inspect",image,"--format","{{json .Config.Volumes}}"],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))
|
|
213
|
+
declared=json.loads(conf["stdout"].strip()) or {}
|
|
214
|
+
mapped={v["target"] for v in service.get("volumes",[])} | set(service.get("tmpfs",[]))
|
|
215
|
+
if set(declared)-mapped: raise Failure("image declares unmapped VOLUME(s), refusing anonymous persistence: "+str(sorted(set(declared)-mapped)))
|
|
216
|
+
command(base+["up","--detach","--remove-orphans","--wait","--wait-timeout",str(op.get("wait_timeout",120))],cwd=str(root),timeout=op.get("timeout",1200),secrets=req.get("secrets",[]))
|
|
217
|
+
ids=command(base+["ps","--all","--quiet"],cwd=str(root),timeout=30)["stdout"].split()
|
|
218
|
+
if not ids: raise Failure("Compose returned no containers")
|
|
219
|
+
for cid in ids:
|
|
220
|
+
item=json.loads(command(docker+["inspect",cid],cwd=str(root),timeout=30,secrets=req.get("secrets",[]))["stdout"])[0]
|
|
221
|
+
for mount in item.get("Mounts",[]):
|
|
222
|
+
if mount["Type"]=="volume": raise Failure("anonymous/named persistence detected after start; stop and reconcile")
|
|
223
|
+
if mount["Type"]=="bind": under(mount["Source"],str(root))
|
|
224
|
+
return {"containers":ids,"docker_data_root":actual,"compose_name":op["compose_name"],"verified_at":stamp()}
|
|
225
|
+
|
|
226
|
+
def allocation(op,req):
|
|
227
|
+
provider=checked_path(op["provider_root"],req)
|
|
228
|
+
aid=op["allocation_id"];check_id(aid)
|
|
229
|
+
marker=provider/"allocations"/(aid+".json")
|
|
230
|
+
if op.get("compose_name"):
|
|
231
|
+
info=json.loads(command(docker_base(op)+["info","--format","{{json .}}"],cwd=str(provider),timeout=30)["stdout"])
|
|
232
|
+
if info["ID"]!=op["expected_docker_id"]:raise Failure("provider Docker daemon identity drift")
|
|
233
|
+
ownership={"allocation_id":aid,"resource":op["resource"],"app_username":op["app_username"],
|
|
234
|
+
"owner_project_id":op["owner_project_id"],"environment":op["environment"],"credential_ref":op["credential_ref"]}
|
|
235
|
+
if marker.exists():
|
|
236
|
+
if rj(marker)!=ownership:raise Failure("allocation ownership conflict")
|
|
237
|
+
return {"allocation_id":aid,"status":"already-owned-no-password-reset"}
|
|
238
|
+
if op["kind"]=="mysql-allocation":
|
|
239
|
+
base=docker_base(op)+["compose","--project-name",op["compose_name"],"--project-directory",str(provider),"--file",str(provider/"compose/compose.yaml"),
|
|
240
|
+
"exec","-T","-e","MYSQL_PWD",op["compose_service"],"mysql","--batch","--skip-column-names","--user",op["admin_username"]]
|
|
241
|
+
env={"MYSQL_PWD":op["admin_password"]}
|
|
242
|
+
def sql(q):return command(base,cwd=str(provider),env=env,stdin=q,timeout=120,secrets=req.get("secrets",[]))["stdout"].strip()
|
|
243
|
+
db=op["resource"];user=op["app_username"]
|
|
244
|
+
exists=sql("SELECT (SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='"+db+"')+(SELECT COUNT(*) FROM mysql.user WHERE user='"+user+"');\n")
|
|
245
|
+
if exists!="0":raise Failure("database/user already exists without allocation marker; verified adoption required")
|
|
246
|
+
# A server-side hex literal plus QUOTE avoids SQL/argv injection by arbitrary passwords.
|
|
247
|
+
hx=op["app_password"].encode().hex()
|
|
248
|
+
q="CREATE DATABASE `"+db+"`;\nSET @p=CONVERT(0x"+hx+" USING utf8mb4);\nSET @s=CONCAT('CREATE USER ''"+user+"''@''%'' IDENTIFIED BY ',QUOTE(@p));\nPREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;\nGRANT "+",".join(op["privileges"])+" ON `"+db+"`.* TO '"+user+"'@'%';\n"
|
|
249
|
+
sql(q)
|
|
250
|
+
app=[*base[:-1],user,"--database",db] # Replace the admin username, preserving --user.
|
|
251
|
+
command(app,cwd=str(provider),env={"MYSQL_PWD":op["app_password"]},stdin="SELECT DATABASE();\n",timeout=30,secrets=req.get("secrets",[]))
|
|
252
|
+
elif op["kind"]=="redis-allocation":
|
|
253
|
+
base=docker_base(op)+["compose","--project-name",op["compose_name"],"--project-directory",str(provider),"--file",str(provider/"compose/compose.yaml"),
|
|
254
|
+
"exec","-T","-e","REDISCLI_AUTH",op["compose_service"],"redis-cli","--user",op["admin_username"],"--raw"]
|
|
255
|
+
env={"REDISCLI_AUTH":op["admin_password"]}
|
|
256
|
+
old=command(base+["ACL","GETUSER",op["app_username"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"].strip()
|
|
257
|
+
if old:raise Failure("Redis user exists without allocation marker; verified adoption required")
|
|
258
|
+
args=["ACL","SETUSER",op["app_username"],"reset","on",">"+op["app_password"],"~"+op["prefix"]+":*","resetchannels","-@all","+@read","+@write","-@dangerous","+ping"]
|
|
259
|
+
resp="*"+str(len(args))+"\r\n"+"".join("$"+str(len(x.encode()))+"\r\n"+x+"\r\n" for x in args)
|
|
260
|
+
reply=command(base+["--pipe"],cwd=str(provider),env=env,stdin=resp,secrets=req.get("secrets",[]))["stdout"]
|
|
261
|
+
if "errors: 0" not in reply:raise Failure("Redis ACL pipe did not confirm zero errors")
|
|
262
|
+
saved=command(base+["ACL","SAVE"],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"].strip()
|
|
263
|
+
if saved!="OK":raise Failure("Redis ACL persistence was not confirmed")
|
|
264
|
+
app=[*base];app[app.index("--user")+1]=op["app_username"]
|
|
265
|
+
pong=command(app+["PING"],cwd=str(provider),env={"REDISCLI_AUTH":op["app_password"]},secrets=req.get("secrets",[]))["stdout"].strip()
|
|
266
|
+
if pong!="PONG":raise Failure("new Redis ACL could not authenticate")
|
|
267
|
+
elif op["kind"]=="minio-allocation":
|
|
268
|
+
import urllib.parse
|
|
269
|
+
u=urllib.parse.urlsplit(op["endpoint"])
|
|
270
|
+
if u.scheme not in ("http","https") or not u.hostname or u.username or u.password:raise Failure("invalid MinIO endpoint")
|
|
271
|
+
auth=urllib.parse.quote(op["admin_username"],safe="")+":"+urllib.parse.quote(op["admin_password"],safe="")+"@"
|
|
272
|
+
url=urllib.parse.urlunsplit((u.scheme,auth+u.netloc,u.path,u.query,u.fragment))
|
|
273
|
+
env={"MC_HOST_ops":url};mc=[op["client_path"],"--config-dir",str(provider/"run/mc"),"--json"]
|
|
274
|
+
mkdir(provider/"run/mc",0o700)
|
|
275
|
+
# Listing is bounded to metadata; unknown existing bucket/user is not silently adopted.
|
|
276
|
+
buckets=command(mc+["ls","ops"],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"]
|
|
277
|
+
if any(json.loads(line).get("key","").rstrip("/")==op["resource"] for line in buckets.splitlines() if line.strip()):raise Failure("MinIO bucket exists without allocation marker")
|
|
278
|
+
users=command(mc+["admin","user","list","ops"],cwd=str(provider),env=env,secrets=req.get("secrets",[]))["stdout"]
|
|
279
|
+
if op["app_username"] in users:raise Failure("MinIO user exists without allocation marker")
|
|
280
|
+
command(mc+["mb","ops/"+op["resource"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
|
|
281
|
+
if not op.get("secret_argv_acknowledged"):raise Failure("MinIO secret argv exposure not approved")
|
|
282
|
+
command(mc+["admin","user","add","ops",op["app_username"],op["app_password"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
|
|
283
|
+
policy={"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::"+op["resource"]]},
|
|
284
|
+
{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],"Resource":["arn:aws:s3:::"+op["resource"]+"/*"]}]}
|
|
285
|
+
policy_path=provider/"config/minio/policies"/(aid+".json");wj(policy_path,policy)
|
|
286
|
+
command(mc+["admin","policy","create","ops",aid,str(policy_path)],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
|
|
287
|
+
command(mc+["admin","policy","attach","ops",aid,"--user",op["app_username"]],cwd=str(provider),env=env,secrets=req.get("secrets",[]))
|
|
288
|
+
appauth=urllib.parse.quote(op["app_username"],safe="")+":"+urllib.parse.quote(op["app_password"],safe="")+"@"
|
|
289
|
+
appurl=urllib.parse.urlunsplit((u.scheme,appauth+u.netloc,u.path,u.query,u.fragment))
|
|
290
|
+
command(mc+["ls","ops/"+op["resource"]],cwd=str(provider),env={"MC_HOST_ops":appurl},secrets=req.get("secrets",[]))
|
|
291
|
+
else:raise Failure("unknown allocation adapter")
|
|
292
|
+
wj(marker,ownership)
|
|
293
|
+
return {"allocation_id":aid,"status":"provisioned-and-authenticated","ownership_path":str(marker)}
|
|
294
|
+
|
|
295
|
+
def execute(op,req):
|
|
296
|
+
kind=op["kind"]; secrets=req.get("secrets",[])
|
|
297
|
+
if kind.endswith("-allocation"): return allocation(op,req)
|
|
298
|
+
if kind=="grant-runtime":
|
|
299
|
+
if os.name=="nt":raise Failure("POSIX runtime grant cannot be used on Windows")
|
|
300
|
+
import pwd
|
|
301
|
+
account=pwd.getpwnam(op["account"]);root=checked_path(op["project_root"],req)
|
|
302
|
+
hostroot=pathlib.Path(req["root"]);os.chmod(hostroot,0o755)
|
|
303
|
+
for p in [root,*[x for x in root.parents if x!=hostroot and hostroot in x.parents]]:
|
|
304
|
+
no_links(p);os.chown(p,-1,account.pw_gid);os.chmod(p,0o750)
|
|
305
|
+
for rel in op["directories"]:
|
|
306
|
+
base=under(str(root/rel),str(root));mkdir(base)
|
|
307
|
+
count=0
|
|
308
|
+
for p in [base,*base.rglob("*")]:
|
|
309
|
+
count+=1
|
|
310
|
+
if count>10000:raise Failure("runtime permission grant exceeds scan bound")
|
|
311
|
+
no_links(p);os.chown(p,-1,account.pw_gid)
|
|
312
|
+
# Data ownership is application-specific; code/config remain deployment-owner controlled.
|
|
313
|
+
if rel.split("/")[0] in ("data","logs","run"):os.chown(p,account.pw_uid,account.pw_gid)
|
|
314
|
+
os.chmod(p,0o750 if p.is_dir() or p.stat().st_mode&0o111 else 0o640)
|
|
315
|
+
return {"account":op["account"],"project_root":str(root),"directories":op["directories"]}
|
|
316
|
+
if kind=="mkdir":
|
|
317
|
+
p=checked_path(op["path"],req); mkdir(p,int(op.get("mode",488)))
|
|
318
|
+
if os.name!="nt" and "mode" in op:os.chmod(p,op["mode"])
|
|
319
|
+
if os.name!="nt" and ("uid" in op or "gid" in op): os.chown(p,op.get("uid",-1),op.get("gid",-1))
|
|
320
|
+
return {"path":str(p),"state":file_state(p)}
|
|
321
|
+
if kind=="write":
|
|
322
|
+
p=checked_path(op["path"],req); data=base64.b64decode(op["content_b64"],validate=True)
|
|
323
|
+
current=file_state(p); expected=op["expected"]
|
|
324
|
+
if p.name==".ops-project.json" and current["kind"]=="file" and rj(p)!=json.loads(data):raise Failure("cannot overwrite another deployment ownership marker")
|
|
325
|
+
if current["kind"]=="file" and current["sha256"]==sha(data):
|
|
326
|
+
if os.name=="nt":secure_file(p)
|
|
327
|
+
else:os.chmod(p,op.get("mode",0o600))
|
|
328
|
+
return {"path":str(p),"sha256":sha(data),"unchanged":True,"state":file_state(p)}
|
|
329
|
+
if current!=expected: raise Failure("file drift since plan: "+str(p))
|
|
330
|
+
if current["kind"]=="file":
|
|
331
|
+
backup=pathlib.Path(req["root"])/"_host/runs"/req["run_id"]/"before"/(sha(str(p).encode())+".bin")
|
|
332
|
+
if not backup.exists(): atomic(backup,p.read_bytes())
|
|
333
|
+
atomic(p,data,op.get("mode",384))
|
|
334
|
+
return {"path":str(p),"sha256":sha(data),"state":file_state(p)}
|
|
335
|
+
if kind in ("command","verify-command"):
|
|
336
|
+
cwd=checked_path(op["cwd"],req,allow_root=True)
|
|
337
|
+
r=command(op["argv"],cwd=str(cwd),env=op.get("env"),stdin=op.get("stdin"),timeout=op.get("timeout",300),secrets=secrets)
|
|
338
|
+
if "expect_stdout" in op and r["stdout"].strip()!=op["expect_stdout"].strip(): raise Failure("verification stdout mismatch")
|
|
339
|
+
if "stdout_pattern" in op and not re.search(op["stdout_pattern"],r["stdout"]): raise Failure("verification pattern mismatch")
|
|
340
|
+
return r
|
|
341
|
+
if kind=="compose-up":return compose_up(op,req)
|
|
342
|
+
if kind=="compose-stop":
|
|
343
|
+
root=checked_path(op["project_root"],req)
|
|
344
|
+
# Never 'down --volumes'; preserve data and cross-project networks.
|
|
345
|
+
return command(compose_base(op)+["stop"],cwd=str(root),timeout=120,secrets=secrets)
|
|
346
|
+
if kind=="health":
|
|
347
|
+
deadline=time.monotonic()+op.get("timeout",60);last=""
|
|
348
|
+
while time.monotonic()<deadline:
|
|
349
|
+
try:
|
|
350
|
+
if op["type"]=="tcp":
|
|
351
|
+
with socket.create_connection((op["hostname"],op["port"]),timeout=3): pass
|
|
352
|
+
elif op["type"]=="http":
|
|
353
|
+
u=urllib.parse.urlsplit(op["url"])
|
|
354
|
+
if u.scheme not in ("http","https") or u.username or u.password: raise Failure("invalid health URL")
|
|
355
|
+
with urllib.request.urlopen(op["url"],timeout=4) as r:
|
|
356
|
+
if r.status!=op.get("status",200):raise Failure("unexpected HTTP status")
|
|
357
|
+
if op.get("contains") and op["contains"] not in r.read(1048576).decode("utf8","replace"):raise Failure("health body mismatch")
|
|
358
|
+
else:raise Failure("unsupported health type")
|
|
359
|
+
return {"healthy":True,"at":stamp()}
|
|
360
|
+
except (OSError,urllib.error.URLError,Failure) as e:last=str(e);time.sleep(1)
|
|
361
|
+
raise Failure("health timeout: "+last)
|
|
362
|
+
if kind=="assert-file":
|
|
363
|
+
p=checked_path(op["path"],req);s=file_state(p)
|
|
364
|
+
if s["kind"]!="file":raise Failure("expected persistent file missing")
|
|
365
|
+
if op.get("sha256") and s["sha256"]!=op["sha256"]:raise Failure("persistent file hash mismatch")
|
|
366
|
+
return s
|
|
367
|
+
if kind=="purge-quarantine":
|
|
368
|
+
src=checked_path(op["path"],req);qroot=pathlib.Path(req["root"])/"_host/quarantine"
|
|
369
|
+
try:parts=src.relative_to(qroot).parts
|
|
370
|
+
except ValueError:raise Failure("purge outside quarantine")
|
|
371
|
+
if len(parts)!=2:raise Failure("purge must name one run/item, not a quarantine root")
|
|
372
|
+
original_receipts=pathlib.Path(req["root"])/"_host/runs"/parts[0]/"receipts"
|
|
373
|
+
owned=any(rj(p).get("result",{}).get("quarantine_path")==str(src) and rj(p).get("status")=="succeeded" for p in original_receipts.glob("*.json") if not p.name.endswith(".started.json"))
|
|
374
|
+
if not owned:raise Failure("no successful isolation receipt owns this quarantine item")
|
|
375
|
+
observed=tree_state(src)
|
|
376
|
+
if observed!=op["expected"]:raise Failure("quarantine content changed after planning")
|
|
377
|
+
before=shutil.disk_usage(src).free
|
|
378
|
+
if src.is_dir():shutil.rmtree(src)
|
|
379
|
+
else:src.unlink()
|
|
380
|
+
after=shutil.disk_usage(qroot).free
|
|
381
|
+
return {"deleted_quarantine":str(src),"logical_bytes":observed["logical_bytes"],"observed_free_space_delta":after-before,"irreversible":True}
|
|
382
|
+
if kind=="quarantine":
|
|
383
|
+
src=checked_path(op["path"],req);rel=src.relative_to(pathlib.Path(req["root"]))
|
|
384
|
+
if not (str(rel).replace("\\","/").startswith("_host/cache/") or "logs" in rel.parts):
|
|
385
|
+
raise Failure("cleanup only supports registered cache/log targets; data/env/backups/releases are protected")
|
|
386
|
+
if any(p in ("data","env","backups","releases") for p in rel.parts):raise Failure("protected path")
|
|
387
|
+
if file_state(src)!=op["expected"]:raise Failure("cleanup candidate drift")
|
|
388
|
+
dst=pathlib.Path(req["root"])/"_host/quarantine"/req["run_id"]/op["item_id"]
|
|
389
|
+
mkdir(dst.parent)
|
|
390
|
+
if dst.exists():raise Failure("quarantine destination exists")
|
|
391
|
+
os.rename(src,dst)
|
|
392
|
+
return {"quarantine_path":str(dst),"released_bytes":0,"note":"same-volume isolation is not disk reclamation"}
|
|
393
|
+
if kind=="defaults":
|
|
394
|
+
current=inventory({})
|
|
395
|
+
for name,expected in op["expected"].items():
|
|
396
|
+
actual=current["tools"].get(name)
|
|
397
|
+
if not actual or actual.get("version")!=expected.get("version"):
|
|
398
|
+
raise Failure("default runtime not restored: "+name)
|
|
399
|
+
return {"defaults_verified":list(op["expected"]),"at":stamp()}
|
|
400
|
+
raise Failure("unsupported operation kind: "+kind)
|
|
401
|
+
|
|
402
|
+
def benchmark_mirrors(req):
|
|
403
|
+
import statistics
|
|
404
|
+
candidates=req.get("candidates",[])
|
|
405
|
+
if not 1<=len(candidates)<=6:raise Failure("mirror test requires 1..6 explicitly approved candidates")
|
|
406
|
+
rounds=req.get("rounds",3)
|
|
407
|
+
if rounds not in (1,2,3):raise Failure("mirror rounds must be 1..3")
|
|
408
|
+
limit=262144;results=[]
|
|
409
|
+
class SameOrigin(urllib.request.HTTPRedirectHandler):
|
|
410
|
+
def redirect_request(self,request,fp,code,msg,headers,newurl):
|
|
411
|
+
old=urllib.parse.urlsplit(request.full_url);new=urllib.parse.urlsplit(newurl)
|
|
412
|
+
if (old.scheme,old.netloc)!=(new.scheme,new.netloc):raise Failure("cross-origin mirror redirect rejected")
|
|
413
|
+
return super().redirect_request(request,fp,code,msg,headers,newurl)
|
|
414
|
+
opener=urllib.request.build_opener(SameOrigin())
|
|
415
|
+
for item in candidates:
|
|
416
|
+
if set(item)!={"id","ecosystem","url","expected_sha256","trust","approved"}:raise Failure("mirror candidate contract mismatch")
|
|
417
|
+
check_id(item["id"])
|
|
418
|
+
url=urllib.parse.urlsplit(item["url"])
|
|
419
|
+
if url.scheme!="https" or not url.hostname or url.username or url.password:raise Failure("mirror samples require credential-free HTTPS with certificate verification")
|
|
420
|
+
if not item["approved"] or item["trust"] not in ("official","intranet","approved-third-party"):raise Failure("unapproved mirror candidate")
|
|
421
|
+
if not re.fullmatch(r"[a-f0-9]{64}",item["expected_sha256"]):raise Failure("mirror sample needs a preverified SHA-256")
|
|
422
|
+
times=[];error=None
|
|
423
|
+
for _ in range(rounds):
|
|
424
|
+
try:
|
|
425
|
+
started=time.monotonic()
|
|
426
|
+
with opener.open(urllib.request.Request(item["url"],headers={"User-Agent":"Speculo-OPS/2.2 mirror-probe"}),timeout=5) as r:
|
|
427
|
+
data=r.read(limit+1)
|
|
428
|
+
elapsed=time.monotonic()-started
|
|
429
|
+
if len(data)>limit:raise Failure("sample exceeds 256 KiB bound")
|
|
430
|
+
if sha(data)!=item["expected_sha256"]:raise Failure("sample integrity mismatch")
|
|
431
|
+
times.append(elapsed)
|
|
432
|
+
except (OSError,Failure,urllib.error.URLError) as e:error=str(e);break
|
|
433
|
+
results.append({"id":item["id"],"ecosystem":item["ecosystem"],"url":item["url"],"verified":len(times)==rounds,"median_seconds":statistics.median(times) if times else None,"error":error})
|
|
434
|
+
best={}
|
|
435
|
+
for row in results:
|
|
436
|
+
if row["verified"] and (row["ecosystem"] not in best or row["median_seconds"]<best[row["ecosystem"]]["median_seconds"]):best[row["ecosystem"]]=row
|
|
437
|
+
return {"identity":fingerprint(),"observed_at":stamp(),"candidates":results,"best_by_ecosystem":best,
|
|
438
|
+
"configuration_changed":False,"note":"Candidate trust and sample digest are supplied by the administrator. Measured latency is not a guarantee of future download speed. A separate approved plan changes configuration."}
|
|
439
|
+
|
|
440
|
+
def main(req):
|
|
441
|
+
identity=fingerprint()
|
|
442
|
+
if req.get("identity") and identity!=req["identity"]:raise Failure("target identity drift")
|
|
443
|
+
action=req["action"]
|
|
444
|
+
if action=="probe":return inventory(req)
|
|
445
|
+
if action=="mirror-probe":return benchmark_mirrors(req)
|
|
446
|
+
if action=="snapshot":return {"identity":identity,"paths":{p:file_state(p) for p in req.get("paths",[])},"at":stamp()}
|
|
447
|
+
root=pathlib.Path(req["root"])
|
|
448
|
+
if not root.is_absolute() or len(root.parts)<(2 if os.name=="nt" else 3) or ".." in root.parts:raise Failure("unsafe host root")
|
|
449
|
+
no_links(root)
|
|
450
|
+
for key in ("host_id","run_id","controller_id"):check_id(req[key])
|
|
451
|
+
owner={k:req[k] for k in ("host_id","run_id","controller_id","plan_digest")}
|
|
452
|
+
marker=root/".ops-host.json"
|
|
453
|
+
lockdir=root/"_host/execution.lock"
|
|
454
|
+
if action=="lock":
|
|
455
|
+
if root.exists() and not marker.exists() and any(root.iterdir()) and not req.get("adopt_root"):
|
|
456
|
+
raise Failure("nonempty unowned host root; explicit adoption plan required")
|
|
457
|
+
mkdir(root,0o755)
|
|
458
|
+
if os.name!="nt" and root.stat().st_mode&0o022:raise Failure("host root is group/world writable; secure it in an explicit host preparation step before deployment")
|
|
459
|
+
if os.name=="nt":
|
|
460
|
+
who=subprocess.run(["whoami","/user","/fo","csv","/nh"],capture_output=True,text=True,check=True)
|
|
461
|
+
sid=next(csv.reader([who.stdout.strip()]))[1]
|
|
462
|
+
subprocess.run(["icacls",str(root),"/inheritance:r","/grant:r",f"*{sid}:(OI)(CI)F","*S-1-5-18:(OI)(CI)F"],capture_output=True,check=True)
|
|
463
|
+
if marker.exists():
|
|
464
|
+
if rj(marker)!={"host_id":req["host_id"],"identity":identity}:raise Failure("host root ownership conflict")
|
|
465
|
+
else:wj(marker,{"host_id":req["host_id"],"identity":identity})
|
|
466
|
+
mkdir(lockdir.parent)
|
|
467
|
+
try:lockdir.mkdir(mode=0o700);wj(lockdir/"owner.json",owner)
|
|
468
|
+
except FileExistsError:
|
|
469
|
+
if not (lockdir/"owner.json").exists() or rj(lockdir/"owner.json")!=owner:raise Failure("target lock held by another operation")
|
|
470
|
+
return {"locked":True,"owner":owner}
|
|
471
|
+
if not marker.exists() or rj(marker)!={"host_id":req["host_id"],"identity":identity}:raise Failure("target root marker missing/conflicting")
|
|
472
|
+
if action=="receipts":
|
|
473
|
+
folder=root/"_host/runs"/req["run_id"]/"receipts"
|
|
474
|
+
return {"receipts":{p.stem:rj(p) for p in sorted(folder.glob("*.json"))} if folder.exists() else {}}
|
|
475
|
+
if not lockdir.exists() or rj(lockdir/"owner.json")!=owner:raise Failure("target lock not owned")
|
|
476
|
+
if action=="unlock":
|
|
477
|
+
(lockdir/"owner.json").unlink();lockdir.rmdir();return {"unlocked":True}
|
|
478
|
+
if action!="step":raise Failure("unknown agent action")
|
|
479
|
+
op=req["operation"];check_id(op["step_id"])
|
|
480
|
+
receipts=root/"_host/runs"/req["run_id"]/"receipts";mkdir(receipts,0o700)
|
|
481
|
+
receipt=receipts/(op["step_id"]+".json");started=receipts/(op["step_id"]+".started.json")
|
|
482
|
+
op_digest=req["operation_digest"]
|
|
483
|
+
if receipt.exists():
|
|
484
|
+
r=rj(receipt)
|
|
485
|
+
if r["operation_digest"]!=op_digest:raise Failure("operation digest mismatch")
|
|
486
|
+
return r
|
|
487
|
+
if started.exists():return {"status":"unknown","step_id":op["step_id"],"reason":"started without terminal receipt; do not replay blindly"}
|
|
488
|
+
call_lock=lockdir/"active-call"
|
|
489
|
+
try:call_lock.mkdir(mode=0o700)
|
|
490
|
+
except FileExistsError:raise Failure("another target call is executing or crashed; inspect before manual recovery")
|
|
491
|
+
try:
|
|
492
|
+
wj(started,{"operation_digest":op_digest,"at":stamp(),"pid":os.getpid()})
|
|
493
|
+
try:
|
|
494
|
+
result=execute(op,req)
|
|
495
|
+
if isinstance(result,dict): result={k:v for k,v in result.items() if k not in ("stdout","stderr")}
|
|
496
|
+
record={"status":"succeeded","step_id":op["step_id"],"operation_digest":op_digest,"at":stamp(),"result":result}
|
|
497
|
+
except Exception as e:
|
|
498
|
+
record={"status":"failed","step_id":op["step_id"],"operation_digest":op_digest,"at":stamp(),
|
|
499
|
+
"error":strip_secrets(str(e),req.get("secrets",[])),"side_effects_possible":op["kind"] not in ("health","assert-file","defaults","verify-command")}
|
|
500
|
+
wj(receipt,record);return record
|
|
501
|
+
finally:call_lock.rmdir()
|
|
502
|
+
|
|
503
|
+
if __name__=="__main__":
|
|
504
|
+
try:
|
|
505
|
+
req=globals().get("OPS_REQUEST")
|
|
506
|
+
if req is None:req=json.load(sys.stdin)
|
|
507
|
+
response={"ok":True,"result":main(req)}
|
|
508
|
+
except Exception as e:
|
|
509
|
+
response={"ok":False,"error":strip_secrets(str(e),(globals().get("OPS_REQUEST") or {}).get("secrets",[]))}
|
|
510
|
+
sys.stdout.write(json.dumps(response,ensure_ascii=False)+"\n")
|