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

+3 -1
View File
@@ -54,7 +54,9 @@ services:
context: ..
dockerfile: services/api/Dockerfile
target: api
args: *version
args:
<<: *version
REVISION: ${PIPU_COMMIT:-}
env_file: [config.local, .env]
environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
+8
View File
@@ -414,5 +414,13 @@
"default_value": "3",
"secret": false,
"readonly": false
},
{
"key": "PIPU_COMMIT",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": false,
"readonly": true
}
]
+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})
+2 -1
View File
@@ -116,7 +116,7 @@ class DeploymentTests(unittest.TestCase):
target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n")
self.env["TARGET_INSTALLER"] = target.as_posix()
(self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8")
self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; esac\n')
self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; rev-parse) echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;; esac\n')
self.stub("docker", 'echo "docker $*" >> "$TRACE"\n')
def test_update_installs_target_revision_worker_before_container_operations(self):
@@ -127,6 +127,7 @@ class DeploymentTests(unittest.TestCase):
self.assertLess(trace.index("git checkout"), trace.index("setup --ensure"))
self.assertLess(trace.index("setup --ensure"), trace.index("docker compose"))
self.assertIn("up -d --remove-orphans", trace)
self.assertIn("PIPU_COMMIT=" + "a" * 40, (self.deploy / ".env").read_text())
def test_failed_host_setup_stops_update_before_container_operations(self):
self.update_fixture(install_exit=1)
+57
View File
@@ -33,8 +33,65 @@ class AgentTests(unittest.TestCase):
(self.deploy / "config.local").write_text("", encoding="utf-8")
(self.deploy / ".env").write_text("LOG_LEVEL=warn\nGITEA_TOKEN=top-secret\n", encoding="utf-8")
self.agent = Agent(self.deploy, self.mailbox, self.private)
self.running_commit = "0" * 40
self.agent.installed_commit = lambda body: body.get("_running_commit", self.running_commit)
self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
def test_unchanged_configuration_needs_no_restart_even_without_saved_baseline(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"LOG_LEVEL": "debug"}})
self.assertFalse(result["restart_required"], "LOG_LEVEL is overridden by .env")
def test_returning_to_applied_values_removes_pending_restart(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
changed = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
self.assertTrue(changed["restart_required"])
restored = self.agent.save_config({"revision": changed["revision"], "values": {"TRAIN_HOUR": None}})
self.assertFalse(restored["restart_required"])
def test_explicit_default_value_does_not_require_a_restart(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "3"}})
self.assertFalse(result["restart_required"])
def test_update_check_and_start_both_reject_installed_main_commit(self):
refs = {"refs/heads/main": self.running_commit}
body = {"source_id": "origin", "channel": "main"}
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job") as start:
self.assertEqual("current", self.agent.handle("updates.get", body)["update_state"])
with self.assertRaises(ManagementError) as error:
self.agent.handle("updates.start", body)
self.assertEqual("management.already_installed", error.exception.code)
start.assert_not_called()
def test_check_distinguishes_missing_selection_and_changed_target(self):
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job") as start:
self.assertEqual("select_version", self.agent.handle("updates.get", {"channel": "version"})["update_state"])
with self.assertRaises(ManagementError) as error:
self.agent.handle("updates.start", {"channel": "main", "expected_commit": "b" * 40})
self.assertEqual("management.target_changed", error.exception.code)
start.assert_not_called()
def test_unknown_installed_commit_and_older_release_cannot_be_installed(self):
with patch.object(self.agent, "refs", return_value={"refs/tags/v1.8.0": "a" * 40}), patch.object(self.agent, "start_job") as start:
for body, state in [({"_running_commit": ""}, "unknown"), ({"_running_version": "1.9.0-1-gbc55528"}, "ahead")]:
self.assertEqual(state, self.agent.handle("updates.get", body)["update_state"])
with self.assertRaises(ManagementError):
self.agent.handle("updates.start", body)
start.assert_not_called()
def test_installed_identity_uses_running_build_and_resolves_legacy_describe(self):
with patch.object(self.agent, "command", return_value="a" * 40) as command:
self.assertEqual("b" * 40, Agent.installed_commit(self.agent, {"_running_commit": "b" * 40}))
command.assert_not_called()
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "1.9.0-1-gbc55528"}))
self.assertEqual("bc55528^{commit}", command.call_args.args[0][-1])
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "main-bc55528"}))
self.assertEqual("", Agent.installed_commit(self.agent, {"_running_version": "0.0.0-dev"}))
def test_env_priority_and_secrets_never_returned(self):
(self.deploy / "config.local").write_text("LOG_LEVEL=debug\nTRAIN_HOUR=5\n", encoding="utf-8")
config = self.agent.config()
+8
View File
@@ -97,6 +97,7 @@ fi
# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему.
CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)"
CURRENT_COMMIT="$(sed -n 's/^PIPU_COMMIT=//p' .env)"
TARGET="${REF#v}"
core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; }
cur_core="$(core "$CURRENT" || true)"
@@ -136,6 +137,12 @@ else
VERSION="$(git describe --tags --always --dirty)"
fi
VERSION="${VERSION#v}"
COMMIT="$(git rev-parse HEAD)"
if grep -q '^PIPU_COMMIT=' .env; then
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$COMMIT|" .env
else
echo "PIPU_COMMIT=$COMMIT" >> .env
fi
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
@@ -143,6 +150,7 @@ compose config --quiet
if ! compose build --pull; then
# A build failure leaves running containers untouched; keep their version in .env.
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$CURRENT_COMMIT|" .env
echo 'Build failed; running containers were not replaced' >&2
exit 1
fi