@namewta/speculo 1.0.6 → 1.0.7

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 (51) 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 +1 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +2 -2
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +24 -24
  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/schemas/host.schema.json +1 -1
  13. package/template/workflows/ops/common/schemas/plan.schema.json +3 -3
  14. package/template/workflows/ops/common/schemas/spec.schema.json +1 -1
  15. package/template/workflows/ops/common/schemas/status.schema.json +1 -1
  16. package/template/workflows/ops/common/tests/test_ops.mjs +752 -0
  17. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  18. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  19. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  20. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  21. package/template/workflows/ops/common/tools/opslib/agent.mjs +873 -0
  22. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  23. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  24. package/template/workflows/ops/common/tools/opslib/docs.mjs +252 -0
  25. package/template/workflows/ops/common/tools/opslib/execution.mjs +378 -0
  26. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  27. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  28. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  29. package/template/workflows/ops/common/tools/opslib/planner.mjs +687 -0
  30. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  31. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  32. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  33. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  34. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  35. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  36. package/template/workflows/ops/common/tools/ops.py +0 -7
  37. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  38. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  39. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  40. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  41. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  42. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  43. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  44. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  45. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  46. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  47. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  48. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  49. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  50. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  51. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -1,497 +0,0 @@
1
- """Compile user-reviewed specifications into immutable, identity-bound execution plans."""
2
- from __future__ import annotations
3
- import base64, copy, datetime as dt, json, os, re, shlex
4
- from pathlib import Path, PurePosixPath, PureWindowsPath
5
- from .core import *
6
- from .model import load, ledger_load, validate, validate_status, deployment_root
7
- from .transport import call, host_transport_digest
8
-
9
- MUTATING_OPERATIONS={"prepare","deploy","upgrade","rollback","uninstall","maintain","migrate"}
10
-
11
- def run_dir(state: Path, plan: dict) -> Path:
12
- if plan["worker"] in ("I","H") and len(plan["hosts"])==1:
13
- return state/"hosts"/next(iter(plan["hosts"]))/"runs"/plan["run_id"]
14
- return state/"releases"/plan["run_id"]
15
-
16
- def locate_plan(state: Path, value: str) -> Path:
17
- p=Path(value)
18
- if p.is_file():
19
- resolved=p.absolute();no_symlinks(resolved,allow_missing=False)
20
- if not resolved.is_relative_to(state.absolute()):raise OpsError("plan must belong to the selected controller state root")
21
- return resolved
22
- identifier(value,"run_id")
23
- matches=list((state/"hosts").glob(f"*/runs/{value}/plan.json"))
24
- direct=state/"releases"/value/"plan.json"
25
- if direct.exists():matches.append(direct)
26
- if len(matches)!=1:raise OpsError("run_id must resolve to exactly one stored plan")
27
- return matches[0]
28
-
29
- def templates(value, ctx, status):
30
- if isinstance(value,str):
31
- for k,v in ctx.items():value=value.replace("{{"+k+"}}",v)
32
- def binding(m):
33
- kind,key,field=m.groups(); table=status["bindings" if kind=="binding" else "allocations"]
34
- if key not in table or field not in table[key] or not isinstance(table[key][field],str):raise OpsError("unknown binding/allocation substitution")
35
- return table[key][field]
36
- return re.sub(r"\{\{(binding|allocation):([a-z0-9-]+):([a-z_]+)\}\}",binding,value)
37
- if isinstance(value,list):return [templates(x,ctx,status) for x in value]
38
- if isinstance(value,dict):return {k:templates(v,ctx,status) for k,v in value.items()}
39
- return value
40
-
41
- def project_path(host, root, rel):
42
- rel=relative(rel)
43
- cls=PureWindowsPath if host["platform"]=="windows" else PurePosixPath
44
- result=str(cls(root).joinpath(*rel.split("/")))
45
- if not within(result,root,host["platform"]):raise OpsError("project path escapes root")
46
- return result
47
-
48
- def env_file(values: dict, *, systemd=False) -> str:
49
- lines=[]
50
- for k,v in sorted(values.items()):
51
- if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*",k) or not isinstance(v,str) or any(x in v for x in ("\r","\n","\x00")):
52
- raise OpsError("env files require valid keys and single-line strings; multiline values belong in mounted files")
53
- if systemd:v='"'+v.replace('\\','\\\\').replace('"','\\"')+'"'
54
- lines.append(k+"="+v)
55
- return "\n".join(lines)+"\n"
56
-
57
- def compose_model(spec,host,root,name,envs,ctx):
58
- model=copy.deepcopy(spec["compose"])
59
- if model.get("name") not in (None,name):raise OpsError("Compose identity cannot override registered deployment identity")
60
- model["name"]=name
61
- if not model["services"]:raise OpsError("Compose deployment needs at least one service")
62
- mounts=[]
63
- allowed={"image","build","command","entrypoint","environment","env_file","volumes","ports","healthcheck","depends_on",
64
- "restart","user","read_only","tmpfs","labels","networks","cap_drop","security_opt","deploy","init","working_dir",
65
- "mem_limit","cpus","stop_grace_period","logging","profiles"}
66
- for service,s in model["services"].items():
67
- identifier(service,"compose service")
68
- if set(s)-allowed:raise OpsError("unsupported or unsafe Compose fields: "+str(sorted(set(s)-allowed)))
69
- if "image" not in s and "build" not in s:raise OpsError("service requires a pinned image or build")
70
- if "build" not in s and not re.fullmatch(r"[^\s]+@sha256:[a-f0-9]{64}",s["image"]):
71
- raise OpsError("production Compose images must be pinned by sha256 digest, not floating tags")
72
- if "build" in s:
73
- if not isinstance(s["build"],dict) or set(s["build"])-{"context","dockerfile","args","target"}:raise OpsError("build needs an explicit local context and Dockerfile")
74
- s["build"]["context"]=project_path(host,root,"compose/build")
75
- s["build"]["dockerfile"]=project_path(host,root,"compose/Dockerfile")
76
- if credentials_in(s.get("environment",{})):raise OpsError("credentials must be injected through env/ files, not inline Compose environment")
77
- s.setdefault("restart","unless-stopped")
78
- if s.get("read_only") is False:raise OpsError("writable container rootfs hides undeclared persistence; declare bind/tmpfs paths instead")
79
- s["read_only"]=True
80
- s.setdefault("tmpfs",["/tmp","/run"])
81
- labels=s.setdefault("labels",{})
82
- if not isinstance(labels,dict):raise OpsError("Compose labels must be a map")
83
- labels.update({"ops.managed":"true","ops.deployment":name})
84
- files=s.get("env_file",[])
85
- if isinstance(files,str):files=[files]
86
- if not files and len(envs)==1:files=list(envs)
87
- converted=[]
88
- for f in files:
89
- if not isinstance(f,str):raise OpsError("env_file input must name files from this project's env map")
90
- f=f.removeprefix("env/")
91
- if f not in envs:raise OpsError("Compose references an undeclared environment file: "+f)
92
- converted.append({"path":project_path(host,root,"env/"+f),"required":True,"format":"raw"})
93
- if converted:s["env_file"]=converted
94
- for mount in s.get("volumes",[]):
95
- if not isinstance(mount,dict) or set(mount)-{"type","source","target","read_only","bind","consistency"}:raise OpsError("volume must use explicit safe long syntax")
96
- if mount.get("type")!="bind":raise OpsError("named/anonymous volumes violate the fixed persistence-root contract")
97
- source=mount["source"]
98
- if not source.startswith(("/","\\")) and not re.match(r"^[A-Za-z]:",source):source=project_path(host,root,source)
99
- if not within(source,root,host["platform"]):raise OpsError("bind mount outside this project's root")
100
- cls=PureWindowsPath if host["platform"]=="windows" else PurePosixPath
101
- rel=str(cls(source).relative_to(cls(root))).replace("\\","/")
102
- area=rel.split("/")[0]
103
- if area not in {"data","logs","config","env","backups","run"}:raise OpsError("mount must be under data/logs/config/env/backups/run")
104
- if not mount.get("read_only") and area not in {"data","logs","backups","run"}:raise OpsError("config/env mounts must be read-only")
105
- if area=="data" and len(rel.split("/"))<3:raise OpsError("data needs component/purpose naming")
106
- if not isinstance(mount.get("target"),str) or not mount["target"].startswith("/"):raise OpsError("container target must be absolute")
107
- mount["source"]=source
108
- mount["bind"]={"create_host_path":False}
109
- mounts.append((source,area,mount.get("read_only",False)))
110
- # YAML accepts JSON; no YAML dependency or template interpolation parser is needed.
111
- # Escape literal dollars in the Compose model, not in raw env_file values.
112
- def dollars(v):
113
- if isinstance(v,str):return v.replace("$","$$")
114
- if isinstance(v,list):return [dollars(x) for x in v]
115
- if isinstance(v,dict):return {k:dollars(x) for k,x in v.items()}
116
- return v
117
- return dollars(model),mounts
118
-
119
- def compile_plan(state: Path, spec_path: Path) -> dict:
120
- spec=read_json(spec_path);validate(spec,"spec")
121
- with lock(state/".locks/catalog",{"operation":"plan"}):
122
- current=load(state)
123
- if current["controller"] is None:raise OpsError("initialize the controller first")
124
- after=copy.deepcopy(current);ledger=ledger_load(state)
125
- revisions=spec.get("resource_updates",{})
126
- for group,idkey,fixed in (("hosts","host_id",("host_id","root","identity","platform")),("projects","project_id",("project_id","kind","service_type"))):
127
- for item in revisions.get(group,[]):
128
- previous=current[group].get(item[idkey])
129
- if previous is None or any(previous[k]!=item[k] for k in fixed):
130
- raise OpsError("resource update cannot change identity/root/kind; create explicit migration resources")
131
- after[group][item[idkey]]=copy.deepcopy(item)
132
- rid=identifier(spec.get("run_id",new_id("run")))
133
- if rid in current["releases"]:raise OpsError("run ID already exists")
134
- for group,key in (("allocations","allocation_id"),("bindings","binding_id")):
135
- for item in spec.get(group,[]):
136
- if item[key] in after[group] and after[group][item[key]]!=item:
137
- if group=="allocations":raise OpsError("allocation identity/ownership changes require new resource and migration")
138
- if spec["operation"] not in ("migrate","upgrade","rollback"):raise OpsError("binding update requires migration/upgrade/rollback plan")
139
- after[group][item[key]]=copy.deepcopy(item)
140
- for bid in spec.get("retire_bindings",[]):
141
- if bid not in after["bindings"]:raise OpsError("unknown binding to retire")
142
- after["bindings"][bid]["status"]="retired"
143
- hosts=set(spec.get("hosts",[])) | {h["host_id"] for h in revisions.get("hosts",[])}
144
- ops=[];external={};source_digests={};specs={};doc_targets=[]
145
- def add(host_id,did,kind,**kw):
146
- hosts.add(host_id)
147
- op={"step_id":f"step-{len(ops)+1:04d}","host_id":host_id,"deployment_id":did,"kind":kind,**kw}
148
- ops.append(op);return op
149
- def file_op(host_id,did,path,content=None,binary=None,mode=0o600):
150
- data={"path":path,"mode":mode}
151
- if content is not None:data["content"]=content
152
- else:data["content_b64"]=base64.b64encode(binary).decode()
153
- return add(host_id,did,"write",**data)
154
- def health_op(host_id,did,item,ctx):
155
- h=templates(item,ctx,after);typ=h.pop("type")
156
- if typ=="command":
157
- if not h.get("argv"):raise OpsError("command health check needs argv")
158
- if credentials_in(h["argv"]):raise OpsError("do not put credentials in command argv")
159
- return add(host_id,did,"verify-command",argv=h["argv"],cwd=ctx["root"],timeout=h.get("timeout",60),env=h.get("env",{}),**({"stdout_pattern":h["stdout_pattern"]} if "stdout_pattern" in h else {}))
160
- if typ=="file":
161
- p=h["path"]
162
- if not within(p,ctx["root"],after["hosts"][host_id]["platform"]):p=project_path(after["hosts"][host_id],ctx["root"],p)
163
- return add(host_id,did,"assert-file",path=p,**({"sha256":h["sha256"]} if "sha256" in h else {}))
164
- return add(host_id,did,"health",type=typ,**h)
165
- # Catalog records are built before rendering binding substitutions.
166
- for d in spec.get("deployments",[]):
167
- did=d["deployment_id"]
168
- if did in specs:raise OpsError("duplicate deployment in specification")
169
- specs[did]=d;hid=d["host_id"];hosts.add(hid)
170
- if hid not in after["hosts"] or d["project_id"] not in after["projects"]:raise OpsError("register host and project before planning")
171
- host=after["hosts"][hid];p=after["projects"][d["project_id"]]
172
- root=deployment_root(host,d["project_id"],d["environment"],d["instance"],d["layout"])
173
- old=current["deployments"].get(did)
174
- if old and any(old[k]!=d[k] for k in ("project_id","host_id","environment","instance","layout")):
175
- raise OpsError("deployment identity/path change: create a new deployment and an explicit data-migration plan")
176
- if old and spec["operation"]=="deploy":raise OpsError("existing deployment requires upgrade/rollback/maintain, not fresh deploy")
177
- after["deployments"][did]={"deployment_id":did,"project_id":d["project_id"],"host_id":hid,"environment":d["environment"],
178
- "instance":d["instance"],"layout":d["layout"],"method":d["method"],"version":d["version"],"observed_version":old.get("observed_version") if old else None,"root":root,"status":"planned",
179
- "installed_at":old["installed_at"] if old else None,"updated_at":None,"run_id":rid,
180
- "compose_name":"ops-"+did if d["method"]=="compose" else None,"storage":[],"commands":{"start":[],"stop":[],"verify":[]},
181
- "credential_refs":d.get("credential_refs",[]),"backup":d["backup"],"recovery":d["recovery"],"notes":d.get("notes",[]),"source":p["source"],"service":d.get("service",{})}
182
- doc_targets.append(did)
183
- # Host maintenance also refreshes local/remote deployment documents after verification.
184
- affected=set()
185
- touched_provider=set(specs)|set(spec.get("retire_deployments",[]))
186
- touched_hosts={x["host_id"] for x in spec.get("host_actions",[])}
187
- for did,d in current["deployments"].items():
188
- if d["host_id"] in touched_hosts and d["status"]!="retired":affected.add(did);touched_provider.add(did);doc_targets.append(did)
189
- for b in current["bindings"].values():
190
- if b["status"]=="active" and b["provider_deployment_id"] in touched_provider:affected.add(b["consumer_deployment_id"])
191
- if affected-set(spec.get("acknowledged_consumers",[])):
192
- raise OpsError("maintenance impact must be acknowledged for consumers: "+", ".join(sorted(affected-set(spec.get("acknowledged_consumers",[])))))
193
- for item in spec.get("host_actions",[]):
194
- hid=item["host_id"]
195
- if hid not in after["hosts"]:raise OpsError("unknown host action target")
196
- host=after["hosts"][hid];kind=item["kind"];hostroot=host["root"]
197
- if kind in ("write-control","write-file"):
198
- if kind=="write-file":
199
- path=target_join(host,relative(item["path"]))
200
- if "content" in item:file_op(hid,None,path,content=item["content"],mode=item.get("mode",0o600))
201
- elif "source" in item:
202
- src=Path(item["source"]).absolute();no_symlinks(src,allow_missing=False)
203
- if src.stat().st_size>16*1024*1024:raise OpsError("host file exceeds 16 MiB")
204
- data=src.read_bytes();source_digests[str(src)]=digest(data);file_op(hid,None,path,binary=data,mode=item.get("mode",0o600))
205
- else:raise OpsError("host write-file requires content/source")
206
- continue
207
- path=item["path"]
208
- if path!="/etc/docker/daemon.json" and not re.fullmatch(r"/etc/systemd/system/ops-[a-z0-9-]+\.service",path):raise OpsError("unrecognized external control file; persistent data cannot be an exception")
209
- external.setdefault(hid,[]).append(path)
210
- file_op(hid,None,path,content=item["content"])
211
- elif kind=="mkdir":add(hid,None,"mkdir",path=target_join(host,relative(item["path"])),mode=item.get("mode",0o750))
212
- elif kind in ("quarantine","purge-quarantine"):
213
- path=item["path"]
214
- if not within(path,hostroot,host["platform"]):raise OpsError("cleanup must target a registered cache/log path under host_root")
215
- if kind=="purge-quarantine" and not within(path,target_join(host,"_host/quarantine"),host["platform"]):raise OpsError("purge can only target one previously isolated quarantine item")
216
- add(hid,None,kind,path=path,item_id=f"item-{len(ops)+1:04d}")
217
- elif kind=="defaults":add(hid,None,"defaults",expected=item["expected_defaults"])
218
- else:
219
- argv=item.get("argv")
220
- if not argv or credentials_in(argv):raise OpsError("host command needs argv without plaintext credential arguments")
221
- cwd=item.get("cwd",hostroot)
222
- if cwd!=hostroot and not within(cwd,hostroot,host["platform"]):raise OpsError("host action cwd outside root")
223
- if not item.get("writes"):raise OpsError("host command needs explicit write-set declaration")
224
- for path in item["writes"]:
225
- if not within(path,hostroot,host["platform"]) and path not in external.get(hid,[]):
226
- if kind!="install-toolchain":raise OpsError("host command persistent writes outside root")
227
- if kind=="install-toolchain" and not item.get("expected_defaults"):raise OpsError("toolchain migration requires explicit old default expectations")
228
- add(hid,None,"command",argv=argv,cwd=cwd,env=item.get("env",{}),timeout=item.get("timeout",1800),declared_writes=item["writes"],reason=item["reason"])
229
- if not item.get("verification"):raise OpsError("host mutations require explicit post-verification")
230
- for h in item["verification"]:health_op(hid,None,h,{"root":hostroot,"host_root":hostroot})
231
- if item.get("expected_defaults"):add(hid,None,"defaults",expected=item["expected_defaults"])
232
- provisions={}
233
- for p in spec.get("provision",[]):
234
- if p["allocation_id"] not in after["allocations"]:raise OpsError("provision references unknown allocation")
235
- a=after["allocations"][p["allocation_id"]]
236
- if a["status"]!="planned":raise OpsError("active allocations are reused, not reprovisioned or password-reset")
237
- provisions.setdefault(a["provider_deployment_id"],[]).append(p)
238
- def provision_for(provider):
239
- if provider not in after["deployments"]:raise OpsError("provider deployment not found")
240
- dep=after["deployments"][provider];host=after["hosts"][dep["host_id"]]
241
- for p in provisions.get(provider,[]):
242
- a=after["allocations"][p["allocation_id"]];adapter=p["adapter"]
243
- if adapter=="existing":
244
- if "existing_verification" not in p:raise OpsError("adopted allocation requires actual verification")
245
- h=health_op(dep["host_id"],provider,p["existing_verification"],{"root":dep["root"],"host_root":host["root"]})
246
- h["allocation_id"]=a["allocation_id"]
247
- else:
248
- from .services import allocation_operation
249
- cid,version=a["credential_ref"].split("@")
250
- try:account=ledger["entries"][cid][version]["values"]["username"]
251
- except KeyError as e:raise OpsError("allocation credential must contain the actual username and password") from e
252
- if account!=p.get("app_username"):raise OpsError("allocation username differs from plaintext ledger username")
253
- op=allocation_operation(after,dep,a,p)
254
- add(dep["host_id"],provider,op.pop("kind"),allocation_id=a["allocation_id"],**op)
255
- for provider in provisions:
256
- if provider not in specs:
257
- if after["deployments"][provider]["status"] not in ("completed","running","docs_pending"):raise OpsError("existing provider is not verified active")
258
- provision_for(provider);doc_targets.append(provider)
259
- # Dependencies determine deployment ordering, never alphabetical coincidence.
260
- ordered=[];visiting=set()
261
- def visit(did):
262
- if did in ordered:return
263
- if did in visiting:raise OpsError("dependency cycle")
264
- visiting.add(did)
265
- for b in after["bindings"].values():
266
- if b["status"]=="active" and b["consumer_deployment_id"]==did and b["provider_deployment_id"] in specs:visit(b["provider_deployment_id"])
267
- visiting.remove(did);ordered.append(did)
268
- for did in specs:visit(did)
269
- for did in ordered:
270
- d=specs[did];dep=after["deployments"][did];host=after["hosts"][dep["host_id"]];hid=host["host_id"];root=dep["root"]
271
- ctx={"root":root,"host_root":host["root"],"data":project_path(host,root,"data"),"env":project_path(host,root,"env"),
272
- "logs":project_path(host,root,"logs"),"artifact":project_path(host,root,f"releases/{rid}/artifact"),"run_id":rid}
273
- d=templates(d,ctx,after)
274
- for path in (root,*[project_path(host,root,x) for x in ("env","data","config","logs","backups/owned","backups/dependencies","run",f"releases/{rid}/artifact")]):
275
- add(hid,did,"mkdir",path=path)
276
- marker={"deployment_id":did,"project_id":dep["project_id"],"host_id":hid,"environment":dep["environment"],"instance":dep["instance"]}
277
- file_op(hid,did,project_path(host,root,".ops-project.json"),content=json.dumps(marker,ensure_ascii=False,indent=2)+"\n")
278
- for item in d.get("storage",[]):
279
- area=item.get("area","data");rel=f"{area}/{item['component']}/{item['purpose']}";path=project_path(host,root,rel)
280
- add(hid,did,"mkdir",path=path,**{k:item[k] for k in ("uid","gid","mode") if k in item})
281
- dep["storage"].append({"component":item["component"],"purpose":item["purpose"],"path":path})
282
- for f in d.get("files",[]):
283
- rel=relative(f["path"])
284
- if rel in ("README.md","OPERATIONS.md","project.yaml",".ops-project.json") or rel.startswith(("run/","env/")):
285
- raise OpsError("generated ownership/docs/env paths cannot be supplied as arbitrary files")
286
- if rel.startswith("artifact/"):rel=f"releases/{rid}/"+rel
287
- elif rel.split("/")[0] not in {"compose","config","scripts","service","data"}:raise OpsError("project files must use artifact/compose/config/scripts/service/data")
288
- dest=project_path(host,root,rel)
289
- if ("content" in f)==("source" in f):raise OpsError("file requires exactly one of content/source")
290
- if "content" in f:file_op(hid,did,dest,content=f["content"],mode=f.get("mode",0o600))
291
- else:
292
- src=Path(f["source"])
293
- if not src.is_absolute():src=spec_path.parent/src
294
- no_symlinks(src,allow_missing=False)
295
- if not src.is_file() or src.stat().st_size>16*1024*1024:raise OpsError("source must be a regular file <=16 MiB")
296
- content=src.read_bytes();source_digests[str(src.absolute())]=digest(content)
297
- file_op(hid,did,dest,binary=content,mode=f.get("mode",0o600))
298
- envs=d.get("env",{})
299
- for values in [*envs.values(),d.get("native",{}).get("environment",{})]:
300
- for key,value in values.items():
301
- if re.search(r"(?:DATA|UPLOAD|CACHE|LOG|TMP|TEMP|HOME|DIR|STORAGE)",key,re.I) and key not in ("JAVA_HOME",):
302
- if (value.startswith("/") or re.match(r"^[A-Za-z]:[\\/]",value)) and not within(value,root,host["platform"]):raise OpsError("persistent environment path outside project root: "+key)
303
- if value.startswith("sqlite:///"):
304
- sqlite_path=value[len("sqlite:///"):]
305
- if sqlite_path.startswith("/") and not within(sqlite_path,root,host["platform"]):raise OpsError("SQLite URL escapes project persistence root")
306
- for filename,values in envs.items():
307
- if "/" in relative(filename) or not (filename==".env" or filename.endswith(".env")):raise OpsError("environment filenames must be .env or *.env")
308
- envop=file_op(hid,did,project_path(host,root,"env/"+filename),content=env_file(values))
309
- envop.update(env_values=values,env_format="raw")
310
- runtime_env={"OPS_PROJECT_ROOT":root,"OPS_DATA_ROOT":ctx["data"],"OPS_LOG_ROOT":ctx["logs"],"OPS_RUN_ROOT":project_path(host,root,"run"),
311
- "XDG_DATA_HOME":project_path(host,root,"data/app/storage"),"XDG_CACHE_HOME":project_path(host,root,"run/cache"),
312
- "HOME":project_path(host,root,"data/app/home"),"USERPROFILE":project_path(host,root,"data/app/home"),
313
- "XDG_CONFIG_HOME":project_path(host,root,"data/app/settings"),"APPDATA":project_path(host,root,"data/app/settings"),"LOCALAPPDATA":project_path(host,root,"data/app/settings"),
314
- "UV_CACHE_DIR":project_path(host,root,"run/cache/uv"),"PIP_CACHE_DIR":project_path(host,root,"run/cache/pip"),"npm_config_cache":project_path(host,root,"run/cache/npm"),
315
- "TMPDIR":project_path(host,root,"run/tmp"),"TMP":project_path(host,root,"run/tmp"),"TEMP":project_path(host,root,"run/tmp")}
316
- if d["method"]=="compose":
317
- if "compose" not in d:raise OpsError("Compose method requires a model")
318
- model,mounts=compose_model(d,host,root,dep["compose_name"],envs,ctx)
319
- written={o.get("path") for o in ops if o["kind"]=="write"}
320
- for path,area,ro in mounts:
321
- if path not in written:add(hid,did,"mkdir",path=path)
322
- if area=="data" and not any(x["path"]==path for x in dep["storage"]):
323
- parts=path.replace("\\","/").split("/");dep["storage"].append({"component":parts[-2],"purpose":parts[-1],"path":path})
324
- file_op(hid,did,project_path(host,root,"compose/compose.yaml"),content=json.dumps(model,ensure_ascii=False,indent=2)+"\n")
325
- file_op(hid,did,project_path(host,root,"compose/build/.dockerignore"),content=".git\n.env\n*.env\ndata/\nbackups/\nOPERATIONS.md\nprivate/\n")
326
- add(hid,did,"compose-up",project_root=root,compose_name=dep["compose_name"])
327
- base=["docker","compose","--project-name",dep["compose_name"],"--project-directory",root,"--file",project_path(host,root,"compose/compose.yaml")]
328
- dep["commands"]["start"]=[base+["up","-d","--wait"]];dep["commands"]["stop"]=[base+["stop"]];dep["commands"]["verify"]=[base+["ps"]]
329
- else:
330
- if "native" not in d:raise OpsError("native method requires supervisor and argv")
331
- n=d["native"];argv=n["argv"]
332
- if credentials_in(argv):raise OpsError("native credentials belong in env files, not argv")
333
- if any(x in ("-c","-e","--eval") for x in argv[1:]):raise OpsError("native application code must be a fixed artifact, not inline eval")
334
- for arg in argv[1:]:
335
- candidate=arg.split("=",1)[-1]
336
- if (candidate.startswith("/") or re.match(r"^[A-Za-z]:[\\/]",candidate)) and not within(candidate,root,host["platform"]):
337
- raise OpsError("native argument references a path outside the project root: "+candidate)
338
- if not within(argv[0],root,host["platform"]) and not (argv[0] in {"python3","python","java","node","bash","sh","dotnet"} or os.path.isabs(argv[0])):
339
- raise OpsError("native executable must be explicit or a recognized runtime")
340
- if set(n.get("environment",{}))&set(runtime_env):raise OpsError("native environment cannot override persistence roots")
341
- selected_envs=n.get("env_files", list(envs) if len(envs)==1 else [])
342
- if len(envs)>1 and "env_files" not in n:raise OpsError("native deployment with multiple env files requires explicit env_files order")
343
- combined={}
344
- for filename in selected_envs:
345
- if filename not in envs:raise OpsError("native references an undeclared env file")
346
- for key,value in envs[filename].items():
347
- if key in combined and combined[key]!=value:raise OpsError("conflicting native environment values; reconcile explicitly")
348
- combined[key]=value
349
- combined.update(n.get("environment",{}))
350
- if set(combined)&set(runtime_env):raise OpsError("native env files cannot override reserved persistence roots")
351
- runtime_env.update(combined)
352
- for rel in ("run/tmp","run/cache","data/app/storage","data/app/home","data/app/settings","logs/app"):
353
- add(hid,did,"mkdir",path=project_path(host,root,rel))
354
- if not any(x["path"]==runtime_env["XDG_DATA_HOME"] for x in dep["storage"]):
355
- dep["storage"].append({"component":"app","purpose":"storage","path":runtime_env["XDG_DATA_HOME"]})
356
- supervisor=n["supervisor"]
357
- if supervisor=="systemd":
358
- if host["platform"]!="linux":raise OpsError("systemd is a Linux adapter")
359
- if any("\n" in x or "\r" in x for x in argv):raise OpsError("newline in systemd argv")
360
- unit="ops-"+did+".service";unitpath="/etc/systemd/system/"+unit
361
- account=n.get("account")
362
- if not account or not re.fullmatch(r"[a-z_][a-z0-9_-]*[$]?",account):raise OpsError("native systemd requires an explicit service account")
363
- envpath=project_path(host,root,"env/service.env")
364
- envop=file_op(hid,did,envpath,content=env_file(runtime_env,systemd=True))
365
- envop.update(env_values=runtime_env,env_format="systemd")
366
- quote=lambda s:'"'+s.replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"'
367
- content="[Unit]\nDescription=OPS "+did+"\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser="+account+"\nWorkingDirectory="+quote(ctx["artifact"])+"\nEnvironmentFile="+quote(envpath)+"\nExecStart=:"+" ".join(quote(x) for x in argv)+"\nRestart=on-failure\nUMask=0027\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=read-only\nReadWritePaths="+" ".join(quote(project_path(host,root,x)) for x in ("data","logs","run","backups"))+"\nStandardOutput=append:"+project_path(host,root,"logs/app/stdout.log")+"\nStandardError=append:"+project_path(host,root,"logs/app/stderr.log")+"\n\n[Install]\nWantedBy=multi-user.target\n"
368
- file_op(hid,did,project_path(host,root,"service/"+unit),content=content,mode=0o644)
369
- external.setdefault(hid,[]).append(unitpath);file_op(hid,did,unitpath,content=content,mode=0o644)
370
- add(hid,did,"grant-runtime",project_root=root,account=account,directories=[f"releases/{rid}/artifact","config","scripts","data","logs","run"])
371
- for a in (["systemctl","daemon-reload"],["systemctl","enable",unit],["systemctl","restart",unit],["systemctl","is-active","--quiet",unit]):add(hid,did,"command" if a[1]!="is-active" else "verify-command",argv=a,cwd=root)
372
- dep["commands"]={"start":[["systemctl","start",unit]],"stop":[["systemctl","stop",unit]],"verify":[["systemctl","status",unit]]}
373
- elif supervisor=="windows-task":
374
- if host["platform"]!="windows":raise OpsError("Windows task adapter requires a native Windows host")
375
- from .native_windows import task_files
376
- for rel,content in task_files(did,root,argv,runtime_env,n).items():
377
- taskop=file_op(hid,did,project_path(host,root,rel),content=content)
378
- if rel.endswith("task.json"):taskop["json_values"]=json.loads(content)
379
- a=["powershell","-NoProfile","-NonInteractive","-File",project_path(host,root,"service/install-task.ps1")]
380
- add(hid,did,"command",argv=a,cwd=root)
381
- dep["commands"]={"start":[["schtasks","/Run","/TN","OPS-"+did]],"stop":[["schtasks","/End","/TN","OPS-"+did]],"verify":[["schtasks","/Query","/TN","OPS-"+did,"/V"]]}
382
- else:
383
- add(hid,did,"command",argv=argv,cwd=root,env=runtime_env,timeout=n.get("timeout",300),declared_writes=[ctx["data"],ctx["logs"],project_path(host,root,"run")])
384
- dep["commands"]={"start":[argv],"stop":[],"verify":[]}
385
- dep["notes"].append("Native oneshot: successful finite application run, not a resident daemon.")
386
- for h in d["health"]:health_op(hid,did,h,ctx)
387
- snapshot={"run_id":rid,"version":dep["version"],"source":dep["source"],"planned_at":now(),"method":dep["method"]}
388
- file_op(hid,did,project_path(host,root,f"releases/{rid}/release.json"),content=json.dumps(snapshot,ensure_ascii=False,indent=2)+"\n")
389
- provision_for(did)
390
- # A retirement stops only the explicitly named deployment and preserves all bytes.
391
- for did in spec.get("retire_deployments",[]):
392
- if did not in after["deployments"]:raise OpsError("unknown deployment to retire")
393
- dep=after["deployments"][did];hid=dep["host_id"];hosts.add(hid)
394
- consumers=[b["consumer_deployment_id"] for b in after["bindings"].values() if b["status"]=="active" and b["provider_deployment_id"]==did]
395
- if consumers:raise OpsError("cannot retire shared service with active consumers: "+",".join(consumers))
396
- for b in after["bindings"].values():
397
- if b["consumer_deployment_id"]==did:b["status"]="retired"
398
- if dep["method"]=="compose":add(hid,did,"compose-stop",project_root=dep["root"],compose_name=dep["compose_name"])
399
- else:
400
- for a in dep["commands"]["stop"]:add(hid,did,"command",argv=a,cwd=dep["root"])
401
- marker={k:dep[k] for k in ("deployment_id","project_id","host_id","environment","instance")}
402
- add(hid,did,"assert-file",path=project_path(after["hosts"][hid],dep["root"],".ops-project.json"),sha256=digest((json.dumps(marker,ensure_ascii=False,indent=2)+"\n").encode()))
403
- dep["status"]="retired";dep["run_id"]=rid;doc_targets.append(did)
404
- # Do not fake a newly allocated service without a provision/adoption action.
405
- provision_ids={x["allocation_id"] for x in spec.get("provision",[])}
406
- for a in spec.get("allocations",[]):
407
- if a["allocation_id"] not in current["allocations"] and a["allocation_id"] not in provision_ids:
408
- raise OpsError("new allocation requires a typed provisioning action or verified adoption")
409
- doc_targets.extend(affected)
410
- for b in after["bindings"].values():
411
- if b["status"]=="active" and b["consumer_deployment_id"] in doc_targets and b["provider_deployment_id"]:
412
- doc_targets.append(b["provider_deployment_id"])
413
- doc_targets=sorted(set(doc_targets))
414
- for did in doc_targets:hosts.add(after["deployments"][did]["host_id"])
415
- if not hosts:raise OpsError("plan requires at least one registered host")
416
- if set(hosts)-set(after["hosts"]):raise OpsError("unregistered target host")
417
- validate_status(after)
418
- if len(ops)>2000 or len(canonical(ops))>64*1024*1024:raise OpsError("plan exceeds bounded 2000 operations/64 MiB; split into reviewed releases")
419
- selected={hid:after["hosts"][hid] for hid in sorted(hosts)}
420
- inventories={};snapshots={};doc_preconditions={}
421
- from .docs import remote_paths
422
- for hid,host in selected.items():
423
- paths={o["path"] for o in ops if o["host_id"]==hid and o["kind"] in ("write","quarantine","purge-quarantine")}
424
- for did in doc_targets:
425
- if after["deployments"][did]["host_id"]==hid:paths.update(remote_paths(after,after["deployments"][did]))
426
- paths.update([target_join(host,"knowledge/INDEX.md"),target_join(host,"README.md"),target_join(host,"DEPLOYMENTS.md"),target_join(host,"docs/standards/DEPLOYMENT-STANDARD.md")])
427
- for did in specs:
428
- dep=after["deployments"][did]
429
- if dep["host_id"]==hid:paths.add(dep["root"])
430
- needs_docker=any(after["deployments"][did]["host_id"]==hid and after["deployments"][did]["method"]=="compose" for did in doc_targets)
431
- inv=call(host,{"action":"probe","paths":sorted(paths),"disk_roots":[host["root"]],"include_docker":needs_docker,"deep_paths":[o["path"] for o in ops if o["host_id"]==hid and o["kind"]=="purge-quarantine"]},timeout=180)
432
- if needs_docker:
433
- control=inv.get("docker_control")
434
- if not control or control.get("status")!="observed":raise OpsError("Docker/Compose preparation is not verified; finish an approved H plan before D")
435
- if not control["endpoint"].startswith(("unix://","npipe://")):raise OpsError("Docker context points to a different machine; register that machine as the target host")
436
- wanted=target_join(host,"_runtime/docker")
437
- if after["policies"]["strict_docker_root"] and control["data_root"]!=wanted:raise OpsError("existing Docker data-root differs from unified root; explicit H migration required, never silently move it")
438
- version=tuple(int(x) for x in re.findall(r"\d+",control["compose_version"])[:3])
439
- if version<(2,30,0):raise OpsError("generated raw env_file contract requires Docker Compose >=2.30")
440
- for op in ops:
441
- if op["host_id"]==hid and op.get("compose_name"):
442
- op["context"]=control["context"];op["expected_docker_id"]=control["id"]
443
- for did in doc_targets:
444
- dep=after["deployments"][did]
445
- if dep["host_id"]==hid and dep["method"]=="compose":
446
- dep["service"].update(docker_context=control["context"],docker_id=control["id"])
447
- for commands in dep["commands"].values():
448
- for argv in commands:
449
- if argv[:2]==["docker","compose"]:argv[1:1]=["--context",control["context"]]
450
- inventories[hid]=inv;snapshots[hid]=inv["snapshots"]
451
- doc_preconditions[hid]={p:v for p,v in inv["snapshots"].items() if p not in {o.get("path") for o in ops if o["host_id"]==hid}}
452
- for did,d in specs.items():
453
- dep=after["deployments"][did];snap=snapshots[dep["host_id"]][dep["root"]]
454
- if did not in current["deployments"] and snap["kind"]!="absent" and not d.get("adopt_existing"):
455
- raise OpsError("project directory exists: explicit verified adoption is required for "+did)
456
- seen=set()
457
- for op in ops:
458
- if op["kind"] in ("write","quarantine","purge-quarantine"):
459
- op["expected"]=snapshots[op["host_id"]][op["path"]]
460
- if op["path"].endswith(".ops-project.json") and op["expected"].get("owner") not in (None,json.loads(op["content"])):
461
- raise OpsError("project root is owned by a different deployment")
462
- if op["kind"]=="write":
463
- key=(op["host_id"],op["path"])
464
- if key in seen:raise OpsError("two writes to the same file in one plan: "+op["path"])
465
- seen.add(key)
466
- if op["expected"]["kind"] not in ("file","absent"):raise OpsError("file destination is not a regular file")
467
- refs=credentials_in(ops)
468
- from .docs import credential_refs
469
- for d in after["deployments"].values():refs.update(credential_refs(after,d))
470
- for a in after["allocations"].values():
471
- if a["provider_deployment_id"] in doc_targets:refs.add(a["credential_ref"])
472
- for b in after["bindings"].values():
473
- if b["consumer_deployment_id"] in doc_targets:refs.add(b["credential_ref"])
474
- cv={}
475
- for ref in sorted(refs):
476
- cid,version=ref.split("@")
477
- try:value=ledger["entries"][cid][version]
478
- except KeyError as e:raise OpsError("missing plaintext credential version: "+ref) from e
479
- cv[ref]=digest(value)
480
- created=now();expires=(dt.datetime.now(dt.timezone.utc)+dt.timedelta(hours=spec.get("expires_hours",24))).isoformat(timespec="seconds").replace("+00:00","Z")
481
- plan={"schema_version":1,"artifact":"ops-resource-plan","run_id":rid,"worker":spec["worker"],"operation":spec["operation"],"reason":spec["reason"],
482
- "created_at":created,"expires_at":expires,"controller_id":current["controller"]["controller_id"],"registry_digest":digest(current),"registry_revision":current["revision"],
483
- "hosts":selected,"transport_digests":{hid:host_transport_digest(h) for hid,h in selected.items()},"inventories":inventories,"operations":ops,
484
- "registry_after":after,"credential_versions":cv,"document_targets":doc_targets,"document_preconditions":doc_preconditions,
485
- "allow_adopt_roots":spec.get("allow_adopt_roots",[]),"external_files":external,"affected_consumers":sorted(affected),"rollback_note":spec["rollback_note"],
486
- "risk":spec.get("risk","production-critical" if spec["worker"]=="D" else "external-mutation"),"source_digests":source_digests}
487
- from .execution import engine_digest
488
- plan["engine_digest"]=engine_digest()
489
- validate(plan,"plan")
490
- path=run_dir(state,plan)
491
- write_json(path/"plan.json",plan,exclusive=True)
492
- from .docs import plan_report
493
- atomic_write(path/"PLAN.md",plan_report(plan),exclusive=True)
494
- for hid in selected:
495
- link=state/"hosts"/hid/"runs"/rid/"reference.json"
496
- write_json(link,{"run_id":rid,"plan_path":str((path/"plan.json").relative_to(state)),"plan_digest":digest(plan)},exclusive=True)
497
- return {"run_id":rid,"plan_path":str(path/"plan.json"),"report":str(path/"PLAN.md"),"plan_digest":digest(plan),"steps":len(ops),"status":"awaiting-approval"}
@@ -1,47 +0,0 @@
1
- """Typed shared-service allocation contracts. No guessing unknown service products."""
2
- from __future__ import annotations
3
- import re
4
- from .core import *
5
-
6
- def secret(ref,field):return "{{credential:"+ref+":"+field+"}}"
7
-
8
- def allocation_operation(status,dep,a,p):
9
- adapter=p["adapter"]
10
- project=status["projects"][dep["project_id"]]
11
- if project["service_type"]!=adapter:raise OpsError("allocation adapter differs from registered provider service_type")
12
- if not p.get("admin_credential_ref") or p["admin_credential_ref"]==a["credential_ref"]:
13
- raise OpsError("application credential must differ from administrator credential")
14
- if p["admin_credential_ref"] not in dep["credential_refs"]:
15
- raise OpsError("admin credential must be registered on the provider deployment")
16
- if not p.get("app_username") or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{1,47}",p["app_username"]):
17
- raise OpsError("application username needs an explicit safe name")
18
- service=dep["service"]
19
- common={"kind":adapter+"-allocation","provider_root":dep["root"],"compose_name":dep["compose_name"],
20
- "resource":a["resource_name"],"app_username":p["app_username"],"app_password":secret(a["credential_ref"],"password"),
21
- "admin_username":secret(p["admin_credential_ref"],"username"),"admin_password":secret(p["admin_credential_ref"],"password"),
22
- "owner_project_id":a["owner_project_id"],"environment":a["environment"],"credential_ref":a["credential_ref"]}
23
- if adapter in ("mysql","redis"):
24
- if dep["method"]!="compose" or not service.get("compose_service"):raise OpsError("built-in MySQL/Redis allocator requires a managed Compose provider and explicit compose_service")
25
- common["compose_service"]=identifier(service["compose_service"])
26
- if adapter=="mysql":
27
- if a["resource_kind"]!="database" or not re.fullmatch(r"[a-z][a-z0-9_]{1,47}",a["resource_name"]):raise OpsError("MySQL requires a safe logical database name")
28
- privileges=p.get("privileges",["SELECT","INSERT","UPDATE","DELETE"])
29
- allowed={"SELECT","INSERT","UPDATE","DELETE","CREATE","ALTER","INDEX","REFERENCES","DROP","CREATE TEMPORARY TABLES","EXECUTE"}
30
- if not privileges or set(privileges)-allowed:raise OpsError("unsupported MySQL grants; administrative/global privileges are forbidden")
31
- common["privileges"]=privileges
32
- elif adapter=="redis":
33
- if a["resource_kind"]!="redis-acl" or not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9:_-]+",p.get("prefix","")):
34
- raise OpsError("Redis shared allocation requires an explicit key prefix and redis-acl resource")
35
- if service.get("acl_persistence")!="data/redis/acl/users.acl":raise OpsError("Redis provider must persist ACLs at data/redis/acl/users.acl and configure aclfile")
36
- if a["recovery_scope"] not in ("provider-wide","application-export","unverified"):
37
- raise OpsError("Redis prefix/ACL is not proof of independently restorable data")
38
- common["prefix"]=p["prefix"]
39
- elif adapter=="minio":
40
- if a["resource_kind"]!="bucket" or not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]",a["resource_name"]):raise OpsError("MinIO allocation needs a valid bucket name")
41
- if not service.get("endpoint"):raise OpsError("MinIO provider requires explicit administrative endpoint")
42
- if p.get("allow_secret_argv") is not True:
43
- raise OpsError("mc admin user add exposes the new password briefly to privileged local process inspection; explicitly review allow_secret_argv or use an existing verified allocation")
44
- if not p.get("client_path"):raise OpsError("MinIO needs a reviewed installed mc client_path")
45
- common.update(endpoint=service["endpoint"],client_path=p["client_path"],secret_argv_acknowledged=True)
46
- else:raise OpsError("unknown provider adapter: use a version-verified explicit existing-resource probe, not guessed commands")
47
- return common
@@ -1,27 +0,0 @@
1
- """Explicit pinned Git acquisition into controller-owned immutable source snapshots."""
2
- import os,re,subprocess
3
- from pathlib import Path
4
- from urllib.parse import urlsplit
5
- from .core import *
6
- from .model import load
7
-
8
- def fetch_source(state:Path,project_id:str,repo:str,commit:str,allow_network:bool):
9
- load(state);identifier(project_id)
10
- u=urlsplit(repo)
11
- if not allow_network:raise OpsError("source-fetch requires explicit --allow-network")
12
- if u.scheme!="https" or not u.hostname or u.username or u.password:raise OpsError("use an explicit credential-free HTTPS repository URL; authentication belongs in an approved Git helper")
13
- if not re.fullmatch(r"[a-f0-9]{40}",commit):raise OpsError("Git source must be pinned by full 40-character commit SHA")
14
- dest=state/"sources"/project_id/commit;hooks=state/"sources/.empty-hooks";private_dir(hooks)
15
- with lock(state/".locks/source",{"project_id":project_id,"commit":commit}):
16
- env={**os.environ,"GIT_TERMINAL_PROMPT":"0","GIT_CONFIG_NOSYSTEM":"1","GIT_CONFIG_GLOBAL":os.devnull,"GIT_LFS_SKIP_SMUDGE":"1"}
17
- git=["git","-c","core.hooksPath="+str(hooks)]
18
- if not dest.exists():
19
- private_dir(dest.parent)
20
- subprocess.run(git+["clone","--no-checkout","--",repo,str(dest)],env=env,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,timeout=600)
21
- subprocess.run(git+["-C",str(dest),"checkout","--detach",commit],env=env,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,timeout=300)
22
- actual=subprocess.run(git+["-C",str(dest),"rev-parse","HEAD"],env=env,capture_output=True,text=True,check=True).stdout.strip()
23
- if actual!=commit:raise OpsError("source snapshot identity mismatch; do not overwrite existing checkout")
24
- from .cli import analyze
25
- result={"project_id":project_id,"repository":repo,"commit":commit,"path":str(dest),"at":now(),"analysis":analyze(dest),"submodules":"not initialized; each requires independent pinned review"}
26
- write_json(dest.parent/(commit+".source.json"),result)
27
- return result