Skip to content

Commit 07e7431

Browse files
authored
feat: confirm mutations and speed up describe (#22)
* feat: confirm mutations and speed up describe - `deployment create`, `deployment refresh`, `project refresh` now emit a success line after the render, matching the convention used by every other mutating command. - `describe_deployment` filters `/tasks` server-side by `deployment_name` and `status=completed` instead of pulling every project task and filtering client-side. Refs RijksICTGilde/RIG-Cluster#51 for the structural fix (no GET endpoint for deployment state) that would retire the task-fusion pattern entirely. * test: cover describe_deployment server-side task filter Adds two respx tests: - verifies the /tasks request carries `deployment_name` and `status=completed` query params - verifies that tasks whose `result.deployment.name` doesn't match the requested deployment are dropped, as defense in depth against upstream contract drift (prefix matches, case folding, etc.) Restores the `dep_info.get("name") != deployment` guard that was removed in the previous commit; the server filter is the primary gate, the client check is the safety net.
1 parent 2889a68 commit 07e7431

4 files changed

Lines changed: 95 additions & 4 deletions

File tree

src/zad_cli/api/client.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -504,14 +504,18 @@ def describe_deployment(self, project: str, deployment: str) -> dict:
504504
}
505505
)
506506

507-
# Get URLs and image info from ALL completed tasks for this deployment
507+
# Upstream has no `GET /projects/{p}/deployments/{d}` endpoint, so URLs
508+
# and image refs are reconstructed from completed task history. Filter
509+
# server-side to avoid paging all project tasks.
508510
urls = {}
509511
images: dict[str, str] = {}
510-
task_response = self._request("GET", "/tasks", params={"project_name": project})
512+
task_response = self._request(
513+
"GET",
514+
"/tasks",
515+
params={"project_name": project, "deployment_name": deployment, "status": "completed"},
516+
)
511517
tasks = task_response.json().get("tasks", [])
512518
for task in tasks:
513-
if task.get("status") != "completed":
514-
continue
515519
result = task.get("result") or {}
516520
if not isinstance(result, dict):
517521
continue

src/zad_cli/commands/deployment.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def create(
173173

174174
result = client.upsert_deployment(project, request.to_api_payload())
175175
formatter.render(result)
176+
formatter.render_success(f"Deployment '{deployment_name}' created/updated in project '{project}'.")
176177

177178

178179
@app.command("update-image")
@@ -236,6 +237,7 @@ def refresh(
236237

237238
result = client.refresh_deployment(project, deployment, force_clone=force_clone)
238239
formatter.render(result)
240+
formatter.render_success(f"Deployment '{deployment}' refreshed.")
239241

240242

241243
@app.command()

src/zad_cli/commands/project.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def refresh(
101101

102102
result = client.refresh_project(project, force_clone=force_clone)
103103
formatter.render(result)
104+
formatter.render_success(f"Project '{project}' refreshed.")
104105

105106

106107
@app.command()

tests/test_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,87 @@ def test_api_key_header(client):
144144
)
145145
client.health()
146146
assert route.calls[0].request.headers["X-API-Key"] == "test-key"
147+
148+
149+
@respx.mock
150+
def test_describe_deployment_filters_tasks_server_side(client):
151+
"""describe_deployment must narrow the /tasks query to the target deployment."""
152+
respx.get("https://api.example.com/logs/my-project").mock(
153+
return_value=httpx.Response(
154+
200,
155+
json={
156+
"results": [
157+
{"component": "web", "deployment": "staging", "namespace": "ns-staging", "k8s_deployment": "web"},
158+
]
159+
},
160+
)
161+
)
162+
tasks_route = respx.get("https://api.example.com/tasks").mock(
163+
return_value=httpx.Response(
164+
200,
165+
json={
166+
"tasks": [
167+
{
168+
"status": "completed",
169+
"result": {
170+
"deployment": {
171+
"name": "staging",
172+
"components": [{"reference": "web", "image": "ghcr.io/org/web:v1"}],
173+
},
174+
"urls": {"staging": {"urls": {"web": "https://staging.example.com"}}},
175+
},
176+
},
177+
]
178+
},
179+
)
180+
)
181+
182+
result = client.describe_deployment("my-project", "staging")
183+
184+
assert tasks_route.called
185+
params = tasks_route.calls[0].request.url.params
186+
assert params["project_name"] == "my-project"
187+
assert params["deployment_name"] == "staging"
188+
assert params["status"] == "completed"
189+
190+
assert result["urls"] == {"web": "https://staging.example.com"}
191+
assert result["components"][0]["image"] == "ghcr.io/org/web:v1"
192+
193+
194+
@respx.mock
195+
def test_describe_deployment_ignores_tasks_with_mismatched_name(client):
196+
"""If the server filter ever leaks a mismatched task, the client guard must drop it."""
197+
respx.get("https://api.example.com/logs/my-project").mock(
198+
return_value=httpx.Response(
199+
200,
200+
json={
201+
"results": [
202+
{"component": "web", "deployment": "staging", "namespace": "ns-staging", "k8s_deployment": "web"},
203+
]
204+
},
205+
)
206+
)
207+
respx.get("https://api.example.com/tasks").mock(
208+
return_value=httpx.Response(
209+
200,
210+
json={
211+
"tasks": [
212+
{
213+
"status": "completed",
214+
"result": {
215+
"deployment": {
216+
"name": "staging-prefix-leak",
217+
"components": [{"reference": "web", "image": "ghcr.io/org/web:wrong"}],
218+
},
219+
"urls": {"staging-prefix-leak": {"urls": {"web": "https://wrong.example.com"}}},
220+
},
221+
},
222+
]
223+
},
224+
)
225+
)
226+
227+
result = client.describe_deployment("my-project", "staging")
228+
229+
assert result["urls"] == {}
230+
assert result["components"][0]["image"] == ""

0 commit comments

Comments
 (0)