Coverage for app/services/tf_state_parser.py: 95.12%
82 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"""Generic Terraform-state parser for the deployment status pipeline.
3Reads the JSON blob persisted in ``Task.tf_state`` (produced by
4``terraform state pull`` in the worker — see
5``worker/app/tasks.py:collect_terraform_state``) and produces a flat,
6typed list of resource entries the rest of the status pipeline can
7join with live OpenStack data.
9Design choices:
11* Whitelist of resource types, not free-form pass-through: the state
12 file contains random_password / data sources / Terraform-internal
13 resources we don't surface. We filter to OpenStack kinds that map
14 onto a meaningful UI card.
16* Address strings match ``terraform state list`` exactly (for both
17 ``count.index`` and ``for_each``), so they round-trip as
18 ``-target=`` / ``-replace=`` arguments without translation.
20* Team-Tag extraction: pulls ``metadata.team`` from Compute-Instance
21 attributes; resources without the tag get ``team=None`` and render
22 as ``Shared``.
24The parser is a pure function — no DB, no HTTP, no SDK calls.
25"""
27from __future__ import annotations
29import json
30import logging
31from dataclasses import dataclass, field
32from typing import Any, Literal
34logger = logging.getLogger(__name__)
37# Resource types we surface in the Infrastructure tab. Anything else
38# is filtered out — keeps the UI focused on infrastructure that maps
39# onto a meaningful card and avoids leaking, e.g., ``random_password``
40# values into a JSON dump.
41ResourceCategory = Literal[
42 "instance",
43 "network",
44 "subnet",
45 "security_group",
46 "floating_ip",
47 "port",
48]
50_TYPE_TO_CATEGORY: dict[str, ResourceCategory] = {
51 "openstack_compute_instance_v2": "instance",
52 "openstack_networking_network_v2": "network",
53 "openstack_networking_subnet_v2": "subnet",
54 "openstack_networking_secgroup_v2": "security_group",
55 "openstack_networking_floatingip_v2": "floating_ip",
56 "openstack_networking_port_v2": "port",
57}
60@dataclass
61class TfResource:
62 """One resource instance from the Terraform state.
64 Attributes:
65 address: Full state address (``terraform state list`` format).
66 Examples:
67 * ``openstack_compute_instance_v2.team_ide["Team-A"]``
68 * ``openstack_networking_network_v2.shared``
69 * ``openstack_compute_instance_v2.worker[0]``
70 Used as the identity key for redeploy targeting.
71 type: Raw HCL resource type, e.g. ``openstack_compute_instance_v2``.
72 category: Coarse UI category.
73 provider_id: The OpenStack-side UUID of the resource.
74 Pulled from ``attributes.id``.
75 display_name: Human-friendly label for the card title.
76 Compute → instance name; others → resource name
77 or fallback to address.
78 team: Value of ``attributes.metadata.team`` for compute
79 instances, otherwise None. The team-contract for
80 app-authors is to set ``metadata = { team = each.key }``
81 on team-scoped compute resources.
82 attributes: Raw attributes dict, kept for callers that need
83 cheap access to fields without re-parsing the
84 state (e.g. flavor_id, image_id, network info).
85 """
86 address: str
87 type: str
88 category: ResourceCategory
89 provider_id: str
90 display_name: str
91 team: str | None = None
92 attributes: dict[str, Any] = field(default_factory=dict)
95def parse_tf_state(state_json: str | dict | None) -> list[TfResource]:
96 """Parse a ``terraform state pull`` JSON blob into a typed list.
98 Accepts the raw string (as stored in ``Task.tf_state``) or an
99 already-parsed dict. Returns an empty list for ``None`` / invalid
100 JSON / empty state — callers don't need to special-case that, the
101 Infrastructure tab simply renders "no resources yet" in those
102 cases (e.g. before the first apply completes).
103 """
104 state = _coerce_state(state_json)
105 if not state:
106 return []
108 out: list[TfResource] = []
109 for resource_block in state.get("resources", []):
110 raw_type = resource_block.get("type")
111 category = _TYPE_TO_CATEGORY.get(raw_type)
112 if category is None:
113 # Not a UI-relevant type (random_password, data sources,
114 # whatever the app declares for internal use). Skip.
115 continue
116 resource_name = resource_block.get("name") or "unnamed"
117 for instance in resource_block.get("instances", []):
118 entry = _build_entry(
119 resource_block=resource_block,
120 instance=instance,
121 category=category,
122 resource_name=resource_name,
123 )
124 if entry is not None:
125 out.append(entry)
126 return out
129# ----------------------------------------------------------------
130# Internals
131# ----------------------------------------------------------------
132def _coerce_state(state_json: str | dict | None) -> dict | None:
133 """Best-effort: return a state dict or None.
135 Invalid JSON is logged at WARN and treated as empty — better than
136 a 500 in the resource endpoint. The TF-state column can be
137 surprisingly empty for failed-deploy tasks (no ``apply`` ever
138 succeeded), and we want a clean empty-list output in that case.
139 """
140 if state_json is None:
141 return None
142 if isinstance(state_json, dict):
143 return state_json
144 try:
145 return json.loads(state_json)
146 except (TypeError, json.JSONDecodeError) as exc:
147 logger.warning("parse_tf_state: invalid state JSON (%s)", exc)
148 return None
151def _build_entry(
152 *,
153 resource_block: dict,
154 instance: dict,
155 category: ResourceCategory,
156 resource_name: str,
157) -> TfResource | None:
158 """Materialise one resource INSTANCE into a ``TfResource``.
160 Returns None when the instance has no provider-side ID (apply
161 failed before OpenStack persisted the resource); a redeploy target
162 without an ID wouldn't work, so it is dropped.
163 """
164 attrs = instance.get("attributes") or {}
165 provider_id = attrs.get("id")
166 if not provider_id:
167 return None
169 address = _format_address(
170 resource_type=resource_block["type"],
171 resource_name=resource_name,
172 instance=instance,
173 )
174 display_name = _pick_display_name(category, attrs, address)
175 team = _extract_team(category, attrs)
177 return TfResource(
178 address=address,
179 type=resource_block["type"],
180 category=category,
181 provider_id=str(provider_id),
182 display_name=display_name,
183 team=team,
184 attributes=attrs,
185 )
188def _format_address(
189 *, resource_type: str, resource_name: str, instance: dict
190) -> str:
191 """Build the ``terraform state list``-style address string.
193 Three shapes we need to support:
194 * Simple resource (no index_key) → ``type.name``
195 * ``count`` (int index_key) → ``type.name[0]``
196 * ``for_each`` (string index_key) → ``type.name["Team-A"]``
198 The double-quotes around for_each keys MUST be present — they're
199 part of the terraform CLI contract for ``-target=`` / ``-replace=``.
200 The frontend URL-encodes them before the redeploy call and FastAPI
201 restores them.
202 """
203 base = f"{resource_type}.{resource_name}"
204 index_key = instance.get("index_key")
205 if index_key is None:
206 return base
207 if isinstance(index_key, str):
208 return f'{base}["{index_key}"]'
209 if isinstance(index_key, int):
210 return f"{base}[{index_key}]"
211 # Unexpected — log and fall back to no index so the UI at least
212 # renders something. Live-fetch will likely fail downstream and
213 # the resource appears as drift=stale.
214 logger.warning(
215 "_format_address: unexpected index_key type %r for %s",
216 index_key, base,
217 )
218 return base
221def _pick_display_name(
222 category: ResourceCategory, attrs: dict, fallback: str
223) -> str:
224 """Choose the most informative human-facing label per category.
226 Order:
227 1. ``name`` attribute (true for most networking + compute kinds)
228 2. ``description`` for SGs that have no ``name``
229 3. The address itself
230 """
231 name = attrs.get("name")
232 if isinstance(name, str) and name:
233 return name
234 if category == "security_group":
235 desc = attrs.get("description")
236 if isinstance(desc, str) and desc:
237 return desc
238 return fallback
241def _extract_team(category: ResourceCategory, attrs: dict) -> str | None:
242 """Pull ``metadata.team`` from a compute-instance, else None.
244 Apps follow the team-contract by setting
245 ``metadata = { team = each.key }`` on team-scoped instances. The
246 team is not inferred from the address. Only Compute-Instances carry
247 the contract; other kinds are treated as shared.
248 """
249 if category != "instance":
250 return None
251 metadata = attrs.get("metadata")
252 if not isinstance(metadata, dict):
253 return None
254 team = metadata.get("team")
255 if isinstance(team, str) and team:
256 return team
257 return None