Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions app/routes/v1/deployments/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
Expand Down Expand Up @@ -91,23 +92,52 @@ async def create_deployment(payload: DeploymentCreate,
workspace_path=str(ws.path),
)
session.add(row)
await session.commit()
await session.refresh(row)
# Flush, do not commit: this reserves (codename, scenario_label) at the DB
# level — closing the race the pre-check above cannot — while leaving the
# transaction open. The secret is written inside that window, so a failed
# write rolls the row back and the caller can simply retry. Committing
# first would strand a deployment whose workspace has no password, and the
# retry would then hit the 409 rather than fixing itself.
try:
await session.flush()
except IntegrityError as e:
await session.rollback()
raise Range42Error(
Comment on lines +101 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict conflict handling to the workspace uniqueness constraint

When project_id or target_host_id references a nonexistent row, the foreign-key checks enabled in app/core/db.py make this flush raise the same IntegrityError, so the API now incorrectly returns 409 DEPLOYMENT_EXISTS even though no deployment with this name exists. Inspect the violated constraint before translating the exception, and reserve this response for uq_deployment_workspace.

Useful? React with 👍 / 👎.

error="conflict",
code="DEPLOYMENT_EXISTS",
status=409,
message=(
f"A deployment named {payload.codename}/"
f"{payload.scenario_label} already exists"
),
details=[{"field": "codename", "reason": "created concurrently"}],
) from e

# Everything below mutates the workspace, so it runs only once the row is
# persisted — a failed create must never touch another deployment's files.
#
# Seed the workspace vault password: deploy_trigger reads
# <ws>/secrets/vault_pass.txt to set ANSIBLE_VAULT_PASSWORD_FILE and to
# unlock the SSH keys for the run, and nothing else writes it. Absent or
# empty leaves an operator-seeded file alone.
if payload.secrets and payload.secrets.get("vault_password"):
vault_pass_file = ws.path / "secrets" / "vault_pass.txt"
vault_pass_file.parent.mkdir(parents=True, exist_ok=True)
# chmod before the content so the secret is never briefly world-readable.
vault_pass_file.touch(mode=0o600, exist_ok=True)
vault_pass_file.chmod(0o600)
vault_pass_file.write_text(payload.secrets["vault_password"])
try:
vault_pass_file = ws.path / "secrets" / "vault_pass.txt"
vault_pass_file.parent.mkdir(parents=True, exist_ok=True)
# chmod before the content so the secret is never briefly
# world-readable.
vault_pass_file.touch(mode=0o600, exist_ok=True)
vault_pass_file.chmod(0o600)
vault_pass_file.write_text(payload.secrets["vault_password"])
except OSError as e:
await session.rollback()
raise Range42Error(
error="workspace_error",
code="VAULT_SEED_FAILED",
status=500,
message="Could not write the workspace vault password",
details=[{"field": "secrets.vault_password", "reason": str(e)}],
) from e

await session.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compensate when the database commit fails after seeding

If the SQLite commit fails after the password write—for example because of an I/O or disk-full error—the request exits with no committed deployment while vault_pass.txt remains in the reused workspace. A later create without a password skips the seed branch and silently adopts that stale secret, and the failed request may also leave its secret on disk indefinitely; preserve/restore the prior file or remove the newly seeded file when this commit fails.

Useful? React with 👍 / 👎.

await session.refresh(row)

# Best-effort proxmox token provisioning: requires an app-level vault
# password file and a proxmox_token secret in the payload. Missing
Expand Down
41 changes: 41 additions & 0 deletions tests/routes/test_deployment_vault_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
created through the API could not decrypt anything — the UI collects the
password on the deploy form and it had nowhere to go.
"""
import pathlib
import stat

import pytest
Expand Down Expand Up @@ -159,3 +160,43 @@ async def test_duplicate_codename_is_rejected_without_touching_the_workspace(
assert body["code"] == "DEPLOYMENT_EXISTS"
assert first.json()["id"] in str(body["details"])
assert vault_pass.read_text() == VAULT_PW, "the live deployment was clobbered"


@pytest.mark.asyncio
async def test_failed_seed_leaves_no_deployment_row(tmp_path, monkeypatch):
"""A write failure must roll the deployment back, not strand it.

Committing before the seed left a row whose workspace had no password,
and the obvious retry then hit the 409 instead of fixing itself — the
deployment was unusable until someone deleted it by hand.
"""
app = await _boot(tmp_path, monkeypatch)

real_write = pathlib.Path.write_text

def _boom(self, *a, **kw):
if self.name == "vault_pass.txt":
raise OSError(28, "No space left on device")
return real_write(self, *a, **kw)

monkeypatch.setattr(pathlib.Path, "write_text", _boom)

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://t"
) as c:
failed = await c.post("/v1/deployments/",
json=_payload(secrets={"vault_password": VAULT_PW}))
assert failed.status_code == 500, failed.text
assert failed.json()["code"] == "VAULT_SEED_FAILED"

listed = await c.get("/v1/deployments/")
assert listed.json()["items"] == [], "the failed create left a row behind"

# The obvious next move must work rather than 409.
monkeypatch.setattr(pathlib.Path, "write_text", real_write)
retry = await c.post("/v1/deployments/",
json=_payload(secrets={"vault_password": VAULT_PW}))
assert retry.status_code == 201, retry.text

vault_pass = tmp_path / "ws" / "ALPHA-demo_lab" / "secrets" / "vault_pass.txt"
assert vault_pass.read_text() == VAULT_PW
Loading