@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,172 +0,0 @@
1
- """CLI for explicit local recording and approved target mutations."""
2
- from __future__ import annotations
3
- import argparse, copy, json, os, re, shutil, sys
4
- from pathlib import Path
5
- from .core import *
6
- from .model import *
7
- from .transport import call,probe_local
8
- from .planner import compile_plan,locate_plan
9
- from .execution import approval,apply,verify_journal,request_base
10
-
11
-
12
- def initialize(state: Path,controller_id: str) -> dict:
13
- identifier(controller_id)
14
- if state.exists():
15
- no_symlinks(state)
16
- if (state/"status.json").exists():
17
- existing=read_json(state/"status.json")
18
- if existing.get("schema_version")==2 and existing.get("active")==[] and existing.get("archived")==[]:
19
- # Only an untouched template seed is convertible without a legacy import.
20
- extras=[p.name for p in state.iterdir() if p.name not in {"status.json","changes","archive"}]
21
- if extras or any(p.is_file() and p.name!=".gitkeep" for name in ("changes","archive") for p in (state/name).rglob("*")):
22
- raise OpsError("legacy runtime contains evidence; use import-legacy into a new state root")
23
- else:
24
- validate_status(existing)
25
- if existing["controller"] is not None:
26
- if existing["controller"]["controller_id"]!=controller_id:raise OpsError("controller identity already fixed")
27
- return {"status":"already-initialized","controller_id":controller_id,"state_root":str(state)}
28
- private_dir(state)
29
- with lock(state/".locks/catalog",{"operation":"initialize"}):
30
- s=empty_status();s["controller"]={"controller_id":controller_id,"state_root":str(state),"created_at":now()}
31
- for rel in ("hosts","projects","releases","private","controller/inventory","docs/standards","knowledge"):
32
- private_dir(state/rel)
33
- save(state,s)
34
- write_json(state/"private/credentials.json",{"schema_version":1,"entries":{}})
35
- inv=probe_local();write_json(state/"controller/inventory"/(new_id("snapshot")+".json"),inv)
36
- atomic_write(state/".gitignore","*\n!.gitignore\n")
37
- from .docs import STANDARD
38
- atomic_write(state/"docs/standards/DEPLOYMENT-STANDARD.md",STANDARD)
39
- atomic_write(state/"README.md","# OPS 控制端\n\n资源事实:status.json;主机:hosts/;发布:releases/;明文账本:private/credentials.json。替换静态 workflow 时绝不能删除这里。首次部署后自动生成双边详细档案。\n")
40
- return {"status":"initialized","controller_id":controller_id,"state_root":str(state),"inventory_recorded":True}
41
-
42
- def analyze(source: Path) -> dict:
43
- no_symlinks(source,allow_missing=False)
44
- if not source.is_dir():raise OpsError("source must be a project directory")
45
- skip={".git","node_modules",".venv","venv","data","backups",".speculo","dist","build","target"}
46
- evidence=[];detected=[]
47
- names={"pyproject.toml":"python","requirements.txt":"python","package.json":"node","pom.xml":"java","build.gradle":"java","Dockerfile":"docker","compose.yaml":"compose","docker-compose.yml":"compose","Cargo.toml":"rust","go.mod":"go"}
48
- for root,dirs,files in os.walk(source,followlinks=False):
49
- dirs[:]=[d for d in dirs if d not in skip and not Path(root,d).is_symlink()]
50
- for name in files:
51
- p=Path(root,name)
52
- if p.is_symlink() or name==".env" or name.endswith(".env"):continue
53
- if name in names:
54
- if p.stat().st_size>1024*1024:raise OpsError("manifest exceeds scan bound")
55
- evidence.append({"path":str(p.relative_to(source)),"sha256":digest(p.read_bytes()),"kind":names[name]});detected.append(names[name])
56
- if len(evidence)>2000:raise OpsError("source analysis bound exceeded")
57
- return {"source":str(source),"detected":sorted(set(detected)),"evidence":evidence,
58
- "side_effects":"read-only; no repository code executed, no network download", "next":"Create a deployment spec from actual project manifests; fix revision and storage/health/dependency mappings."}
59
-
60
- def inspect_run(state,run):
61
- p=locate_plan(state,run);plan=read_json(p);ledger=ledger_load(state)
62
- results={}
63
- for hid,h in plan["hosts"].items():
64
- try:results[hid]=call(h,{**request_base(plan,hid,ledger),"action":"receipts"})
65
- except OpsError as e:results[hid]={"unavailable":str(e)}
66
- return {"run_id":plan["run_id"],"targets":results,"journal":verify_journal(p.parent/"journal.jsonl")}
67
-
68
- def break_controller_lock(state,ack):
69
- if ack!="I-VERIFIED-NO-EXECUTION-IS-RUNNING":raise OpsError("explicit recovery acknowledgement required")
70
- p=state/".locks/catalog";no_symlinks(p)
71
- owner=read_json(p/"owner.json")
72
- if owner["machine"]!=socket.gethostname():raise OpsError("lock owner belongs to a different controller machine; verify there first")
73
- pid=owner["pid"]
74
- try:os.kill(pid,0)
75
- except ProcessLookupError:pass
76
- except PermissionError:raise OpsError("cannot prove owner process is stopped")
77
- else:raise OpsError("owner PID is still alive (or reused); refusing automatic lock removal")
78
- evidence=state/"controller/recovery"/(new_id("lock")+".json")
79
- write_json(evidence,{"owner":owner,"ack":ack,"recovered_at":now()})
80
- (p/"owner.json").unlink();p.rmdir()
81
- return {"status":"controller-lock-released","evidence":str(evidence),"target_locks":"not modified; inspect remote receipts before resume"}
82
-
83
- def import_legacy(state,source,controller_id):
84
- no_symlinks(source,allow_missing=False)
85
- if state.exists() and any(state.iterdir()):raise OpsError("legacy import destination must be empty and separate")
86
- if source==state or state.is_relative_to(source) or source.is_relative_to(state):raise OpsError("legacy source/destination must be disjoint")
87
- for p in source.rglob("*"):
88
- if p.is_symlink():raise OpsError("legacy evidence contains symlinks; preserve manually without following them")
89
- result=initialize(state,controller_id)
90
- dest=state/"legacy"/new_id("import");private_dir(dest)
91
- manifest={}
92
- for p in sorted(source.rglob("*")):
93
- if p.is_file():
94
- rel=p.relative_to(source);data=p.read_bytes();atomic_write(dest/rel,data)
95
- manifest[str(rel)]={"sha256":digest(data),"source_mode":p.stat().st_mode&0o777}
96
- write_json(dest/"IMPORT-MANIFEST.json",{"source":str(source),"at":now(),"files":manifest,"approvals_reused":False})
97
- return {**result,"legacy_evidence":str(dest),"note":"source preserved, no hosts/deployments/passwords/approvals inferred"}
98
-
99
- def main(argv=None):
100
- parser=argparse.ArgumentParser(description="OPS 2.2 resource workflow: explicit plan -> approve -> apply -> dual docs")
101
- parser.add_argument("--state",help="Persistent controller state root; never the static workflow directory")
102
- parser.add_argument("--version",action="version",version=VERSION)
103
- sub=parser.add_subparsers(dest="command",required=True)
104
- p=sub.add_parser("init");p.add_argument("--controller-id",required=True)
105
- p=sub.add_parser("probe");p.add_argument("--host");p.add_argument("--connection-file");p.add_argument("--output")
106
- p=sub.add_parser("register");p.add_argument("--file",required=True)
107
- p=sub.add_parser("mirror-probe");p.add_argument("--host",required=True);p.add_argument("--file",required=True);p.add_argument("--allow-network",action="store_true")
108
- p=sub.add_parser("credential-put");p.add_argument("--file",required=True)
109
- p=sub.add_parser("analyze");p.add_argument("--source",required=True)
110
- p=sub.add_parser("environment-spec");p.add_argument("--host",required=True);p.add_argument("--file",required=True);p.add_argument("--output",required=True)
111
- p=sub.add_parser("source-fetch");p.add_argument("--project",required=True);p.add_argument("--repository",required=True);p.add_argument("--commit",required=True);p.add_argument("--allow-network",action="store_true")
112
- p=sub.add_parser("plan");p.add_argument("--file",required=True)
113
- p=sub.add_parser("approve");p.add_argument("--run",required=True);p.add_argument("--digest",required=True);p.add_argument("--by",required=True);p.add_argument("--statement",required=True)
114
- for c in ("apply","resume","docs-sync","inspect-run"):
115
- p=sub.add_parser(c);p.add_argument("--run",required=True)
116
- p=sub.add_parser("validate");p.add_argument("--file");p.add_argument("--schema",choices=["host","project","deployment","allocation","binding","spec","plan","status","approval"])
117
- p=sub.add_parser("status")
118
- p=sub.add_parser("recover-controller-lock");p.add_argument("--ack",required=True)
119
- p=sub.add_parser("import-legacy");p.add_argument("--source",required=True);p.add_argument("--controller-id",required=True)
120
- args=parser.parse_args(argv)
121
- if not args.state and args.command not in ("analyze","probe","validate"):parser.error("--state is required; it is never guessed from cwd")
122
- state=Path(args.state).absolute() if args.state else None
123
- try:
124
- cmd=args.command
125
- if cmd=="init":result=initialize(state,args.controller_id)
126
- elif cmd=="probe":
127
- if args.host:
128
- if state is None:raise OpsError("--host needs --state")
129
- s=load(state);result=call(s["hosts"][args.host],{"action":"probe"},timeout=180)
130
- write_json(state/"hosts"/args.host/"inventory"/(new_id("snapshot")+".json"),result)
131
- elif args.connection_file:
132
- h=read_json(Path(args.connection_file))
133
- discovery=h.get("identity")=="discover"
134
- if discovery:h["identity"]="0"*64
135
- validate_host(h)
136
- result=call(h,{"action":"probe",**({"identity":None} if discovery else {})},timeout=180)
137
- if discovery:result["registration_note"]="Read-only discovery over your pinned known_hosts. Review identity and store it explicitly; discover is never valid for register/apply."
138
- else:result=probe_local()
139
- if args.output:write_json(Path(args.output).absolute(),result)
140
- elif cmd=="register":result=register(state,read_json(Path(args.file)))
141
- elif cmd=="mirror-probe":
142
- if not args.allow_network:raise OpsError("mirror benchmarking requires explicit --allow-network")
143
- s=load(state);data=read_json(Path(args.file));result=call(s["hosts"][args.host],{"action":"mirror-probe","candidates":data["candidates"],"rounds":data.get("rounds",3)},timeout=180)
144
- path=state/"hosts"/args.host/"mirrors"/(new_id("probe")+".json");write_json(path,result);result["local_report"]=str(path)
145
- elif cmd=="credential-put":result=put_credential(state,read_json(Path(args.file)))
146
- elif cmd=="analyze":result=analyze(Path(args.source).absolute())
147
- elif cmd=="environment-spec":
148
- from .host_recipes import environment_spec
149
- spec=environment_spec(state,args.host,read_json(Path(args.file)));write_json(Path(args.output).absolute(),spec);result={"spec_path":str(Path(args.output).absolute()),"status":"generated-not-executed","next":"plan --file this-spec"}
150
- elif cmd=="source-fetch":
151
- from .sources import fetch_source
152
- result=fetch_source(state,args.project,args.repository,args.commit,args.allow_network)
153
- elif cmd=="plan":result=compile_plan(state,Path(args.file).absolute())
154
- elif cmd=="approve":result=approval(state,args.run,args.digest,args.by,args.statement)
155
- elif cmd in ("apply","resume","docs-sync"):result=apply(state,args.run,resume=cmd=="resume",docs_only=cmd=="docs-sync")
156
- elif cmd=="inspect-run":result=inspect_run(state,args.run)
157
- elif cmd=="validate":
158
- if not args.file and state is None:raise OpsError("validate requires --state or --file --schema")
159
- if args.file:
160
- if not args.schema:raise OpsError("--file requires --schema")
161
- value=read_json(Path(args.file));validate(value,args.schema)
162
- if args.schema=="status":validate_status(value)
163
- result={"valid":True,"schema":args.schema}
164
- else:result={"valid":True,"revision":load(state)["revision"]}
165
- elif cmd=="status":
166
- s=load(state);result={"controller":s["controller"],"revision":s["revision"],"hosts":list(s["hosts"]),"deployments":[{"deployment_id":d["deployment_id"],"host_id":d["host_id"],"project_id":d["project_id"],"status":d["status"],"version":d["version"],"observed_version":d.get("observed_version")} for d in s["deployments"].values()]}
167
- elif cmd=="recover-controller-lock":result=break_controller_lock(state,args.ack)
168
- elif cmd=="import-legacy":result=import_legacy(state,Path(args.source).absolute(),args.controller_id)
169
- print(json.dumps(result,ensure_ascii=False,indent=2))
170
- return 2 if result.get("status") in ("failed","partial","unknown","docs_pending") else 0
171
- except (OpsError,OSError,ValueError,KeyError) as e:
172
- print(json.dumps({"status":"blocked","error":str(e)},ensure_ascii=False),file=sys.stderr);return 2
@@ -1,199 +0,0 @@
1
- """OPS resource runtime primitives. Python >=3.10, standard library only."""
2
- from __future__ import annotations
3
- import contextlib, datetime as dt, hashlib, json, os, re, socket, stat, subprocess, tempfile, uuid
4
- from pathlib import Path, PurePosixPath, PureWindowsPath
5
- from typing import Any, Iterator
6
-
7
- VERSION = "2.2.0"
8
- ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
9
- SECRET_RE = re.compile(r"\{\{credential:([a-z0-9-]+)@([1-9][0-9]*):([A-Za-z_][A-Za-z0-9_]*)\}\}")
10
- class OpsError(Exception):
11
- """A blocked operation; callers must not turn this into success."""
12
- class UnknownResult(OpsError):
13
- """Transport interruption: probe target receipts before retrying."""
14
-
15
- def now() -> str:
16
- return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
17
-
18
- def canonical(value: Any) -> bytes:
19
- return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
20
-
21
- def digest(value: Any) -> str:
22
- return hashlib.sha256(value if isinstance(value, bytes) else canonical(value)).hexdigest()
23
-
24
- def new_id(prefix: str) -> str:
25
- return f"{prefix}-{dt.datetime.now(dt.timezone.utc):%Y%m%d%H%M%S}-{uuid.uuid4().hex[:8]}"
26
-
27
- def identifier(value: str, label: str = "id") -> str:
28
- if not isinstance(value, str) or not ID_RE.fullmatch(value) or len(value) > 80:
29
- raise OpsError(f"{label}: expected lowercase kebab id (1..80 characters)")
30
- if re.fullmatch(r"(?:con|prn|aux|nul|com[0-9]|lpt[0-9])", value):
31
- raise OpsError(f"{label}: Windows reserved name")
32
- return value
33
-
34
- def exact(value: dict, allowed: set[str], required: set[str], label: str) -> None:
35
- if not isinstance(value, dict):
36
- raise OpsError(f"{label}: expected object")
37
- if set(value) - allowed:
38
- raise OpsError(f"{label}: unknown fields: {sorted(set(value)-allowed)}")
39
- if required - set(value):
40
- raise OpsError(f"{label}: missing fields: {sorted(required-set(value))}")
41
-
42
- def relative(value: str) -> str:
43
- if not isinstance(value, str) or not value or "\\" in value or "\x00" in value or ":" in value:
44
- raise OpsError(f"unsafe relative path: {value!r}")
45
- if value.startswith("/") or any(x in ("", ".", "..") for x in value.split("/")):
46
- raise OpsError(f"unsafe relative path: {value!r}")
47
- return value
48
-
49
- def root_path(value: str, platform: str) -> str:
50
- cls = PureWindowsPath if platform == "windows" else PurePosixPath
51
- p = cls(value)
52
- if not p.is_absolute() or ".." in p.parts or "\x00" in value:
53
- raise OpsError("host root must be an absolute, non-traversing path")
54
- if platform == "windows":
55
- if str(p).startswith("\\\\") or len(p.parts) < 2 or any(":" in x for x in p.parts[1:]):
56
- raise OpsError("UNC, drive root and ADS paths are not host roots")
57
- if str(p).lower().rstrip("\\") in {r"c:\windows", r"c:\program files", r"c:\users", r"c:\programdata"}:
58
- raise OpsError("a system directory cannot be host_root")
59
- elif str(p) in {"/", "/etc", "/usr", "/var", "/home", "/root", "/tmp", "/srv", "/opt", "/mnt"}:
60
- raise OpsError("host_root must be an OPS-specific child directory")
61
- return str(p)
62
-
63
- def target_join(host: dict, *parts: str) -> str:
64
- cls = PureWindowsPath if host["platform"] == "windows" else PurePosixPath
65
- p = cls(host["root"])
66
- for part in parts:
67
- p = p.joinpath(*relative(part).split("/"))
68
- return str(p)
69
-
70
- def within(path: str, root: str, platform: str) -> bool:
71
- cls = PureWindowsPath if platform == "windows" else PurePosixPath
72
- p, r = cls(path), cls(root)
73
- if ".." in p.parts or not p.is_absolute():
74
- return False
75
- try:
76
- p.relative_to(r)
77
- return p != r
78
- except ValueError:
79
- return False
80
-
81
- def no_symlinks(path: Path, *, allow_missing: bool = True) -> None:
82
- p = path.absolute()
83
- for q in [*reversed(p.parents), p]:
84
- try:
85
- s = q.lstat()
86
- except FileNotFoundError:
87
- if allow_missing:
88
- continue
89
- raise OpsError(f"missing path: {q}")
90
- if stat.S_ISLNK(s.st_mode) or getattr(s, "st_file_attributes", 0) & 0x400:
91
- raise OpsError(f"symlink/reparse point rejected: {q}")
92
-
93
- def secure(path: Path, directory: bool = False) -> None:
94
- """No chmod-only security claim on Windows; use a protected explicit ACL."""
95
- if os.name != "nt":
96
- os.chmod(path, 0o700 if directory else 0o600)
97
- return
98
- who = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True, text=True, check=True)
99
- import csv
100
- sid = next(csv.reader([who.stdout.strip()]))[1]
101
- flags = "(OI)(CI)F" if directory else "F"
102
- subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"*{sid}:{flags}", "*S-1-5-18:F"],
103
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=True)
104
-
105
- def private_dir(path: Path) -> None:
106
- no_symlinks(path)
107
- missing = []
108
- p = path
109
- while not p.exists():
110
- missing.append(p); p = p.parent
111
- for p in reversed(missing):
112
- p.mkdir(mode=0o700)
113
- secure(p, True)
114
- secure(path, True)
115
-
116
- def atomic_write(path: Path, data: bytes | str, mode: int = 0o600, *, exclusive: bool = False) -> None:
117
- if isinstance(data, str):
118
- data = data.encode("utf-8")
119
- no_symlinks(path)
120
- private_dir(path.parent)
121
- if exclusive and path.exists():
122
- raise OpsError(f"immutable artifact already exists: {path}")
123
- fd, name = tempfile.mkstemp(prefix=".ops-write-", dir=path.parent)
124
- tmp = Path(name)
125
- try:
126
- os.fchmod(fd, mode) if hasattr(os, "fchmod") else None
127
- with os.fdopen(fd, "wb") as handle:
128
- handle.write(data); handle.flush(); os.fsync(handle.fileno())
129
- if os.name == "nt": secure(tmp)
130
- if exclusive:
131
- # Link is an atomic create-if-absent on the same filesystem.
132
- os.link(tmp, path)
133
- tmp.unlink()
134
- else:
135
- os.replace(tmp, path)
136
- if os.name != "nt": os.chmod(path, mode)
137
- if hasattr(os, "O_DIRECTORY"):
138
- d = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
139
- try: os.fsync(d)
140
- finally: os.close(d)
141
- finally:
142
- tmp.unlink(missing_ok=True)
143
-
144
- def write_json(path: Path, value: Any, *, exclusive: bool = False) -> None:
145
- atomic_write(path, json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", exclusive=exclusive)
146
-
147
- def read_json(path: Path) -> Any:
148
- no_symlinks(path, allow_missing=False)
149
- try:
150
- def pairs(items):
151
- obj = {}
152
- for k, v in items:
153
- if k in obj: raise ValueError("duplicate JSON key: " + k)
154
- obj[k] = v
155
- return obj
156
- return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=pairs,
157
- parse_constant=lambda v: (_ for _ in ()).throw(ValueError(v)))
158
- except (ValueError, OSError) as exc:
159
- raise OpsError(f"invalid JSON: {path}: {exc}") from exc
160
-
161
- @contextlib.contextmanager
162
- def lock(path: Path, owner: dict) -> Iterator[None]:
163
- private_dir(path.parent)
164
- try: path.mkdir(mode=0o700)
165
- except FileExistsError as exc: raise OpsError(f"lock-held: {path}; inspect owner, never auto-break") from exc
166
- write_json(path / "owner.json", {**owner, "pid": os.getpid(), "machine": socket.gethostname(), "at": now()})
167
- try:
168
- yield
169
- finally:
170
- (path / "owner.json").unlink(missing_ok=True)
171
- path.rmdir()
172
-
173
- def redact(text: str, secrets: list[str]) -> str:
174
- for value in sorted(set(secrets), key=len, reverse=True):
175
- if value: text = text.replace(value, "[REDACTED]")
176
- text = re.sub(r"(?i)(password|passwd|token|secret|access_key)(\s*[=:]\s*)[^\s,;]+", r"\1\2[REDACTED]", text)
177
- return text
178
-
179
- def credentials_in(value: Any) -> set[str]:
180
- return {f"{m[0]}@{m[1]}" for m in SECRET_RE.findall(json.dumps(value, ensure_ascii=False))}
181
-
182
- def resolve_secrets(value: Any, ledger: dict) -> Any:
183
- if isinstance(value, str):
184
- def sub(match):
185
- cid, version, field = match.groups()
186
- try: result = ledger["entries"][cid][version]["values"][field]
187
- except KeyError as exc: raise OpsError(f"missing credential: {cid}@{version}:{field}") from exc
188
- if not isinstance(result, str): raise OpsError("credential values must be strings")
189
- return result
190
- return SECRET_RE.sub(sub, value)
191
- if isinstance(value, list): return [resolve_secrets(v, ledger) for v in value]
192
- if isinstance(value, dict): return {k: resolve_secrets(v, ledger) for k, v in value.items()}
193
- return value
194
-
195
- def empty_status() -> dict:
196
- return {"schema_version": 3, "workflow": "ops", "revision": 0, "controller": None,
197
- "hosts": {}, "projects": {}, "deployments": {}, "allocations": {}, "bindings": {}, "releases": {},
198
- "policies": {"server_readme_credentials": False, "server_operations": True,
199
- "strict_docker_root": True}, "updated_at": None}
@@ -1,199 +0,0 @@
1
- """Deterministic server/controller documentation and protected plaintext delivery bundles."""
2
- from __future__ import annotations
3
- import base64, copy, html, json, re, shlex
4
- from pathlib import Path
5
- from .core import *
6
-
7
- STANDARD="""# OPS 部署与持久化规范
8
-
9
- APP 和公共服务同级:host_root/project_id。明确启用多实例时使用 project_id/instances/environment/instance。
10
-
11
- 每个项目的 compose 或 service、env、config、data、logs、backups、releases 和 README 聚合在该项目目录。业务数据必须位于 data/component/purpose;不能回退到临时目录、代码目录、用户默认目录、匿名卷或命名卷。Docker 全局 data-root 另登记在 host_root/_runtime/docker;既有引擎迁移必须单独审批。
12
-
13
- README 记录实际版本、来源、主机、时间、路径、依赖、启停和备份恢复。它默认不含密码。OPERATIONS.md 和部署机明文账本按策略保存真实账号密码,权限必须限制;不得进入 Git、镜像构建上下文、Web 静态目录或普通日志。
14
-
15
- 共享服务拥有物理数据;APP 只拥有获批的逻辑数据库、桶、命名空间和应用账号。复制 APP 目录不是共享依赖完整备份。卸载 APP 不删除公共服务、共享网络、逻辑资源或任何数据。单 APP 回滚不允许恢复整个共享实例。
16
-
17
- 旧环境默认值恢复和健康验证失败不得清理原环境。缓存隔离不等于释放空间;禁止把 data/env/backups 或数据库持久日志当成垃圾。
18
-
19
- 计划 → 明确批准 → 执行 → 实际验证 → 双边文档回执完成。远程断线意味着结果未知,不能重跑迁移或重新生成密码。
20
- """
21
-
22
- def code(value) -> str:
23
- s=str(value)
24
- return "<code>"+html.escape(s).replace("\n","<br>")+"</code>"
25
-
26
- def block(value,language="json") -> str:
27
- text=json.dumps(value,ensure_ascii=False,indent=2) if not isinstance(value,str) else value
28
- ticks="`"*max(3,max((len(x) for x in re.findall(r"`+",text)),default=0)+1)
29
- return ticks+language+"\n"+text+"\n"+ticks+"\n"
30
-
31
- def path_for(status,dep,relative_name):
32
- return target_join({**status["hosts"][dep["host_id"]],"root":dep["root"]},relative_name)
33
-
34
- def credential_refs(status,dep):
35
- refs=set(dep["credential_refs"])
36
- for b in status["bindings"].values():
37
- if b["status"]=="active" and b["consumer_deployment_id"]==dep["deployment_id"]:refs.add(b["credential_ref"])
38
- for a in status["allocations"].values():
39
- if a["provider_deployment_id"]==dep["deployment_id"] and a["status"]!="retired":refs.add(a["credential_ref"])
40
- return sorted(refs)
41
-
42
- def credentials_text(refs,ledger):
43
- out=["## 明文账号密码\n","本文件是受限明文交付,不是脱敏报告。只向获授权管理员及对应运行账户开放。\n"]
44
- if not refs:return "\n".join(out+["本部署没有登记密码凭据。密钥认证本身不存在登录密码;未知旧密码不能伪造。\n"])
45
- for ref in refs:
46
- cid,v=ref.split("@")
47
- try:item=ledger["entries"][cid][v]
48
- except KeyError:raise OpsError("document delivery blocked by missing credential: "+ref)
49
- out.extend(["### "+ref+"\n",code(item["purpose"])+"\n"])
50
- for key,value in item["values"].items():out.extend(["**"+key+"**\n",block(value,"text")])
51
- return "\n".join(out)
52
-
53
- def dependencies(status,dep):
54
- lines=["## 依赖与数据归属\n"]
55
- bindings=[b for b in status["bindings"].values() if b["consumer_deployment_id"]==dep["deployment_id"] and b["status"]=="active"]
56
- if not bindings:lines.append("未登记共享或外部服务依赖;专用组件的数据仍归本项目目录。\n")
57
- for b in bindings:
58
- lines += ["### "+b["component"]+" / "+b["binding_id"]+"\n",f"模式:{b['mode']};端点:{code(b['endpoint'])};网络:{code(b['network'])}。\n"]
59
- if b["mode"]=="shared":
60
- a=status["allocations"][b["allocation_id"]];provider=status["deployments"][b["provider_deployment_id"]]
61
- lines.append(f"提供者:{provider['project_id']} / {provider['deployment_id']},主机 {provider['host_id']};服务目录 {code(provider['root'])}。\n")
62
- lines.append(f"逻辑资源:{code(a['resource_name'])}({a['resource_kind']});数据组 {a['data_group']};账号版本 {a['credential_ref']};恢复粒度 {a['recovery_scope']}。\n")
63
- for storage in provider["storage"]:lines.append(f"提供者物理路径:{code(storage['path'])}({storage['component']}/{storage['purpose']})。\n")
64
- lines.append("公共服务的物理数据不复制到 APP 目录;单 APP 恢复不得覆盖其他消费者。\n")
65
- consumers=[b for b in status["bindings"].values() if b["provider_deployment_id"]==dep["deployment_id"] and b["status"]=="active"]
66
- if consumers:
67
- lines.append("## 公共服务消费者\n\n| APP 实例 | 所在主机 | 分配 | 凭据版本 |\n|---|---|---|---|\n")
68
- for b in consumers:
69
- consumer=status["deployments"][b["consumer_deployment_id"]]
70
- lines.append(f"| {consumer['deployment_id']} | {consumer['host_id']} | {b['allocation_id']} | {b['credential_ref']} |\n")
71
- return "\n".join(lines)
72
-
73
- def deployment_readme(status,dep,run_id,generated_at,*,ledger=None,include_credentials=False,controller=False):
74
- host=status["hosts"][dep["host_id"]];project=status["projects"][dep["project_id"]]
75
- out=[f"# {project['display_name']} — {dep['deployment_id']}\n",
76
- f"文档代次:{run_id};生成时间(UTC):{generated_at}。部署验证与双边交付以部署机对应运行记录和 docs-receipt.json 为准。\n",
77
- "## 部署事实\n\n| 项目 | 值 |\n|---|---|",
78
- f"| 主机 / 账户 | {host['host_id']} / {code(host['connection'].get('username','本机执行账户'))} |",
79
- f"| 连接 | {code(host['connection'].get('hostname','local'))},SSH 端口 {host['connection'].get('port','不适用')} |",
80
- f"| 类型 / 方式 | {project['kind']} / {dep['method']} |",
81
- f"| 生命周期状态 | {dep['status']} |",
82
- f"| 环境 / 实例 | {dep['environment']} / {dep['instance']} |",
83
- f"| 计划版本 | {code(dep['version'])} |",
84
- f"| 最后运行验证版本 | {code(dep.get('observed_version') or '尚未验证')} |",
85
- f"| 首次部署时间(UTC) | {dep['installed_at'] or '尚未完成'} |",
86
- f"| 最近部署验证时间(UTC) | {dep['updated_at'] or '尚未验证'} |",
87
- f"| 项目根目录 | {code(dep['root'])} |",
88
- f"| 来源 | {code(dep['source']['location'])} |",
89
- f"| 固定来源版本 | {code(dep['source']['revision'])} |\n",
90
- "## 目录与持久化\n",
91
- f"部署定义:{code(path_for(status,dep,'compose/compose.yaml') if dep['method']=='compose' else path_for(status,dep,'service'))}。\n",
92
- f"环境文件:{code(path_for(status,dep,'env'))};配置:{code(path_for(status,dep,'config'))}。\n",
93
- f"日志:{code(path_for(status,dep,'logs'))};发布历史:{code(path_for(status,dep,'releases'))};备份:{code(path_for(status,dep,'backups'))}。\n",
94
- "| 组件 | 用途 | 自有持久化路径 |\n|---|---|---|"]
95
- for p in dep["storage"]:out.append(f"| {p['component']} | {p['purpose']} | {code(p['path'])} |")
96
- if not dep["storage"]:out.append("| — | 无登记的业务持久化内容 | 不应生成匿名数据路径 |")
97
- out += ["\n"+dependencies(status,dep),"## 日常操作\n"]
98
- for title,key in (("启动/运行","start"),("停止(保留数据)","stop"),("查看与验证","verify")):
99
- out.append("### "+title+"\n")
100
- if not dep["commands"][key]:out.append("不适用或由已批准运行计划执行。\n")
101
- for argv in dep["commands"][key]:
102
- # argv JSON is unambiguous on both POSIX and Windows, without shell quotation ambiguity.
103
- out.append(block(argv))
104
- out += ["## 更新步骤\n","先盘点主机与依赖;固定版本;检查备份及恢复能力;生成新计划并确认;分批执行;验证运行与数据;核对服务端和部署机文档回执。不得直接删除 data/env/backups,也不得用旧批准授权新迁移。\n",
105
- "## 备份\n",dep["backup"]+"\n","## 恢复与回滚\n",dep["recovery"]+"\n",
106
- "切换旧代码不能自动恢复数据库结构。共享服务整实例恢复需要全部消费者维护审批。\n"]
107
- if dep["notes"]:out += ["## 限制与备注\n", "\n\n".join(dep["notes"])+"\n"]
108
- replicas=[d for d in status["deployments"].values() if d["project_id"]==dep["project_id"]]
109
- out += ["## 同项目部署位置\n", "\n".join(f"- {d['host_id']} / {d['deployment_id']}:{code(d['root'])}" for d in sorted(replicas,key=lambda x:x["deployment_id"]))+"\n"]
110
- if controller:
111
- out.append("\n本目录是部署机的配置、凭据、运行证据和远端文档记录;不声称自动复制了远端业务数据。数据备份需要独立的备份策略和回执。\n")
112
- if include_credentials:out.append(credentials_text(credential_refs(status,dep),ledger))
113
- else:out.append("\n## 凭据记录\n\n本 README 默认不写密码。获授权管理员读取本项目 OPERATIONS.md(启用时)或部署机明文手册;密码未知时必须补齐,不能编造。\n")
114
- return "\n".join(out)+"\n"
115
-
116
- def host_readme(status,hid,run_id,at,*,ledger=None,full=False,controller=False):
117
- host=status["hosts"][hid]
118
- out=[f"# 主机 {host['display_name']} / {hid}\n",f"主机持久化根:{code(host['root'])};更新:{at};运行:{run_id}。\n",
119
- "APP 与公共服务同级。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。DEPLOYMENTS.md 为本机部署手册。\n",
120
- "| APP / 服务 | 实例 | 版本(最近验证) | 目录 | 最近验证时间 |\n|---|---|---|---|---|"]
121
- deps=sorted([d for d in status["deployments"].values() if d["host_id"]==hid],key=lambda d:d["deployment_id"])
122
- for d in deps:out.append(f"| {d['project_id']} | {d['deployment_id']} | {code(d.get('observed_version') or '未验证')} | {code(d['root'])} | {d['updated_at'] or '未验证'} |")
123
- out.append("\n公共服务数据归提供者;其他主机使用的服务通过依赖绑定记录,不在本机创建假的空服务目录。\n")
124
- if full:
125
- for d in deps:out.append(deployment_readme(status,d,run_id,at,ledger=ledger,include_credentials=controller or status["policies"]["server_operations"]))
126
- return "\n".join(out)+"\n"
127
-
128
- def remote_paths(status,dep):
129
- names=["README.md","project.yaml","run/release-state.json"]
130
- if status["policies"]["server_operations"]:names.append("OPERATIONS.md")
131
- if status["projects"][dep["project_id"]]["kind"]=="shared-service":names.append("consumers.md")
132
- if dep["layout"]=="instances":
133
- return [path_for(status,dep,n) for n in names]+[target_join(status["hosts"][dep["host_id"]],dep["project_id"]+"/README.md")]
134
- return [path_for(status,dep,n) for n in names]
135
-
136
- def plan_report(plan):
137
- out=[f"# OPS 执行计划 {plan['run_id']}\n",f"Worker:{plan['worker']};操作:{plan['operation']};风险:{plan['risk']}。\n",
138
- f"计划摘要:`{digest(plan)}`\n\n创建:{plan['created_at']};批准有效截止:{plan['expires_at']}。\n",
139
- "## 目标与理由\n",plan["reason"]+"\n"]
140
- for hid,h in plan["hosts"].items():out.append(f"主机 {hid}:{h['transport']} / {h['platform']} / 根 {code(h['root'])} / 身份 {h['identity']}。\n")
141
- out += ["## 受影响消费者\n",", ".join(plan["affected_consumers"]) or "没有登记的既有消费者受影响。","\n## 完整动作集合\n"]
142
- for op in plan["operations"]:
143
- out += [f"### {op['step_id']} — {op['host_id']} / {op.get('deployment_id') or 'host'} / {op['kind']}\n"]
144
- display={k:v for k,v in op.items() if k not in ("content_b64",)}
145
- if "content_b64" in op:display["payload_sha256"]=digest(base64.b64decode(op["content_b64"]))
146
- # Placeholders are not secrets; retain them so the account version/write set is reviewable.
147
- out.append(block(display))
148
- if op.get("secret_argv_acknowledged"):out.append("**该 MinIO 管理动作的应用密码会短暂出现在受特权用户可见的 mc 子进程参数中。批准包含此风险。**\n")
149
- out += ["## 明文文件与双边交付\n","服务器 README 默认不含密码;受限 OPERATIONS.md 和部署机手册按已批准策略写真实明文。env 和本地配置副本按 0600/受限 ACL 写入。目标与部署机文件均回读校验;缺一不可标记 completed。\n",
150
- block({"document_targets":plan["document_targets"],"credential_versions":list(plan["credential_versions"]),"policies":plan["registry_after"]["policies"]}),
151
- "## 数据保护和恢复\n",plan["rollback_note"]+"\n",
152
- "没有默认删除数据、逻辑资源、公共服务、卷或备份的动作。失败停止,断线标记 unknown;同一计划不能盲目重跑迁移。\n",
153
- "## 批准\n","必须由用户确认上述目标、完整写入集合、凭据版本、影响集合和恢复限制。approve 必须携带本报告对应的完整摘要;编辑计划会使旧批准失效。\n"]
154
- return "\n".join(out)
155
-
156
- def delivery_bundle(state,plan,status,ledger,verified_ids):
157
- at=now();rid=plan["run_id"];remote={};local={}
158
- def add_remote(hid,path,text):
159
- try:expected=plan["document_preconditions"][hid][path]
160
- except KeyError:raise OpsError("document write not included in approved preconditions: "+path)
161
- remote[(hid,path)]={"host_id":hid,"path":path,"content":text,"sha256":digest(text.encode()),"expected":expected}
162
- for did in sorted(set(verified_ids)):
163
- d=status["deployments"][did];hid=d["host_id"]
164
- readme=deployment_readme(status,d,rid,at,ledger=ledger,include_credentials=status["policies"]["server_readme_credentials"])
165
- add_remote(hid,path_for(status,d,"README.md"),readme)
166
- operations=deployment_readme(status,d,rid,at,ledger=ledger,include_credentials=True)
167
- if status["policies"]["server_operations"]:add_remote(hid,path_for(status,d,"OPERATIONS.md"),operations)
168
- add_remote(hid,path_for(status,d,"project.yaml"),json.dumps(d,ensure_ascii=False,indent=2)+"\n")
169
- add_remote(hid,path_for(status,d,"run/release-state.json"),json.dumps({"run_id":rid,"version":d["version"],"observed_version":d.get("observed_version"),"verified_at":d["updated_at"],"document_generation":at},ensure_ascii=False,indent=2)+"\n")
170
- if status["projects"][d["project_id"]]["kind"]=="shared-service":add_remote(hid,path_for(status,d,"consumers.md"),dependencies(status,d))
171
- if d["layout"]=="instances":
172
- projectroot=target_join(status["hosts"][hid],d["project_id"])
173
- peers=[x for x in status["deployments"].values() if x["host_id"]==hid and x["project_id"]==d["project_id"]]
174
- text="# "+d["project_id"]+" 实例索引\n\n"+"\n".join(f"- {x['environment']}/{x['instance']}:{code(x['root'])},版本 {code(x['version'])}" for x in peers)+"\n"
175
- add_remote(hid,target_join({**status["hosts"][hid],"root":projectroot},"README.md"),text)
176
- prefix=f"hosts/{hid}/deployments/{did}"
177
- local[prefix+"/README.md"]=deployment_readme(status,d,rid,at,ledger=ledger,include_credentials=True,controller=True)
178
- local[prefix+"/OPERATIONS.md"]=operations
179
- local[prefix+"/deployment.json"]=json.dumps(d,ensure_ascii=False,indent=2)+"\n"
180
- local[prefix+"/server/README.md"]=readme
181
- touched_hosts=sorted({status["deployments"][d]["host_id"] for d in verified_ids}|set(plan["hosts"]))
182
- for hid in touched_hosts:
183
- h=status["hosts"][hid]
184
- add_remote(hid,target_join(h,"README.md"),host_readme(status,hid,rid,at))
185
- add_remote(hid,target_join(h,"DEPLOYMENTS.md"),host_readme(status,hid,rid,at,ledger=ledger,full=True))
186
- add_remote(hid,target_join(h,"docs/standards/DEPLOYMENT-STANDARD.md"),STANDARD)
187
- knowledge_path=target_join(h,"knowledge/INDEX.md")
188
- if plan["document_preconditions"][hid].get(knowledge_path,{"kind":"file"})["kind"]=="absent":
189
- add_remote(hid,knowledge_path,"# 共享知识索引\n\n通用规范见 ../docs/standards/DEPLOYMENT-STANDARD.md。只收录经用户确认、带来源和最后验证时间的知识,不存密码。\n")
190
- local[f"hosts/{hid}/README.md"]=host_readme(status,hid,rid,at)
191
- local[f"hosts/{hid}/DEPLOYMENTS.md"]=host_readme(status,hid,rid,at,ledger=ledger,full=True,controller=True)
192
- global_text=["# 全域部署总册(明文)\n",f"最近文档代次:{rid};生成时间:{at}。\n","本地账本和配置副本不等于远端业务数据备份。\n"]
193
- for hid in sorted(status["hosts"]):global_text.append(host_readme(status,hid,rid,at,ledger=ledger,full=True,controller=True))
194
- local["FLEET-DEPLOYMENTS.md"]="\n".join(global_text)
195
- local["README.md"]="# OPS 控制端\n\n主机记录位于 hosts/<host_id>/;项目关联位于 status.json;项目部署记录位于 hosts/<host_id>/deployments/<deployment_id>/;完整明文总册位于 FLEET-DEPLOYMENTS.md;凭据账本位于 private/credentials.json;执行证据按 host runs / release 保存。\n\n不得清理此运行态目录来替换静态 workflow。双边文档完成由每次运行 docs-receipt.json 证明。\n"
196
- local["docs/standards/DEPLOYMENT-STANDARD.md"]=STANDARD
197
- if not (state/"knowledge/INDEX.md").exists():local["knowledge/INDEX.md"]="# 共享知识索引\n\n只收录经用户批准、带来源与最后验证日期的通用知识。运行时环境和密码不自动提升为共享知识。\n"
198
- return {"schema_version":1,"run_id":rid,"plan_digest":digest(plan),"generated_at":at,
199
- "remote":list(remote.values()),"local":[{"path":p,"content":text,"sha256":digest(text.encode())} for p,text in local.items()],"acks":{}}