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
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:
1 parent
bc55528afd
commit
a6c9fd8a00
30 files changed
+833
-395
No files matched your search
+100
-17
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user