apizit-cli 0.2.0b1__py3-none-any.whl
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.
- apizit_cli/__init__.py +3 -0
- apizit_cli/authentication.py +396 -0
- apizit_cli/commands.py +337 -0
- apizit_cli/configuration.py +246 -0
- apizit_cli/context.py +155 -0
- apizit_cli/credential_store.py +170 -0
- apizit_cli/errors.py +19 -0
- apizit_cli/http.py +162 -0
- apizit_cli/main.py +164 -0
- apizit_cli/operations.py +745 -0
- apizit_cli/sources.py +259 -0
- apizit_cli-0.2.0b1.dist-info/METADATA +44 -0
- apizit_cli-0.2.0b1.dist-info/RECORD +16 -0
- apizit_cli-0.2.0b1.dist-info/WHEEL +5 -0
- apizit_cli-0.2.0b1.dist-info/entry_points.txt +2 -0
- apizit_cli-0.2.0b1.dist-info/top_level.txt +1 -0
apizit_cli/commands.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Customer command definitions, shared by argparse and the published reference."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, replace
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Option:
|
|
8
|
+
flags: tuple[str, ...]
|
|
9
|
+
help: str
|
|
10
|
+
action: str | None = None
|
|
11
|
+
choices: tuple[str, ...] | None = None
|
|
12
|
+
value_type: str = "str"
|
|
13
|
+
default: object = None
|
|
14
|
+
nargs: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Command:
|
|
19
|
+
path: str
|
|
20
|
+
summary: str
|
|
21
|
+
permission: str
|
|
22
|
+
options: tuple[Option, ...] = ()
|
|
23
|
+
example: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def argument(name, help, *, optional=False):
|
|
27
|
+
return Option((name,), help, nargs="?" if optional else None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def flag(name, help, **kwargs):
|
|
31
|
+
return Option((name,), help, **kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
GLOBAL_OPTIONS = (
|
|
35
|
+
flag("--json", "Write one versioned JSON result to stdout.", action="store_true"),
|
|
36
|
+
flag("--workspace", "Workspace ID; overrides environment and saved context."),
|
|
37
|
+
flag("--endpoint", "APIZIT HTTPS endpoint; defaults to the development beta."),
|
|
38
|
+
flag("--non-interactive", "Never prompt or open a browser.", action="store_true"),
|
|
39
|
+
flag("--token-stdin", "Read a CI token from stdin without saving it.", action="store_true"),
|
|
40
|
+
)
|
|
41
|
+
WAIT_OPTIONS = (
|
|
42
|
+
flag("--wait", "Wait for completion (default for interactive sessions).", action="store_true"),
|
|
43
|
+
flag("--no-wait", "Return identifiers immediately after submission.", action="store_true"),
|
|
44
|
+
flag("--timeout", "Maximum local wait in seconds; default 600.", value_type="positive_float"),
|
|
45
|
+
)
|
|
46
|
+
WRITE_OPTIONS = (
|
|
47
|
+
flag(
|
|
48
|
+
"--idempotency-key", "Stable operation key; reuse the same key after uncertain submission."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
YES = flag("--yes", "Confirm this deletion or traffic change.", action="store_true")
|
|
52
|
+
FORMAT = flag(
|
|
53
|
+
"--format", "Launch format.", choices=("automatic", "standard", "expanded", "max_power")
|
|
54
|
+
)
|
|
55
|
+
REGION = flag("--region", "Logical region, as returned by apizit regions.")
|
|
56
|
+
API = flag("--api", "API ID; defaults to the API linked to this local project.")
|
|
57
|
+
REF = flag("--ref", "Git branch, tag or exact revision to verify on the server.")
|
|
58
|
+
SOURCE = argument("source", "HTTPS Git URL or local folder.", optional=True)
|
|
59
|
+
SCAN_OPTIONS = (REF, FORMAT, REGION) + WAIT_OPTIONS + WRITE_OPTIONS
|
|
60
|
+
|
|
61
|
+
COMMANDS = (
|
|
62
|
+
Command(
|
|
63
|
+
"login",
|
|
64
|
+
"Connect securely through your browser.",
|
|
65
|
+
"human",
|
|
66
|
+
(flag("--timeout", "Login timeout in seconds; default 180.", value_type="positive_float"),),
|
|
67
|
+
),
|
|
68
|
+
Command("logout", "Revoke the saved session and clear the system vault.", "human"),
|
|
69
|
+
Command("whoami", "Show the current identity.", "read"),
|
|
70
|
+
Command("workspace list", "List accessible workspaces.", "read"),
|
|
71
|
+
Command(
|
|
72
|
+
"workspace use",
|
|
73
|
+
"Select and verify a workspace.",
|
|
74
|
+
"read",
|
|
75
|
+
(argument("id", "Workspace ID."),),
|
|
76
|
+
),
|
|
77
|
+
Command("workspace show", "Show the selected workspace and its current capabilities.", "read"),
|
|
78
|
+
Command(
|
|
79
|
+
"workspace create",
|
|
80
|
+
"Create a workspace within your account's allowances.",
|
|
81
|
+
"human",
|
|
82
|
+
(argument("name", "Workspace name."),),
|
|
83
|
+
),
|
|
84
|
+
Command(
|
|
85
|
+
"init",
|
|
86
|
+
"Create a version 2 deployment configuration.",
|
|
87
|
+
"local",
|
|
88
|
+
(
|
|
89
|
+
argument(
|
|
90
|
+
"directory", "Project folder; defaults to the current directory.", optional=True
|
|
91
|
+
),
|
|
92
|
+
flag("--name", "Project name; defaults to the folder name."),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
Command(
|
|
96
|
+
"config validate",
|
|
97
|
+
"Validate the single deployment configuration.",
|
|
98
|
+
"local",
|
|
99
|
+
(argument("directory", "Project folder.", optional=True),),
|
|
100
|
+
),
|
|
101
|
+
Command(
|
|
102
|
+
"config show",
|
|
103
|
+
"Resolve configuration and show the origin of each value.",
|
|
104
|
+
"read",
|
|
105
|
+
(argument("directory", "Project folder.", optional=True), API, FORMAT, REGION),
|
|
106
|
+
),
|
|
107
|
+
Command("config schema", "Print the version 2 JSON schema.", "local"),
|
|
108
|
+
Command(
|
|
109
|
+
"scan",
|
|
110
|
+
"Analyze a Git URL or folder; use scan show/wait to resume.",
|
|
111
|
+
"scan",
|
|
112
|
+
(
|
|
113
|
+
argument("source", "Source, or the action show/wait."),
|
|
114
|
+
argument("id", "Scan ID for show/wait.", optional=True),
|
|
115
|
+
)
|
|
116
|
+
+ SCAN_OPTIONS,
|
|
117
|
+
"apizit scan . --wait",
|
|
118
|
+
),
|
|
119
|
+
Command(
|
|
120
|
+
"deploy",
|
|
121
|
+
"Launch an API or prepare a release of --api.",
|
|
122
|
+
"deploy",
|
|
123
|
+
(
|
|
124
|
+
SOURCE,
|
|
125
|
+
API,
|
|
126
|
+
REF,
|
|
127
|
+
FORMAT,
|
|
128
|
+
REGION,
|
|
129
|
+
flag("--from-scan", "Reuse the exact source and configuration of a successful scan."),
|
|
130
|
+
flag("--strategy", "Release strategy.", choices=("direct", "blue_green")),
|
|
131
|
+
flag("--name", "API name; overrides project.name."),
|
|
132
|
+
flag("--api-key-required", "Protect calls with an API key.", action="store_true"),
|
|
133
|
+
flag("--no-api-key", "Explicitly allow calls without an API key.", action="store_true"),
|
|
134
|
+
YES,
|
|
135
|
+
)
|
|
136
|
+
+ WAIT_OPTIONS
|
|
137
|
+
+ WRITE_OPTIONS,
|
|
138
|
+
"apizit deploy . --wait",
|
|
139
|
+
),
|
|
140
|
+
Command("api list", "List APIs in the workspace.", "read"),
|
|
141
|
+
Command(
|
|
142
|
+
"api show",
|
|
143
|
+
"Show an API, its live URL and releases.",
|
|
144
|
+
"read",
|
|
145
|
+
(argument("id", "API ID; defaults to the linked API.", optional=True),),
|
|
146
|
+
),
|
|
147
|
+
Command(
|
|
148
|
+
"api logs",
|
|
149
|
+
"Read a bounded page of redacted API logs.",
|
|
150
|
+
"logs",
|
|
151
|
+
(
|
|
152
|
+
argument("id", "API ID; defaults to the linked API.", optional=True),
|
|
153
|
+
flag("--from", "Inclusive start time in ISO 8601 with timezone."),
|
|
154
|
+
flag("--to", "Inclusive end time in ISO 8601 with timezone."),
|
|
155
|
+
flag("--level", "Log level.", choices=("all", "error", "warning", "info")),
|
|
156
|
+
flag("--query", "Search text, without control characters."),
|
|
157
|
+
flag("--limit", "Page size; the server applies workspace limits.", value_type="int"),
|
|
158
|
+
flag("--cursor", "Opaque next-page cursor returned by the previous request."),
|
|
159
|
+
flag("--release", "Release ID; otherwise uses the current release."),
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
Command(
|
|
163
|
+
"api usage",
|
|
164
|
+
"Show API consumption and current release usage.",
|
|
165
|
+
"read",
|
|
166
|
+
(argument("id", "API ID; defaults to the linked API.", optional=True),),
|
|
167
|
+
),
|
|
168
|
+
Command(
|
|
169
|
+
"api openapi",
|
|
170
|
+
"Export the current release's OpenAPI document.",
|
|
171
|
+
"read",
|
|
172
|
+
(
|
|
173
|
+
argument("id", "API ID; defaults to the linked API.", optional=True),
|
|
174
|
+
flag("--output", "New output file; otherwise returns the parsed document."),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
Command(
|
|
178
|
+
"api key",
|
|
179
|
+
"Explicitly retrieve a protected API's call key.",
|
|
180
|
+
"credentials:read",
|
|
181
|
+
(
|
|
182
|
+
argument("id", "API ID; defaults to the linked API.", optional=True),
|
|
183
|
+
flag("--release", "Release ID; defaults to the current release."),
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
Command(
|
|
187
|
+
"api delete",
|
|
188
|
+
"Delete an API and its releases after confirmation.",
|
|
189
|
+
"delete",
|
|
190
|
+
(argument("id", "API ID."), YES) + WAIT_OPTIONS + WRITE_OPTIONS,
|
|
191
|
+
),
|
|
192
|
+
Command(
|
|
193
|
+
"release list", "List retained releases and the current traffic workflow.", "read", (API,)
|
|
194
|
+
),
|
|
195
|
+
Command("release show", "Show a release.", "read", (argument("id", "Release ID."),)),
|
|
196
|
+
Command(
|
|
197
|
+
"release promote",
|
|
198
|
+
"Send traffic to the ready candidate release.",
|
|
199
|
+
"traffic",
|
|
200
|
+
(argument("id", "Candidate release ID."), API, YES) + WAIT_OPTIONS + WRITE_OPTIONS,
|
|
201
|
+
),
|
|
202
|
+
Command(
|
|
203
|
+
"release rollback",
|
|
204
|
+
"Restore the existing compatible rollback release without a build.",
|
|
205
|
+
"traffic",
|
|
206
|
+
(API, YES) + WAIT_OPTIONS + WRITE_OPTIONS,
|
|
207
|
+
),
|
|
208
|
+
Command(
|
|
209
|
+
"release discard",
|
|
210
|
+
"Delete a release that is no longer serving traffic.",
|
|
211
|
+
"delete",
|
|
212
|
+
(argument("id", "Release ID."), YES) + WAIT_OPTIONS + WRITE_OPTIONS,
|
|
213
|
+
),
|
|
214
|
+
Command("job show", "Show a remote operation.", "read", (argument("id", "Job ID."),)),
|
|
215
|
+
Command(
|
|
216
|
+
"job wait",
|
|
217
|
+
"Resume observing a remote operation without restarting it.",
|
|
218
|
+
"read",
|
|
219
|
+
(argument("id", "Job ID."),) + WAIT_OPTIONS,
|
|
220
|
+
),
|
|
221
|
+
Command(
|
|
222
|
+
"job cancel",
|
|
223
|
+
"Request cancellation only when the server permits it.",
|
|
224
|
+
"deploy",
|
|
225
|
+
(argument("id", "Job ID."), YES) + WAIT_OPTIONS + WRITE_OPTIONS,
|
|
226
|
+
),
|
|
227
|
+
Command(
|
|
228
|
+
"regions",
|
|
229
|
+
"Show available regions for this workspace and launch format.",
|
|
230
|
+
"read",
|
|
231
|
+
(FORMAT, API),
|
|
232
|
+
),
|
|
233
|
+
Command("plans", "Read the current public plans from APIZIT.", "public"),
|
|
234
|
+
Command("usage", "Show the workspace's current consumption.", "read"),
|
|
235
|
+
Command("capabilities", "Read current platform capabilities and defaults.", "read"),
|
|
236
|
+
Command(
|
|
237
|
+
"token create",
|
|
238
|
+
"Create a limited, expiring workspace token; displayed once.",
|
|
239
|
+
"human",
|
|
240
|
+
(
|
|
241
|
+
argument("name", "Token name."),
|
|
242
|
+
flag("--scope", "Permission; repeat to grant several.", action="append"),
|
|
243
|
+
flag("--expires-days", "Lifetime in days; default 30, maximum 90.", value_type="int"),
|
|
244
|
+
),
|
|
245
|
+
),
|
|
246
|
+
Command("token list", "List workspace tokens without their values.", "human"),
|
|
247
|
+
Command(
|
|
248
|
+
"token revoke", "Revoke a token immediately.", "human", (argument("id", "Token ID."), YES)
|
|
249
|
+
),
|
|
250
|
+
Command(
|
|
251
|
+
"secret set",
|
|
252
|
+
"Store a new secret version without echoing its value.",
|
|
253
|
+
"secrets:write",
|
|
254
|
+
(
|
|
255
|
+
argument("name", "Workspace secret name."),
|
|
256
|
+
flag("--stdin", "Read the exact UTF-8 secret value from stdin.", action="store_true"),
|
|
257
|
+
flag("--from-env", "Read the value from a local environment variable."),
|
|
258
|
+
)
|
|
259
|
+
+ WRITE_OPTIONS,
|
|
260
|
+
),
|
|
261
|
+
Command("secret list", "List secret names and versions without reading values.", "read"),
|
|
262
|
+
Command(
|
|
263
|
+
"secret delete",
|
|
264
|
+
"Delete an unreferenced workspace secret.",
|
|
265
|
+
"secrets:write",
|
|
266
|
+
(argument("name", "Workspace secret name."), YES) + WRITE_OPTIONS,
|
|
267
|
+
),
|
|
268
|
+
Command(
|
|
269
|
+
"docs",
|
|
270
|
+
"Get or open the public CLI documentation.",
|
|
271
|
+
"public",
|
|
272
|
+
(
|
|
273
|
+
argument("topic", "Documentation section or quoted command name.", optional=True),
|
|
274
|
+
flag("--open", "Open the documentation in your browser.", action="store_true"),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
Command(
|
|
278
|
+
"doctor", "Check client installation, connectivity and secure credential storage.", "local"
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_EXAMPLES = {
|
|
284
|
+
"login": "apizit login",
|
|
285
|
+
"logout": "apizit logout",
|
|
286
|
+
"whoami": "apizit whoami --json",
|
|
287
|
+
"workspace list": "apizit workspace list --json",
|
|
288
|
+
"workspace use": "apizit workspace use ws_YOUR_WORKSPACE",
|
|
289
|
+
"workspace show": "apizit workspace show --json",
|
|
290
|
+
"workspace create": 'apizit workspace create "Weather APIs"',
|
|
291
|
+
"init": "apizit init . --name weather-api",
|
|
292
|
+
"config validate": "apizit config validate . --json",
|
|
293
|
+
"config show": "apizit config show . --region europe --json",
|
|
294
|
+
"config schema": "apizit config schema --json",
|
|
295
|
+
"api list": "apizit api list --json",
|
|
296
|
+
"api show": "apizit api show api_YOUR_API --json",
|
|
297
|
+
"api logs": "apizit api logs api_YOUR_API --level error --limit 50 --json",
|
|
298
|
+
"api usage": "apizit api usage api_YOUR_API --json",
|
|
299
|
+
"api openapi": "apizit api openapi api_YOUR_API --output openapi.json",
|
|
300
|
+
"api key": "apizit api key api_YOUR_API --json",
|
|
301
|
+
"api delete": "apizit api delete api_YOUR_API --yes --wait --json",
|
|
302
|
+
"release list": "apizit release list --api api_YOUR_API --json",
|
|
303
|
+
"release show": "apizit release show dep_YOUR_RELEASE --json",
|
|
304
|
+
"release promote": "apizit release promote dep_YOUR_CANDIDATE --api api_YOUR_API --idempotency-key promote-release-001 --yes --json",
|
|
305
|
+
"release rollback": "apizit release rollback --api api_YOUR_API --idempotency-key rollback-release-001 --yes --json",
|
|
306
|
+
"release discard": "apizit release discard dep_YOUR_OLD_RELEASE --yes --wait --json",
|
|
307
|
+
"job show": "apizit job show job_YOUR_JOB --json",
|
|
308
|
+
"job wait": "apizit job wait job_YOUR_JOB --timeout 900 --json",
|
|
309
|
+
"job cancel": "apizit job cancel job_YOUR_JOB --yes --json",
|
|
310
|
+
"regions": "apizit regions --format standard --json",
|
|
311
|
+
"plans": "apizit plans --json",
|
|
312
|
+
"usage": "apizit usage --json",
|
|
313
|
+
"capabilities": "apizit capabilities --json",
|
|
314
|
+
"token create": 'apizit token create "Release CI" --scope read --scope scan --scope deploy --expires-days 30',
|
|
315
|
+
"token list": "apizit token list --json",
|
|
316
|
+
"token revoke": "apizit token revoke YOUR_TOKEN_ID --yes",
|
|
317
|
+
"secret set": "apizit secret set weather_api_key --from-env WEATHER_API_KEY",
|
|
318
|
+
"secret list": "apizit secret list --json",
|
|
319
|
+
"secret delete": "apizit secret delete weather_api_key --yes",
|
|
320
|
+
"docs": 'apizit docs "release promote"',
|
|
321
|
+
"doctor": "apizit doctor --json",
|
|
322
|
+
}
|
|
323
|
+
COMMANDS = tuple(
|
|
324
|
+
replace(command, example=command.example or _EXAMPLES[command.path]) for command in COMMANDS
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def reference():
|
|
329
|
+
return {
|
|
330
|
+
"schema_version": 1,
|
|
331
|
+
"global_options": [asdict(option) for option in GLOBAL_OPTIONS],
|
|
332
|
+
"commands": [asdict(command) for command in COMMANDS],
|
|
333
|
+
"scan_actions": {
|
|
334
|
+
"scan show": "apizit scan show <id>",
|
|
335
|
+
"scan wait": "apizit scan wait <id> [--timeout SECONDS]",
|
|
336
|
+
},
|
|
337
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Public, provider-independent deployment configuration contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
CONFIG_FILES = ("apizit_config.yaml", "apizit_config.yml", "apizit_config.json")
|
|
13
|
+
MAX_CONFIG_BYTES = 64 * 1024
|
|
14
|
+
FORMAT_OPTIONS = {
|
|
15
|
+
"automatic": ("auto", "automatic"),
|
|
16
|
+
"standard": ("lambda_zip", "automatic"),
|
|
17
|
+
"expanded": ("lambda_ecr", "automatic"),
|
|
18
|
+
"max_power": ("lambda_ecr", "max_power"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigurationError(ValueError):
|
|
23
|
+
"""A safe, field-oriented error, without the submitted value."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StrictModel(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid", strict=True, validate_assignment=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProjectConfiguration(StrictModel):
|
|
31
|
+
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LaunchConfiguration(StrictModel):
|
|
35
|
+
format: Literal["automatic", "standard", "expanded", "max_power"] = "automatic"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ApiConfiguration(StrictModel):
|
|
39
|
+
api_key_required: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HealthCheckConfiguration(StrictModel):
|
|
43
|
+
mode: Literal["auto", "custom", "disabled"] = "auto"
|
|
44
|
+
path: str | None = Field(default=None, max_length=1024)
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def valid_path(self):
|
|
48
|
+
path = self.path
|
|
49
|
+
if self.mode == "custom" and not path:
|
|
50
|
+
raise ValueError("custom health check requires path")
|
|
51
|
+
if path is not None and (
|
|
52
|
+
not path.startswith("/")
|
|
53
|
+
or path.startswith("//")
|
|
54
|
+
or any(ord(char) < 32 for char in path)
|
|
55
|
+
or any(char in path for char in ("\\", "?", "#"))
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("path must be an absolute API path without query or fragment")
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeploymentConfiguration(StrictModel):
|
|
62
|
+
version: Literal[2] = 2
|
|
63
|
+
project: ProjectConfiguration = Field(default_factory=ProjectConfiguration)
|
|
64
|
+
region: Literal["europe", "asia", "africa", "north_america", "south_america"] | None = None
|
|
65
|
+
launch: LaunchConfiguration = Field(default_factory=LaunchConfiguration)
|
|
66
|
+
api: ApiConfiguration = Field(default_factory=ApiConfiguration)
|
|
67
|
+
health_check: HealthCheckConfiguration = Field(default_factory=HealthCheckConfiguration)
|
|
68
|
+
environment: dict[str, str] = Field(default_factory=dict)
|
|
69
|
+
secrets: dict[str, str] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
@field_validator("version", mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def strict_version(cls, value):
|
|
74
|
+
if type(value) is not int or value != 2:
|
|
75
|
+
raise ValueError("version must be the integer 2")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
@field_validator("environment", "secrets")
|
|
79
|
+
@classmethod
|
|
80
|
+
def validate_names(cls, values):
|
|
81
|
+
import re
|
|
82
|
+
import unicodedata
|
|
83
|
+
|
|
84
|
+
reserved = {
|
|
85
|
+
"_HANDLER",
|
|
86
|
+
"_X_AMZN_TRACE_ID",
|
|
87
|
+
"LAMBDA_RUNTIME_DIR",
|
|
88
|
+
"LAMBDA_TASK_ROOT",
|
|
89
|
+
}
|
|
90
|
+
if len(values) > 100:
|
|
91
|
+
raise ValueError("at most 100 variables are supported")
|
|
92
|
+
for name, value in values.items():
|
|
93
|
+
if (
|
|
94
|
+
not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]+", name)
|
|
95
|
+
or name.startswith(("APIZIT_", "AWS_", "LAMBDA_"))
|
|
96
|
+
or name in reserved
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("variable names must be valid and not platform-reserved")
|
|
99
|
+
if len(value.encode("utf-8")) > 2048 or "\x00" in value:
|
|
100
|
+
raise ValueError("variable values must be text of at most 2048 UTF-8 bytes")
|
|
101
|
+
if any(
|
|
102
|
+
unicodedata.category(character) in {"Cc", "Cs", "Zl", "Zp"} for character in value
|
|
103
|
+
):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"environment values cannot contain line breaks or control characters"
|
|
106
|
+
)
|
|
107
|
+
if "{{resolve:" in value:
|
|
108
|
+
raise ValueError("provider references are managed exclusively by APIZIT")
|
|
109
|
+
return values
|
|
110
|
+
|
|
111
|
+
@field_validator("secrets")
|
|
112
|
+
@classmethod
|
|
113
|
+
def validate_secret_references(cls, values):
|
|
114
|
+
import re
|
|
115
|
+
|
|
116
|
+
if len(values) > 32:
|
|
117
|
+
raise ValueError("a release supports at most 32 secret variables")
|
|
118
|
+
|
|
119
|
+
if any(
|
|
120
|
+
not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", value) for value in values.values()
|
|
121
|
+
):
|
|
122
|
+
raise ValueError("secret references must be names, not values or provider identifiers")
|
|
123
|
+
return values
|
|
124
|
+
|
|
125
|
+
@model_validator(mode="after")
|
|
126
|
+
def validate_environment(self):
|
|
127
|
+
if self.environment.keys() & self.secrets.keys():
|
|
128
|
+
raise ValueError("a variable cannot be both an environment value and a secret")
|
|
129
|
+
if sum(len(k.encode()) + len(v.encode()) for k, v in self.environment.items()) > 4096:
|
|
130
|
+
raise ValueError("environment exceeds the 4096-byte runtime limit")
|
|
131
|
+
if (
|
|
132
|
+
len(json.dumps(self.model_dump(mode="json"), separators=(",", ":")).encode())
|
|
133
|
+
> MAX_CONFIG_BYTES
|
|
134
|
+
):
|
|
135
|
+
raise ValueError("configuration exceeds 64 KiB")
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def explicit_options(self) -> dict:
|
|
139
|
+
"""Only explicit choices may override repository or server defaults."""
|
|
140
|
+
return self.model_dump(exclude_unset=True, exclude={"version"}, mode="json")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _StrictLoader(yaml.SafeLoader):
|
|
144
|
+
def construct_mapping(self, node, deep=False):
|
|
145
|
+
if not isinstance(node, yaml.MappingNode):
|
|
146
|
+
raise ConfigurationError("Expected a YAML object.")
|
|
147
|
+
pairs = []
|
|
148
|
+
for key, value in node.value:
|
|
149
|
+
pairs.append(
|
|
150
|
+
(self.construct_object(key, deep=deep), self.construct_object(value, deep=deep))
|
|
151
|
+
)
|
|
152
|
+
return _unique_object(pairs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _unique_object(pairs):
|
|
156
|
+
result = {}
|
|
157
|
+
for key, value in pairs:
|
|
158
|
+
if not isinstance(key, str) or key in result:
|
|
159
|
+
raise ConfigurationError("Configuration keys must be unique text names.")
|
|
160
|
+
result[key] = value
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_configuration(text: str, *, json_format: bool = False) -> DeploymentConfiguration:
|
|
165
|
+
if len(text.encode("utf-8")) > MAX_CONFIG_BYTES:
|
|
166
|
+
raise ConfigurationError("Configuration exceeds 64 KiB.")
|
|
167
|
+
try:
|
|
168
|
+
if json_format:
|
|
169
|
+
raw = json.loads(text, object_pairs_hook=_unique_object)
|
|
170
|
+
else:
|
|
171
|
+
if any(
|
|
172
|
+
isinstance(token, (yaml.AliasToken, yaml.AnchorToken)) for token in yaml.scan(text)
|
|
173
|
+
):
|
|
174
|
+
raise ConfigurationError("YAML anchors and aliases are not supported.")
|
|
175
|
+
raw = yaml.load(text, Loader=_StrictLoader)
|
|
176
|
+
if not isinstance(raw, dict) or "version" not in raw:
|
|
177
|
+
raise ConfigurationError("Configuration must be an object with version: 2.")
|
|
178
|
+
return DeploymentConfiguration.model_validate(raw)
|
|
179
|
+
except (yaml.YAMLError, json.JSONDecodeError, UnicodeError, RecursionError):
|
|
180
|
+
raise ConfigurationError("Configuration is not valid UTF-8 YAML or JSON.") from None
|
|
181
|
+
except ValidationError as error:
|
|
182
|
+
details = "; ".join(
|
|
183
|
+
f"{'.'.join(str(part) for part in item['loc']) or 'configuration'}: {item['msg']}"
|
|
184
|
+
for item in error.errors(include_input=False, include_url=False)
|
|
185
|
+
)
|
|
186
|
+
raise ConfigurationError(details) from None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def load_configuration(root: Path) -> tuple[DeploymentConfiguration, Path | None]:
|
|
190
|
+
root = Path(root).resolve(strict=True)
|
|
191
|
+
candidates = [
|
|
192
|
+
root / name for name in CONFIG_FILES if (root / name).exists() or (root / name).is_symlink()
|
|
193
|
+
]
|
|
194
|
+
if len(candidates) > 1:
|
|
195
|
+
raise ConfigurationError("Keep exactly one apizit_config.yaml, .yml or .json file.")
|
|
196
|
+
if not candidates:
|
|
197
|
+
return DeploymentConfiguration(), None
|
|
198
|
+
path = candidates[0]
|
|
199
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_CONFIG_BYTES:
|
|
200
|
+
raise ConfigurationError("Configuration must be a regular file of at most 64 KiB.")
|
|
201
|
+
try:
|
|
202
|
+
text = path.read_text(encoding="utf-8")
|
|
203
|
+
except (OSError, UnicodeError):
|
|
204
|
+
raise ConfigurationError("Configuration could not be read as UTF-8.") from None
|
|
205
|
+
return parse_configuration(text, json_format=path.suffix == ".json"), path
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_configuration(*, configuration: dict, overrides: dict, defaults: dict) -> dict:
|
|
209
|
+
"""Resolve explicit fields only; callers supply the current server defaults."""
|
|
210
|
+
import hashlib
|
|
211
|
+
|
|
212
|
+
origins = {}
|
|
213
|
+
merged = {}
|
|
214
|
+
|
|
215
|
+
def apply(target, values, origin, prefix=""):
|
|
216
|
+
for key, value in values.items():
|
|
217
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
218
|
+
if isinstance(value, dict) and key not in {"environment", "secrets"}:
|
|
219
|
+
child = target.setdefault(key, {})
|
|
220
|
+
apply(child, value, origin, path)
|
|
221
|
+
else:
|
|
222
|
+
target[key] = value
|
|
223
|
+
for previous in list(origins):
|
|
224
|
+
if previous.startswith(path + "."):
|
|
225
|
+
del origins[previous]
|
|
226
|
+
origins[path] = origin
|
|
227
|
+
if isinstance(value, dict):
|
|
228
|
+
origins.update({f"{path}.{name}": origin for name in value})
|
|
229
|
+
|
|
230
|
+
for values, origin in (
|
|
231
|
+
(defaults, "server_default"),
|
|
232
|
+
(configuration, "configuration"),
|
|
233
|
+
(overrides, "explicit_option"),
|
|
234
|
+
):
|
|
235
|
+
# Validation must not turn omitted options into client-side defaults.
|
|
236
|
+
validated = parse_configuration(json.dumps({"version": 2, **values}), json_format=True)
|
|
237
|
+
apply(merged, validated.explicit_options(), origin)
|
|
238
|
+
resolved = parse_configuration(json.dumps({"version": 2, **merged}), json_format=True)
|
|
239
|
+
document = resolved.model_dump(mode="json")
|
|
240
|
+
canonical = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
241
|
+
return {
|
|
242
|
+
"configuration": document,
|
|
243
|
+
"origins": origins,
|
|
244
|
+
"configuration_digest_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
|
|
245
|
+
"configuration_contract_version": 2,
|
|
246
|
+
}
|