Coverage for app/services/openstack_auth.py: 100.00%
99 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 16:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 16:05 +0000
1"""Per-task OpenStack credential materialization.
3The worker receives an encrypted credential envelope on the Celery task args
4(see backend `crud.openstack_credentials.get_dispatch_envelope`). This module
5decrypts the envelope in-process and writes a `clouds.yaml` (mode 0600) into
6the per-deployment workspace for the duration of one task. The file is
7removed eagerly when the context manager exits — so, even on a crash, the
8plaintext credential lives on disk only for the active phase of the build.
10The plaintext is never logged. The `creds` dict is wiped from memory in
11`__exit__` so the GC can reclaim it quickly.
12"""
14from __future__ import annotations
16import base64
17import contextlib
18import os
19from typing import Any
21import yaml
23from ..utils.crypto import decrypt
26class CredentialEnvelopeError(Exception):
27 """Raised when the dispatched envelope is missing fields or fails to decrypt."""
30class PerTaskCloudsConfig:
31 """Context manager: materialize a per-task `clouds.yaml`, then shred it.
33 Usage:
34 with PerTaskCloudsConfig(envelope, work_dir=repo_path) as env:
35 packer.run(env_vars=env)
36 terraform.run(env_vars=env)
37 """
39 # Profile name written into the per-task ``clouds.yaml``. App
40 # Terraform templates reference this via ``provider "openstack" {
41 # cloud = "openstack" }`` (matching the OpenStack convention used
42 # in their docs), so changing this requires updating every
43 # template — keep it as ``"openstack"`` unless there's a strong
44 # reason to switch.
45 CLOUD_NAME = "openstack"
47 def __init__(self, envelope: dict[str, Any], work_dir: str):
48 if not isinstance(envelope, dict):
49 raise CredentialEnvelopeError("OpenStack credential envelope is missing or invalid")
51 try:
52 id_b64 = envelope["encrypted_identifier_b64"]
53 secret_b64 = envelope["encrypted_secret_b64"]
54 auth_type = envelope["auth_type"]
55 auth_url = envelope["auth_url"]
56 except KeyError as e:
57 raise CredentialEnvelopeError(f"OpenStack credential envelope missing field: {e}")
59 try:
60 identifier = decrypt(base64.b64decode(id_b64.encode("ascii")))
61 secret = decrypt(base64.b64decode(secret_b64.encode("ascii")))
62 except Exception as e:
63 # Don't surface InvalidToken text — only the type — to keep logs clean.
64 raise CredentialEnvelopeError(
65 f"Failed to decrypt OpenStack credential envelope ({type(e).__name__}). "
66 "Check that backend and worker share the same CREDENTIAL_ENCRYPTION_KEY."
67 )
69 self._creds: dict[str, Any] | None = {
70 "auth_type": auth_type,
71 "auth_url": auth_url,
72 "region_name": envelope.get("region_name"),
73 "interface": envelope.get("interface") or "public",
74 "identity_api_version": envelope.get("identity_api_version") or "3",
75 "project_id": envelope.get("project_id"),
76 "project_name": envelope.get("project_name"),
77 "user_domain_name": envelope.get("user_domain_name"),
78 "project_domain_name": envelope.get("project_domain_name") or envelope.get("user_domain_name"),
79 "_identifier": identifier,
80 "_secret": secret,
81 }
83 os.makedirs(work_dir, exist_ok=True)
84 self.path = os.path.join(work_dir, "clouds.yaml")
86 def _cloud_block(self) -> dict[str, Any]:
87 c = self._creds
88 if c is None:
89 raise CredentialEnvelopeError("Credentials already shredded")
91 block: dict[str, Any] = {
92 "auth_type": c["auth_type"],
93 "auth": {"auth_url": c["auth_url"]},
94 "interface": c["interface"],
95 "identity_api_version": c["identity_api_version"],
96 }
97 if c["region_name"]:
98 block["region_name"] = c["region_name"]
100 auth = block["auth"]
101 if c["auth_type"] == "v3applicationcredential":
102 auth["application_credential_id"] = c["_identifier"]
103 auth["application_credential_secret"] = c["_secret"]
104 else:
105 auth["username"] = c["_identifier"]
106 auth["password"] = c["_secret"]
107 if c["project_id"]:
108 auth["project_id"] = c["project_id"]
109 if c["project_name"]:
110 auth["project_name"] = c["project_name"]
111 if c["user_domain_name"]:
112 auth["user_domain_name"] = c["user_domain_name"]
113 if c["project_domain_name"]:
114 auth["project_domain_name"] = c["project_domain_name"]
115 return block
117 def _env_vars(self) -> dict[str, str]:
118 """Mirror selected creds into OS_* env vars for tools that ignore clouds.yaml."""
119 c = self._creds or {}
120 env: dict[str, str] = {}
121 if c.get("auth_url"):
122 env["OS_AUTH_URL"] = c["auth_url"]
123 if c.get("region_name"):
124 env["OS_REGION_NAME"] = c["region_name"]
125 if c.get("interface"):
126 env["OS_INTERFACE"] = c["interface"]
127 if c.get("identity_api_version"):
128 env["OS_IDENTITY_API_VERSION"] = c["identity_api_version"]
130 if c.get("auth_type") == "v3applicationcredential":
131 env["OS_AUTH_TYPE"] = "v3applicationcredential"
132 env["OS_APPLICATION_CREDENTIAL_ID"] = c["_identifier"]
133 env["OS_APPLICATION_CREDENTIAL_SECRET"] = c["_secret"]
134 else:
135 env["OS_AUTH_TYPE"] = "password"
136 env["OS_USERNAME"] = c["_identifier"]
137 env["OS_PASSWORD"] = c["_secret"]
138 if c.get("project_id"):
139 env["OS_PROJECT_ID"] = c["project_id"]
140 if c.get("project_name"):
141 env["OS_PROJECT_NAME"] = c["project_name"]
142 if c.get("user_domain_name"):
143 env["OS_USER_DOMAIN_NAME"] = c["user_domain_name"]
144 if c.get("project_domain_name"):
145 env["OS_PROJECT_DOMAIN_NAME"] = c["project_domain_name"]
146 return env
148 def __enter__(self) -> dict[str, str]:
149 # O_EXCL: refuse to overwrite a stale file from a previous task that
150 # crashed before reaching its `__exit__`. mode 0600 is enforced by
151 # the syscall, not by a separate chmod (no race window).
152 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
153 try:
154 fd = os.open(self.path, flags, 0o600)
155 except FileExistsError:
156 # A stale file means a prior task in this same workspace died. Clean and retry.
157 with contextlib.suppress(FileNotFoundError):
158 os.remove(self.path)
159 fd = os.open(self.path, flags, 0o600)
161 try:
162 with os.fdopen(fd, "w") as f:
163 yaml.safe_dump(
164 {"clouds": {self.CLOUD_NAME: self._cloud_block()}},
165 f,
166 default_flow_style=False,
167 )
168 except Exception:
169 with contextlib.suppress(FileNotFoundError):
170 os.remove(self.path)
171 raise
173 env = {
174 "OS_CLIENT_CONFIG_FILE": self.path,
175 "OS_CLOUD": self.CLOUD_NAME,
176 }
177 env.update(self._env_vars())
178 return env
180 def __exit__(self, exc_type, exc, tb) -> None:
181 # Shred file eagerly. The repo workspace cleanup in tasks.py:finally
182 # would also remove it, but doing it here narrows the window.
183 with contextlib.suppress(FileNotFoundError):
184 os.remove(self.path)
185 # Drop plaintext references; let GC reclaim them quickly.
186 self._creds = None