Coverage for fake_kubernetes_client/resource_registry.py: 51%

142 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-07-29 12:31 +0300

1"""FakeResourceRegistry implementation for fake Kubernetes client""" 

2 

3import logging 

4from collections import defaultdict 

5from typing import Any, DefaultDict, Union 

6 

7from fake_kubernetes_client.resource_field import FakeResourceField 

8from ocp_resources.utils.schema_validator import SchemaValidator 

9 

10logger = logging.getLogger(__name__) 

11 

12 

13class FakeResourceRegistry: 

14 """Registry for resource definitions""" 

15 

16 def __init__(self) -> None: 

17 # Store by kind to allow searching across API groups 

18 self.resources: DefaultDict[str, list[dict[str, Any]]] = defaultdict(list) 

19 self._resource_mappings_cache: Union[dict[str, Any], None] = None 

20 self._builtin_resources: dict[tuple[str, str], dict[str, Any]] = {} 

21 self._additional_resources: dict[str, list[dict[str, Any]]] = {} 

22 self._load_resource_definitions() 

23 self._register_additional_resources() 

24 

25 def _generate_plural_form(self, kind: str) -> str: 

26 """Generate plural form of resource kind using standard Kubernetes rules""" 

27 kind_lower = kind.lower() 

28 if kind_lower.endswith("s"): 

29 return kind_lower 

30 elif kind_lower.endswith("y"): 

31 return kind_lower[:-1] + "ies" 

32 elif kind_lower.endswith(("sh", "ch", "x", "z")): 

33 return kind_lower + "es" 

34 else: 

35 return kind_lower + "s" 

36 

37 def _get_resource_mappings(self) -> dict[str, Any]: 

38 """Load and cache the resource mappings file using SchemaValidator""" 

39 if self._resource_mappings_cache is None: 

40 # Use SchemaValidator to load the mappings - single source of truth 

41 if SchemaValidator.load_mappings_data(): 

42 self._resource_mappings_cache = SchemaValidator.get_mappings_data() or {} 

43 logger.debug(f"Loaded {len(self._resource_mappings_cache)} resource mappings from SchemaValidator") 

44 else: 

45 logger.error("Failed to load resource mappings from SchemaValidator") 

46 self._resource_mappings_cache = {} 

47 

48 return self._resource_mappings_cache 

49 

50 def _apply_known_corrections(self, kind: str, resource_def: dict[str, Any]) -> dict[str, Any]: 

51 """Apply known corrections to resource definitions""" 

52 # Known corrections for incorrect data in the JSON file 

53 corrections = { 

54 # Service is incorrectly marked as non-namespaced in the JSON 

55 "Service": {"namespaced": True}, 

56 # Event is incorrectly marked as non-namespaced in the JSON 

57 "Event": {"namespaced": True}, 

58 # Add other known corrections here as needed 

59 } 

60 

61 if kind in corrections: 

62 resource_def.update(corrections[kind]) 

63 

64 return resource_def 

65 

66 def _load_resource_definitions(self) -> None: 

67 """Load resource definitions from JSON file - single source of truth""" 

68 mappings = self._get_resource_mappings() 

69 if not mappings: 

70 # If no mappings file found, registry will be empty 

71 # This will cause proper errors when resources are requested 

72 return 

73 

74 for kind_lower, resource_mappings in mappings.items(): 

75 if not isinstance(resource_mappings, list) or not resource_mappings: 

76 continue 

77 

78 # Process all mappings for this kind (there might be multiple API groups) 

79 for mapping in resource_mappings: 

80 # Extract kubernetes metadata from x-kubernetes-group-version-kind 

81 k8s_gvk = mapping.get("x-kubernetes-group-version-kind", []) 

82 if not k8s_gvk or not isinstance(k8s_gvk, list) or not k8s_gvk: 

83 continue 

84 

85 # Process each group-version-kind entry 

86 for gvk in k8s_gvk: 

87 schema_group = gvk.get("group", "") 

88 schema_version = gvk.get("version") 

89 schema_kind = gvk.get("kind", kind_lower.title()) 

90 

91 # Skip if no version found in mappings 

92 if not schema_version: 

93 continue 

94 

95 # Build full API version 

96 if schema_group: 

97 full_api_version = f"{schema_group}/{schema_version}" 

98 else: 

99 full_api_version = schema_version 

100 

101 # Get namespace info from mappings 

102 is_namespaced = mapping.get("namespaced") 

103 if is_namespaced is None: 

104 continue 

105 

106 # Generate plural form 

107 plural = self._generate_plural_form(schema_kind) 

108 

109 resource_def = { 

110 "kind": schema_kind, 

111 "api_version": schema_version, # Just version part for KubeAPIVersion compatibility 

112 "group": schema_group, 

113 "version": schema_version, 

114 "group_version": full_api_version, # Full group/version for storage operations 

115 "plural": plural, 

116 "singular": schema_kind.lower(), 

117 "namespaced": is_namespaced, 

118 "shortNames": [], 

119 "categories": ["all"], 

120 "schema_source": "mappings", 

121 } 

122 

123 # Apply known corrections 

124 resource_def = self._apply_known_corrections(schema_kind, resource_def) 

125 

126 # Store in both places for compatibility 

127 self.resources[schema_kind].append(resource_def) 

128 key = (full_api_version, schema_kind) 

129 self._builtin_resources[key] = resource_def 

130 

131 def _register_additional_resources(self) -> None: 

132 """Register additional resources that are not in OpenShift schema""" 

133 # MTQ resources (not in OpenShift schema) 

134 additional_resources = [ 

135 { 

136 "kind": "MigrationToolkitQuota", 

137 "api_version": "v1alpha1", 

138 "group": "mtq.kubevirt.io", 

139 "version": "v1alpha1", 

140 "group_version": "mtq.kubevirt.io/v1alpha1", 

141 "plural": "migrationtoolkitquotas", 

142 "singular": "migrationtoolkitquota", 

143 "namespaced": False, 

144 "shortNames": ["mtq"], 

145 "categories": ["all"], 

146 "schema_source": "additional", 

147 }, 

148 { 

149 "kind": "MTQ", 

150 "api_version": "v1alpha1", 

151 "group": "mtq.kubevirt.io", 

152 "version": "v1alpha1", 

153 "group_version": "mtq.kubevirt.io/v1alpha1", 

154 "plural": "mtqs", 

155 "singular": "mtq", 

156 "namespaced": False, 

157 "shortNames": [], 

158 "categories": ["all"], 

159 "schema_source": "additional", 

160 }, 

161 { 

162 "kind": "Service", 

163 "api_version": "v1", 

164 "group": "serving.knative.dev", 

165 "version": "v1", 

166 "group_version": "serving.knative.dev/v1", 

167 "plural": "services", 

168 "singular": "service", 

169 "namespaced": True, 

170 "shortNames": ["ksvc"], 

171 "categories": ["all"], 

172 "schema_source": "additional", 

173 }, 

174 { 

175 "kind": "PodMetrics", 

176 "api_version": "v1beta1", 

177 "group": "metrics.k8s.io", 

178 "version": "v1beta1", 

179 "group_version": "metrics.k8s.io/v1beta1", 

180 "plural": "podmetrics", 

181 "singular": "podmetrics", 

182 "namespaced": True, 

183 "shortNames": [], 

184 "categories": ["all"], 

185 "schema_source": "additional", 

186 }, 

187 { 

188 "kind": "Image", 

189 "api_version": "v1alpha1", 

190 "group": "caching.internal.knative.dev", 

191 "version": "v1alpha1", 

192 "group_version": "caching.internal.knative.dev/v1alpha1", 

193 "plural": "images", 

194 "singular": "image", 

195 "namespaced": True, 

196 "shortNames": [], 

197 "categories": ["all"], 

198 "schema_source": "additional", 

199 }, 

200 ] 

201 

202 for resource_def in additional_resources: 

203 kind = str(resource_def["kind"]) 

204 group_version = str(resource_def["group_version"]) 

205 self.resources[kind].append(resource_def) 

206 key = (group_version, kind) 

207 self._builtin_resources[key] = resource_def 

208 self._additional_resources.setdefault(kind, []).append(resource_def) 

209 

210 def register_resources(self, resources: Union[dict[str, Any], list[dict[str, Any]]]) -> None: 

211 """ 

212 Register custom resources dynamically. 

213 

214 Args: 

215 resources: Either a single resource definition dict or a list of resource definitions. 

216 Each resource definition should contain: 

217 - kind: Resource kind (required) 

218 - api_version: API version without group (required) 

219 - group: API group (optional, empty string for core resources) 

220 - version: Same as api_version (required) 

221 - group_version: Full group/version string (required) 

222 - plural: Plural name (optional, will be generated if not provided) 

223 - singular: Singular name (optional, defaults to lowercase kind) 

224 - namespaced: Whether resource is namespaced (optional, defaults to True) 

225 - shortNames: List of short names (optional) 

226 - categories: List of categories (optional, defaults to ["all"]) 

227 

228 Example: 

229 client.registry.register_resources({ 

230 "kind": "MyCustomResource", 

231 "api_version": "v1alpha1", 

232 "group": "example.com", 

233 "version": "v1alpha1", 

234 "group_version": "example.com/v1alpha1", 

235 "plural": "mycustomresources", 

236 "singular": "mycustomresource", 

237 "namespaced": True, 

238 "shortNames": ["mcr"], 

239 "categories": ["all"] 

240 }) 

241 """ 

242 # Convert single resource to list for uniform processing 

243 resource_list = [resources] if isinstance(resources, dict) else resources 

244 

245 for resource_def in resource_list: 

246 # Validate required fields 

247 if not isinstance(resource_def, dict): 

248 raise ValueError(f"Resource definition must be a dictionary, got {type(resource_def)}") 

249 

250 kind = resource_def.get("kind", "") 

251 if not kind: 

252 raise ValueError("Resource definition must have 'kind' field") 

253 

254 api_version = resource_def.get("api_version", "") 

255 if not api_version: 

256 raise ValueError(f"Resource {kind} must have 'api_version' field") 

257 

258 # Build complete resource definition with defaults 

259 group = resource_def.get("group", "") 

260 version = resource_def.get("version", api_version) 

261 

262 # Generate group_version if not provided 

263 if "group_version" not in resource_def: 

264 group_version = f"{group}/{version}" if group else version 

265 else: 

266 group_version = resource_def["group_version"] 

267 

268 # Generate plural if not provided 

269 plural = resource_def.get("plural", self._generate_plural_form(kind)) 

270 

271 # Build complete resource definition 

272 complete_def = { 

273 "kind": kind, 

274 "api_version": api_version, 

275 "group": group, 

276 "version": version, 

277 "group_version": group_version, 

278 "plural": plural, 

279 "singular": resource_def.get("singular", kind.lower()), 

280 "namespaced": resource_def.get("namespaced", True), 

281 "shortNames": resource_def.get("shortNames", []), 

282 "categories": resource_def.get("categories", ["all"]), 

283 "schema_source": "user_defined", 

284 } 

285 

286 # Register the resource 

287 self.resources[kind].append(complete_def) 

288 key = (str(group_version), str(kind)) 

289 self._builtin_resources[key] = complete_def 

290 self._additional_resources.setdefault(kind, []).append(complete_def) 

291 

292 def search( 

293 self, 

294 kind: Union[str, None] = None, 

295 group: Union[str, None] = None, 

296 api_version: Union[str, None] = None, 

297 **kwargs: Any, 

298 ) -> list[FakeResourceField]: 

299 """Search for resource definitions""" 

300 results = [] 

301 

302 # If searching by kind and group, look for that specific combination 

303 if kind and group is not None: 

304 definitions = self.resources.get(kind, []) 

305 for definition in definitions: 

306 if definition.get("group") == group: 

307 results.append(FakeResourceField(data=definition)) 

308 else: 

309 # General search through all resources 

310 for resource_kind, definitions in self.resources.items(): 

311 # Filter by kind if specified 

312 if kind and resource_kind != kind: 

313 continue 

314 

315 for definition in definitions: 

316 # Filter by group if specified 

317 if group and definition.get("group") != group: 

318 continue 

319 # Filter by api_version if specified 

320 if api_version and definition.get("group_version") != api_version: 

321 continue 

322 

323 results.append(FakeResourceField(data=definition)) 

324 

325 return results 

326 

327 def get_resource_definitions(self, kind: str) -> list[dict[str, Any]]: 

328 """Get all resource definitions for a kind""" 

329 return self.resources.get(kind, []) 

330 

331 def get_resource_definition(self, kind: str, api_version: str) -> Union[dict[str, Any], None]: 

332 """Get specific resource definition by kind and API version""" 

333 definitions = self.resources.get(kind, []) 

334 

335 # If api_version doesn't contain '/', it's a core resource (no group) 

336 if "/" not in api_version: 

337 # First, try to find a core resource (empty group) with this version 

338 for definition in definitions: 

339 if definition.get("group") == "" and definition["version"] == api_version: 

340 return definition 

341 

342 # If not found, fall back to checking group_version for compatibility 

343 for definition in definitions: 

344 if definition.get("group_version") == api_version: 

345 return definition 

346 else: 

347 # API version contains group, do exact match on group_version 

348 for definition in definitions: 

349 if definition.get("group_version") == api_version: 

350 return definition 

351 

352 return None 

353 

354 def get_resource_definition_by_plural(self, plural: str, api_version: str) -> Union[dict[str, Any], None]: 

355 """Get resource definition by plural name and API version""" 

356 for kind, definitions in self.resources.items(): 

357 for definition in definitions: 

358 if definition.get("plural") == plural and ( 

359 definition["api_version"] == api_version or definition.get("group_version") == api_version 

360 ): 

361 return definition 

362 return None 

363 

364 def list_api_resources(self, api_version: str) -> FakeResourceField: 

365 """List all resources for an API version""" 

366 resources = [] 

367 for kind, definitions in self.resources.items(): 

368 for definition in definitions: 

369 # Check both api_version and group_version 

370 if definition["api_version"] == api_version or definition.get("group_version") == api_version: 

371 resources.append({ 

372 "name": definition.get("plural", f"{kind.lower()}s"), 

373 "singularName": definition.get("singular", kind.lower()), 

374 "namespaced": definition.get("namespaced", True), 

375 "kind": kind, 

376 "verbs": ["create", "delete", "deletecollection", "get", "list", "patch", "update", "watch"], 

377 }) 

378 

379 # Create APIResourceList response 

380 response = { 

381 "apiVersion": "v1", 

382 "groupVersion": api_version, 

383 "kind": "APIResourceList", 

384 "resources": resources, 

385 } 

386 

387 return FakeResourceField(data=response)