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
+3
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user