@namewta/speculo 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ops-resources.js +1 -1
- package/dist/src/ops-resources.js.map +1 -1
- package/package.json +1 -1
- package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
- package/template/workflows/ops/H-host-manage/H-host-manage.md +5 -1
- package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
- package/template/workflows/ops/README.md +8 -5
- package/template/workflows/ops/common/CAPABILITIES.md +2 -2
- package/template/workflows/ops/common/USAGE.md +25 -25
- package/template/workflows/ops/common/examples/README.md +1 -1
- package/template/workflows/ops/common/examples/register.example.json +1 -1
- package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
- package/template/workflows/ops/common/schemas/host.schema.json +65 -2
- package/template/workflows/ops/common/schemas/plan.schema.json +315 -3
- package/template/workflows/ops/common/schemas/spec.schema.json +137 -1
- package/template/workflows/ops/common/schemas/status.schema.json +143 -1
- package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
- package/template/workflows/ops/common/service-profiles/redis.md +4 -0
- package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
- package/template/workflows/ops/common/templates/HOST-README.md +12 -2
- package/template/workflows/ops/common/tests/test_ops.mjs +982 -0
- package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
- package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
- package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
- package/template/workflows/ops/common/tools/ops.mjs +4 -0
- package/template/workflows/ops/common/tools/opslib/agent.mjs +904 -0
- package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
- package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
- package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
- package/template/workflows/ops/common/tools/opslib/docs.mjs +436 -0
- package/template/workflows/ops/common/tools/opslib/execution.mjs +396 -0
- package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
- package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
- package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
- package/template/workflows/ops/common/tools/opslib/planner.mjs +781 -0
- package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
- package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
- package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
- package/template/workflows/ops/common/tests/test_ops.py +0 -392
- package/template/workflows/ops/common/tools/demo-local.py +0 -64
- package/template/workflows/ops/common/tools/ops.py +0 -7
- package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
- 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 +0 -510
- package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
- package/template/workflows/ops/common/tools/opslib/core.py +0 -199
- package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
- package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
- package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
- package/template/workflows/ops/common/tools/opslib/model.py +0 -199
- package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
- package/template/workflows/ops/common/tools/opslib/services.py +0 -47
- package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
- package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
"""Digest-bound approval, target receipts, fail-closed resume and dual documentation delivery."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
import base64, copy, datetime as dt, json, os
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from .core import *
|
|
6
|
-
from .model import load, save, ledger_load, validate, validate_status
|
|
7
|
-
from .planner import locate_plan, env_file
|
|
8
|
-
from .transport import call, host_transport_digest
|
|
9
|
-
from .docs import delivery_bundle
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def engine_digest() -> str:
|
|
13
|
-
base=Path(__file__).resolve().parents[2]
|
|
14
|
-
files=sorted(list((base/"tools/opslib").glob("*.py"))+list((base/"schemas").glob("*.json")))
|
|
15
|
-
return digest({str(p.relative_to(base)):digest(p.read_bytes()) for p in files})
|
|
16
|
-
|
|
17
|
-
def approval(state: Path, run: str, expected: str, by: str, statement: str) -> dict:
|
|
18
|
-
path=locate_plan(state,run);plan=read_json(path);validate(plan,"plan")
|
|
19
|
-
if digest(plan)!=expected:raise OpsError("approval digest mismatch: reread the full plan report")
|
|
20
|
-
if not by.strip() or len(statement.strip())<8:raise OpsError("approval needs identified approver and a meaningful explicit confirmation statement")
|
|
21
|
-
if dt.datetime.now(dt.timezone.utc)>=dt.datetime.fromisoformat(plan["expires_at"].replace("Z","+00:00")):raise OpsError("plan expired; replan")
|
|
22
|
-
value={"schema_version":1,"artifact":"ops-plan-approval","run_id":plan["run_id"],"plan_digest":expected,
|
|
23
|
-
"approved_by":by,"approved_at":now(),"decision":"approved","statement":statement}
|
|
24
|
-
validate(value,"approval")
|
|
25
|
-
with lock(state/".locks/catalog",{"operation":"approve","run_id":plan["run_id"]}):
|
|
26
|
-
if digest(load(state))!=plan["registry_digest"]:raise OpsError("catalog drift since plan; replan before approval")
|
|
27
|
-
write_json(path.parent/"approval.json",value,exclusive=True)
|
|
28
|
-
return {"run_id":plan["run_id"],"status":"approved","plan_digest":expected}
|
|
29
|
-
|
|
30
|
-
def journal(folder: Path, event: dict) -> None:
|
|
31
|
-
path=folder/"journal.jsonl";no_symlinks(path);private_dir(path.parent)
|
|
32
|
-
# Exclusive controller lock serializes append order; sequence hash makes truncation/tampering visible.
|
|
33
|
-
previous="0"*64;seq=1
|
|
34
|
-
if path.exists():
|
|
35
|
-
with path.open("rb") as f:
|
|
36
|
-
for line in f:
|
|
37
|
-
old=json.loads(line);seq=old["sequence"]+1;previous=old["event_digest"]
|
|
38
|
-
value={"sequence":seq,"at":now(),"previous_digest":previous,**event};value["event_digest"]=digest(value)
|
|
39
|
-
fd=os.open(path,os.O_WRONLY|os.O_APPEND|os.O_CREAT,0o600)
|
|
40
|
-
with os.fdopen(fd,"ab") as f:f.write(canonical(value)+b"\n");f.flush();os.fsync(f.fileno())
|
|
41
|
-
secure(path)
|
|
42
|
-
|
|
43
|
-
def verify_journal(path: Path) -> dict:
|
|
44
|
-
previous="0"*64;expected=1
|
|
45
|
-
if not path.exists():return {"events":0}
|
|
46
|
-
for line in path.read_text(encoding="utf8").splitlines():
|
|
47
|
-
v=json.loads(line);claimed=v.pop("event_digest")
|
|
48
|
-
if v["sequence"]!=expected or v["previous_digest"]!=previous or digest(v)!=claimed:raise OpsError("journal integrity mismatch")
|
|
49
|
-
previous=claimed;expected+=1
|
|
50
|
-
return {"events":expected-1,"last_digest":previous}
|
|
51
|
-
|
|
52
|
-
def request_base(plan,hid,ledger):
|
|
53
|
-
values=[]
|
|
54
|
-
for ref in plan["credential_versions"]:
|
|
55
|
-
cid,v=ref.split("@");values.extend(ledger["entries"][cid][v]["values"].values())
|
|
56
|
-
return {"host_id":hid,"root":plan["hosts"][hid]["root"],"controller_id":plan["controller_id"],"run_id":plan["run_id"],
|
|
57
|
-
"plan_digest":digest(plan),"adopt_root":hid in plan["allow_adopt_roots"],"external_files":plan["external_files"].get(hid,[]),
|
|
58
|
-
"strict_docker_root":plan["registry_after"]["policies"]["strict_docker_root"],"secrets":values}
|
|
59
|
-
|
|
60
|
-
def resolved_op(operation,ledger):
|
|
61
|
-
op=resolve_secrets(copy.deepcopy(operation),ledger)
|
|
62
|
-
if op["kind"]=="write":
|
|
63
|
-
if "json_values" in operation:op["content"]=json.dumps(resolve_secrets(operation["json_values"],ledger),ensure_ascii=False,indent=2)+"\n"
|
|
64
|
-
op.pop("json_values",None)
|
|
65
|
-
if "env_values" in operation:
|
|
66
|
-
op["content"]=env_file(resolve_secrets(operation["env_values"],ledger),systemd=operation.get("env_format")=="systemd")
|
|
67
|
-
if "content" in op:op["content_b64"]=base64.b64encode(op.pop("content").encode()).decode()
|
|
68
|
-
op.pop("env_values",None);op.pop("env_format",None)
|
|
69
|
-
return op
|
|
70
|
-
|
|
71
|
-
def commit_observations(state,current,plan,execution):
|
|
72
|
-
result=copy.deepcopy(current);desired=plan["registry_after"];records=execution["steps"]
|
|
73
|
-
# The desired registry is not treated as observed reality. Preserve the previous verified version on failure.
|
|
74
|
-
result["policies"]=desired["policies"]
|
|
75
|
-
result["hosts"]=copy.deepcopy(desired["hosts"])
|
|
76
|
-
result["projects"]=copy.deepcopy(desired["projects"])
|
|
77
|
-
for did in plan["document_targets"]:
|
|
78
|
-
proposed=copy.deepcopy(desired["deployments"][did]);old=current["deployments"].get(did)
|
|
79
|
-
ops=[o for o in plan["operations"] if o.get("deployment_id")==did]
|
|
80
|
-
statuses=[records.get(o["step_id"],{}).get("status","not-started") for o in ops]
|
|
81
|
-
changing=bool(ops)
|
|
82
|
-
if changing and all(x=="succeeded" for x in statuses):
|
|
83
|
-
proposed["status"]="retired" if proposed["status"]=="retired" else "docs_pending"
|
|
84
|
-
proposed["observed_version"]=proposed["version"]
|
|
85
|
-
proposed["installed_at"]=proposed["installed_at"] or now();proposed["updated_at"]=now()
|
|
86
|
-
elif changing:
|
|
87
|
-
proposed["status"]="unknown" if "unknown" in statuses else ("failed" if "failed" in statuses else "planned")
|
|
88
|
-
proposed["observed_version"]=old.get("observed_version") if old else None
|
|
89
|
-
proposed["notes"].append("本次发布未完成;配置/数据可能部分变更。计划版本不是已验证运行版本。不得盲目重跑。")
|
|
90
|
-
elif old:proposed=copy.deepcopy(old)
|
|
91
|
-
else:proposed["status"]="planned";proposed["observed_version"]=None
|
|
92
|
-
result["deployments"][did]=proposed
|
|
93
|
-
for aid,a in desired["allocations"].items():
|
|
94
|
-
if aid in current["allocations"] and current["allocations"][aid]["status"]!="planned":result["allocations"][aid]=copy.deepcopy(current["allocations"][aid]);continue
|
|
95
|
-
item=copy.deepcopy(a)
|
|
96
|
-
provisioning=[o for o in plan["operations"] if o.get("allocation_id")==aid]
|
|
97
|
-
item["status"]="active" if provisioning and all(records.get(o["step_id"],{}).get("status")=="succeeded" for o in provisioning) else "planned"
|
|
98
|
-
result["allocations"][aid]=item
|
|
99
|
-
for bid,b in desired["bindings"].items():
|
|
100
|
-
item=copy.deepcopy(b);consumer=result["deployments"].get(b["consumer_deployment_id"])
|
|
101
|
-
if item["status"]=="active" and (not consumer or consumer["status"] in ("planned","failed","unknown")):
|
|
102
|
-
item["status"]="planned"
|
|
103
|
-
if item["mode"]=="shared" and result["allocations"][item["allocation_id"]]["status"]!="active" and item["status"]=="active":item["status"]="planned"
|
|
104
|
-
result["bindings"][bid]=item
|
|
105
|
-
result["releases"][plan["run_id"]]={"run_id":plan["run_id"],"worker":plan["worker"],"status":execution["status"],
|
|
106
|
-
"plan_path":str(Path(execution["plan_path"]).relative_to(state)),"host_ids":sorted(plan["hosts"]),"deployment_ids":plan["document_targets"],"updated_at":now(),
|
|
107
|
-
"results":{sid:{"status":v["status"],"at":v.get("at")} for sid,v in records.items()}}
|
|
108
|
-
save(state,result)
|
|
109
|
-
execution["committed_registry_digest"]=digest(result)
|
|
110
|
-
return result
|
|
111
|
-
|
|
112
|
-
def mirror_configuration(state,plan,execution,ledger):
|
|
113
|
-
for original in plan["operations"]:
|
|
114
|
-
if original["kind"]!="write" or not original.get("deployment_id") or execution["steps"].get(original["step_id"],{}).get("status")!="succeeded":continue
|
|
115
|
-
dep=plan["registry_after"]["deployments"][original["deployment_id"]];h=plan["hosts"][dep["host_id"]]
|
|
116
|
-
cls=PureWindowsPath if h["platform"]=="windows" else PurePosixPath
|
|
117
|
-
try:rel=str(cls(original["path"]).relative_to(cls(dep["root"]))).replace("\\","/")
|
|
118
|
-
except ValueError:continue # Control file also has an in-project copy.
|
|
119
|
-
if rel.split("/")[0] not in {"env","compose","config","service","scripts"}:continue
|
|
120
|
-
op=resolved_op(original,ledger)
|
|
121
|
-
dest=state/"hosts"/dep["host_id"]/"deployments"/dep["deployment_id"]/"server-files"/relative(rel)
|
|
122
|
-
atomic_write(dest,base64.b64decode(op["content_b64"]))
|
|
123
|
-
if digest(dest.read_bytes())!=digest(base64.b64decode(op["content_b64"])):raise OpsError("local configuration mirror readback mismatch")
|
|
124
|
-
|
|
125
|
-
def deliver(state,folder,plan,status,execution,ledger):
|
|
126
|
-
outbox=folder/"outbox.json"
|
|
127
|
-
eligible=[did for did in plan["document_targets"] if status["deployments"][did]["status"] not in ("planned","failed","unknown")]
|
|
128
|
-
if not outbox.exists():
|
|
129
|
-
document_status=copy.deepcopy(status)
|
|
130
|
-
for did in eligible:
|
|
131
|
-
if document_status["deployments"][did]["status"]=="docs_pending":document_status["deployments"][did]["status"]="completed"
|
|
132
|
-
write_json(outbox,delivery_bundle(state,plan,document_status,ledger,eligible),exclusive=True)
|
|
133
|
-
bundle=read_json(outbox)
|
|
134
|
-
if bundle["plan_digest"]!=digest(plan):raise OpsError("outbox does not belong to approved plan")
|
|
135
|
-
# Local files are durable first. A later remote failure cannot erase the controller's record.
|
|
136
|
-
for item in bundle["local"]:
|
|
137
|
-
path=state/relative(item["path"])
|
|
138
|
-
if item["path"]=="knowledge/INDEX.md" and path.exists():continue # Never overwrite user-owned permanent knowledge.
|
|
139
|
-
atomic_write(path,item["content"])
|
|
140
|
-
if digest(path.read_bytes())!=item["sha256"]:raise OpsError("local document readback mismatch")
|
|
141
|
-
bundle["acks"]["local:"+item["path"]]={"sha256":item["sha256"],"verified_at":now()}
|
|
142
|
-
write_json(outbox,bundle)
|
|
143
|
-
mirror_configuration(state,plan,execution,ledger)
|
|
144
|
-
for item in bundle["remote"]:
|
|
145
|
-
hid=item["host_id"];base=request_base(plan,hid,ledger)
|
|
146
|
-
step="docs-"+digest((hid+":"+item["path"]).encode())[:24]
|
|
147
|
-
op={"step_id":step,"kind":"write","path":item["path"],"content_b64":base64.b64encode(item["content"].encode()).decode(),"mode":0o600,"expected":item["expected"]}
|
|
148
|
-
result=call(plan["hosts"][hid],{**base,"action":"step","operation":op,"operation_digest":digest(op)})
|
|
149
|
-
if result["status"]!="succeeded":raise OpsError("target document write did not complete: "+item["path"]+" / "+result["status"])
|
|
150
|
-
observed=call(plan["hosts"][hid],{"action":"snapshot","paths":[item["path"]]})["paths"][item["path"]]
|
|
151
|
-
if observed.get("sha256")!=item["sha256"]:raise OpsError("target document readback mismatch: "+item["path"])
|
|
152
|
-
if plan["hosts"][hid]["platform"]!="windows" and observed["mode"]&0o077:raise OpsError("target plaintext documentation mode is too broad")
|
|
153
|
-
bundle["acks"]["remote:"+hid+":"+item["path"]]={"sha256":item["sha256"],"verified_at":now()}
|
|
154
|
-
write_json(outbox,bundle)
|
|
155
|
-
receipt={"schema_version":1,"run_id":plan["run_id"],"plan_digest":digest(plan),"completed_at":now(),"documents":bundle["acks"],"status":"both-sides-verified"}
|
|
156
|
-
write_json(folder/"docs-receipt.json",receipt)
|
|
157
|
-
for did in eligible:
|
|
158
|
-
dep=status["deployments"][did]
|
|
159
|
-
write_json(state/"hosts"/dep["host_id"]/"deployments"/did/"docs-receipt.json",receipt)
|
|
160
|
-
return receipt
|
|
161
|
-
|
|
162
|
-
def apply(state: Path,run: str,*,resume=False,docs_only=False) -> dict:
|
|
163
|
-
path=locate_plan(state,run);folder=path.parent;plan=read_json(path);validate(plan,"plan")
|
|
164
|
-
approved=read_json(folder/"approval.json");validate(approved,"approval")
|
|
165
|
-
if approved["plan_digest"]!=digest(plan) or approved["run_id"]!=plan["run_id"]:raise OpsError("plan differs from approval")
|
|
166
|
-
if plan.get("engine_digest")!=engine_digest():raise OpsError("executor/schema code changed after planning; replan with this implementation")
|
|
167
|
-
exec_path=folder/"execution.json"
|
|
168
|
-
if exec_path.exists() and not (resume or docs_only):raise OpsError("run already started; use resume or docs-sync, never a blind second apply")
|
|
169
|
-
if not exec_path.exists() and dt.datetime.now(dt.timezone.utc)>=dt.datetime.fromisoformat(plan["expires_at"].replace("Z","+00:00")):raise OpsError("approved plan expired before first execution")
|
|
170
|
-
with lock(state/".locks/catalog",{"operation":"apply","run_id":plan["run_id"]}):
|
|
171
|
-
current=load(state);ledger=ledger_load(state)
|
|
172
|
-
execution=read_json(exec_path) if exec_path.exists() else {"schema_version":1,"run_id":plan["run_id"],"plan_path":str(path),"status":"executing","started_at":now(),"steps":{},"errors":[],"committed_registry_digest":None}
|
|
173
|
-
expected=execution["committed_registry_digest"] or plan["registry_digest"]
|
|
174
|
-
if digest(current)!=expected:raise OpsError("controller registry changed since this approved run; compile a new plan")
|
|
175
|
-
if execution["status"]=="completed":return {"run_id":plan["run_id"],"status":"completed","unchanged":True,"report":str(folder/"RESULT.md")}
|
|
176
|
-
verify_journal(folder/"journal.jsonl")
|
|
177
|
-
for hid,h in plan["hosts"].items():
|
|
178
|
-
if host_transport_digest(h)!=plan["transport_digests"][hid]:raise OpsError("SSH connection/known_hosts changed since approval")
|
|
179
|
-
for ref,wanted in plan["credential_versions"].items():
|
|
180
|
-
cid,v=ref.split("@")
|
|
181
|
-
if digest(ledger["entries"].get(cid,{}).get(v))!=wanted:raise OpsError("credential version changed/missing since approval: "+ref)
|
|
182
|
-
locked=[];unknown=False;operation_failed=False;doc_error=None
|
|
183
|
-
write_json(exec_path,execution)
|
|
184
|
-
journal(folder,{"kind":"attempt-start","run_id":plan["run_id"],"resume":resume,"docs_only":docs_only})
|
|
185
|
-
try:
|
|
186
|
-
for hid,h in plan["hosts"].items():
|
|
187
|
-
call(h,{**request_base(plan,hid,ledger),"action":"lock"});locked.append(hid)
|
|
188
|
-
if not docs_only:
|
|
189
|
-
for original in plan["operations"]:
|
|
190
|
-
sid=original["step_id"]
|
|
191
|
-
previous=execution["steps"].get(sid)
|
|
192
|
-
if previous and previous["status"]=="succeeded":continue
|
|
193
|
-
if previous and previous["status"]=="failed":raise OpsError("a terminal failed action needs a new recovery plan; failed actions are not replayed")
|
|
194
|
-
hid=original["host_id"];op=resolved_op(original,ledger)
|
|
195
|
-
journal(folder,{"kind":"step-dispatch","step_id":sid,"host_id":hid,"operation_digest":digest(original)})
|
|
196
|
-
result=call(plan["hosts"][hid],{**request_base(plan,hid,ledger),"action":"step","operation":op,"operation_digest":digest(original)},timeout=max(1800,op.get("timeout",300)+120))
|
|
197
|
-
execution["steps"][sid]=result;write_json(exec_path,execution)
|
|
198
|
-
journal(folder,{"kind":"step-result","step_id":sid,"status":result["status"],"receipt_digest":digest(result)})
|
|
199
|
-
if result["status"]!="succeeded":
|
|
200
|
-
unknown=result["status"]=="unknown";operation_failed=True;break
|
|
201
|
-
elif any(execution["steps"].get(o["step_id"],{}).get("status")!="succeeded" for o in plan["operations"]):
|
|
202
|
-
raise OpsError("docs-sync only retries documentation after all operation receipts succeeded")
|
|
203
|
-
complete_ops=all(execution["steps"].get(o["step_id"],{}).get("status")=="succeeded" for o in plan["operations"])
|
|
204
|
-
execution["status"]="unknown" if unknown else ("docs_pending" if complete_ops else "partial")
|
|
205
|
-
if not docs_only or not execution["committed_registry_digest"]:
|
|
206
|
-
current=commit_observations(state,current,plan,execution);write_json(exec_path,execution)
|
|
207
|
-
if complete_ops:
|
|
208
|
-
try:
|
|
209
|
-
deliver(state,folder,plan,current,execution,ledger)
|
|
210
|
-
for did in plan["document_targets"]:
|
|
211
|
-
if current["deployments"][did]["status"]=="docs_pending":current["deployments"][did]["status"]="completed"
|
|
212
|
-
execution["status"]="completed"
|
|
213
|
-
current["releases"][plan["run_id"]]["status"]="completed";current["releases"][plan["run_id"]]["updated_at"]=now()
|
|
214
|
-
save(state,current);execution["committed_registry_digest"]=digest(current)
|
|
215
|
-
except (OpsError,OSError,ValueError) as e:
|
|
216
|
-
execution["status"]="docs_pending";doc_error=str(e)
|
|
217
|
-
execution["errors"].append({"phase":"documentation","at":now(),"error":str(e)})
|
|
218
|
-
else:
|
|
219
|
-
# A durable controller record survives partial remote deployment even when server docs cannot be published.
|
|
220
|
-
for did in plan["document_targets"]:
|
|
221
|
-
dep=current["deployments"][did]
|
|
222
|
-
write_json(state/"hosts"/dep["host_id"]/"deployments"/did/"deployment.json",dep)
|
|
223
|
-
except UnknownResult as e:
|
|
224
|
-
unknown=True;execution["status"]="unknown";execution["errors"].append({"phase":"transport","error":str(e),"at":now()})
|
|
225
|
-
if not execution["committed_registry_digest"]:
|
|
226
|
-
# Record a dispatched step as unknown, not not-started.
|
|
227
|
-
if 'original' in locals() and original["step_id"] not in execution["steps"]:
|
|
228
|
-
execution["steps"][original["step_id"]]={"status":"unknown","at":now(),"reason":"transport interrupted"}
|
|
229
|
-
current=commit_observations(state,current,plan,execution)
|
|
230
|
-
except (OpsError,OSError,ValueError) as e:
|
|
231
|
-
execution["status"]="failed";execution["errors"].append({"phase":"execution","error":str(e),"at":now()})
|
|
232
|
-
if not execution["committed_registry_digest"]:current=commit_observations(state,current,plan,execution)
|
|
233
|
-
finally:
|
|
234
|
-
# Unknown results keep target reservations, preventing another controller from racing recovery.
|
|
235
|
-
if not unknown:
|
|
236
|
-
for hid in reversed(locked):
|
|
237
|
-
try:call(plan["hosts"][hid],{**request_base(plan,hid,ledger),"action":"unlock"})
|
|
238
|
-
except (OpsError,UnknownResult) as e:
|
|
239
|
-
execution["errors"].append({"phase":"unlock","host_id":hid,"error":str(e),"at":now()})
|
|
240
|
-
if execution["status"]=="completed":execution["status"]="unknown"
|
|
241
|
-
execution["updated_at"]=now()
|
|
242
|
-
if plan["run_id"] in current["releases"] and current["releases"][plan["run_id"]]["status"]!=execution["status"]:
|
|
243
|
-
current["releases"][plan["run_id"]]["status"]=execution["status"];save(state,current);execution["committed_registry_digest"]=digest(current)
|
|
244
|
-
write_json(exec_path,execution)
|
|
245
|
-
journal(folder,{"kind":"attempt-end","status":execution["status"],"errors":len(execution["errors"])})
|
|
246
|
-
result_md="# 执行结果 "+plan["run_id"]+"\n\n状态:"+execution["status"]+"\n\n"+"\n".join(f"- {sid}: {v['status']}" for sid,v in execution["steps"].items())+"\n\n错误/未完成项:\n"+json.dumps(execution["errors"],ensure_ascii=False,indent=2)+"\n\n双边文档验证仅在 docs-receipt.json 存在且内容为 both-sides-verified 时完成。业务数据不自动镜像到部署机。\n"
|
|
247
|
-
atomic_write(folder/"RESULT.md",result_md)
|
|
248
|
-
return {"run_id":plan["run_id"],"status":execution["status"],"report":str(folder/"RESULT.md"),"documentation_error":doc_error,"errors":execution["errors"]}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""Pinned environment-management recipes. No latest-version guessing or implicit profile edits."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
import re,shlex
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from .core import *
|
|
6
|
-
from .model import load
|
|
7
|
-
from .transport import call
|
|
8
|
-
|
|
9
|
-
def environment_spec(state: Path,hid: str,request: dict) -> dict:
|
|
10
|
-
exact(request,{"account","python_versions","uv_path","node_version","npm_version","volta_path","java_version","original_java_candidate","sdkman_init"},{"account"},"environment request")
|
|
11
|
-
identifier(request["account"],"toolchain account")
|
|
12
|
-
status=load(state);host=status["hosts"][hid]
|
|
13
|
-
inventory=call(host,{"action":"probe","disk_roots":[host["root"]]},timeout=180)
|
|
14
|
-
base=target_join(host,"_host/toolchains/"+request["account"])
|
|
15
|
-
defaults={k:v for k,v in inventory["tools"].items() if k in ("java","python","python3","node","npm") and v["status"]=="observed"}
|
|
16
|
-
actions=[]
|
|
17
|
-
def mkdir(rel):actions.append({"host_id":hid,"kind":"mkdir","reason":"统一工具链与缓存根","path":rel})
|
|
18
|
-
def command(argv,env,writes,verify,reason):
|
|
19
|
-
actions.append({"host_id":hid,"kind":"install-toolchain","reason":reason,"argv":argv,"cwd":host["root"],"env":env,"writes":writes,
|
|
20
|
-
"timeout":3600,"expected_defaults":defaults,"verification":[{**verify,"env":env}]})
|
|
21
|
-
def pinned(value,label):
|
|
22
|
-
if not isinstance(value,str) or not re.fullmatch(r"[0-9][A-Za-z0-9.+_-]*",value):raise OpsError(label+" must be a concrete version/candidate, not latest or a range")
|
|
23
|
-
return value
|
|
24
|
-
if request.get("python_versions"):
|
|
25
|
-
uv=request.get("uv_path") or inventory["tools"]["uv"].get("path")
|
|
26
|
-
if not uv:raise OpsError("uv is missing: first stage a checksum-verified installer in a separate host/bootstrap plan")
|
|
27
|
-
install=base+("\\uv-python" if host["platform"]=="windows" else "/uv-python")
|
|
28
|
-
cache=target_join(host,"_host/cache/"+request["account"]+"/uv")
|
|
29
|
-
mkdir("_host/toolchains/"+request["account"]+"/uv-python");mkdir("_host/cache/"+request["account"]+"/uv")
|
|
30
|
-
for version in request["python_versions"]:
|
|
31
|
-
pinned(version,"Python")
|
|
32
|
-
command([uv,"python","install",version],{"UV_PYTHON_INSTALL_DIR":install,"UV_CACHE_DIR":cache},[install,cache],
|
|
33
|
-
{"type":"command","argv":[uv,"python","find",version],"stdout_pattern":re.escape(install)},"Install explicitly pinned Python; never replace OS Python or change the old PATH/default.")
|
|
34
|
-
if request.get("node_version"):
|
|
35
|
-
v=pinned(request["node_version"],"Node");volta=request.get("volta_path") or inventory["tools"]["volta"].get("path")
|
|
36
|
-
if not volta:raise OpsError("Volta missing: install a reviewed pinned release before the managed environment recipe")
|
|
37
|
-
home=base+("\\volta" if host["platform"]=="windows" else "/volta")
|
|
38
|
-
mkdir("_host/toolchains/"+request["account"]+"/volta")
|
|
39
|
-
old=inventory["tools"]["node"].get("version","").strip().removeprefix("v")
|
|
40
|
-
if old and not re.fullmatch(r"\d+\.\d+\.\d+",old):raise OpsError("cannot confidently identify original Node default")
|
|
41
|
-
# Fetch installs additional versions without changing Volta's default.
|
|
42
|
-
command([volta,"fetch","node@"+v],{"VOLTA_HOME":home},[home],{"type":"command","argv":[volta,"list","all"]},"Populate the managed Volta store without changing the user's default Node.")
|
|
43
|
-
if old:
|
|
44
|
-
command([volta,"install","node@"+old],{"VOLTA_HOME":home},[home],{"type":"command","argv":[volta,"list","all"]},"Set the managed Volta default back to the exact observed Node version; no shell profile modification.")
|
|
45
|
-
if request.get("npm_version"):
|
|
46
|
-
npm=pinned(request["npm_version"],"npm")
|
|
47
|
-
command([volta,"fetch","npm@"+npm],{"VOLTA_HOME":home},[home],{"type":"command","argv":[volta,"list","all"]},"Fetch explicit npm version without changing user default.")
|
|
48
|
-
oldnpm=inventory["tools"]["npm"].get("version","").strip()
|
|
49
|
-
if re.fullmatch(r"\d+\.\d+\.\d+",oldnpm):
|
|
50
|
-
command([volta,"install","npm@"+oldnpm],{"VOLTA_HOME":home},[home],{"type":"command","argv":[volta,"list","all"]},"Restore the observed npm default inside the managed Volta home.")
|
|
51
|
-
if request.get("java_version"):
|
|
52
|
-
if host["platform"]=="windows":raise OpsError("SDKMAN is not a native Windows adapter; register WSL as a distinct Linux execution host or retain native JDK")
|
|
53
|
-
version=pinned(request["java_version"],"JDK candidate");old=pinned(request.get("original_java_candidate"),"original SDKMAN JDK candidate")
|
|
54
|
-
init=request.get("sdkman_init");sdkroot=base+"/sdkman"
|
|
55
|
-
if not init or not within(init,sdkroot,host["platform"]):raise OpsError("SDKMAN initialization must be staged in the managed SDKMAN root; never silently migrate an existing ~/.sdkman")
|
|
56
|
-
q=shlex.quote
|
|
57
|
-
script="set -euo pipefail\nexport SDKMAN_DIR="+q(sdkroot)+"\nsource "+q(init)+"\nsdkman_auto_answer=true\nsdkman_selfupdate_enable=false\nsdk current java | grep -F -- "+q(old)+"\nsdk install java "+q(version)+"\nsdk default java "+q(old)+"\nsdk use java "+q(old)+"\njava -version\n"
|
|
58
|
-
rel="_host/scripts/"+new_id("sdkman")+".sh"
|
|
59
|
-
actions.append({"host_id":hid,"kind":"write-file","reason":"Version-pinned SDKMAN script with original-default restoration","path":rel,"content":script,"mode":0o700})
|
|
60
|
-
command(["bash",target_join(host,rel)],{},[sdkroot],{"type":"command","argv":["java","-version"]},"Install approved JDK, then restore and verify original candidate. Old JDK is never deleted.")
|
|
61
|
-
if not actions:raise OpsError("no explicit toolchain versions requested")
|
|
62
|
-
consumers={d["deployment_id"] for d in status["deployments"].values() if d["host_id"]==hid and d["status"]!="retired"}
|
|
63
|
-
for b in status["bindings"].values():
|
|
64
|
-
if b["provider_deployment_id"] in consumers and b["status"]=="active":consumers.add(b["consumer_deployment_id"])
|
|
65
|
-
return {"schema_version":1,"worker":"H","operation":"prepare","reason":"Managed toolchain preparation with observed-default preservation",
|
|
66
|
-
"hosts":[hid],"host_actions":actions,"acknowledged_consumers":sorted(consumers),
|
|
67
|
-
"rollback_note":"Do not remove old toolchains. A failed default check stops the run. The plan does not modify user shell profiles; adoption of the managed activation environment is a separate explicit change.","risk":"external-mutation"}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
"""Strict resource contracts and atomic controller-side catalog operations."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
import copy, json, os, re
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
6
|
-
from .core import *
|
|
7
|
-
|
|
8
|
-
SCHEMAS = Path(__file__).resolve().parents[2] / "schemas"
|
|
9
|
-
|
|
10
|
-
def validate(value: Any, schema_name: str) -> None:
|
|
11
|
-
schema = json.loads((SCHEMAS / (schema_name + ".schema.json")).read_text(encoding="utf-8"))
|
|
12
|
-
def walk(v, s, label):
|
|
13
|
-
if "$ref" in s:
|
|
14
|
-
node = schema
|
|
15
|
-
for k in s["$ref"].removeprefix("#/").split("/"): node = node[k]
|
|
16
|
-
return walk(v, node, label)
|
|
17
|
-
if "oneOf" in s:
|
|
18
|
-
successes = 0
|
|
19
|
-
for branch in s["oneOf"]:
|
|
20
|
-
try: walk(v, branch, label); successes += 1
|
|
21
|
-
except OpsError: pass
|
|
22
|
-
if successes != 1: raise OpsError(f"{label}: oneOf contract not satisfied")
|
|
23
|
-
kinds = {"object": lambda x: isinstance(x, dict), "array": lambda x: isinstance(x, list),
|
|
24
|
-
"string": lambda x: isinstance(x, str), "integer": lambda x: type(x) is int,
|
|
25
|
-
"boolean": lambda x: type(x) is bool, "null": lambda x: x is None,
|
|
26
|
-
"number": lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)}
|
|
27
|
-
if "type" in s:
|
|
28
|
-
types = s["type"] if isinstance(s["type"], list) else [s["type"]]
|
|
29
|
-
if not any(kinds[t](v) for t in types): raise OpsError(f"{label}: wrong type")
|
|
30
|
-
if "const" in s and v != s["const"]: raise OpsError(f"{label}: wrong constant")
|
|
31
|
-
if "enum" in s and v not in s["enum"]: raise OpsError(f"{label}: invalid enum")
|
|
32
|
-
if isinstance(v, str):
|
|
33
|
-
if len(v) < s.get("minLength", 0) or len(v) > s.get("maxLength", 10000000): raise OpsError(f"{label}: string length")
|
|
34
|
-
if "pattern" in s and not re.search(s["pattern"], v): raise OpsError(f"{label}: invalid pattern")
|
|
35
|
-
if type(v) in (int, float):
|
|
36
|
-
if v < s.get("minimum", -float("inf")) or v > s.get("maximum", float("inf")): raise OpsError(f"{label}: number out of range")
|
|
37
|
-
if isinstance(v, dict):
|
|
38
|
-
if set(s.get("required", [])) - set(v): raise OpsError(f"{label}: missing {sorted(set(s['required'])-set(v))}")
|
|
39
|
-
props = s.get("properties", {})
|
|
40
|
-
for k, item in v.items():
|
|
41
|
-
if k in props: walk(item, props[k], label+"."+k)
|
|
42
|
-
elif s.get("additionalProperties") is False: raise OpsError(f"{label}: unknown field {k}")
|
|
43
|
-
elif isinstance(s.get("additionalProperties"), dict): walk(item, s["additionalProperties"], label+"."+k)
|
|
44
|
-
if "propertyNames" in s:
|
|
45
|
-
for k in v: walk(k, s["propertyNames"], label+".<key>")
|
|
46
|
-
if isinstance(v, list):
|
|
47
|
-
if len(v) < s.get("minItems", 0): raise OpsError(f"{label}: too few items")
|
|
48
|
-
if s.get("uniqueItems") and len({canonical(x) for x in v}) != len(v): raise OpsError(f"{label}: duplicate items")
|
|
49
|
-
for i, x in enumerate(v): walk(x, s.get("items", {}), f"{label}[{i}]")
|
|
50
|
-
walk(value, schema, schema_name)
|
|
51
|
-
|
|
52
|
-
def deployment_root(host: dict, project_id: str, environment: str, instance: str, layout: str) -> str:
|
|
53
|
-
for x in (project_id, environment, instance): identifier(x)
|
|
54
|
-
if layout == "flat": return target_join(host, project_id)
|
|
55
|
-
if layout == "instances": return target_join(host, project_id, f"instances/{environment}/{instance}")
|
|
56
|
-
raise OpsError("unknown deployment layout")
|
|
57
|
-
|
|
58
|
-
def validate_host(host: dict) -> None:
|
|
59
|
-
validate(host, "host")
|
|
60
|
-
identifier(host["host_id"])
|
|
61
|
-
if root_path(host["root"], host["platform"]) != host["root"]: raise OpsError("host root must be normalized")
|
|
62
|
-
if host["transport"] == "local" and host["connection"]:
|
|
63
|
-
raise OpsError("local host connection must be empty")
|
|
64
|
-
if host["transport"] == "ssh":
|
|
65
|
-
c = host["connection"]
|
|
66
|
-
for k in ("hostname", "username", "known_hosts", "python"):
|
|
67
|
-
if not c.get(k): raise OpsError("ssh connection missing " + k)
|
|
68
|
-
if not re.fullmatch(r"[A-Za-z0-9_.:-]+", c["hostname"]) or c["hostname"].startswith("-"):
|
|
69
|
-
raise OpsError("unsafe SSH hostname")
|
|
70
|
-
if not re.fullmatch(r"[A-Za-z0-9_.-]+", c["username"]) or c["username"].startswith("-"):
|
|
71
|
-
raise OpsError("unsafe SSH username")
|
|
72
|
-
python_pattern = r"[A-Za-z0-9_./:\\ +-]+" if host["platform"] == "windows" else r"[/A-Za-z0-9_.+-]+"
|
|
73
|
-
if not re.fullmatch(python_pattern, c["python"]) or c["python"].startswith("-"):
|
|
74
|
-
raise OpsError("SSH Python must be a safe executable path, not a command")
|
|
75
|
-
if c.get("shell", "posix") not in ("posix", "powershell"): raise OpsError("unsupported remote shell")
|
|
76
|
-
if not re.fullmatch(r"[a-f0-9]{64}", host["identity"]): raise OpsError("host identity must be a probed machine digest")
|
|
77
|
-
|
|
78
|
-
def validate_status(s: dict) -> None:
|
|
79
|
-
if s.get("schema_version") != 3:
|
|
80
|
-
raise OpsError("ops-legacy-state: preserve the old state; run import-legacy into a new empty state root")
|
|
81
|
-
validate(s, "status")
|
|
82
|
-
roots = set()
|
|
83
|
-
physical_roots = []
|
|
84
|
-
for hid, h in s["hosts"].items():
|
|
85
|
-
validate_host(h)
|
|
86
|
-
if hid != h["host_id"]: raise OpsError("host index identity mismatch")
|
|
87
|
-
physical = (h["identity"], h["root"].casefold() if h["platform"] == "windows" else h["root"])
|
|
88
|
-
if physical in roots: raise OpsError("duplicate physical host/root registered under different IDs")
|
|
89
|
-
roots.add(physical)
|
|
90
|
-
for identity, other, platform in physical_roots:
|
|
91
|
-
if identity == h["identity"] and platform != h["platform"]: raise OpsError("same physical identity has inconsistent platform")
|
|
92
|
-
if identity == h["identity"] and (within(h["root"], other, platform) or within(other, h["root"], platform)):
|
|
93
|
-
raise OpsError("overlapping registered host roots")
|
|
94
|
-
physical_roots.append((h["identity"],h["root"],h["platform"]))
|
|
95
|
-
for pid, p in s["projects"].items():
|
|
96
|
-
validate(p, "project"); identifier(pid)
|
|
97
|
-
if pid != p["project_id"]: raise OpsError("project index identity mismatch")
|
|
98
|
-
if p["kind"] == "shared-service" and not p.get("service_type"): raise OpsError("shared provider requires service_type")
|
|
99
|
-
used = set()
|
|
100
|
-
occupied = []
|
|
101
|
-
for did, d in s["deployments"].items():
|
|
102
|
-
validate(d, "deployment")
|
|
103
|
-
if did != d["deployment_id"]: raise OpsError("deployment index mismatch")
|
|
104
|
-
if d["host_id"] not in s["hosts"] or d["project_id"] not in s["projects"]: raise OpsError("orphan deployment")
|
|
105
|
-
h = s["hosts"][d["host_id"]]
|
|
106
|
-
expected = deployment_root(h, d["project_id"], d["environment"], d["instance"], d["layout"])
|
|
107
|
-
if d["root"] != expected: raise OpsError("deployment root does not follow host/project policy")
|
|
108
|
-
slot = (h["identity"], expected.casefold() if h["platform"] == "windows" else expected)
|
|
109
|
-
if slot in used: raise OpsError("two deployments occupy the same directory; use explicit instances layout")
|
|
110
|
-
used.add(slot)
|
|
111
|
-
for identity, other, platform in occupied:
|
|
112
|
-
if identity == h["identity"] and platform != h["platform"]: raise OpsError("same physical identity has inconsistent platform")
|
|
113
|
-
if identity == h["identity"] and (within(expected, other, platform) or within(other, expected, platform)):
|
|
114
|
-
raise OpsError("overlapping flat/instances deployment roots require an explicit migration")
|
|
115
|
-
occupied.append((h["identity"], expected, h["platform"]))
|
|
116
|
-
for item in d["storage"]:
|
|
117
|
-
if not within(item["path"], d["root"], h["platform"]): raise OpsError("persistent path outside APP root")
|
|
118
|
-
for aid, a in s["allocations"].items():
|
|
119
|
-
validate(a, "allocation")
|
|
120
|
-
if aid != a["allocation_id"] or a["provider_deployment_id"] not in s["deployments"]: raise OpsError("orphan allocation")
|
|
121
|
-
pd = s["deployments"][a["provider_deployment_id"]]
|
|
122
|
-
if s["projects"][pd["project_id"]]["kind"] != "shared-service": raise OpsError("allocation provider must be shared-service")
|
|
123
|
-
if a["owner_project_id"] not in s["projects"]: raise OpsError("allocation owner missing")
|
|
124
|
-
resources = set()
|
|
125
|
-
for a in s["allocations"].values():
|
|
126
|
-
if a["status"] == "retired": continue
|
|
127
|
-
key = (a["provider_deployment_id"], a["resource_kind"], a["resource_name"])
|
|
128
|
-
if key in resources: raise OpsError("duplicate logical allocation: use one allocation for replicas")
|
|
129
|
-
resources.add(key)
|
|
130
|
-
edges = {}
|
|
131
|
-
for bid, b in s["bindings"].items():
|
|
132
|
-
validate(b, "binding")
|
|
133
|
-
if bid != b["binding_id"] or b["consumer_deployment_id"] not in s["deployments"]: raise OpsError("orphan binding")
|
|
134
|
-
consumer = s["deployments"][b["consumer_deployment_id"]]
|
|
135
|
-
if b["mode"] == "shared":
|
|
136
|
-
if b["allocation_id"] not in s["allocations"]: raise OpsError("binding allocation missing")
|
|
137
|
-
a = s["allocations"][b["allocation_id"]]
|
|
138
|
-
if a["provider_deployment_id"] != b["provider_deployment_id"]: raise OpsError("binding provider disagrees with allocation")
|
|
139
|
-
if a["owner_project_id"] != consumer["project_id"] or a["environment"] != consumer["environment"]:
|
|
140
|
-
if consumer["project_id"] not in a.get("shared_owners", []): raise OpsError("cross-app/environment sharing requires explicit shared owners")
|
|
141
|
-
if b["credential_ref"] != a["credential_ref"]: raise OpsError("binding must use allocation's application credential")
|
|
142
|
-
if a["status"] == "retired" and b["status"] == "active": raise OpsError("active consumer uses retired allocation")
|
|
143
|
-
edges.setdefault(b["consumer_deployment_id"], set()).add(b["provider_deployment_id"])
|
|
144
|
-
elif b["mode"] == "external":
|
|
145
|
-
if b["provider_deployment_id"] is not None or b["allocation_id"] is not None: raise OpsError("external binding cannot claim local provider data")
|
|
146
|
-
elif b["mode"] == "dedicated":
|
|
147
|
-
if b["allocation_id"] is not None or b["provider_deployment_id"] is not None: raise OpsError("dedicated component cannot reference shared provider/allocation")
|
|
148
|
-
if b["status"] == "active" and consumer["status"] == "retired": raise OpsError("retired deployment retains active binding")
|
|
149
|
-
def visit(node, trail, done):
|
|
150
|
-
if node in trail: raise OpsError("cyclic service dependencies")
|
|
151
|
-
if node in done: return
|
|
152
|
-
for nxt in edges.get(node, []): visit(nxt, trail | {node}, done)
|
|
153
|
-
done.add(node)
|
|
154
|
-
done = set()
|
|
155
|
-
for node in edges: visit(node, set(), done)
|
|
156
|
-
|
|
157
|
-
def load(state: Path) -> dict:
|
|
158
|
-
s = read_json(state / "status.json"); validate_status(s); return s
|
|
159
|
-
|
|
160
|
-
def save(state: Path, s: dict, *, bump: bool = True) -> None:
|
|
161
|
-
validate_status(s)
|
|
162
|
-
if bump: s["revision"] += 1
|
|
163
|
-
s["updated_at"] = now(); write_json(state / "status.json", s)
|
|
164
|
-
|
|
165
|
-
def ledger_load(state: Path) -> dict:
|
|
166
|
-
p = state / "private/credentials.json"
|
|
167
|
-
if not p.exists(): return {"schema_version": 1, "entries": {}}
|
|
168
|
-
if os.name != "nt" and p.stat().st_mode & 0o077: raise OpsError("plaintext ledger permissions too broad; run chmod 600 explicitly")
|
|
169
|
-
return read_json(p)
|
|
170
|
-
|
|
171
|
-
def put_credential(state: Path, value: dict) -> dict:
|
|
172
|
-
exact(value, {"credential_id", "version", "values", "purpose"}, {"credential_id", "version", "values", "purpose"}, "credential")
|
|
173
|
-
identifier(value["credential_id"])
|
|
174
|
-
if type(value["version"]) is not int or value["version"] < 1 or not value["values"]: raise OpsError("credential version/values invalid")
|
|
175
|
-
for k, v in value["values"].items():
|
|
176
|
-
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k) or not isinstance(v, str) or not v or "\x00" in v: raise OpsError("credential fields must be named nonempty strings")
|
|
177
|
-
with lock(state / ".locks/catalog", {"operation": "credential-import"}):
|
|
178
|
-
load(state)
|
|
179
|
-
ledger = ledger_load(state); entries = ledger["entries"].setdefault(value["credential_id"], {})
|
|
180
|
-
version = str(value["version"])
|
|
181
|
-
if version in entries:
|
|
182
|
-
if entries[version]["values"] != value["values"]: raise OpsError("immutable credential version: use a new explicit version")
|
|
183
|
-
return {"credential_ref": value["credential_id"]+"@"+version, "status": "unchanged"}
|
|
184
|
-
entries[version] = {"values": value["values"], "purpose": value["purpose"], "created_at": now()}
|
|
185
|
-
write_json(state / "private/credentials.json", ledger)
|
|
186
|
-
return {"credential_ref": value["credential_id"]+"@"+version, "status": "recorded-not-rotated-on-server"}
|
|
187
|
-
|
|
188
|
-
def register(state: Path, request: dict) -> dict:
|
|
189
|
-
exact(request, {"hosts", "projects"}, set(), "registration")
|
|
190
|
-
with lock(state / ".locks/catalog", {"operation": "register"}):
|
|
191
|
-
s = load(state)
|
|
192
|
-
for group, idkey in (("hosts", "host_id"), ("projects", "project_id")):
|
|
193
|
-
for item in request.get(group, []):
|
|
194
|
-
key = item[idkey]
|
|
195
|
-
if key in s[group] and s[group][key] != item:
|
|
196
|
-
raise OpsError(f"registered identity is immutable through register: {group}/{key}; use a reviewed migration")
|
|
197
|
-
s[group][key] = item
|
|
198
|
-
save(state, s)
|
|
199
|
-
return {"revision": s["revision"], "hosts": list(s["hosts"]), "projects": list(s["projects"])}
|