Coverage for app/services/clouds_yaml_parser.py: 81.13%
53 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Parse a raw `clouds.yaml` blob into an `OpenStackCredentialUpsert`.
3Used by the `/me/openstack-credentials/from-yaml` convenience endpoint so
4users can paste/upload the file Horizon hands them instead of filling
5the form manually. The parser is strict: anything weird gets a 422 with
6a message the UI can render verbatim.
7"""
8from __future__ import annotations
10import yaml
11from fastapi import HTTPException, status
13from app.models import OpenStackAuthType
14from app.schemas import OpenStackCredentialUpsert
16_SUPPORTED_AUTH_TYPES = {
17 "v3applicationcredential": OpenStackAuthType.APPLICATION_CREDENTIAL,
18 "password": OpenStackAuthType.PASSWORD,
19 # `clouds.yaml` from Horizon often omits `auth_type` for password;
20 # treat the absence as password.
21 None: OpenStackAuthType.PASSWORD,
22}
25def _bad(msg: str) -> HTTPException:
26 return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
29def parse(clouds_yaml: str, cloud_name: str | None = None) -> OpenStackCredentialUpsert:
30 try:
31 doc = yaml.safe_load(clouds_yaml)
32 except yaml.YAMLError as e:
33 raise _bad(f"Invalid YAML: {e}")
35 if not isinstance(doc, dict) or "clouds" not in doc or not isinstance(doc["clouds"], dict):
36 raise _bad("clouds.yaml must contain a top-level 'clouds:' mapping")
38 clouds = doc["clouds"]
39 if not clouds:
40 raise _bad("clouds.yaml has no clouds defined")
42 if cloud_name is None:
43 if len(clouds) == 1:
44 cloud_name = next(iter(clouds.keys()))
45 else:
46 names = ", ".join(sorted(clouds.keys()))
47 raise _bad(f"Multiple clouds in YAML; specify cloud_name (one of: {names})")
49 if cloud_name not in clouds:
50 names = ", ".join(sorted(clouds.keys()))
51 raise _bad(f"Cloud '{cloud_name}' not found in YAML (available: {names})")
53 cloud = clouds[cloud_name]
54 if not isinstance(cloud, dict):
55 raise _bad(f"Cloud '{cloud_name}' is not a mapping")
57 auth = cloud.get("auth")
58 if not isinstance(auth, dict):
59 raise _bad(f"Cloud '{cloud_name}' is missing 'auth:' block")
61 raw_auth_type = cloud.get("auth_type")
62 if raw_auth_type not in _SUPPORTED_AUTH_TYPES:
63 raise _bad(
64 f"Unsupported auth_type '{raw_auth_type}'. "
65 f"Supported: v3applicationcredential, password"
66 )
67 auth_type = _SUPPORTED_AUTH_TYPES[raw_auth_type]
69 auth_url = auth.get("auth_url")
70 if not auth_url:
71 raise _bad("auth.auth_url is required")
73 common = {
74 "auth_url": auth_url,
75 "region_name": cloud.get("region_name"),
76 "interface": cloud.get("interface", "public"),
77 "identity_api_version": str(cloud.get("identity_api_version", "3")),
78 "project_id": auth.get("project_id"),
79 "project_name": auth.get("project_name"),
80 "user_domain_name": auth.get("user_domain_name"),
81 "project_domain_name": auth.get("project_domain_name"),
82 }
84 if auth_type == OpenStackAuthType.APPLICATION_CREDENTIAL:
85 identifier = auth.get("application_credential_id")
86 secret = auth.get("application_credential_secret")
87 if not identifier or not secret:
88 raise _bad(
89 "Application credential requires application_credential_id "
90 "and application_credential_secret"
91 )
92 else:
93 identifier = auth.get("username")
94 secret = auth.get("password")
95 if not identifier or not secret:
96 raise _bad("Password auth requires auth.username and auth.password")
98 try:
99 return OpenStackCredentialUpsert(
100 auth_type=auth_type,
101 identifier=identifier,
102 secret=secret,
103 **common,
104 )
105 except ValueError as e:
106 raise _bad(str(e))