Skip to content

Commit a028994

Browse files
authored
fix(ui): surface kept_from_previous_deployment in the deploy result (CashPilot-23yb) (#328)
* fix(ui): surface kept_from_previous_deployment in the deploy result (CashPilot-23yb) A redeploy rebuilds from the RECORDED spec where it diverges from the catalog, and the API has always reported what it kept — but nothing rendered the field, so the operator saw a plain success while their typed-in values were quietly superseded. The divergence list now lands as a warning toast per deploy, e.g. 'storj: kept from the previous deployment — resources: keeping the limits this service was deployed with'. * fix(deploy): let the deploy response carry kept_from_previous_deployment The route was annotated -> dict[str, str], and FastAPI validates the response against that annotation AFTER the deploy has run and persisted: exactly the divergent redeployments that had something to tell the user 500'd, and a retry could deploy again. Proven through HTTP before the fix; negative control pins that a plain deploy carries no kept key.
1 parent af0ecfd commit a028994

3 files changed

Lines changed: 91 additions & 2 deletions

File tree

app/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1567,7 +1567,11 @@ async def api_deploy(
15671567
body: DeployRequest,
15681568
worker_id: int | None = None,
15691569
_auth: dict[str, Any] = Depends(_require_owner),
1570-
) -> dict[str, str]:
1570+
) -> dict[str, Any]:
1571+
# dict[str, Any], not dict[str, str]: kept_from_previous_deployment is a
1572+
# list, and FastAPI validates the response against this annotation AFTER
1573+
# the deploy has run and persisted — a stricter type turns the successful
1574+
# deployments that had something to report into 500s.
15711575
worker_id = await _resolve_worker_id(worker_id)
15721576
svc = catalog.get_service(slug)
15731577
if not svc:

app/static/js/app.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2386,8 +2386,16 @@ const CP = (() => {
23862386
let ok = 0, fail = 0;
23872387
for (const wid of workerIds) {
23882388
try {
2389-
await api(`/api/deploy/${slug}?worker_id=${wid}`, { method: 'POST', body: { env } });
2389+
const res = await api(`/api/deploy/${slug}?worker_id=${wid}`, { method: 'POST', body: { env } });
23902390
ok++;
2391+
// A redeploy rebuilds from the RECORDED spec where it diverges from
2392+
// the catalog. The API has always reported what it kept; nothing
2393+
// rendered it, so the operator saw a plain success while e.g. their
2394+
// typed-in catalog values were quietly superseded (CashPilot-23yb).
2395+
const kept = res && res.kept_from_previous_deployment;
2396+
if (Array.isArray(kept) && kept.length) {
2397+
toast(`${slug}: kept from the previous deployment — ${kept.join('; ')}`, 'warning');
2398+
}
23912399
} catch (err) {
23922400
fail++;
23932401
toast(`Deploy to worker ${wid} failed: ${err.message}`, 'error');

tests/test_main_deploy_routes.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,83 @@ def test_deploy_success(self, client):
135135
data = resp.json()
136136
assert data["status"] == "deployed"
137137

138+
def test_divergent_redeploy_returns_the_kept_list_over_http(self, client):
139+
"""CashPilot-23yb: the deploy response must survive FastAPI's response model.
140+
141+
`kept_from_previous_deployment` is a list, and the route used to be
142+
annotated `-> dict[str, str]` — so the very deployments that had
143+
something to tell the user 500'd AFTER deploying and persisting, and a
144+
retry could deploy again. Exercised through HTTP because a direct call
145+
to the function bypasses response validation entirely.
146+
"""
147+
svc = {
148+
"slug": "honeygain",
149+
"name": "Honeygain",
150+
"docker": {
151+
"image": "honeygain/honeygain:latest",
152+
"env": [{"key": "EMAIL", "default": "user@test.com"}],
153+
"ports": ["8080:80/tcp"],
154+
"volumes": ["/data:/app/data"],
155+
},
156+
}
157+
# The recorded container ran with a command the catalog no longer has:
158+
# _merge_recorded_spec keeps it and reports the divergence.
159+
recorded = {"image": "honeygain/honeygain:latest", "command": "--legacy-flag"}
160+
worker = _online_worker()
161+
162+
async def _fake_deploy(worker_id, slug, spec):
163+
return {"container_id": "abc123"}
164+
165+
with (
166+
_auth_owner(),
167+
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]),
168+
patch("app.main.catalog.get_service", return_value=svc),
169+
patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker),
170+
patch(
171+
"app.main.database.get_deployment_spec",
172+
new_callable=AsyncMock,
173+
return_value=recorded,
174+
),
175+
patch("app.main._proxy_worker_deploy", side_effect=_fake_deploy),
176+
patch("app.main.database.save_deployment", new_callable=AsyncMock),
177+
patch("app.main.database.record_health_event", new_callable=AsyncMock),
178+
patch("app.main._run_collection", new_callable=AsyncMock),
179+
):
180+
resp = client.post("/api/deploy/honeygain", json={"env": {}})
181+
assert resp.status_code == 200, resp.text
182+
data = resp.json()
183+
assert data["status"] == "deployed"
184+
kept = data["kept_from_previous_deployment"]
185+
assert isinstance(kept, list) and kept
186+
assert any("command" in line for line in kept)
187+
188+
def test_plain_deploy_response_has_no_kept_key(self, client):
189+
# Negative control: without a recorded spec there is no divergence and
190+
# the key must be absent — the toast only fires when there is news.
191+
svc = {
192+
"slug": "honeygain",
193+
"name": "Honeygain",
194+
"docker": {"image": "honeygain/honeygain:latest", "env": []},
195+
}
196+
worker = _online_worker()
197+
198+
async def _fake_deploy(worker_id, slug, spec):
199+
return {"container_id": "abc123"}
200+
201+
with (
202+
_auth_owner(),
203+
patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]),
204+
patch("app.main.catalog.get_service", return_value=svc),
205+
patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker),
206+
patch("app.main._proxy_worker_deploy", side_effect=_fake_deploy),
207+
patch("app.main.database.save_deployment", new_callable=AsyncMock),
208+
patch("app.main.database.record_health_event", new_callable=AsyncMock),
209+
patch("app.main._run_collection", new_callable=AsyncMock),
210+
):
211+
resp = client.post("/api/deploy/honeygain", json={"env": {}})
212+
assert resp.status_code == 200, resp.text
213+
assert "kept_from_previous_deployment" not in resp.json()
214+
138215
def test_deploy_service_not_found(self, client):
139216
with (
140217
_auth_owner(),

0 commit comments

Comments
 (0)