-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmake-json-files
More file actions
executable file
·291 lines (225 loc) · 10.8 KB
/
make-json-files
File metadata and controls
executable file
·291 lines (225 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#!/usr/bin/env python3
"""Convert synadia-nats-channels.conf into JSON files for site deployment.
Replaces the shell+jq implementation with a pure Python 3.13 stdlib solution.
Invoked as part of site deployment whenever stable channel versions are bumped.
This is for files available for the client installers running on machines,
where we are distributing those installers here.
The .sh installer is not allowed to rely upon JSON handling being available,
and the core config supports that, but other installers should get something
saner. We support whatever JSON formats an installer tool wants or needs.
Usage: make-json-files [deploy_dir] (default: public)
"""
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Final
# ── Constants ─────────────────────────────────────────────────────────────────
NIGHTLY_DATE_URL: Final = "https://get-nats.io/current-nightly"
SH_CHANNELS_FILE: Final = "synadia-nats-channels.conf"
SUPPORTED_PLATFORMS: Final[tuple[str, ...]] = (
"darwin-amd64",
"darwin-arm64",
"freebsd-amd64",
"linux-386",
"linux-amd64",
"linux-arm64",
"linux-arm6",
"linux-arm7",
"windows-386",
"windows-amd64",
"windows-arm64",
"windows-arm6",
"windows-arm7",
)
# Matches the shell script's PLATFORM_EXE_EXTENSION associative array exactly.
# Only these two platforms get an .exe suffix; the others do not.
PLATFORM_EXE_EXTENSION: Final[dict[str, str]] = {
"windows-arm6": "exe",
"windows-arm64": "exe",
}
# ── Config file parsing ────────────────────────────────────────────────────────
# This is for the shell-compatible assignments file synadia-nats-channels.conf
_KEY_VALUE_RE: Final = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)")
def parse_conf(path: Path) -> dict[str, str]:
"""Parse a shell-style key=value config file.
Skips blank lines and lines whose first non-whitespace character is '#'.
Values are stripped of leading/trailing whitespace.
"""
conf: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
m = _KEY_VALUE_RE.match(stripped)
if m:
conf[m.group(1)] = m.group(2).strip()
return conf
def _require(conf: dict[str, str], key: str, source: str) -> str:
"""Return conf[key], raising ValueError with a clear message if absent."""
try:
return conf[key]
except KeyError:
raise ValueError(f"missing '{key}' in {source}") from None
# ── Template expansion ─────────────────────────────────────────────────────────
def expand(template: str, substitutions: dict[str, str]) -> str:
"""Replace ``%KEY%`` placeholders using *substitutions*, applied sequentially.
Replicates the shell script's ``expand_config_value`` / ``expand_all`` logic:
substitutions are applied one at a time in iteration order, so a replacement
value may itself contain a placeholder resolved by a later substitution.
"""
result = template
for key, value in substitutions.items():
result = result.replace(f"%{key}%", value)
return result
# ── Data model ─────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ToolTemplates:
"""Raw (unexpanded) template strings for one tool within one channel."""
zipfile: str
checksumfile: str
urldir: str
version: str | None # None for the nightly channel
@dataclass(frozen=True)
class ChannelSpec:
"""All configuration for a single release channel."""
name: str
tools: dict[str, ToolTemplates]
version_url: str | None # populated only for the nightly channel
# ── Loading ────────────────────────────────────────────────────────────────────
def load_channel(
conf: dict[str, str],
channel: str,
tools: list[str],
source: str,
) -> ChannelSpec:
is_nightly = channel == "nightly"
version_url = NIGHTLY_DATE_URL if is_nightly else None
tool_templates: dict[str, ToolTemplates] = {}
for tool in tools:
suffix = f"{channel}_{tool}"
tool_templates[tool] = ToolTemplates(
zipfile=_require(conf, f"ZIPFILE_{suffix}", source),
checksumfile=_require(conf, f"CHECKSUMS_{suffix}", source),
urldir=_require(conf, f"URLDIR_{suffix}", source),
version=None if is_nightly else _require(conf, f"VERSION_{suffix}", source),
)
return ChannelSpec(name=channel, tools=tool_templates, version_url=version_url)
# ── JSON builders ──────────────────────────────────────────────────────────────
def build_channels_json(channels: list[ChannelSpec]) -> dict:
"""Build the structure for ``synadia-nats-channels.json``.
Top-level keys are channel names. Each channel object holds the raw
(unexpanded) template strings for every tool, plus ``version_url`` for the
nightly channel.
"""
result: dict = {}
for ch in channels:
channel_obj: dict = {}
if ch.version_url is not None:
channel_obj["version_url"] = ch.version_url
for tool_name, tmpl in ch.tools.items():
tool_obj: dict = {
"zipfile": tmpl.zipfile,
"checksumfile": tmpl.checksumfile,
"urldir": tmpl.urldir,
}
if tmpl.version is not None:
tool_obj["version"] = tmpl.version
channel_obj[tool_name] = tool_obj
result[ch.name] = channel_obj
return result
def build_platforms_json(channels: list[ChannelSpec]) -> dict:
"""Build the structure for ``synadia-nats-platforms.json``.
Top-level keys are channel names. Each channel object contains a
``platforms`` dict keyed by platform string, whose values hold a ``tools``
dict with fully-expanded URLs and executable names for that platform.
"""
result: dict = {}
for ch in channels:
is_nightly = ch.name == "nightly"
channel_obj: dict = {}
if ch.version_url is not None:
channel_obj["version_url"] = ch.version_url
platforms_obj: dict = {}
channel_obj["platforms"] = platforms_obj
# Outer loop: tools; inner loop: platforms.
# This matches the shell script's loop order, which determines the
# insertion order of keys within each platform's "tools" dict.
for tool_name, tmpl in ch.tools.items():
# Base substitutions available at channel+tool scope.
base_subs: dict[str, str] = {
"TOOLNAME": tool_name,
"ZIPFILE": tmpl.zipfile,
"CHECKFILE": tmpl.checksumfile,
}
if is_nightly:
# For nightly, version placeholders are left as %NIGHTLY% so
# the installer can substitute the date it fetches at runtime.
base_subs["VERSIONTAG"] = "%NIGHTLY%"
base_subs["VERSIONNOV"] = "%NIGHTLY%"
else:
assert tmpl.version is not None
base_subs["VERSIONTAG"] = tmpl.version
base_subs["VERSIONNOV"] = tmpl.version.lstrip("v")
for platform in SUPPORTED_PLATFORMS:
os_name, _, arch = platform.partition("-")
# Platform-level substitutions extend the base set; order
# matches the shell script's platform_expands array.
subs = dict(base_subs)
subs["OSNAME"] = os_name
subs["GOARCH"] = arch
zip_url = expand(tmpl.urldir + tmpl.zipfile, subs)
chk_url = expand(tmpl.urldir + tmpl.checksumfile, subs)
ext = PLATFORM_EXE_EXTENSION.get(platform, "")
executable = f"{tool_name}.{ext}" if ext else tool_name
# Initialise platform entry on first visit (//= semantics).
if platform not in platforms_obj:
platforms_obj[platform] = {"tools": {}}
tool_entry: dict = {
"executable": executable,
"zip_url": zip_url,
"checksum_url": chk_url,
}
if not is_nightly:
tool_entry["version_tag"] = tmpl.version # type: ignore[assignment]
tool_entry["version_bare"] = tmpl.version.lstrip("v") # type: ignore[union-attr]
platforms_obj[platform]["tools"][tool_name] = tool_entry
result[ch.name] = channel_obj
return result
# ── Main ───────────────────────────────────────────────────────────────────────
def _note(msg: str) -> None:
print(f"make-json-files.py: {msg}", file=sys.stderr)
def main(argv: list[str]) -> int:
deploy_dir = Path(argv[1]) if len(argv) > 1 else Path("public")
conf_path = Path(SH_CHANNELS_FILE)
out_channels = deploy_dir / "synadia-nats-channels.json"
out_platforms = deploy_dir / "synadia-nats-platforms.json"
if not conf_path.exists():
_note(f"cannot read {conf_path}")
return 1
try:
conf = parse_conf(conf_path)
channels_raw = _require(conf, "CHANNELS", SH_CHANNELS_FILE).split()
tools_raw = _require(conf, "TOOLS", SH_CHANNELS_FILE).split()
if not channels_raw:
raise ValueError("CHANNELS list is empty")
if not tools_raw:
raise ValueError("TOOLS list is empty")
channels = [
load_channel(conf, ch, tools_raw, SH_CHANNELS_FILE)
for ch in channels_raw
]
except ValueError as exc:
_note(str(exc))
return 1
channels_data = build_channels_json(channels)
platforms_data = build_platforms_json(channels)
deploy_dir.mkdir(parents=True, exist_ok=True)
_note(f"Writing: {out_channels}")
out_channels.write_text(json.dumps(channels_data, indent=2) + "\n", encoding="utf-8")
_note(f"Writing: {out_platforms}")
out_platforms.write_text(json.dumps(platforms_data, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))