@redpanda-data/docs-extensions-and-macros 4.8.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doc-tools.js +236 -54
- package/package.json +1 -1
- package/tools/property-extractor/Makefile +68 -50
- package/tools/property-extractor/cloud_config.py +594 -0
- package/tools/property-extractor/compare-properties.js +378 -0
- package/tools/property-extractor/generate-handlebars-docs.js +444 -0
- package/tools/property-extractor/helpers/and.js +10 -0
- package/tools/property-extractor/helpers/eq.js +9 -0
- package/tools/property-extractor/helpers/formatPropertyValue.js +128 -0
- package/tools/property-extractor/helpers/formatUnits.js +26 -0
- package/tools/property-extractor/helpers/index.js +13 -0
- package/tools/property-extractor/helpers/join.js +18 -0
- package/tools/property-extractor/helpers/ne.js +9 -0
- package/tools/property-extractor/helpers/not.js +8 -0
- package/tools/property-extractor/helpers/or.js +10 -0
- package/tools/property-extractor/helpers/renderPropertyExample.js +42 -0
- package/tools/property-extractor/package-lock.json +77 -0
- package/tools/property-extractor/package.json +6 -0
- package/tools/property-extractor/parser.py +27 -1
- package/tools/property-extractor/property_extractor.py +1428 -49
- package/tools/property-extractor/requirements.txt +2 -0
- package/tools/property-extractor/templates/deprecated-properties.hbs +25 -0
- package/tools/property-extractor/templates/deprecated-property.hbs +7 -0
- package/tools/property-extractor/templates/property-cloud.hbs +105 -0
- package/tools/property-extractor/templates/property-page.hbs +22 -0
- package/tools/property-extractor/templates/property.hbs +85 -0
- package/tools/property-extractor/templates/topic-property-cloud.hbs +97 -0
- package/tools/property-extractor/templates/topic-property.hbs +73 -0
- package/tools/property-extractor/transformers.py +178 -6
- package/tools/property-extractor/json-to-asciidoc/generate_docs.py +0 -491
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cloud configuration integration for property documentation generation.
|
|
4
|
+
|
|
5
|
+
This module fetches cloud configuration from the cloudv2 repository to determine
|
|
6
|
+
which Redpanda properties are supported, editable, or readonly in cloud deployments.
|
|
7
|
+
|
|
8
|
+
Prerequisites:
|
|
9
|
+
- GITHUB_TOKEN environment variable set with appropriate permissions
|
|
10
|
+
- Internet connection to access GitHub API
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from cloud_config import fetch_cloud_config, add_cloud_support_metadata
|
|
14
|
+
|
|
15
|
+
# Fetch cloud configuration
|
|
16
|
+
config = fetch_cloud_config()
|
|
17
|
+
if config:
|
|
18
|
+
properties = add_cloud_support_metadata(properties, config)
|
|
19
|
+
|
|
20
|
+
Error Handling:
|
|
21
|
+
- Network errors: Logged with retry suggestions
|
|
22
|
+
- Authentication errors: Clear instructions for token setup
|
|
23
|
+
- Parsing errors: Specific file and line information
|
|
24
|
+
- Missing dependencies: Installation instructions provided
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Dict, Set, Optional, List
|
|
32
|
+
|
|
33
|
+
# Check for required dependencies early
|
|
34
|
+
try:
|
|
35
|
+
import requests
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
raise ImportError("Missing required dependency 'requests': install with pip install requests") from e
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import yaml
|
|
41
|
+
except ImportError as e:
|
|
42
|
+
raise ImportError("Missing required dependency 'PyYAML': install with pip install pyyaml") from e
|
|
43
|
+
|
|
44
|
+
# Set up logging with production-ready configuration
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
class CloudConfigError(Exception):
|
|
48
|
+
"""Base exception for cloud configuration errors."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
class GitHubAuthError(CloudConfigError):
|
|
52
|
+
"""Raised when GitHub authentication fails."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class CloudConfigParsingError(CloudConfigError):
|
|
56
|
+
"""Raised when cloud configuration parsing fails."""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
class NetworkError(CloudConfigError):
|
|
60
|
+
"""Raised when network operations fail."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CloudConfig:
|
|
65
|
+
"""Cloud configuration data for a specific version."""
|
|
66
|
+
version: str
|
|
67
|
+
customer_managed_configs: List[Dict]
|
|
68
|
+
readonly_cluster_config: List[str]
|
|
69
|
+
|
|
70
|
+
def get_editable_properties(self) -> Set[str]:
|
|
71
|
+
"""Get set of property names that customers can edit."""
|
|
72
|
+
return {config.get('name') for config in self.customer_managed_configs if config.get('name')}
|
|
73
|
+
|
|
74
|
+
def get_readonly_properties(self) -> Set[str]:
|
|
75
|
+
"""Get set of property names that are read-only for customers."""
|
|
76
|
+
return set(self.readonly_cluster_config)
|
|
77
|
+
|
|
78
|
+
def get_all_cloud_properties(self) -> Set[str]:
|
|
79
|
+
"""
|
|
80
|
+
Return the set of all property names present in the cloud configuration (union of editable and readonly).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Set[str]: Property names that are either customer-editable or readonly in cloud deployments.
|
|
84
|
+
"""
|
|
85
|
+
return self.get_editable_properties() | self.get_readonly_properties()
|
|
86
|
+
|
|
87
|
+
def is_byoc_only(self, property_name: str) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Return True if the given property is defined in customer_managed_configs and its `cluster_types` list is exactly ['byoc'].
|
|
90
|
+
|
|
91
|
+
Parameters:
|
|
92
|
+
property_name (str): Name of the property to check.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
bool: True when a matching config entry exists and its `cluster_types` equals ['byoc']; False otherwise.
|
|
96
|
+
"""
|
|
97
|
+
for config in self.customer_managed_configs:
|
|
98
|
+
if config.get('name') == property_name:
|
|
99
|
+
cluster_types = config.get('cluster_types', [])
|
|
100
|
+
return cluster_types == ['byoc']
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fetch_cloud_config(github_token: Optional[str] = None) -> CloudConfig:
|
|
105
|
+
"""
|
|
106
|
+
Fetch the latest cloud configuration from the redpanda-data/cloudv2 repository and return it as a CloudConfig.
|
|
107
|
+
|
|
108
|
+
This function uses a GitHub personal access token for authentication. If `github_token` is not provided, it will read GITHUB_TOKEN or REDPANDA_GITHUB_TOKEN from the environment. It downloads the most recent versioned YAML from the repository's install-pack directory, validates expected sections (`customer_managed_configs` and `readonly_cluster_config`), and constructs a CloudConfig instance.
|
|
109
|
+
|
|
110
|
+
Parameters:
|
|
111
|
+
github_token (Optional[str]): Personal access token for GitHub API. If omitted, the function will try environment variables GITHUB_TOKEN or REDPANDA_GITHUB_TOKEN.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
CloudConfig: Parsed cloud configuration for the latest available version.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
GitHubAuthError: Authentication or access problems with the GitHub API (including 401/403 responses).
|
|
118
|
+
NetworkError: Network connectivity or timeout failures when contacting the GitHub API.
|
|
119
|
+
CloudConfigParsingError: Failure to parse or validate the repository YAML files or their expected structure.
|
|
120
|
+
CloudConfigError: Generic configuration error (e.g., missing token) or unexpected internal failures.
|
|
121
|
+
"""
|
|
122
|
+
if not github_token:
|
|
123
|
+
github_token = os.environ.get('GITHUB_TOKEN') or os.environ.get('REDPANDA_GITHUB_TOKEN')
|
|
124
|
+
|
|
125
|
+
if not github_token:
|
|
126
|
+
error_msg = (
|
|
127
|
+
"No GitHub token provided.\n"
|
|
128
|
+
"Cloud configuration requires authentication to access private repositories.\n"
|
|
129
|
+
"To fix this:\n"
|
|
130
|
+
"1. Go to https://github.com/settings/tokens\n"
|
|
131
|
+
"2. Generate a personal access token with 'repo' scope\n"
|
|
132
|
+
"3. Set the token: export GITHUB_TOKEN=your_token_here\n"
|
|
133
|
+
"4. Re-run the command with --cloud-support flag"
|
|
134
|
+
)
|
|
135
|
+
logger.error(error_msg)
|
|
136
|
+
raise GitHubAuthError(error_msg)
|
|
137
|
+
|
|
138
|
+
headers = {
|
|
139
|
+
'Authorization': f'token {github_token}',
|
|
140
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
141
|
+
'User-Agent': 'Redpanda-Docs-Property-Extractor/1.0'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# First, list all YAML files in the install-pack directory
|
|
146
|
+
logger.info("Fetching install-pack directory listing from cloudv2 repository...")
|
|
147
|
+
url = 'https://api.github.com/repos/redpanda-data/cloudv2/contents/install-pack'
|
|
148
|
+
|
|
149
|
+
response = requests.get(url, headers=headers, timeout=30)
|
|
150
|
+
|
|
151
|
+
# Handle common HTTP error responses with detailed guidance
|
|
152
|
+
if response.status_code in [401, 403, 404]:
|
|
153
|
+
status_messages = {
|
|
154
|
+
401: {
|
|
155
|
+
"title": "GitHub authentication failed (HTTP 401)",
|
|
156
|
+
"causes": [
|
|
157
|
+
"Invalid or expired GitHub token",
|
|
158
|
+
"Token lacks 'repo' scope for private repositories",
|
|
159
|
+
"Token user doesn't have access to redpanda-data/cloudv2"
|
|
160
|
+
],
|
|
161
|
+
"fixes": [
|
|
162
|
+
"Verify token at: https://github.com/settings/tokens",
|
|
163
|
+
"Ensure 'repo' scope is enabled",
|
|
164
|
+
"Contact team lead if access is needed to cloudv2 repository"
|
|
165
|
+
],
|
|
166
|
+
"exception": GitHubAuthError
|
|
167
|
+
},
|
|
168
|
+
403: {
|
|
169
|
+
"title": f"GitHub API access denied (HTTP 403)",
|
|
170
|
+
"causes": [
|
|
171
|
+
"API rate limit exceeded (5000 requests/hour for authenticated users)",
|
|
172
|
+
"Repository access denied"
|
|
173
|
+
],
|
|
174
|
+
"fixes": [
|
|
175
|
+
"Wait for rate limit reset if exceeded",
|
|
176
|
+
"Verify repository access permissions",
|
|
177
|
+
"Contact team lead if repository access is needed"
|
|
178
|
+
],
|
|
179
|
+
"extra": f"Rate limit remaining: {response.headers.get('X-RateLimit-Remaining', 'unknown')}\n"
|
|
180
|
+
f"Rate limit resets at: {response.headers.get('X-RateLimit-Reset', 'unknown')}",
|
|
181
|
+
"exception": GitHubAuthError
|
|
182
|
+
},
|
|
183
|
+
404: {
|
|
184
|
+
"title": "Install-pack directory not found (HTTP 404)",
|
|
185
|
+
"causes": [
|
|
186
|
+
"Directory 'install-pack' doesn't exist in cloudv2 repository",
|
|
187
|
+
"Repository 'redpanda-data/cloudv2' not accessible",
|
|
188
|
+
"Directory path has changed"
|
|
189
|
+
],
|
|
190
|
+
"fixes": [
|
|
191
|
+
"Verify directory exists in repository",
|
|
192
|
+
"Check if directory path has changed",
|
|
193
|
+
"Contact cloud team for current configuration location"
|
|
194
|
+
],
|
|
195
|
+
"exception": NetworkError
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
msg_config = status_messages[response.status_code]
|
|
200
|
+
error_msg = f"{msg_config['title']}.\n"
|
|
201
|
+
error_msg += f"Possible causes:\n"
|
|
202
|
+
for i, cause in enumerate(msg_config['causes'], 1):
|
|
203
|
+
error_msg += f"{i}. {cause}\n"
|
|
204
|
+
if 'extra' in msg_config:
|
|
205
|
+
error_msg += f"{msg_config['extra']}\n"
|
|
206
|
+
error_msg += f"\nTo fix:\n"
|
|
207
|
+
for i, fix in enumerate(msg_config['fixes'], 1):
|
|
208
|
+
error_msg += f"{i}. {fix}\n"
|
|
209
|
+
|
|
210
|
+
logger.error(error_msg)
|
|
211
|
+
raise msg_config['exception'](error_msg)
|
|
212
|
+
|
|
213
|
+
response.raise_for_status()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
files = response.json()
|
|
217
|
+
except ValueError as e:
|
|
218
|
+
error_msg = (
|
|
219
|
+
f"Invalid JSON response from GitHub API.\n"
|
|
220
|
+
"This indicates an API format change or server error.\n"
|
|
221
|
+
"Contact development team to update integration."
|
|
222
|
+
)
|
|
223
|
+
logger.exception(error_msg)
|
|
224
|
+
raise CloudConfigParsingError(error_msg) from e
|
|
225
|
+
|
|
226
|
+
if not isinstance(files, list):
|
|
227
|
+
error_msg = (
|
|
228
|
+
f"Expected list of files, got {type(files)}: {files}\n"
|
|
229
|
+
"This indicates an API format change.\n"
|
|
230
|
+
"Contact development team to update integration."
|
|
231
|
+
)
|
|
232
|
+
logger.error(error_msg)
|
|
233
|
+
raise CloudConfigParsingError(error_msg)
|
|
234
|
+
|
|
235
|
+
# Find YAML files with version numbers
|
|
236
|
+
version_files = []
|
|
237
|
+
for file in files:
|
|
238
|
+
if not isinstance(file, dict):
|
|
239
|
+
logger.warning(f"Skipping non-dictionary file entry: {file}")
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
file_name = file.get('name', '')
|
|
243
|
+
download_url = file.get('download_url', '')
|
|
244
|
+
|
|
245
|
+
if not file_name or not download_url:
|
|
246
|
+
logger.warning(f"Skipping file with missing name/url: {file}")
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Look for version YAML files (e.g., "25.1.yml", "25.2.yml")
|
|
250
|
+
if file_name.endswith('.yml'):
|
|
251
|
+
version_part = file_name.replace('.yml', '')
|
|
252
|
+
# Check if it looks like a version number (e.g., "25.1", "25.2.1")
|
|
253
|
+
if version_part.replace('.', '').isdigit():
|
|
254
|
+
version_files.append((version_part, download_url))
|
|
255
|
+
logger.debug(f"Found version file: {file_name} -> {version_part}")
|
|
256
|
+
|
|
257
|
+
if not version_files:
|
|
258
|
+
error_msg = (
|
|
259
|
+
"No version YAML files found in cloudv2/install-pack directory.\n"
|
|
260
|
+
"Expected files like '25.1.yml', '25.2.yml', etc.\n"
|
|
261
|
+
"Available files: " + ", ".join([f.get('name', 'unknown') for f in files]) + "\n"
|
|
262
|
+
"Contact cloud team to verify configuration file naming convention."
|
|
263
|
+
)
|
|
264
|
+
logger.error(error_msg)
|
|
265
|
+
raise CloudConfigParsingError(error_msg)
|
|
266
|
+
|
|
267
|
+
# Parse and filter valid version entries before sorting
|
|
268
|
+
valid_versions = []
|
|
269
|
+
for version_str, download_url in version_files:
|
|
270
|
+
try:
|
|
271
|
+
# Parse version string into tuple of integers
|
|
272
|
+
version_tuple = tuple(int(part) for part in version_str.split('.'))
|
|
273
|
+
valid_versions.append((version_tuple, version_str, download_url))
|
|
274
|
+
logger.debug(f"Valid version parsed: {version_str} -> {version_tuple}")
|
|
275
|
+
except ValueError as e:
|
|
276
|
+
logger.warning(f"Skipping invalid version format: {version_str} (error: {e})")
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
# Check if we have any valid versions
|
|
280
|
+
if not valid_versions:
|
|
281
|
+
error_msg = (
|
|
282
|
+
"No valid version files found in cloudv2/install-pack directory.\n"
|
|
283
|
+
f"Found {len(version_files)} files but none had valid version formats.\n"
|
|
284
|
+
f"Available files: {[v[0] for v in version_files]}\n"
|
|
285
|
+
"Expected version format: 'X.Y' or 'X.Y.Z' (e.g., '25.1', '25.2.1')\n"
|
|
286
|
+
"Contact cloud team to verify configuration file naming convention."
|
|
287
|
+
)
|
|
288
|
+
logger.error(error_msg)
|
|
289
|
+
raise CloudConfigParsingError(error_msg)
|
|
290
|
+
|
|
291
|
+
# Sort by parsed version tuple and get the latest
|
|
292
|
+
valid_versions.sort(key=lambda x: x[0]) # Sort by version tuple
|
|
293
|
+
latest_version_tuple, latest_version, download_url = valid_versions[-1]
|
|
294
|
+
|
|
295
|
+
logger.info(f"Found {len(valid_versions)} valid version files, using latest: {latest_version}")
|
|
296
|
+
logger.info(f"Valid versions: {[v[1] for v in valid_versions]}")
|
|
297
|
+
if len(version_files) > len(valid_versions):
|
|
298
|
+
logger.info(f"Skipped {len(version_files) - len(valid_versions)} invalid version files")
|
|
299
|
+
|
|
300
|
+
# Download the latest version file
|
|
301
|
+
logger.info(f"Downloading configuration file for version {latest_version}...")
|
|
302
|
+
response = requests.get(download_url, headers=headers, timeout=60)
|
|
303
|
+
response.raise_for_status()
|
|
304
|
+
|
|
305
|
+
# Parse YAML content
|
|
306
|
+
try:
|
|
307
|
+
config_data = yaml.safe_load(response.text)
|
|
308
|
+
except yaml.YAMLError as e:
|
|
309
|
+
error_msg = (
|
|
310
|
+
f"Failed to parse cloud configuration YAML for version {latest_version}: {e}\n"
|
|
311
|
+
f"File URL: {download_url}\n"
|
|
312
|
+
"The configuration file contains invalid YAML syntax.\n"
|
|
313
|
+
"Contact cloud team to fix configuration file.\n"
|
|
314
|
+
f"Parse error details: {str(e)}"
|
|
315
|
+
)
|
|
316
|
+
logger.error(error_msg)
|
|
317
|
+
raise CloudConfigParsingError(error_msg)
|
|
318
|
+
|
|
319
|
+
if not isinstance(config_data, dict):
|
|
320
|
+
error_msg = (
|
|
321
|
+
f"Cloud configuration root is not a dictionary: {type(config_data)}\n"
|
|
322
|
+
f"Version: {latest_version}\n"
|
|
323
|
+
"Expected YAML file to contain a dictionary at root level.\n"
|
|
324
|
+
"Contact cloud team to verify configuration file format."
|
|
325
|
+
)
|
|
326
|
+
logger.error(error_msg)
|
|
327
|
+
raise CloudConfigParsingError(error_msg)
|
|
328
|
+
|
|
329
|
+
# Extract and validate the relevant sections
|
|
330
|
+
customer_managed = config_data.get('customer_managed_configs', [])
|
|
331
|
+
readonly_config = config_data.get('readonly_cluster_config', [])
|
|
332
|
+
|
|
333
|
+
if not isinstance(customer_managed, list):
|
|
334
|
+
error_msg = (
|
|
335
|
+
f"'customer_managed_configs' section is not a list: {type(customer_managed)}\n"
|
|
336
|
+
f"Version: {latest_version}\n"
|
|
337
|
+
"Expected format:\n"
|
|
338
|
+
"customer_managed_configs:\n"
|
|
339
|
+
" - name: property_name\n"
|
|
340
|
+
" ...\n"
|
|
341
|
+
"Contact cloud team to verify configuration file format."
|
|
342
|
+
)
|
|
343
|
+
logger.error(error_msg)
|
|
344
|
+
raise CloudConfigParsingError(error_msg)
|
|
345
|
+
|
|
346
|
+
if not isinstance(readonly_config, list):
|
|
347
|
+
error_msg = (
|
|
348
|
+
f"'readonly_cluster_config' section is not a list: {type(readonly_config)}\n"
|
|
349
|
+
f"Version: {latest_version}\n"
|
|
350
|
+
"Expected format:\n"
|
|
351
|
+
"readonly_cluster_config:\n"
|
|
352
|
+
" - property_name_1\n"
|
|
353
|
+
" - property_name_2\n"
|
|
354
|
+
"Contact cloud team to verify configuration file format."
|
|
355
|
+
)
|
|
356
|
+
logger.error(error_msg)
|
|
357
|
+
raise CloudConfigParsingError(error_msg)
|
|
358
|
+
|
|
359
|
+
# Validate customer_managed_configs structure
|
|
360
|
+
for i, config in enumerate(customer_managed):
|
|
361
|
+
if not isinstance(config, dict):
|
|
362
|
+
logger.warning(f"customer_managed_configs[{i}] is not a dictionary: {config}, skipping")
|
|
363
|
+
continue
|
|
364
|
+
if 'name' not in config:
|
|
365
|
+
logger.warning(f"customer_managed_configs[{i}] missing 'name' field: {config}, skipping")
|
|
366
|
+
|
|
367
|
+
# Validate readonly_cluster_config structure
|
|
368
|
+
for i, prop_name in enumerate(readonly_config):
|
|
369
|
+
if not isinstance(prop_name, str):
|
|
370
|
+
logger.warning(f"readonly_cluster_config[{i}] is not a string: {prop_name} ({type(prop_name)}), converting")
|
|
371
|
+
readonly_config[i] = str(prop_name)
|
|
372
|
+
|
|
373
|
+
config = CloudConfig(
|
|
374
|
+
version=latest_version,
|
|
375
|
+
customer_managed_configs=customer_managed,
|
|
376
|
+
readonly_cluster_config=readonly_config
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Log summary statistics
|
|
380
|
+
editable_count = len(config.get_editable_properties())
|
|
381
|
+
readonly_count = len(config.get_readonly_properties())
|
|
382
|
+
total_count = len(config.get_all_cloud_properties())
|
|
383
|
+
|
|
384
|
+
logger.info(f"Cloud configuration loaded successfully:")
|
|
385
|
+
logger.info(f" Version: {latest_version}")
|
|
386
|
+
logger.info(f" Editable properties: {editable_count}")
|
|
387
|
+
logger.info(f" Readonly properties: {readonly_count}")
|
|
388
|
+
logger.info(f" Total cloud properties: {total_count}")
|
|
389
|
+
|
|
390
|
+
return config
|
|
391
|
+
|
|
392
|
+
except requests.exceptions.ConnectionError as e:
|
|
393
|
+
error_msg = (
|
|
394
|
+
"Network connection failed.\n"
|
|
395
|
+
"Possible causes:\n"
|
|
396
|
+
"1. No internet connection\n"
|
|
397
|
+
"2. Corporate firewall blocking GitHub\n"
|
|
398
|
+
"3. DNS resolution issues\n"
|
|
399
|
+
"\nTo fix:\n"
|
|
400
|
+
"1. Check internet connectivity\n"
|
|
401
|
+
"2. Try: curl -I https://api.github.com\n"
|
|
402
|
+
"3. Contact IT if behind corporate firewall"
|
|
403
|
+
)
|
|
404
|
+
logger.exception(error_msg)
|
|
405
|
+
raise NetworkError(error_msg) from e
|
|
406
|
+
|
|
407
|
+
except requests.exceptions.Timeout as e:
|
|
408
|
+
error_msg = (
|
|
409
|
+
"Request timeout after 30 seconds.\n"
|
|
410
|
+
"GitHub API may be experiencing issues.\n"
|
|
411
|
+
"To fix:\n"
|
|
412
|
+
"1. Check GitHub status: https://status.github.com/\n"
|
|
413
|
+
"2. Try again in a few minutes\n"
|
|
414
|
+
"3. Check network connectivity"
|
|
415
|
+
)
|
|
416
|
+
logger.exception(error_msg)
|
|
417
|
+
raise NetworkError(error_msg) from e
|
|
418
|
+
|
|
419
|
+
except (GitHubAuthError, NetworkError, CloudConfigParsingError):
|
|
420
|
+
# Re-raise our custom exceptions
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
except Exception as e:
|
|
424
|
+
error_msg = (
|
|
425
|
+
"Unexpected error fetching cloud configuration.\n"
|
|
426
|
+
"This is likely a bug in the cloud configuration integration.\n"
|
|
427
|
+
"Please report this error to the development team with:\n"
|
|
428
|
+
"1. Full error message above\n"
|
|
429
|
+
"2. Command that triggered the error\n"
|
|
430
|
+
"3. Environment details (OS, Python version)\n"
|
|
431
|
+
"4. GitHub token permissions (without revealing the token)"
|
|
432
|
+
)
|
|
433
|
+
logger.exception(error_msg)
|
|
434
|
+
raise CloudConfigError(error_msg) from e
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def add_cloud_support_metadata(properties: Dict, cloud_config: CloudConfig) -> Dict:
|
|
438
|
+
"""
|
|
439
|
+
Annotate property definitions with cloud-support metadata derived from a CloudConfig.
|
|
440
|
+
|
|
441
|
+
Mutates the provided properties dictionary in place by adding the boolean fields
|
|
442
|
+
'cloud_editable', 'cloud_readonly', 'cloud_supported', and 'cloud_byoc_only' for
|
|
443
|
+
each property. Only entries whose value is a dict and whose 'config_scope' is
|
|
444
|
+
one of 'cluster', 'broker', or 'topic' are processed; other entries are skipped.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
The same properties dictionary, updated with cloud metadata.
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
CloudConfigError: If `properties` is not a dict or if required data cannot be
|
|
451
|
+
extracted from the provided CloudConfig.
|
|
452
|
+
"""
|
|
453
|
+
if not isinstance(properties, dict):
|
|
454
|
+
error_msg = f"Properties argument must be a dictionary, got {type(properties)}"
|
|
455
|
+
logger.error(error_msg)
|
|
456
|
+
raise CloudConfigError(error_msg)
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
editable_props = cloud_config.get_editable_properties()
|
|
460
|
+
readonly_props = cloud_config.get_readonly_properties()
|
|
461
|
+
except (AttributeError, KeyError) as e:
|
|
462
|
+
error_msg = f"Failed to extract property sets from cloud configuration: {e}"
|
|
463
|
+
logger.error(error_msg)
|
|
464
|
+
raise CloudConfigError(error_msg) from e
|
|
465
|
+
|
|
466
|
+
logger.info(f"Applying cloud metadata using configuration version {cloud_config.version}")
|
|
467
|
+
logger.info(f"Cloud properties: {len(editable_props)} editable, {len(readonly_props)} readonly")
|
|
468
|
+
|
|
469
|
+
# Counters for reporting
|
|
470
|
+
processed_count = 0
|
|
471
|
+
cloud_supported_count = 0
|
|
472
|
+
editable_count = 0
|
|
473
|
+
readonly_count = 0
|
|
474
|
+
byoc_only_count = 0
|
|
475
|
+
skipped_count = 0
|
|
476
|
+
errors = []
|
|
477
|
+
|
|
478
|
+
for prop_name, prop_data in properties.items():
|
|
479
|
+
try:
|
|
480
|
+
if not isinstance(prop_data, dict):
|
|
481
|
+
error_msg = f"Property '{prop_name}' data is not a dictionary: {type(prop_data)}"
|
|
482
|
+
logger.warning(error_msg)
|
|
483
|
+
errors.append(error_msg)
|
|
484
|
+
skipped_count += 1
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
# Only process cluster, broker, and topic properties for cloud support
|
|
488
|
+
config_scope = prop_data.get('config_scope', '')
|
|
489
|
+
if config_scope not in ['cluster', 'broker', 'topic']:
|
|
490
|
+
# Skip node config properties and others without cloud relevance
|
|
491
|
+
skipped_count += 1
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
processed_count += 1
|
|
495
|
+
|
|
496
|
+
# Initialize cloud metadata with defaults
|
|
497
|
+
prop_data['cloud_editable'] = False
|
|
498
|
+
prop_data['cloud_readonly'] = False
|
|
499
|
+
prop_data['cloud_supported'] = False
|
|
500
|
+
prop_data['cloud_byoc_only'] = False
|
|
501
|
+
|
|
502
|
+
# Determine cloud support status
|
|
503
|
+
if prop_name in editable_props:
|
|
504
|
+
prop_data['cloud_editable'] = True
|
|
505
|
+
prop_data['cloud_readonly'] = False
|
|
506
|
+
prop_data['cloud_supported'] = True
|
|
507
|
+
cloud_supported_count += 1
|
|
508
|
+
editable_count += 1
|
|
509
|
+
|
|
510
|
+
# Check if BYOC only
|
|
511
|
+
if cloud_config.is_byoc_only(prop_name):
|
|
512
|
+
prop_data['cloud_byoc_only'] = True
|
|
513
|
+
byoc_only_count += 1
|
|
514
|
+
else:
|
|
515
|
+
prop_data['cloud_byoc_only'] = False
|
|
516
|
+
|
|
517
|
+
elif prop_name in readonly_props:
|
|
518
|
+
prop_data['cloud_editable'] = False
|
|
519
|
+
prop_data['cloud_readonly'] = True
|
|
520
|
+
prop_data['cloud_supported'] = True
|
|
521
|
+
prop_data['cloud_byoc_only'] = False
|
|
522
|
+
cloud_supported_count += 1
|
|
523
|
+
readonly_count += 1
|
|
524
|
+
|
|
525
|
+
else:
|
|
526
|
+
# Property not supported in cloud
|
|
527
|
+
prop_data['cloud_editable'] = False
|
|
528
|
+
prop_data['cloud_readonly'] = False
|
|
529
|
+
prop_data['cloud_supported'] = False
|
|
530
|
+
prop_data['cloud_byoc_only'] = False
|
|
531
|
+
|
|
532
|
+
except Exception as e:
|
|
533
|
+
error_msg = f"Error processing property '{prop_name}': {e}"
|
|
534
|
+
logger.warning(error_msg)
|
|
535
|
+
errors.append(error_msg)
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
# Log comprehensive summary
|
|
539
|
+
logger.info(f"Cloud metadata application completed:")
|
|
540
|
+
logger.info(f" Properties processed: {processed_count}")
|
|
541
|
+
logger.info(f" Properties skipped (non-cloud scope): {skipped_count}")
|
|
542
|
+
logger.info(f" Cloud-supported properties: {cloud_supported_count}")
|
|
543
|
+
logger.info(f" - Editable: {editable_count}")
|
|
544
|
+
logger.info(f" - Readonly: {readonly_count}")
|
|
545
|
+
logger.info(f" - BYOC-only: {byoc_only_count}")
|
|
546
|
+
logger.info(f" Self-managed only: {processed_count - cloud_supported_count}")
|
|
547
|
+
|
|
548
|
+
if errors:
|
|
549
|
+
logger.warning(f"Encountered {len(errors)} errors during processing:")
|
|
550
|
+
for error in errors[:10]: # Log first 10 errors
|
|
551
|
+
logger.warning(f" - {error}")
|
|
552
|
+
if len(errors) > 10:
|
|
553
|
+
logger.warning(f" ... and {len(errors) - 10} more errors")
|
|
554
|
+
|
|
555
|
+
# Validation checks
|
|
556
|
+
if processed_count == 0:
|
|
557
|
+
logger.warning("No properties were processed for cloud metadata. This may indicate:")
|
|
558
|
+
logger.warning(" 1. All properties are node-scoped (not cluster/broker/topic)")
|
|
559
|
+
logger.warning(" 2. Properties dictionary is empty")
|
|
560
|
+
logger.warning(" 3. Properties missing 'config_scope' field")
|
|
561
|
+
|
|
562
|
+
if cloud_supported_count == 0:
|
|
563
|
+
logger.warning("No cloud-supported properties found. This may indicate:")
|
|
564
|
+
logger.warning(" 1. Cloud configuration is empty or invalid")
|
|
565
|
+
logger.warning(" 2. Property names don't match between sources")
|
|
566
|
+
logger.warning(" 3. All properties are self-managed only")
|
|
567
|
+
|
|
568
|
+
# Check for potential mismatches
|
|
569
|
+
unmatched_cloud_props = (editable_props | readonly_props) - {
|
|
570
|
+
name for name, data in properties.items()
|
|
571
|
+
if isinstance(data, dict) and data.get('config_scope') in ['cluster', 'broker', 'topic']
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if unmatched_cloud_props:
|
|
575
|
+
logger.info(f"Cloud configuration contains {len(unmatched_cloud_props)} properties not found in extracted properties:")
|
|
576
|
+
for prop in sorted(list(unmatched_cloud_props)[:10]): # Show first 10
|
|
577
|
+
logger.info(f" - {prop}")
|
|
578
|
+
if len(unmatched_cloud_props) > 10:
|
|
579
|
+
logger.info(f" ... and {len(unmatched_cloud_props) - 10} more")
|
|
580
|
+
logger.info("This is normal if cloud config includes deprecated or future properties.")
|
|
581
|
+
|
|
582
|
+
return properties
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
if __name__ == "__main__":
|
|
586
|
+
# Test the cloud config fetcher
|
|
587
|
+
logging.basicConfig(level=logging.INFO)
|
|
588
|
+
config = fetch_cloud_config()
|
|
589
|
+
if config:
|
|
590
|
+
print(f"Successfully fetched cloud config for version {config.version}")
|
|
591
|
+
print(f"Editable properties: {len(config.get_editable_properties())}")
|
|
592
|
+
print(f"Readonly properties: {len(config.get_readonly_properties())}")
|
|
593
|
+
else:
|
|
594
|
+
print("Failed to fetch cloud configuration")
|