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

1"""Generic Terraform-state parser for the deployment status pipeline. 

2 

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. 

8 

9Design choices: 

10 

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. 

15 

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. 

19 

20* Team-Tag extraction: pulls ``metadata.team`` from Compute-Instance 

21 attributes; resources without the tag get ``team=None`` and render 

22 as ``Shared``. 

23 

24The parser is a pure function — no DB, no HTTP, no SDK calls. 

25""" 

26 

27from __future__ import annotations 

28 

29import json 

30import logging 

31from dataclasses import dataclass, field 

32from typing import Any, Literal 

33 

34logger = logging.getLogger(__name__) 

35 

36 

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] 

49 

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} 

58 

59 

60@dataclass 

61class TfResource: 

62 """One resource instance from the Terraform state. 

63 

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) 

93 

94 

95def parse_tf_state(state_json: str | dict | None) -> list[TfResource]: 

96 """Parse a ``terraform state pull`` JSON blob into a typed list. 

97 

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 [] 

107 

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 

127 

128 

129# ---------------------------------------------------------------- 

130# Internals 

131# ---------------------------------------------------------------- 

132def _coerce_state(state_json: str | dict | None) -> dict | None: 

133 """Best-effort: return a state dict or None. 

134 

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 

149 

150 

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``. 

159 

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 

168 

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) 

176 

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 ) 

186 

187 

188def _format_address( 

189 *, resource_type: str, resource_name: str, instance: dict 

190) -> str: 

191 """Build the ``terraform state list``-style address string. 

192 

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"]`` 

197 

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 

219 

220 

221def _pick_display_name( 

222 category: ResourceCategory, attrs: dict, fallback: str 

223) -> str: 

224 """Choose the most informative human-facing label per category. 

225 

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 

239 

240 

241def _extract_team(category: ResourceCategory, attrs: dict) -> str | None: 

242 """Pull ``metadata.team`` from a compute-instance, else None. 

243 

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