ppkit 0.1.0__py3-none-any.whl
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.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/commands/promote.py
ADDED
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
"""Snapshot a project into a release and switch to it
|
|
2
|
+
|
|
3
|
+
The working tree is dev. A promotion copies one exact state of it (git HEAD, or the tree) into an immutable
|
|
4
|
+
release directory, runs the project's gate ON THOSE BYTES, then flips `current` to it with one rename(2).
|
|
5
|
+
Tools and services are registered by stable links that point through `current`, so a later flip needs no
|
|
6
|
+
relink, and nothing registered ever runs a half-edited tree.
|
|
7
|
+
|
|
8
|
+
pp promote [PATH] promote the project containing PATH (default: cwd)
|
|
9
|
+
pp promote -n [PATH] preflight: the plan and EVERY blocker at once; changes nothing
|
|
10
|
+
pp promote --dirty git mode: snapshot the working tree as-is (tracked + untracked, minus ignored)
|
|
11
|
+
pp promote --again promote even when HEAD already is the current release
|
|
12
|
+
pp promote status [--json] every promoted target: current release, how far the source is ahead, links
|
|
13
|
+
pp promote ls NAME releases, newest first (* = current)
|
|
14
|
+
pp promote rollback NAME [--to STAMP] [-y] flip back to the previous release, rerun `after`; a release
|
|
15
|
+
whose own `after`/`verify` failed is skipped (ls marks it); --to picks any
|
|
16
|
+
pp promote diff NAME what the source has that the current release does not
|
|
17
|
+
pp promote link NAME reassert the links pp promote gc NAME [--keep N] [-n] prune releases, after asking
|
|
18
|
+
pp promote init [PATH] write a starter .pp.toml
|
|
19
|
+
NAME is a promoted target's name, or a path into its project.
|
|
20
|
+
|
|
21
|
+
.pp.toml at the project root:
|
|
22
|
+
name = "pp" # optional, default the directory name; it names the releases dir, so keep it unique
|
|
23
|
+
[promote]
|
|
24
|
+
source = "git" # git: HEAD via `git archive` (a dirty tracked tree refuses unless --dirty)
|
|
25
|
+
# tree: copy the working tree (stamp = content hash)
|
|
26
|
+
exclude = [] # added to .git node_modules __pycache__ .venv *.pyc (tree mode and --dirty)
|
|
27
|
+
build = "make" # optional; cwd = the new release, before the check
|
|
28
|
+
check = "python3 -m unittest discover -s tests -q" # the gate; non-zero refuses, current untouched
|
|
29
|
+
links = { "~/.local/bin/pp" = "bin/pp" } # stable path -> releases/<name>/current/<rel>
|
|
30
|
+
dev_links = { "~/.local/bin/ppd" = "bin/pp" } # stable path -> <source>/<rel>: the dev side
|
|
31
|
+
after = "systemctl --user restart foo" # after the flip
|
|
32
|
+
verify = "curl -fsS localhost:8100/ >/dev/null" # after `after`; non-zero (or a failed `after`) rolls back
|
|
33
|
+
keep = 5 # releases kept; current and the one before it are never pruned
|
|
34
|
+
`after` is ACTIVATION: make what is running pick up `current` (restart or reload a service). It must be
|
|
35
|
+
idempotent and correct for ANY release, because rolling back is "flip to the previous release, run `after`
|
|
36
|
+
again". That is also why a failed `after` rolls back: whatever the cause, the previous release is the last state
|
|
37
|
+
known to work, and leaving `current` on a release nothing runs would make the pointer lie. Irreversible steps
|
|
38
|
+
(schema migrations, data rewrites) do not belong in `after`, because no rollback can undo them; run them by
|
|
39
|
+
hand, and keep them compatible with the release before. `verify` is the health check of the activated release.
|
|
40
|
+
Commands run in the release dir with PP_NAME PP_SOURCE PP_REV PP_RELEASE (where the release's bytes are: a
|
|
41
|
+
staging dir during build/check) and PP_PREV (the previous release dir, or empty). Git submodules are not
|
|
42
|
+
included (git archive leaves them out).
|
|
43
|
+
|
|
44
|
+
Releases are ~/.local/share/pp/releases/<name>/<YYYYmmdd-HHMMSS>-<rev7>[-dirty]/; `current` is a relative
|
|
45
|
+
symlink; revisions.log there records every flip; each run's full output is kept in
|
|
46
|
+
~/.local/state/pp/promote/logs/. The gate's verdict is evidence, not scrollback.
|
|
47
|
+
|
|
48
|
+
Registering a service: point the unit at `current`, let `after` restart it.
|
|
49
|
+
# ~/.config/systemd/user/notes.service
|
|
50
|
+
[Service]
|
|
51
|
+
ExecStart=%h/.local/share/pp/releases/notes/current/bin/serve --port 8100
|
|
52
|
+
# .pp.toml
|
|
53
|
+
[promote]
|
|
54
|
+
check = "./test.sh"
|
|
55
|
+
after = "systemctl --user restart notes"
|
|
56
|
+
verify = "sleep 1; curl -fsS http://127.0.0.1:8100/ >/dev/null"
|
|
57
|
+
Dev runs from the working tree (another port); prod only ever runs promoted bytes.
|
|
58
|
+
"""
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import fnmatch
|
|
62
|
+
import hashlib
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
import shutil
|
|
66
|
+
import signal
|
|
67
|
+
import sys
|
|
68
|
+
import tarfile
|
|
69
|
+
import threading
|
|
70
|
+
import time
|
|
71
|
+
from contextlib import ExitStack, contextmanager
|
|
72
|
+
from datetime import datetime, timezone
|
|
73
|
+
|
|
74
|
+
from pp.lib import config, lock, paths, proc, ui
|
|
75
|
+
|
|
76
|
+
VERBS = ("status", "ls", "rollback", "diff", "link", "gc", "init")
|
|
77
|
+
DEFAULT_EXCLUDE = [".git", "node_modules", "__pycache__", ".venv", "*.pyc"]
|
|
78
|
+
META = paths.RELEASE_META # its fields and readers: pp/lib/paths.py
|
|
79
|
+
NOT_RELEASES = ("current", "revisions.log")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---- targets --------------------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class Target:
|
|
85
|
+
def __init__(self, name: str, source: str, cfg: dict):
|
|
86
|
+
self.name, self.source, self.cfg = name, source, cfg
|
|
87
|
+
self.mode = cfg.get("source", "git")
|
|
88
|
+
self.releases = paths.data_dir("releases", name, create=False)
|
|
89
|
+
|
|
90
|
+
def current(self) -> str | None:
|
|
91
|
+
try:
|
|
92
|
+
return os.readlink(os.path.join(self.releases, "current"))
|
|
93
|
+
except OSError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def dir(self, stamp: str | None) -> str:
|
|
97
|
+
return os.path.join(self.releases, stamp) if stamp else ""
|
|
98
|
+
|
|
99
|
+
def info(self, stamp: str | None) -> dict:
|
|
100
|
+
try:
|
|
101
|
+
with open(os.path.join(self.releases, stamp, META)) as f:
|
|
102
|
+
return json.load(f)
|
|
103
|
+
except (OSError, ValueError, TypeError):
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
def stamps(self) -> list[str]:
|
|
107
|
+
"""Releases, oldest first, by creation time: two stamps in one second sort by rev, not age."""
|
|
108
|
+
try:
|
|
109
|
+
names = [n for n in os.listdir(self.releases)
|
|
110
|
+
if not n.startswith(".") and n not in NOT_RELEASES and os.path.isdir(self.dir(n))]
|
|
111
|
+
except OSError:
|
|
112
|
+
return []
|
|
113
|
+
return sorted(names, key=lambda n: (self.info(n).get("created", 0), n))
|
|
114
|
+
|
|
115
|
+
def previous(self, stamp: str | None) -> str | None:
|
|
116
|
+
"""The newest release before stamp that did not fail activation (`after`/`verify` rolled it back)."""
|
|
117
|
+
s = self.stamps()
|
|
118
|
+
if stamp not in s:
|
|
119
|
+
return None
|
|
120
|
+
return next((p for p in reversed(s[:s.index(stamp)]) if not self.info(p).get("failed")), None)
|
|
121
|
+
|
|
122
|
+
def excludes(self) -> list[str]:
|
|
123
|
+
return DEFAULT_EXCLUDE + list(self.cfg.get("exclude", []))
|
|
124
|
+
|
|
125
|
+
def env(self, rev: str, release: str, prev: str) -> dict:
|
|
126
|
+
return dict(os.environ, PP_NAME=self.name, PP_SOURCE=self.source, PP_REV=rev or "",
|
|
127
|
+
PP_RELEASE=release, PP_PREV=prev or "")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
DEFAULT_KEEP = 5 # releases kept; current and the one before it always stay
|
|
131
|
+
PROMOTE_KEYS = ("source", "exclude", "build", "check", "links", "dev_links", "after", "verify", "keep")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _checked(cfg, root: str) -> dict:
|
|
135
|
+
"""A project's [promote] table, or a ConfigError naming what is wrong: a misspelt `check` must not release
|
|
136
|
+
with nothing gating it."""
|
|
137
|
+
where = f"{root}/.pp.toml"
|
|
138
|
+
if not isinstance(cfg, dict):
|
|
139
|
+
raise config.ConfigError(f"{where}: promote must be a table ([promote] check = ..., links = {{...}})")
|
|
140
|
+
config.check_keys(cfg, f"{where} [promote]", PROMOTE_KEYS)
|
|
141
|
+
if cfg.get("source", "git") not in ("git", "tree"):
|
|
142
|
+
raise config.ConfigError(f"{where} [promote] source = {cfg['source']!r}: git or tree")
|
|
143
|
+
return cfg
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _project(path: str | None) -> Target:
|
|
147
|
+
root, data = config.find_project(path)
|
|
148
|
+
if not root or "promote" not in data:
|
|
149
|
+
ui.die(f"no [promote] table in a .pp.toml at or above {paths.expand(path or '.')}; `pp promote init` writes one", 2)
|
|
150
|
+
return Target(config.project_name(root, data), root, _checked(data["promote"], root))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _registry_file(create: bool = False) -> str:
|
|
154
|
+
return os.path.join(paths.state_dir("promote", create=create), "registry.json")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _registry() -> dict:
|
|
158
|
+
try:
|
|
159
|
+
with open(_registry_file()) as f:
|
|
160
|
+
return json.load(f)
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _registry_lock() -> str:
|
|
166
|
+
"""The registry's lock file. The registry is one file for every target, so a target's own .lock serialises
|
|
167
|
+
nothing here: two promotes of different projects hold different locks, and the later write would otherwise
|
|
168
|
+
forget what the earlier one recorded."""
|
|
169
|
+
return os.path.join(paths.state_dir("promote", create=True), ".lock")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _name_taken(reg: dict, t: Target) -> str | None:
|
|
173
|
+
"""Why this target cannot have its name: a project that is still there registered it first."""
|
|
174
|
+
owner = reg.get(t.name, {}).get("source")
|
|
175
|
+
if owner and owner != t.source and os.path.isdir(owner):
|
|
176
|
+
return f"name '{t.name}' already belongs to {owner}; set a different `name` in .pp.toml"
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _register(t: Target):
|
|
181
|
+
"""Record this project as the owner of its name. The read and the write are one transaction, so a target
|
|
182
|
+
registered meanwhile is still there afterwards; a name another project holds is a refusal, and leaves the
|
|
183
|
+
registry as it was."""
|
|
184
|
+
with paths.json_txn(_registry_file(create=True), lock=_registry_lock(), indent=2) as reg:
|
|
185
|
+
taken = _name_taken(reg, t) # listed with the other blockers too; this one decides
|
|
186
|
+
if taken:
|
|
187
|
+
ui.die(taken, 2)
|
|
188
|
+
reg[t.name] = {"source": t.source}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _resolve(arg: str | None) -> Target:
|
|
192
|
+
"""A NAME argument: a registered target, or a path into a project (default: cwd)."""
|
|
193
|
+
reg = _registry()
|
|
194
|
+
if arg is None:
|
|
195
|
+
root, data = config.find_project(None)
|
|
196
|
+
if not root or "promote" not in data:
|
|
197
|
+
ui.die(f"not in a promoted project: name one ({', '.join(sorted(reg)) or 'none promoted yet'})", 2)
|
|
198
|
+
return _project(None)
|
|
199
|
+
if arg in reg:
|
|
200
|
+
src = reg[arg]["source"]
|
|
201
|
+
root, data = config.find_project(src) if os.path.isdir(src) else (None, {})
|
|
202
|
+
cfg = _checked(data.get("promote", {}), root) if root == src else {}
|
|
203
|
+
if not os.path.isdir(src):
|
|
204
|
+
ui.warn(f"{arg}: its source {src} is gone; releases only, no hooks or links")
|
|
205
|
+
elif not cfg:
|
|
206
|
+
ui.warn(f"{src}: no readable [promote] config; releases only, no hooks")
|
|
207
|
+
return Target(arg, src, cfg)
|
|
208
|
+
if os.path.isdir(paths.expand(arg)):
|
|
209
|
+
return _project(arg)
|
|
210
|
+
ui.die(f"no promoted target '{arg}' (known: {', '.join(sorted(reg)) or 'none'})", 2)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---- snapshots ------------------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _git(src: str, *args: str) -> proc.Result:
|
|
216
|
+
"""git read in src. A git that did not answer is never read as an answer: a run whose time ran out stops the
|
|
217
|
+
command here, since its empty output would read as a clean tree with no files in it."""
|
|
218
|
+
r = proc.git("-C", src, *args, group="own")
|
|
219
|
+
if r.timed_out:
|
|
220
|
+
ui.die(f"git {args[0]} in {src} took more than {proc.GIT_WAIT / 60:g} minutes; nothing changed", 1)
|
|
221
|
+
return r
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _excluded(rel: str, patterns: list[str]) -> bool:
|
|
225
|
+
return any(fnmatch.fnmatch(rel, p) for p in patterns) or \
|
|
226
|
+
any(fnmatch.fnmatch(part, p) for part in rel.split(os.sep) for p in patterns)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _tree_files(src: str, patterns: list[str]) -> list[str]:
|
|
230
|
+
out = []
|
|
231
|
+
for dirpath, dirnames, filenames in os.walk(src):
|
|
232
|
+
rel_dir = os.path.relpath(dirpath, src)
|
|
233
|
+
rel_dir = "" if rel_dir == "." else rel_dir
|
|
234
|
+
keep = []
|
|
235
|
+
for d in dirnames:
|
|
236
|
+
rel = os.path.join(rel_dir, d)
|
|
237
|
+
if _excluded(rel, patterns):
|
|
238
|
+
continue
|
|
239
|
+
if os.path.islink(os.path.join(dirpath, d)):
|
|
240
|
+
out.append(rel) # a symlinked dir is copied as the link, never followed
|
|
241
|
+
else:
|
|
242
|
+
keep.append(d)
|
|
243
|
+
dirnames[:] = keep
|
|
244
|
+
out += [r for r in (os.path.join(rel_dir, f) for f in filenames) if not _excluded(r, patterns)]
|
|
245
|
+
return sorted(out)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _git_files(src: str, patterns: list[str]) -> list[str]:
|
|
249
|
+
"""--dirty: tracked + untracked-not-ignored, as they are on disk now."""
|
|
250
|
+
out = _git(src, "ls-files", "-z", "-co", "--exclude-standard").stdout.split("\0")
|
|
251
|
+
return sorted(r for r in out if r and not _excluded(r, patterns) and os.path.lexists(os.path.join(src, r)))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _tree_hash(src: str, rels: list[str]) -> str:
|
|
255
|
+
h = hashlib.sha256()
|
|
256
|
+
for rel in rels:
|
|
257
|
+
p = os.path.join(src, rel)
|
|
258
|
+
h.update(rel.encode() + b"\0")
|
|
259
|
+
if os.path.islink(p):
|
|
260
|
+
h.update(b"L" + os.readlink(p).encode())
|
|
261
|
+
else:
|
|
262
|
+
h.update(b"X" if os.access(p, os.X_OK) else b"F")
|
|
263
|
+
with open(p, "rb") as f:
|
|
264
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
265
|
+
h.update(chunk)
|
|
266
|
+
h.update(b"\0")
|
|
267
|
+
return h.hexdigest()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _copy(src: str, rels: list[str], dest: str):
|
|
271
|
+
os.makedirs(dest, exist_ok=True)
|
|
272
|
+
for rel in rels:
|
|
273
|
+
s, d = os.path.join(src, rel), os.path.join(dest, rel)
|
|
274
|
+
os.makedirs(os.path.dirname(d), exist_ok=True)
|
|
275
|
+
if os.path.islink(s):
|
|
276
|
+
os.symlink(os.readlink(s), d)
|
|
277
|
+
elif os.path.isfile(s):
|
|
278
|
+
shutil.copy2(s, d) # keeps the exec bit
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _archive(src: str, dest: str):
|
|
282
|
+
"""git HEAD of the project's subtree, extracted with the `data` filter (no absolute or escaping paths)."""
|
|
283
|
+
top = _git(src, "rev-parse", "--show-toplevel").stdout.strip()
|
|
284
|
+
prefix = _git(src, "rev-parse", "--show-prefix").stdout.strip()
|
|
285
|
+
os.makedirs(dest)
|
|
286
|
+
c = proc.git_child("-C", top, "archive", "--format=tar", f"HEAD:{prefix}" if prefix else "HEAD")
|
|
287
|
+
# pp is the reader, so pp bounds the time: a git that says nothing would hold this promote, and the
|
|
288
|
+
# target's lock with it, for good. It gets proc.GIT_WAIT, as every other git pp runs does, and the
|
|
289
|
+
# deadline ends it, which brings the read below back.
|
|
290
|
+
over = []
|
|
291
|
+
bound = threading.Timer(proc.GIT_WAIT, lambda: (over.append(True), c.end(signal.SIGKILL)))
|
|
292
|
+
bound.start()
|
|
293
|
+
try:
|
|
294
|
+
with tarfile.open(fileobj=c.stdout, mode="r|") as tf:
|
|
295
|
+
tf.extractall(dest, filter="data")
|
|
296
|
+
c.stdout.read() # to the end of the stream, so git is never left writing the last of it
|
|
297
|
+
# git's output is closed first. An extraction that stopped part way leaves git blocked writing into a
|
|
298
|
+
# pipe nobody reads, and reading its stderr to the end would then wait for a process that cannot get on.
|
|
299
|
+
c.stdout.close()
|
|
300
|
+
err = c.stderr.read().decode(errors="replace").strip()
|
|
301
|
+
c.close_pipes()
|
|
302
|
+
rc = c.wait(proc.GIT_WAIT)
|
|
303
|
+
except BaseException:
|
|
304
|
+
c.end(signal.SIGKILL) # an extraction that raised, or a Ctrl-C: nothing pp started is left behind
|
|
305
|
+
c.close_pipes() # nothing reads what is left: this archive is ending, and the read has no bound
|
|
306
|
+
c.wait()
|
|
307
|
+
if over:
|
|
308
|
+
raise RuntimeError(f"git archive took more than {proc.GIT_WAIT:g}s and was ended") from None
|
|
309
|
+
raise
|
|
310
|
+
finally:
|
|
311
|
+
bound.cancel()
|
|
312
|
+
if rc:
|
|
313
|
+
raise RuntimeError(f"git archive failed: {ui.printable(err)}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class Facts:
|
|
317
|
+
"""What a promotion would snapshot, and everything standing in its way. Computed without mutating."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, t: Target, dirty_ok: bool):
|
|
320
|
+
self.blockers: list[str] = []
|
|
321
|
+
self.rev = ""
|
|
322
|
+
self.dirty = False
|
|
323
|
+
self.rels: list[str] | None = None # files to copy (tree mode / --dirty); None = git archive
|
|
324
|
+
self.untracked: list[str] = [] # git mode: files on disk that HEAD (so the release) lacks
|
|
325
|
+
if t.mode == "git":
|
|
326
|
+
if not shutil.which("git"):
|
|
327
|
+
self.blockers.append("git is not installed")
|
|
328
|
+
elif _git(t.source, "rev-parse", "--is-inside-work-tree").returncode:
|
|
329
|
+
self.blockers.append(f'{t.source} is not a git repository (git init and commit, or set source = "tree")')
|
|
330
|
+
else:
|
|
331
|
+
head = _git(t.source, "rev-parse", "--verify", "-q", "HEAD^{commit}")
|
|
332
|
+
if head.returncode:
|
|
333
|
+
self.blockers.append("the repository has no commits yet; commit first")
|
|
334
|
+
else:
|
|
335
|
+
self.rev = head.stdout.strip()
|
|
336
|
+
tracked = _git(t.source, "status", "--porcelain", "--untracked-files=no", "--", ".").stdout.strip()
|
|
337
|
+
if dirty_ok:
|
|
338
|
+
self.dirty = bool(_git(t.source, "status", "--porcelain", "--", ".").stdout.strip())
|
|
339
|
+
self.rels = _git_files(t.source, t.excludes())
|
|
340
|
+
elif tracked:
|
|
341
|
+
n = len(tracked.splitlines())
|
|
342
|
+
self.blockers.append(f"{n} tracked file(s) modified; commit, or --dirty to snapshot the tree as-is")
|
|
343
|
+
if not dirty_ok:
|
|
344
|
+
out = _git(t.source, "ls-files", "-z", "-o", "--exclude-standard", "--", ".").stdout.split("\0")
|
|
345
|
+
self.untracked = sorted(r for r in out if r)
|
|
346
|
+
elif t.mode == "tree":
|
|
347
|
+
self.rels = _tree_files(t.source, t.excludes())
|
|
348
|
+
self.rev = _tree_hash(t.source, self.rels)
|
|
349
|
+
else:
|
|
350
|
+
self.blockers.append(f'unknown source = "{t.mode}" (git or tree)')
|
|
351
|
+
|
|
352
|
+
def untracked_note(self) -> str:
|
|
353
|
+
if not self.untracked:
|
|
354
|
+
return ""
|
|
355
|
+
# the names come from git, so an escape sequence in one cannot redraw the terminal around the note
|
|
356
|
+
shown = ui.printable(", ".join(self.untracked[:3])) \
|
|
357
|
+
+ (f" (+{len(self.untracked) - 3} more)" if len(self.untracked) > 3 else "")
|
|
358
|
+
return (f"note: {len(self.untracked)} untracked file(s) are not in HEAD, so not in the release: {shown}"
|
|
359
|
+
f"; git add + commit them, or --dirty")
|
|
360
|
+
|
|
361
|
+
def stamp(self, t: Target) -> str:
|
|
362
|
+
short = self.rev[:10] if t.mode == "tree" else self.rev[:7]
|
|
363
|
+
base = f"{datetime.now():%Y%m%d-%H%M%S}-{short or 'norev'}" + ("-dirty" if self.dirty else "")
|
|
364
|
+
stamp, n = base, 1
|
|
365
|
+
while os.path.lexists(t.dir(stamp)) or os.path.lexists(os.path.join(t.releases, ".staging-" + stamp)):
|
|
366
|
+
n += 1
|
|
367
|
+
stamp = f"{base}-{n}"
|
|
368
|
+
return stamp
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---- links ----------------------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def _inside(path: str, root: str) -> bool:
|
|
374
|
+
for a, b in ((os.path.normpath(path), os.path.normpath(root)), (os.path.realpath(path), os.path.realpath(root))):
|
|
375
|
+
if a == b or a.startswith(b.rstrip(os.sep) + os.sep):
|
|
376
|
+
return True
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _link_plan(t: Target, force: bool) -> list[dict]:
|
|
381
|
+
"""Every link with its action: ok (already right), create, replace (pp's own, or forced), conflict."""
|
|
382
|
+
plan = []
|
|
383
|
+
rel_root = paths.data_dir("releases", create=False)
|
|
384
|
+
for kind, base in (("link", os.path.join(t.releases, "current")), ("dev_link", t.source)):
|
|
385
|
+
for path, rel in (t.cfg.get(kind + "s") or {}).items():
|
|
386
|
+
path, target = paths.expand(path), os.path.join(base, rel)
|
|
387
|
+
row = {"kind": kind, "path": path, "rel": rel, "target": target, "action": "create", "why": "", "foreign": False}
|
|
388
|
+
if os.path.islink(path):
|
|
389
|
+
have = os.readlink(path)
|
|
390
|
+
abs_have = os.path.join(os.path.dirname(path), have)
|
|
391
|
+
if have == target:
|
|
392
|
+
row["action"] = "ok"
|
|
393
|
+
elif _inside(abs_have, rel_root) or _inside(abs_have, t.source):
|
|
394
|
+
row.update(action="replace", why=f"was -> {have}")
|
|
395
|
+
else:
|
|
396
|
+
row.update(action="replace" if force else "conflict", why=f"a symlink to {have}, not pp's", foreign=True)
|
|
397
|
+
elif os.path.isdir(path):
|
|
398
|
+
row.update(action="conflict", why="a directory (never replaced, even with --force-links)")
|
|
399
|
+
elif os.path.lexists(path):
|
|
400
|
+
row.update(action="replace" if force else "conflict", why="a regular file", foreign=True)
|
|
401
|
+
if not os.path.lexists(os.path.join(t.source, rel)) and row["action"] != "conflict":
|
|
402
|
+
# a note here; cmd_run makes it a blocker when there is no build (_in_snapshot), and the release
|
|
403
|
+
# is checked again after its build
|
|
404
|
+
row["why"] = (row["why"] + "; " if row["why"] else "") + f"{rel} not in the source tree"
|
|
405
|
+
plan.append(row)
|
|
406
|
+
return plan
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _link_blockers(plan: list[dict]) -> list[str]:
|
|
410
|
+
return [f"{l['path']} is {l['why']}" + ("; pass --force-links to replace it" if l["foreign"] else "")
|
|
411
|
+
for l in plan if l["action"] == "conflict"]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _apply_links(plan: list[dict], say=print):
|
|
415
|
+
for l in plan:
|
|
416
|
+
if l["action"] not in ("create", "replace"):
|
|
417
|
+
continue
|
|
418
|
+
os.makedirs(os.path.dirname(l["path"]), exist_ok=True)
|
|
419
|
+
# a staging name of this writer's own, so no other applier (a promote, a `pp promote link`) can remove
|
|
420
|
+
# it between the symlink and the rename and leave this one with nothing to rename
|
|
421
|
+
tmp = paths.staging(os.path.dirname(l["path"]), "link")
|
|
422
|
+
os.symlink(l["target"], tmp)
|
|
423
|
+
os.replace(tmp, l["path"])
|
|
424
|
+
say(f" {l['kind']} {l['path']} -> {l['target']}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _confirm_foreign(plan: list[dict], yes: bool) -> bool:
|
|
428
|
+
foreign = [l for l in plan if l["foreign"] and l["action"] == "replace"]
|
|
429
|
+
if not foreign:
|
|
430
|
+
return True
|
|
431
|
+
for l in foreign:
|
|
432
|
+
print(f" will replace {l['path']} ({l['why']})")
|
|
433
|
+
return ui.confirm("replace these with pp links?", yes)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---- the run log, the flip ------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
class Log:
|
|
439
|
+
"""Everything a run prints, also appended to its own log file: evidence that outlives the caller's pipe."""
|
|
440
|
+
|
|
441
|
+
def __init__(self, path: str | None):
|
|
442
|
+
self.path = path
|
|
443
|
+
self.f = open(path, "a") if path else None
|
|
444
|
+
|
|
445
|
+
def __enter__(self) -> "Log":
|
|
446
|
+
return self
|
|
447
|
+
|
|
448
|
+
def __exit__(self, *exc) -> None:
|
|
449
|
+
if self.f:
|
|
450
|
+
self.f.close()
|
|
451
|
+
self.f = None
|
|
452
|
+
|
|
453
|
+
def say(self, msg: str = ""):
|
|
454
|
+
print(msg, flush=True)
|
|
455
|
+
if self.f:
|
|
456
|
+
self.f.write(msg + "\n")
|
|
457
|
+
self.f.flush()
|
|
458
|
+
|
|
459
|
+
def sh(self, label: str, cmd: str, cwd: str, env: dict) -> int:
|
|
460
|
+
"""Run the check, printing its output as it comes. In pp's own group, so a Ctrl-C in this terminal reaches
|
|
461
|
+
the check as well: that is how a person stops a promotion."""
|
|
462
|
+
self.say(f"--- {label}: {cmd}")
|
|
463
|
+
c = proc.spawn(["/bin/sh", "-c", cmd], group="shared", stdin="none", env=env, cwd=cwd, stdout=proc.PIPE,
|
|
464
|
+
stderr=proc.STDOUT)
|
|
465
|
+
for raw in iter(c.stdout.readline, b""):
|
|
466
|
+
line = raw.decode(errors="replace")
|
|
467
|
+
sys.stdout.write(line)
|
|
468
|
+
sys.stdout.flush()
|
|
469
|
+
if self.f:
|
|
470
|
+
self.f.write(line)
|
|
471
|
+
c.close_pipes()
|
|
472
|
+
rc = c.wait()
|
|
473
|
+
self.say(f"--- {label}: {'ok' if rc == 0 else f'FAILED (exit {rc})'}")
|
|
474
|
+
return rc
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _flip(t: Target, stamp: str):
|
|
478
|
+
"""`current` -> stamp, atomically: a reader sees the old release or the new one, never neither."""
|
|
479
|
+
tmp = paths.staging(t.releases, "current")
|
|
480
|
+
os.symlink(stamp, tmp)
|
|
481
|
+
os.replace(tmp, os.path.join(t.releases, "current"))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _revlog(t: Target, line: str):
|
|
485
|
+
with open(os.path.join(t.releases, "revisions.log"), "a") as f:
|
|
486
|
+
f.write(f"{datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} {line}\n")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@contextmanager
|
|
490
|
+
def _lock(t: Target):
|
|
491
|
+
"""A target's own lock: one promote, rollback, link or gc of it at a time. A second one refuses rather than
|
|
492
|
+
waits, since a promotion runs a build and a gate and can take minutes. Ctrl-C reaches those, so the lock
|
|
493
|
+
does not defer signals; what an interrupted promotion leaves is read from disk by the next one."""
|
|
494
|
+
os.makedirs(t.releases, exist_ok=True)
|
|
495
|
+
try:
|
|
496
|
+
with lock.locked(os.path.join(t.releases, ".lock"), block=False):
|
|
497
|
+
yield
|
|
498
|
+
except lock.Busy: # nothing inside asks for a lock without waiting, so it is this one
|
|
499
|
+
ui.die(f"another promote/rollback of {t.name} is running", 1)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _in_snapshot(t: Target, f: Facts, rel: str) -> bool:
|
|
503
|
+
"""Whether the release will hold rel: HEAD's tree in git mode, else what is on disk now."""
|
|
504
|
+
if t.mode == "git" and not f.dirty:
|
|
505
|
+
return _git(t.source, "cat-file", "-e", f"HEAD:./{rel}").returncode == 0
|
|
506
|
+
return os.path.lexists(os.path.join(t.source, rel))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _gc_victims(t: Target, keep: int) -> list[str]:
|
|
510
|
+
stamps = t.stamps()
|
|
511
|
+
cur = t.current()
|
|
512
|
+
spare = set(stamps[-max(keep, 0):] if keep > 0 else []) | {cur, t.previous(cur)}
|
|
513
|
+
return [s for s in stamps if s not in spare]
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _gc(t: Target, gone: list[str], say=print) -> None:
|
|
517
|
+
"""Remove these releases (under the target's lock; current and the one before it are never in the list)."""
|
|
518
|
+
for s in gone:
|
|
519
|
+
shutil.rmtree(t.dir(s), ignore_errors=True)
|
|
520
|
+
if gone:
|
|
521
|
+
say(f"gc: removed {len(gone)} old release(s): {', '.join(gone)}")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ---- verbs ----------------------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
def cmd_run(argv: list[str]) -> int:
|
|
527
|
+
ap = ui.parser("promote", __doc__)
|
|
528
|
+
ap.add_argument("path", nargs="?", help="a path in the project (default: cwd)")
|
|
529
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="preflight: list every blocker and the plan, change nothing")
|
|
530
|
+
ap.add_argument("--dirty", action="store_true", help="git mode: snapshot the working tree as it is")
|
|
531
|
+
ap.add_argument("--no-check", action="store_true", help="skip the gate (recorded in the log)")
|
|
532
|
+
ap.add_argument("--again", action="store_true", help="promote even if HEAD is already current")
|
|
533
|
+
ap.add_argument("--keep-failed", action="store_true", help="leave a failed staging dir for inspection")
|
|
534
|
+
ap.add_argument("--force-links", action="store_true", help="replace regular files / foreign symlinks at link paths")
|
|
535
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
536
|
+
a = ap.parse_args(argv)
|
|
537
|
+
|
|
538
|
+
t = _project(a.path)
|
|
539
|
+
f = Facts(t, a.dirty)
|
|
540
|
+
plan = _link_plan(t, a.force_links)
|
|
541
|
+
blockers = f.blockers + _link_blockers(plan)
|
|
542
|
+
if not t.cfg.get("build"): # with a build, the release may make it; without one it is what is snapshot
|
|
543
|
+
blockers += [f"{l['rel']} is a link target but the release would not have it: commit it, or add a `build`"
|
|
544
|
+
for l in plan if l["kind"] == "link" and not _in_snapshot(t, f, l["rel"])]
|
|
545
|
+
taken = _name_taken(_registry(), t)
|
|
546
|
+
if taken:
|
|
547
|
+
blockers.append(taken)
|
|
548
|
+
cur = t.current()
|
|
549
|
+
cur_info = t.info(cur)
|
|
550
|
+
already = bool(f.rev) and cur_info.get("rev") == f.rev and not cur_info.get("dirty") and not f.dirty
|
|
551
|
+
|
|
552
|
+
if a.dry_run:
|
|
553
|
+
_print_plan(t, f, plan, cur, a, already)
|
|
554
|
+
if f.untracked_note():
|
|
555
|
+
print("\n" + f.untracked_note())
|
|
556
|
+
if blockers:
|
|
557
|
+
print(ui.c(f"\nblocked ({len(blockers)}):", "red"))
|
|
558
|
+
for b in blockers:
|
|
559
|
+
print(f" - {b}")
|
|
560
|
+
return 2
|
|
561
|
+
print(ui.c("\npreflight: clear" + (" (already current; --again to re-release)" if already and not a.again else ""), "green"))
|
|
562
|
+
return 0
|
|
563
|
+
if blockers:
|
|
564
|
+
ui.warn(f"promote {t.name}: REFUSED")
|
|
565
|
+
for b in blockers:
|
|
566
|
+
print(f" - {b}", file=sys.stderr)
|
|
567
|
+
return 2
|
|
568
|
+
# both paths apply the same link plan, so both ask about the same files first: a run that found the
|
|
569
|
+
# release already current replaces nothing of the user's without the answer a promotion needs
|
|
570
|
+
if not _confirm_foreign(plan, a.yes):
|
|
571
|
+
return 2
|
|
572
|
+
if already and not a.again:
|
|
573
|
+
with _lock(t): # a promote applies the same links: one applier at a time
|
|
574
|
+
_apply_links(plan)
|
|
575
|
+
print(f"{t.name}: already current ({cur}, rev {f.rev[:12]}). --again re-releases it")
|
|
576
|
+
if f.untracked_note():
|
|
577
|
+
print(f.untracked_note())
|
|
578
|
+
return 0
|
|
579
|
+
|
|
580
|
+
_register(t) # the registry first, then this target's own lock: that is the order
|
|
581
|
+
with _lock(t), ExitStack() as scope:
|
|
582
|
+
# `current` again, under the lock: PP_PREV, the revisions.log line and the rollback target all come from
|
|
583
|
+
# it, and a promote that finished while this one was asking is the release it really flips away from
|
|
584
|
+
cur = t.current()
|
|
585
|
+
stamp = f.stamp(t)
|
|
586
|
+
log = scope.enter_context(Log(os.path.join(paths.state_dir("promote", "logs"), f"{t.name}-{stamp}.log")))
|
|
587
|
+
log.say(f"promote {t.name}: {t.source} ({t.mode}) rev {f.rev[:12]}{' DIRTY' if f.dirty else ''} -> {stamp}")
|
|
588
|
+
log.say(f"log: {log.path}")
|
|
589
|
+
if f.untracked_note():
|
|
590
|
+
log.say(f.untracked_note())
|
|
591
|
+
staging = os.path.join(t.releases, ".staging-" + stamp)
|
|
592
|
+
|
|
593
|
+
def abandon(why: str) -> int:
|
|
594
|
+
if a.keep_failed:
|
|
595
|
+
log.say(f"kept the failed staging dir: {staging}")
|
|
596
|
+
else:
|
|
597
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
598
|
+
log.say(ui.c(f"promote {t.name}: NOT PROMOTED: {why}; current stays {cur or '(none)'}", "red"))
|
|
599
|
+
log.say(f"full output: {log.path}")
|
|
600
|
+
return 1
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
return _release(t, f, a, plan, log, stamp, staging, cur, abandon)
|
|
604
|
+
except KeyboardInterrupt:
|
|
605
|
+
if os.path.isdir(staging):
|
|
606
|
+
abandon("interrupted")
|
|
607
|
+
else:
|
|
608
|
+
log.say(ui.c(f"promote {t.name}: interrupted after the flip; current is {t.current() or '(none)'} "
|
|
609
|
+
f"and `after`/`verify` may not have finished. `pp promote rollback {t.name}` goes "
|
|
610
|
+
f"back", "red"))
|
|
611
|
+
return 130
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _release(t: Target, f: Facts, a, plan: list[dict], log: Log, stamp: str, staging: str, cur: str | None,
|
|
615
|
+
abandon) -> int:
|
|
616
|
+
"""Snapshot, build, check, flip, activate: the part of a promotion that changes things."""
|
|
617
|
+
final, prev_dir = t.dir(stamp), t.dir(cur)
|
|
618
|
+
try:
|
|
619
|
+
if f.rels is None:
|
|
620
|
+
_archive(t.source, staging)
|
|
621
|
+
else:
|
|
622
|
+
_copy(t.source, f.rels, staging)
|
|
623
|
+
with open(os.path.join(staging, META), "w") as fh:
|
|
624
|
+
json.dump({"name": t.name, "rev": f.rev, "source": t.source, "mode": t.mode, "dirty": f.dirty,
|
|
625
|
+
"checked": bool(t.cfg.get("check")) and not a.no_check, "created": time.time(), "created_iso": datetime.now().isoformat(timespec="seconds")}, fh, indent=2)
|
|
626
|
+
except (OSError, RuntimeError, tarfile.TarError) as e:
|
|
627
|
+
return abandon(f"snapshot failed: {e}")
|
|
628
|
+
env = t.env(f.rev, staging, prev_dir)
|
|
629
|
+
if t.cfg.get("build") and log.sh("build", t.cfg["build"], staging, env):
|
|
630
|
+
return abandon("build failed")
|
|
631
|
+
missing = [l["rel"] for l in plan if l["kind"] == "link" and not os.path.lexists(os.path.join(staging, l["rel"]))]
|
|
632
|
+
if missing:
|
|
633
|
+
return abandon(f"link target(s) not in the release: {', '.join(missing)} (committed? built?)")
|
|
634
|
+
if a.no_check:
|
|
635
|
+
log.say("--- check: SKIPPED (--no-check)")
|
|
636
|
+
elif t.cfg.get("check"):
|
|
637
|
+
if log.sh("check", t.cfg["check"], staging, dict(env, PYTHONDONTWRITEBYTECODE="1")):
|
|
638
|
+
return abandon("check failed")
|
|
639
|
+
else:
|
|
640
|
+
log.say("--- check: none configured; nothing gates this release")
|
|
641
|
+
|
|
642
|
+
os.rename(staging, final)
|
|
643
|
+
_flip(t, stamp)
|
|
644
|
+
_revlog(t, f"promote {cur or '-'} -> {stamp} rev={f.rev}" + (" dirty" if f.dirty else ""))
|
|
645
|
+
log.say(f"current -> {stamp}" + (f" (was {cur})" if cur else ""))
|
|
646
|
+
try:
|
|
647
|
+
_apply_links(plan, log.say)
|
|
648
|
+
env = t.env(f.rev, final, prev_dir)
|
|
649
|
+
failed = next((h for h in ("after", "verify") if t.cfg.get(h) and log.sh(h, t.cfg[h], final, env)), None)
|
|
650
|
+
except OSError as e:
|
|
651
|
+
# past the flip, so there is no abandoning it: say what is live and what to run, never a traceback
|
|
652
|
+
log.say(ui.c(f"promote {t.name}: current is {stamp} but its links or hooks did not finish: {e}", "red"))
|
|
653
|
+
log.say(ui.c(f"`pp promote link {t.name}` reasserts the links; `pp promote rollback {t.name}` goes back "
|
|
654
|
+
f"to {cur or '(nothing: `current` would be removed)'}", "red"))
|
|
655
|
+
log.say(f"full output: {log.path}")
|
|
656
|
+
return 1
|
|
657
|
+
if failed:
|
|
658
|
+
_auto_rollback(t, stamp, cur, failed, log)
|
|
659
|
+
return 1
|
|
660
|
+
_gc(t, _gc_victims(t, int(t.cfg.get("keep", DEFAULT_KEEP))), log.say)
|
|
661
|
+
log.say(ui.c(f"promoted {t.name} -> {stamp}", "green"))
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _auto_rollback(t: Target, new: str, prev: str | None, hook: str, log: Log):
|
|
666
|
+
_mark_failed(t, new, hook)
|
|
667
|
+
if prev:
|
|
668
|
+
_flip(t, prev)
|
|
669
|
+
_revlog(t, f"rollback {new} -> {prev} (auto: {hook} failed)")
|
|
670
|
+
log.say(ui.c(f"{hook} failed: rolled back current -> {prev}", "red"))
|
|
671
|
+
if t.cfg.get("after") and log.sh("after (rollback)", t.cfg["after"], t.dir(prev),
|
|
672
|
+
t.env(t.info(prev).get("rev", ""), t.dir(prev), t.dir(new))):
|
|
673
|
+
log.say(ui.c(f"`after` failed for {prev} too: what is running may be neither release. Fix the cause, "
|
|
674
|
+
f"then `pp promote` again", "red"))
|
|
675
|
+
else:
|
|
676
|
+
os.remove(os.path.join(t.releases, "current"))
|
|
677
|
+
_revlog(t, f"rollback {new} -> - (auto: {hook} failed; nothing before it)")
|
|
678
|
+
log.say(ui.c(f"{hook} failed and there is no earlier release: `current` removed", "red"))
|
|
679
|
+
log.say(f"the failed release stays on disk for inspection (rollback skips it): {t.dir(new)}")
|
|
680
|
+
log.say(f"full output: {log.path}")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _mark_failed(t: Target, stamp: str, hook: str):
|
|
684
|
+
"""Record in the release's own metadata that activating it failed, so `rollback` never lands on it."""
|
|
685
|
+
info = dict(t.info(stamp), failed=hook)
|
|
686
|
+
paths.write_json(os.path.join(t.dir(stamp), META), info, indent=2)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _print_plan(t: Target, f: Facts, plan: list[dict], cur: str | None, a, already: bool):
|
|
690
|
+
keep = int(t.cfg.get("keep", DEFAULT_KEEP))
|
|
691
|
+
rev = (f.rev[:12] or "?") + (" (dirty)" if f.dirty else "")
|
|
692
|
+
check = "SKIPPED (--no-check)" if a.no_check else (t.cfg.get("check") or "- (nothing gates this release)")
|
|
693
|
+
rows = [["target", f"{t.name} ({t.mode} from {t.source})"],
|
|
694
|
+
["rev", rev + (" = current" if already else "")],
|
|
695
|
+
["current", cur or "(none)"],
|
|
696
|
+
["new", f.stamp(t) if f.rev else "?"],
|
|
697
|
+
["releases", t.releases],
|
|
698
|
+
["build", t.cfg.get("build") or "-"], ["check", check],
|
|
699
|
+
["after", t.cfg.get("after") or "-"], ["verify", t.cfg.get("verify") or "-"], ["keep", keep]]
|
|
700
|
+
print(ui.table(rows, indent=" "))
|
|
701
|
+
for l in plan:
|
|
702
|
+
print(f" {l['kind']:<9} {l['path']} -> {l['target']} [{l['action']}{': ' + l['why'] if l['why'] else ''}]")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _status_row(name: str, src: str) -> dict:
|
|
706
|
+
t = _resolve(name)
|
|
707
|
+
cur = t.current()
|
|
708
|
+
info = t.info(cur)
|
|
709
|
+
row = {"name": name, "source": src, "mode": t.mode, "current": cur, "rev": info.get("rev"),
|
|
710
|
+
"created": info.get("created_iso"), "release_dirty": info.get("dirty", False),
|
|
711
|
+
"checked": info.get("checked"), "head": None, "ahead": None, "dirty": None, "links_ok": True,
|
|
712
|
+
"link_problems": []}
|
|
713
|
+
if t.mode == "git" and os.path.isdir(src):
|
|
714
|
+
head = _git(src, "rev-parse", "--verify", "-q", "HEAD").stdout.strip()
|
|
715
|
+
row["head"] = head or None
|
|
716
|
+
if head and info.get("rev"):
|
|
717
|
+
n = _git(src, "rev-list", "--count", f"{info['rev']}..HEAD", "--", ".")
|
|
718
|
+
row["ahead"] = int(n.stdout) if n.returncode == 0 else None
|
|
719
|
+
row["dirty"] = bool(_git(src, "status", "--porcelain", "--untracked-files=no", "--", ".").stdout.strip())
|
|
720
|
+
elif t.mode == "tree" and os.path.isdir(src):
|
|
721
|
+
row["head"] = _tree_hash(src, _tree_files(src, t.excludes()))
|
|
722
|
+
row["ahead"] = 0 if row["head"] == info.get("rev") else None
|
|
723
|
+
row["dirty"] = row["head"] != info.get("rev")
|
|
724
|
+
cur_dir = os.path.join(t.releases, "current")
|
|
725
|
+
for kind, base in (("links", cur_dir), ("dev_links", src)):
|
|
726
|
+
for path, rel in (t.cfg.get(kind) or {}).items():
|
|
727
|
+
p = paths.expand(path)
|
|
728
|
+
if os.path.exists(p) and _inside(os.path.realpath(p), os.path.realpath(base)):
|
|
729
|
+
continue
|
|
730
|
+
if not os.path.lexists(p):
|
|
731
|
+
problem = "missing"
|
|
732
|
+
elif os.path.islink(p) and any(_inside(os.path.realpath(p), os.path.realpath(d)) for d in
|
|
733
|
+
(paths.data_dir("releases", create=False), src)):
|
|
734
|
+
problem = "stale" # pp's own link, to something other than it should (`pp promote link`)
|
|
735
|
+
else:
|
|
736
|
+
problem = "conflict" # a file or a link that is not pp's (preflight says the same)
|
|
737
|
+
row["link_problems"].append({"path": p, "kind": kind[:-1], "problem": problem})
|
|
738
|
+
row["links_ok"] = not row["link_problems"]
|
|
739
|
+
return row
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def cmd_status(argv: list[str]) -> int:
|
|
743
|
+
ap = ui.parser("promote status", "every promoted target: current release, source drift, links")
|
|
744
|
+
ap.add_argument("--json", action="store_true")
|
|
745
|
+
a = ap.parse_args(argv)
|
|
746
|
+
rows = [_status_row(n, r["source"]) for n, r in sorted(_registry().items())]
|
|
747
|
+
if a.json:
|
|
748
|
+
ui.print_json(rows)
|
|
749
|
+
return 0
|
|
750
|
+
if not rows:
|
|
751
|
+
print("nothing promoted yet: `pp promote` in a project with a [promote] table")
|
|
752
|
+
return 0
|
|
753
|
+
table = []
|
|
754
|
+
for r in rows:
|
|
755
|
+
ahead = "-" if r["ahead"] is None else (f"+{r['ahead']}" if r["ahead"] else "0")
|
|
756
|
+
if r["mode"] == "tree":
|
|
757
|
+
ahead = "same" if r["ahead"] == 0 else "differs"
|
|
758
|
+
table.append([r["name"], r["current"] or "(none)", (r["rev"] or "")[:10] + ("*" if r["release_dirty"] else ""),
|
|
759
|
+
ahead, "dirty" if r["dirty"] else "", "ok" if r["links_ok"] else " ".join(f"{x['problem']} {x['path']}" for x in r["link_problems"]),
|
|
760
|
+
r["source"] + ("" if os.path.isdir(r["source"]) else " (gone)")])
|
|
761
|
+
print(ui.table(table, ["NAME", "CURRENT", "REV", "AHEAD", "TREE", "LINKS", "SOURCE"]))
|
|
762
|
+
return 0
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def cmd_ls(argv: list[str]) -> int:
|
|
766
|
+
ap = ui.parser("promote ls", "releases of a target, newest first (* = current)")
|
|
767
|
+
ap.add_argument("name", nargs="?")
|
|
768
|
+
ap.add_argument("--json", action="store_true")
|
|
769
|
+
a = ap.parse_args(argv)
|
|
770
|
+
t = _resolve(a.name)
|
|
771
|
+
cur = t.current()
|
|
772
|
+
rows = [dict(t.info(s), stamp=s, current=(s == cur)) for s in reversed(t.stamps())]
|
|
773
|
+
if a.json:
|
|
774
|
+
ui.print_json(rows)
|
|
775
|
+
return 0
|
|
776
|
+
print(ui.table([["*" if r["current"] else "", r["stamp"], (r.get("rev") or "")[:12],
|
|
777
|
+
" ".join(w for w in ("dirty" if r.get("dirty") else "",
|
|
778
|
+
"unchecked" if r.get("checked") is False else "",
|
|
779
|
+
f"failed ({r['failed']})" if r.get("failed") else "") if w),
|
|
780
|
+
ui.human_age(r.get("created"))] for r in rows], ["", "STAMP", "REV", "", "CREATED"]))
|
|
781
|
+
return 0
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def cmd_rollback(argv: list[str]) -> int:
|
|
785
|
+
ap = ui.parser("promote rollback", "flip `current` back to the previous release (or --to one), rerun `after`")
|
|
786
|
+
ap.add_argument("name", nargs="?")
|
|
787
|
+
ap.add_argument("--to", help="a stamp (or unique prefix of one)")
|
|
788
|
+
ap.add_argument("-y", "--yes", action="store_true")
|
|
789
|
+
a = ap.parse_args(argv)
|
|
790
|
+
t = _resolve(a.name)
|
|
791
|
+
cur, stamps = t.current(), t.stamps()
|
|
792
|
+
if not cur:
|
|
793
|
+
ui.die(f"{t.name}: nothing promoted", 1)
|
|
794
|
+
if a.to:
|
|
795
|
+
hits = [s for s in stamps if s == a.to] or [s for s in stamps if s.startswith(a.to)]
|
|
796
|
+
if len(hits) != 1:
|
|
797
|
+
ui.die(f"--to {a.to}: {'ambiguous: ' + ', '.join(hits) if hits else 'no such release'}", 2)
|
|
798
|
+
target = hits[0]
|
|
799
|
+
if target == cur:
|
|
800
|
+
ui.die(f"{target} is already current", 2)
|
|
801
|
+
else:
|
|
802
|
+
target = t.previous(cur)
|
|
803
|
+
if not target:
|
|
804
|
+
ui.die(f"{t.name}: no release older than {cur} kept", 1)
|
|
805
|
+
if not ui.confirm(f"roll {t.name} back {cur} -> {target}?", a.yes):
|
|
806
|
+
return 2
|
|
807
|
+
with _lock(t), ExitStack() as scope:
|
|
808
|
+
now = t.current()
|
|
809
|
+
if now != cur: # a promote finished while this was asked: it would be undone without being seen
|
|
810
|
+
ui.die(f"{t.name}: current is {now} now, not the {cur} this asked about; nothing was rolled back. "
|
|
811
|
+
f"`pp promote rollback {t.name}` asks again", 2)
|
|
812
|
+
log = scope.enter_context(Log(os.path.join(paths.state_dir("promote", "logs"),
|
|
813
|
+
f"{t.name}-{datetime.now():%Y%m%d-%H%M%S}-rollback.log")))
|
|
814
|
+
_flip(t, target)
|
|
815
|
+
_revlog(t, f"rollback {cur} -> {target} (manual)")
|
|
816
|
+
log.say(f"{t.name}: current {cur} -> {target}")
|
|
817
|
+
rc = 0
|
|
818
|
+
if t.cfg.get("after"):
|
|
819
|
+
rc = log.sh("after", t.cfg["after"], t.dir(target),
|
|
820
|
+
t.env(t.info(target).get("rev", ""), t.dir(target), t.dir(cur)))
|
|
821
|
+
log.say(f"the source is unchanged: the next `pp promote` releases it again; undo this: "
|
|
822
|
+
f"pp promote rollback {t.name} --to {cur}")
|
|
823
|
+
return 1 if rc else 0
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def cmd_diff(argv: list[str]) -> int:
|
|
827
|
+
ap = ui.parser("promote diff", "what the source has that the current release does not")
|
|
828
|
+
ap.add_argument("name", nargs="?")
|
|
829
|
+
ap.add_argument("--json", action="store_true")
|
|
830
|
+
a = ap.parse_args(argv)
|
|
831
|
+
t = _resolve(a.name)
|
|
832
|
+
cur = t.current()
|
|
833
|
+
if not cur:
|
|
834
|
+
ui.die(f"{t.name}: nothing promoted", 1)
|
|
835
|
+
rev = t.info(cur).get("rev", "")
|
|
836
|
+
if t.mode == "git":
|
|
837
|
+
got = {}
|
|
838
|
+
for key, args in (("commits", ("log", "--oneline", f"{rev}..HEAD", "--", ".")),
|
|
839
|
+
("files", ("diff", "--stat", rev, "--", "."))):
|
|
840
|
+
out = _git(t.source, *args)
|
|
841
|
+
if out.returncode:
|
|
842
|
+
ui.die(f"promote diff: git {args[0]}: {ui.printable(out.stderr.strip())}", 1)
|
|
843
|
+
got[key] = out.stdout.rstrip().splitlines()
|
|
844
|
+
else:
|
|
845
|
+
excl = [x for p in t.excludes() + [META] for x in ("-x", p)]
|
|
846
|
+
got = {"files": proc.run(["diff", "-rq", *excl, t.dir(cur), t.source], group="own", stdin="none",
|
|
847
|
+
env="inherit").stdout.rstrip().splitlines()}
|
|
848
|
+
if a.json:
|
|
849
|
+
ui.print_json({"name": t.name, "current": cur, "rev": rev, **got})
|
|
850
|
+
return 0
|
|
851
|
+
print(f"{t.name}: current {cur}" + (f", rev {rev[:12]}" if rev else ""))
|
|
852
|
+
if not any(got.values()):
|
|
853
|
+
print("nothing: the source is level with the release")
|
|
854
|
+
for key, lines in got.items():
|
|
855
|
+
if lines:
|
|
856
|
+
print(f"{key}:")
|
|
857
|
+
# commit subjects and file names, from git: an escape sequence in one would redraw the terminal
|
|
858
|
+
print("\n".join(" " + ui.printable(ln) for ln in lines))
|
|
859
|
+
return 0
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def cmd_link(argv: list[str]) -> int:
|
|
863
|
+
ap = ui.parser("promote link", "reassert a target's links and dev_links")
|
|
864
|
+
ap.add_argument("name", nargs="?")
|
|
865
|
+
ap.add_argument("--force-links", action="store_true")
|
|
866
|
+
ap.add_argument("-y", "--yes", action="store_true")
|
|
867
|
+
a = ap.parse_args(argv)
|
|
868
|
+
t = _resolve(a.name)
|
|
869
|
+
plan = _link_plan(t, a.force_links)
|
|
870
|
+
blockers = _link_blockers(plan)
|
|
871
|
+
if blockers:
|
|
872
|
+
for b in blockers:
|
|
873
|
+
print(f" - {b}", file=sys.stderr)
|
|
874
|
+
return 2
|
|
875
|
+
if not _confirm_foreign(plan, a.yes):
|
|
876
|
+
return 2
|
|
877
|
+
with _lock(t): # a promote applies the same links: one at a time, or they race for the same paths
|
|
878
|
+
_apply_links(plan)
|
|
879
|
+
print(f"{t.name}: {len(plan)} link(s) in place")
|
|
880
|
+
return 0
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def cmd_gc(argv: list[str]) -> int:
|
|
884
|
+
ap = ui.parser("promote gc", "prune old releases (current and the one before it always stay)")
|
|
885
|
+
ap.add_argument("name", nargs="?")
|
|
886
|
+
ap.add_argument("--keep", type=int, default=None)
|
|
887
|
+
ap.add_argument("-n", "--dry-run", action="store_true", help="only show what it would remove")
|
|
888
|
+
ap.add_argument("-y", "--yes", action="store_true", help="do not ask")
|
|
889
|
+
a = ap.parse_args(argv)
|
|
890
|
+
t = _resolve(a.name)
|
|
891
|
+
keep = a.keep if a.keep is not None else int(t.cfg.get("keep", DEFAULT_KEEP))
|
|
892
|
+
gone = _gc_victims(t, keep)
|
|
893
|
+
if not gone:
|
|
894
|
+
print(f"gc: nothing to remove ({len(t.stamps())} release(s); current and the one before it always stay)")
|
|
895
|
+
return 0
|
|
896
|
+
print(f"gc: {len(gone)} old release(s) of {t.name} to remove: {', '.join(gone)}")
|
|
897
|
+
if a.dry_run:
|
|
898
|
+
return 0
|
|
899
|
+
if not ui.confirm("remove them?", a.yes):
|
|
900
|
+
return 2
|
|
901
|
+
with _lock(t):
|
|
902
|
+
_gc(t, [s for s in gone if s in _gc_victims(t, keep)]) # what was shown, if still old under the lock
|
|
903
|
+
return 0
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
STARTER = """[promote]
|
|
907
|
+
source = "{source}" # git: HEAD (commit first; --dirty for the tree as-is) | tree: copy the working tree
|
|
908
|
+
# exclude = ["data", "*.log"]
|
|
909
|
+
# build = "make"
|
|
910
|
+
# check = "python3 -m unittest discover -s tests -q" # runs IN the release: the exact bytes being promoted
|
|
911
|
+
# links = {{ "~/.local/bin/{name}" = "bin/{name}" }} # -> releases/{name}/current/bin/{name}
|
|
912
|
+
# dev_links = {{ "~/.local/bin/{name}d" = "bin/{name}" }} # -> this working tree
|
|
913
|
+
# after = "systemctl --user restart {name}"
|
|
914
|
+
# verify = "curl -fsS http://127.0.0.1:PORT/ >/dev/null" # non-zero rolls back automatically
|
|
915
|
+
keep = 5
|
|
916
|
+
"""
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def cmd_init(argv: list[str]) -> int:
|
|
920
|
+
ap = ui.parser("promote init", "write a starter [promote] table into PATH/.pp.toml")
|
|
921
|
+
ap.add_argument("path", nargs="?", default=".")
|
|
922
|
+
a = ap.parse_args(argv)
|
|
923
|
+
root = paths.expand(a.path)
|
|
924
|
+
f = os.path.join(root, config.PROJECT_FILE)
|
|
925
|
+
have = ""
|
|
926
|
+
if os.path.exists(f):
|
|
927
|
+
with open(f) as fh:
|
|
928
|
+
have = fh.read()
|
|
929
|
+
found, data = config.find_project(root)
|
|
930
|
+
if found == root and "promote" in data:
|
|
931
|
+
ui.die(f"{f} already has a [promote] table", 2)
|
|
932
|
+
name = config.project_name(root) if have else os.path.basename(root)
|
|
933
|
+
source = "git" if config.git_root(root) else "tree"
|
|
934
|
+
head = (f'# pp project config. `pp promote -n` previews, `pp promote` releases.\n# name = "{name}"\n\n'
|
|
935
|
+
if not have else "" if have.endswith("\n\n") else "\n" if have.endswith("\n") else "\n\n")
|
|
936
|
+
# in one step: what the file had, the gap and the new table land together, or the file stays as it was
|
|
937
|
+
paths.write_text(f, have + head + STARTER.format(source=source, name=name))
|
|
938
|
+
print(f"wrote [promote] to {f}; edit it, then `pp promote -n`")
|
|
939
|
+
return 0
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def main(argv: list[str]) -> int:
|
|
943
|
+
if argv and argv[0] in VERBS:
|
|
944
|
+
return globals()["cmd_" + argv[0]](argv[1:])
|
|
945
|
+
return cmd_run(argv)
|