fix(ui): reflect update availability and config changes; expand mobile player metadata
ci / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / db / db (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / ts / ts (packages + web) (push) Successful in 2m40s

This commit is contained in:
mrleo1nid committed 2026-10-23 00:20:59 +03:00
1 parent bc55528afd
commit a6c9fd8a00
30 files changed
+833 -395

No files matched your search

+100 -17
View File
@@ -147,6 +147,13 @@ class Agent:
def revision(self):
return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest()
def effective_revision(self, ignore_local=False):
env = read_env(self.deploy / ".env")
local = {} if ignore_local else read_env(self.deploy / "config.local")
effective = {item["key"]: env.get(item["key"], local.get(item["key"], item["default_value"]))
for item in self.schema() if not item["readonly"]}
return hashlib.sha256(json.dumps(effective, sort_keys=True).encode()).hexdigest()
def config(self):
env = read_env(self.deploy / ".env")
local = read_env(self.deploy / "config.local")
@@ -159,8 +166,84 @@ class Agent:
return "••••••••" if item["secret"] and value else value
entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)),
"effective": display(effective), "source": source})
effective = self.effective_revision()
applied = self.load("applied-effective.json", None)
if applied is None and (not local or self.load("applied.json", None) == self.revision()):
self.save("applied-effective.json", effective)
applied = effective
# Changes shadowed by .env do not change the running configuration.
empty = self.effective_revision(ignore_local=True)
return {"entries": entries, "revision": self.revision(),
"restart_required": self.load("applied.json", "") != self.revision()}
"restart_required": effective != (applied or empty)}
def installed_commit(self, body):
"""Trust the running API's build identity, never the checkout or .env.
The checkout can already point at a failed update. Legacy builds contain
git-describe/main hashes or a release tag, which we resolve locally.
"""
revision = body.get("_running_commit", "")
if re.fullmatch(r"[a-f0-9]{40,64}", revision):
return revision
version = body.get("_running_version", "")
match = re.fullmatch(r"(?:.*-g|main-)([a-f0-9]{7,64})", version)
candidates = [match[1]] if match else []
if version_key(version):
candidates = ["refs/tags/v" + version.removeprefix("v"), "refs/tags/" + version.removeprefix("v")]
for candidate in candidates:
try:
commit = self.command(["git", "rev-parse", "--verify", candidate + "^{commit}"]).strip()
if re.fullmatch(r"[a-f0-9]{40,64}", commit):
return commit
except (subprocess.SubprocessError, OSError):
pass
return ""
def update_target(self, refs, body):
channel = body.get("channel") or "latest"
if channel not in ("latest", "version", "main"):
raise ManagementError("management.target_invalid")
if channel == "latest":
tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r]
if not tags:
return "", ""
target = max(tags, key=version_key)
else:
target = "main" if channel == "main" else body.get("version") or ""
if not target:
return "", ""
if target != "main" and not version_key(target):
raise ManagementError("management.target_invalid")
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
commit = refs.get(ref + "^{}", refs.get(ref, ""))
if not re.fullmatch(r"[a-f0-9]{40,64}", commit):
return target, ""
return target, commit
def update_state(self, target, commit, current, body):
if body.get("channel") == "version" and not body.get("version"):
return "select_version"
if not commit:
return "unavailable"
if not current:
return "unknown"
if current == commit:
return "current"
try:
self.command(["git", "merge-base", "--is-ancestor", commit, current])
return "ahead"
except (subprocess.SubprocessError, OSError):
# A selected source's commit may not have been fetched locally yet.
pass
# A stable tag behind a git-describe build is not a newer release.
running = body.get("_running_version", "")
base = re.match(r"v?(\d+\.\d+\.\d+)(?:-rc\.\d+)?", running)
target_key = version_key(target)
if target_key and base:
running_key = version_key(base[0])
if target_key[:3] < running_key[:3] or (target_key[:3] == running_key[:3] and re.search(r"-\d+-g[0-9a-f]+$", running)):
return "ahead"
return "available"
def save_config(self, body):
if self.job and self.job["state"] == "running":
@@ -233,6 +316,7 @@ class Agent:
return dict(job)
def run_job(self, job, command, extra_env, revision):
effective_revision = self.effective_revision()
# Let the API deliver the accepted job before recreating its container.
time.sleep(2)
# Capture full output privately, expose only a bounded redacted tail.
@@ -267,6 +351,7 @@ class Agent:
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
if result == 0:
self.save("applied.json", revision)
self.save("applied-effective.json", effective_revision)
except (OSError, subprocess.SubprocessError):
result = 1
job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management."
@@ -304,34 +389,32 @@ class Agent:
if not selected:
raise ManagementError("management.source_invalid")
if op == "updates.get":
versions, error = [], ""
versions, error, refs = [], "", {}
try:
refs = self.refs(selected["url"])
refs = self.refs(selected["url"], fresh=bool(body.get("fresh")))
versions = sorted([r.removeprefix("refs/tags/") for r in refs if r.startswith("refs/tags/") and version_key(r[10:])], key=version_key, reverse=True)
except (subprocess.SubprocessError, OSError):
error = "management.source_unavailable"
current = self.installed_commit(body)
target, commit = self.update_target(refs, body)
return {"sources": sources, "versions": versions, "source_error": error,
"current_commit": current, "target_version": target, "target_commit": commit,
"update_state": self.update_state(target, commit, current, body),
"current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job}
if op == "updates.start":
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
channel = body.get("channel")
if channel not in ("latest", "version", "main"):
raise ManagementError("management.target_invalid")
refs = self.refs(selected["url"], fresh=True)
if channel == "latest":
tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r]
if not tags:
raise ManagementError("management.no_releases")
target = max(tags, key=version_key)
else:
target = "main" if channel == "main" else body.get("version", "")
if target != "main" and not version_key(target):
raise ManagementError("management.target_invalid")
target, commit = self.update_target(refs, body)
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
commit = refs.get(ref + "^{}", refs.get(ref))
if not commit or not re.fullmatch(r"[a-f0-9]{40,64}", commit):
if not commit:
raise ManagementError("management.target_invalid")
state = self.update_state(target, commit, self.installed_commit(body), body)
if state != "available":
code = {"current": "management.already_installed", "ahead": "management.downgrade", "unknown": "management.version_unknown"}.get(state, "management.target_invalid")
raise ManagementError(code, 409)
if body.get("expected_commit") and body["expected_commit"] != commit:
raise ManagementError("management.target_changed", 409)
return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target],
{"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit,
"PIPU_UPDATE_REF": ref})