diff --git a/app/services/deploy.py b/app/services/deploy.py index c198ef1..b45d1a1 100644 --- a/app/services/deploy.py +++ b/app/services/deploy.py @@ -20,6 +20,12 @@ from app.services import contract, crypto, guard, onyva_login, store from app.services.mittwald import DeployError, Mittwald, mask_env PORT = "8276" +# Geteilte Secrets, die – falls nicht in der DB gesetzt – aus der Env der Admin-Plattform kommen (gleicher Key wie für Agent/Prompt-Test) +ENV_FALLBACK_SECRETS = ("CORTECS_API_KEY",) + + +def _env_secret(key: str) -> str: + return os.getenv(key, "").strip() if key in ENV_FALLBACK_SECRETS else "" def live_enabled() -> bool: @@ -75,6 +81,10 @@ def build_env(db, t: Tenant, reveal: bool) -> dict[str, str]: env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt" elif not reveal: env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)" + for k in ENV_FALLBACK_SECRETS: # Admin-Env als Rückfall für geteilte Secrets ohne DB-Wert + v = _env_secret(k) + if v and (k not in env or not env[k] or env[k].startswith("⚠")): + env[k] = v if reveal else "•••• gesetzt (aus Admin-Env)" if t.source == "admin": env["TENANT_SOURCE"] = "admin" env["ADMIN_URL"] = setting(db, "admin_public_url", "https://") @@ -85,6 +95,7 @@ def missing_secrets(db, t: Tenant) -> list[str]: """Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben.""" cfg, _ = store.content(db, t, "current") have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc} + have |= {k for k in ENV_FALLBACK_SECRETS if _env_secret(k)} need = set(contract.required_secrets(cfg)) if t.source == "admin": need.add("ADMIN_BUNDLE_TOKEN") diff --git a/app/services/mittwald.py b/app/services/mittwald.py index e92af31..831cc92 100644 --- a/app/services/mittwald.py +++ b/app/services/mittwald.py @@ -83,13 +83,21 @@ class Mittwald: stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False) except DryRunSkip: return "" - items = stacks if isinstance(stacks, list) else (stacks or {}).get("items", []) + items = stacks if isinstance(stacks, list) else ((stacks or {}).get("items") or (stacks or {}).get("stacks") or []) for s in items: if str(s.get("description") or s.get("name") or "").lower() == "default": return s["id"] if items: return items[0]["id"] - raise DeployError("Kein Stack im Projekt gefunden.") + # Rückfall: Jedes Mittwald-Projekt hat einen Standard-Stack, dessen ID der UUID des Projekts entspricht + proj = self.try_api("GET", f"/projects/{project_id}/", write=False, fatal=False) + uuid = (proj or {}).get("id") + if uuid and self.try_api("GET", f"/stacks/{uuid}/", write=False, fatal=False) is not None: + self.log(f"Stack über die Projekt-UUID gefunden: {uuid}") + return uuid + raw = json.dumps(stacks, ensure_ascii=False)[:200] + raise DeployError(f"Kein Stack im Projekt gefunden (Antwort der API: {raw}). STACK_ID in den Einstellungen von Hand eintragen " + f"(mStudio bzw. GET /v2/projects/{project_id}/stacks) und Token-Rechte für Container/Stacks prüfen.") def get_stack(self, stack_id: str) -> dict | None: return self.try_api("GET", f"/stacks/{stack_id}/", write=False) diff --git a/tests/test_core_contract.py b/tests/test_core_contract.py index 624e441..328a02b 100644 --- a/tests/test_core_contract.py +++ b/tests/test_core_contract.py @@ -455,3 +455,40 @@ def test_live_actions_are_locked_without_login(client, monkeypatch): assert client.post("/api/tenants/sprinz/deploy", json={"dry_run": False}).status_code == 403 assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403 assert client.get("/api/deploy/mode").json()["live"] is False + + +# ── Stack-Auflösung & Env-Rückfall ────────────────────────────────────────── + +def test_stack_resolution_falls_back_to_project_uuid_and_explains_failures(): + from app.services.mittwald import DeployError, Mittwald + calls = [] + + def http_ok(method, url, headers, body): + path = url.split("/v2")[1] + calls.append(path) + if path.endswith("/stacks/") and "/projects/" in path: + return 200, [], "" # Liste leer + if path == "/projects/p-1/": + return 200, {"id": "uuid-1"}, "" + if path == "/stacks/uuid-1/": + return 200, {"services": {}}, "" + return 404, None, "nope" + + assert Mittwald("t", "https://api.test/v2", False, lambda m: None, http_ok).resolve_stack_id("p-1", "") == "uuid-1" + assert Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (200, [{"id": "s-9", "description": "default"}], "")).resolve_stack_id("p-1", "") == "s-9" + assert Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda *a: (200, {"stacks": [{"id": "s-7"}]}, "")).resolve_stack_id("p-1", "") == "s-7" + with pytest.raises(DeployError, match="Antwort der API.*STACK_ID in den Einstellungen"): + Mittwald("t", "https://api.test/v2", False, lambda m: None, lambda m, u, h, b: (200, [], "") if u.endswith("/stacks/") else (404, None, "x")).resolve_stack_id("p-1", "") + + +def test_cortecs_key_from_admin_env_counts_as_set_and_reaches_the_container(client, monkeypatch): + client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": ""}) # kein DB-Wert (andere Tests setzen ihn) + with session() as db: + t = db.get(Tenant, "sprinz") + monkeypatch.delenv("CORTECS_API_KEY", raising=False) + assert "CORTECS_API_KEY" in deploy.missing_secrets(db, t) + monkeypatch.setenv("CORTECS_API_KEY", "ck-aus-env") + assert "CORTECS_API_KEY" not in deploy.missing_secrets(db, t) + assert deploy.build_env(db, t, reveal=True)["CORTECS_API_KEY"] == "ck-aus-env" + assert "ck-aus-env" not in str(deploy.build_env(db, t, reveal=False)) + assert "aus Admin-Env" in deploy.build_env(db, t, reveal=False)["CORTECS_API_KEY"]