dockercomposefile 0.1.0__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.
- dockercomposefile/__init__.py +13 -0
- dockercomposefile/builder.py +58 -0
- dockercomposefile/exporter.py +83 -0
- dockercomposefile/models/__init__.py +97 -0
- dockercomposefile/models/build.py +123 -0
- dockercomposefile/models/common.py +412 -0
- dockercomposefile/models/compose.py +58 -0
- dockercomposefile/models/config.py +45 -0
- dockercomposefile/models/deploy.py +117 -0
- dockercomposefile/models/develop.py +33 -0
- dockercomposefile/models/model.py +20 -0
- dockercomposefile/models/network.py +67 -0
- dockercomposefile/models/secret.py +42 -0
- dockercomposefile/models/service.py +598 -0
- dockercomposefile/models/volume.py +73 -0
- dockercomposefile-0.1.0.dist-info/METADATA +143 -0
- dockercomposefile-0.1.0.dist-info/RECORD +20 -0
- dockercomposefile-0.1.0.dist-info/WHEEL +5 -0
- dockercomposefile-0.1.0.dist-info/licenses/LICENSE +201 -0
- dockercomposefile-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Common types and validators shared across all compose models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
from pydantic_core import PydanticCustomError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_list_or_dict(value: Any) -> dict[str, str | None]:
|
|
12
|
+
"""Parse a value that can be either a dict or a list of KEY=VALUE strings."""
|
|
13
|
+
if value is None:
|
|
14
|
+
return {}
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
return {str(k): str(v) if v is not None else None for k, v in value.items()}
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
result: dict[str, str | None] = {}
|
|
19
|
+
for item in value:
|
|
20
|
+
if isinstance(item, str):
|
|
21
|
+
if "=" in item:
|
|
22
|
+
k, v = item.split("=", 1)
|
|
23
|
+
result[k] = v
|
|
24
|
+
else:
|
|
25
|
+
result[item] = None
|
|
26
|
+
return result
|
|
27
|
+
raise PydanticCustomError("list_or_dict", "Value must be a dict or list of strings")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_string_or_list(value: Any) -> list[str]:
|
|
31
|
+
"""Parse a value that can be either a string or a list of strings."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return []
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
return [value]
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [str(item) for item in value]
|
|
38
|
+
raise PydanticCustomError("string_or_list", "Value must be a string or list of strings")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_command(value: Any) -> str | list[str] | None:
|
|
42
|
+
"""Parse a command that can be a string, a list of strings, or None."""
|
|
43
|
+
if value is None:
|
|
44
|
+
return None
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
return value
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return [str(item) for item in value]
|
|
49
|
+
raise PydanticCustomError("command", "Command must be a string or list of strings")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_duration(value: Any) -> str | None:
|
|
53
|
+
"""Validate a duration string.
|
|
54
|
+
|
|
55
|
+
Accepts formats like ``10s``, ``1m30s``, ``1h5m30s20ms``,
|
|
56
|
+
or plain integers (microseconds).
|
|
57
|
+
"""
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
if not isinstance(value, str):
|
|
61
|
+
raise PydanticCustomError("duration", "Duration must be a string")
|
|
62
|
+
# Plain integers are treated as microseconds
|
|
63
|
+
if value.isdigit():
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
import re
|
|
67
|
+
|
|
68
|
+
_DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)(us|ms|s|m|h)$")
|
|
69
|
+
remaining = value
|
|
70
|
+
while remaining:
|
|
71
|
+
m = _DURATION_RE.match(remaining)
|
|
72
|
+
if not m:
|
|
73
|
+
raise PydanticCustomError(
|
|
74
|
+
"duration",
|
|
75
|
+
f"Invalid duration format: {value!r}",
|
|
76
|
+
)
|
|
77
|
+
remaining = remaining[m.end() :]
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_byte_value(value: Any) -> str | int | None:
|
|
82
|
+
"""Validate a byte value string or integer.
|
|
83
|
+
|
|
84
|
+
Accepts formats like ``1gb``, ``300m``, ``1024kb``,
|
|
85
|
+
or plain integers (bytes).
|
|
86
|
+
"""
|
|
87
|
+
if value is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(value, int):
|
|
90
|
+
return value
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
if value.isdigit():
|
|
93
|
+
return int(value)
|
|
94
|
+
import re
|
|
95
|
+
|
|
96
|
+
_BYTE_VALUE_RE = re.compile(
|
|
97
|
+
r"^(\d+(?:\.\d+)?)\s*([kmgt]?b)$", re.IGNORECASE
|
|
98
|
+
)
|
|
99
|
+
_BYTE_VALUE_RE_2 = re.compile(r"^(\d+(?:\.\d+)?)\s*([kmgt])$", re.IGNORECASE)
|
|
100
|
+
m = _BYTE_VALUE_RE.match(value)
|
|
101
|
+
if not m:
|
|
102
|
+
m = _BYTE_VALUE_RE_2.match(value)
|
|
103
|
+
if not m:
|
|
104
|
+
raise PydanticCustomError(
|
|
105
|
+
"byte_value",
|
|
106
|
+
f"Invalid byte value format: {value!r}",
|
|
107
|
+
)
|
|
108
|
+
return value
|
|
109
|
+
raise PydanticCustomError("byte_value", "Byte value must be a string or integer")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_port(value: Any) -> dict[str, Any]:
|
|
113
|
+
"""Parse a port specification from short or long form."""
|
|
114
|
+
if isinstance(value, dict):
|
|
115
|
+
return dict(value)
|
|
116
|
+
if not isinstance(value, str):
|
|
117
|
+
raise PydanticCustomError("port", "Port must be a string or dict")
|
|
118
|
+
|
|
119
|
+
s = value.strip()
|
|
120
|
+
|
|
121
|
+
# Extract protocol
|
|
122
|
+
protocol: str | None = None
|
|
123
|
+
if "/" in s:
|
|
124
|
+
s, protocol = s.split("/", 1)
|
|
125
|
+
|
|
126
|
+
# Handle bracketed IPv6: [::1]:6000:6000
|
|
127
|
+
if s.startswith("["):
|
|
128
|
+
bracket_end = s.find("]")
|
|
129
|
+
if bracket_end == -1:
|
|
130
|
+
raise PydanticCustomError("port", f"Invalid port format: {value!r}")
|
|
131
|
+
host_ip = s[1:bracket_end]
|
|
132
|
+
rest = s[bracket_end + 1 :]
|
|
133
|
+
if rest.startswith(":"):
|
|
134
|
+
rest = rest[1:]
|
|
135
|
+
parts = rest.split(":")
|
|
136
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
137
|
+
return {
|
|
138
|
+
"host_ip": host_ip,
|
|
139
|
+
"published": parts[0],
|
|
140
|
+
"target": int(parts[1]),
|
|
141
|
+
**({"protocol": protocol} if protocol else {}),
|
|
142
|
+
}
|
|
143
|
+
if len(parts) == 1 and parts[0]:
|
|
144
|
+
return {
|
|
145
|
+
"host_ip": host_ip,
|
|
146
|
+
"target": int(parts[0]),
|
|
147
|
+
**({"protocol": protocol} if protocol else {}),
|
|
148
|
+
}
|
|
149
|
+
raise PydanticCustomError("port", f"Invalid port format: {value!r}")
|
|
150
|
+
|
|
151
|
+
parts = s.split(":")
|
|
152
|
+
|
|
153
|
+
if len(parts) == 1:
|
|
154
|
+
# Just container port
|
|
155
|
+
return {
|
|
156
|
+
"target": int(parts[0]),
|
|
157
|
+
**({"protocol": protocol} if protocol else {}),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if len(parts) == 2:
|
|
161
|
+
# HOST:CONTAINER or IP:CONTAINER
|
|
162
|
+
first, second = parts[0], parts[1]
|
|
163
|
+
if "." in first:
|
|
164
|
+
# IPv4 address without host port
|
|
165
|
+
return {
|
|
166
|
+
"host_ip": first,
|
|
167
|
+
"target": int(second),
|
|
168
|
+
**({"protocol": protocol} if protocol else {}),
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
"published": first,
|
|
172
|
+
"target": int(second),
|
|
173
|
+
**({"protocol": protocol} if protocol else {}),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if len(parts) == 3:
|
|
177
|
+
# IP:HOST:CONTAINER
|
|
178
|
+
return {
|
|
179
|
+
"host_ip": parts[0],
|
|
180
|
+
"published": parts[1],
|
|
181
|
+
"target": int(parts[2]),
|
|
182
|
+
**({"protocol": protocol} if protocol else {}),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
raise PydanticCustomError("port", f"Invalid port format: {value!r}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Volume mount parsing (short syntax)
|
|
189
|
+
_VOLUME_MOUNT_RE = __import__("re").compile(
|
|
190
|
+
r"^(?P<source>[^:]+):(?P<target>[^:]+)(?::(?P<mode>[^:]*))?$"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _parse_service_volume(value: Any) -> dict[str, Any]:
|
|
195
|
+
"""Parse a service volume mount from short or long form."""
|
|
196
|
+
if isinstance(value, dict):
|
|
197
|
+
return dict(value)
|
|
198
|
+
if not isinstance(value, str):
|
|
199
|
+
raise PydanticCustomError("volume", "Volume mount must be a string or dict")
|
|
200
|
+
m = _VOLUME_MOUNT_RE.match(value)
|
|
201
|
+
if not m:
|
|
202
|
+
# Could be a named volume without source (just target)
|
|
203
|
+
return {"type": "volume", "target": value}
|
|
204
|
+
source = m.group("source")
|
|
205
|
+
target = m.group("target")
|
|
206
|
+
mode = m.group("mode")
|
|
207
|
+
result: dict[str, Any] = {"target": target}
|
|
208
|
+
if source.startswith(".") or source.startswith("/") or source.startswith("~"):
|
|
209
|
+
result["type"] = "bind"
|
|
210
|
+
result["source"] = source
|
|
211
|
+
elif source.startswith("\\"):
|
|
212
|
+
result["type"] = "npipe"
|
|
213
|
+
result["source"] = source
|
|
214
|
+
else:
|
|
215
|
+
result["type"] = "volume"
|
|
216
|
+
result["source"] = source
|
|
217
|
+
if mode:
|
|
218
|
+
modes = mode.split(",")
|
|
219
|
+
if "ro" in modes:
|
|
220
|
+
result["read_only"] = True
|
|
221
|
+
if "z" in modes:
|
|
222
|
+
result.setdefault("bind", {})
|
|
223
|
+
result["bind"]["selinux"] = "z"
|
|
224
|
+
if "Z" in modes:
|
|
225
|
+
result.setdefault("bind", {})
|
|
226
|
+
result["bind"]["selinux"] = "Z"
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_env_file(value: Any) -> list[dict[str, Any]] | None:
|
|
231
|
+
"""Parse env_file which can be a string, list of strings, or list of objects."""
|
|
232
|
+
if value is None:
|
|
233
|
+
return None
|
|
234
|
+
if isinstance(value, str):
|
|
235
|
+
return [{"path": value, "required": True}]
|
|
236
|
+
if isinstance(value, list):
|
|
237
|
+
result: list[dict[str, Any]] = []
|
|
238
|
+
for item in value:
|
|
239
|
+
if isinstance(item, str):
|
|
240
|
+
result.append({"path": item, "required": True})
|
|
241
|
+
elif isinstance(item, dict):
|
|
242
|
+
result.append(dict(item))
|
|
243
|
+
return result
|
|
244
|
+
raise PydanticCustomError("env_file", "env_file must be a string or list")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_external(value: Any) -> dict[str, Any] | bool:
|
|
248
|
+
"""Parse external which can be a boolean or a dict with a name."""
|
|
249
|
+
if isinstance(value, bool):
|
|
250
|
+
return value
|
|
251
|
+
if isinstance(value, dict):
|
|
252
|
+
return dict(value)
|
|
253
|
+
raise PydanticCustomError("external", "external must be a boolean or dict")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _parse_extra_hosts(value: Any) -> dict[str, str] | None:
|
|
257
|
+
"""Parse extra_hosts which can be a dict or list of HOST=IP strings."""
|
|
258
|
+
if value is None:
|
|
259
|
+
return None
|
|
260
|
+
if isinstance(value, dict):
|
|
261
|
+
return {str(k): str(v) for k, v in value.items()}
|
|
262
|
+
if isinstance(value, list):
|
|
263
|
+
result: dict[str, str] = {}
|
|
264
|
+
for item in value:
|
|
265
|
+
if isinstance(item, str):
|
|
266
|
+
sep = "=" if "=" in item else ":"
|
|
267
|
+
parts = item.split(sep, 1)
|
|
268
|
+
if len(parts) == 2:
|
|
269
|
+
result[parts[0]] = parts[1]
|
|
270
|
+
return result
|
|
271
|
+
raise PydanticCustomError("extra_hosts", "extra_hosts must be a dict or list")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_ulimits(value: Any) -> dict[str, Any] | None:
|
|
275
|
+
"""Parse ulimits which can be a dict of int or dict with soft/hard."""
|
|
276
|
+
if value is None:
|
|
277
|
+
return None
|
|
278
|
+
if isinstance(value, dict):
|
|
279
|
+
return dict(value)
|
|
280
|
+
raise PydanticCustomError("ulimits", "ulimits must be a dict")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_blkio_config(value: Any) -> dict[str, Any] | None:
|
|
284
|
+
"""Parse blkio_config from input."""
|
|
285
|
+
if value is None:
|
|
286
|
+
return None
|
|
287
|
+
if isinstance(value, dict):
|
|
288
|
+
return dict(value)
|
|
289
|
+
raise PydanticCustomError("blkio_config", "blkio_config must be a dict")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _parse_depends_on(value: Any) -> dict[str, Any] | list[str] | None:
|
|
293
|
+
"""Parse depends_on which can be a list of strings or a dict."""
|
|
294
|
+
if value is None:
|
|
295
|
+
return None
|
|
296
|
+
if isinstance(value, list):
|
|
297
|
+
return [str(item) for item in value]
|
|
298
|
+
if isinstance(value, dict):
|
|
299
|
+
return dict(value)
|
|
300
|
+
raise PydanticCustomError("depends_on", "depends_on must be a list or dict")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_tmpfs(value: Any) -> list[str] | None:
|
|
304
|
+
"""Parse tmpfs which can be a string or list of strings."""
|
|
305
|
+
if value is None:
|
|
306
|
+
return None
|
|
307
|
+
if isinstance(value, str):
|
|
308
|
+
return [value]
|
|
309
|
+
if isinstance(value, list):
|
|
310
|
+
return [str(item) for item in value]
|
|
311
|
+
raise PydanticCustomError("tmpfs", "tmpfs must be a string or list")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _parse_volumes_from(value: Any) -> list[str] | None:
|
|
315
|
+
"""Parse volumes_from which is a list of strings."""
|
|
316
|
+
if value is None:
|
|
317
|
+
return None
|
|
318
|
+
if isinstance(value, str):
|
|
319
|
+
return [value]
|
|
320
|
+
if isinstance(value, list):
|
|
321
|
+
return [str(item) for item in value]
|
|
322
|
+
raise PydanticCustomError("volumes_from", "volumes_from must be a string or list")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _parse_annotations(value: Any) -> dict[str, str] | None:
|
|
326
|
+
"""Parse annotations which can be a dict or list of KEY=VALUE strings."""
|
|
327
|
+
if value is None:
|
|
328
|
+
return None
|
|
329
|
+
return _parse_list_or_dict(value)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_labels(value: Any) -> dict[str, str] | None:
|
|
333
|
+
"""Parse labels which can be a dict or list of KEY=VALUE strings."""
|
|
334
|
+
if value is None:
|
|
335
|
+
return None
|
|
336
|
+
return _parse_list_or_dict(value)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _parse_environment(value: Any) -> dict[str, str | None] | None:
|
|
340
|
+
"""Parse environment which can be a dict or list of KEY=VALUE strings."""
|
|
341
|
+
if value is None:
|
|
342
|
+
return None
|
|
343
|
+
return _parse_list_or_dict(value)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_devices(value: Any) -> list[str] | None:
|
|
347
|
+
"""Parse devices which is a list of strings."""
|
|
348
|
+
if value is None:
|
|
349
|
+
return None
|
|
350
|
+
if isinstance(value, str):
|
|
351
|
+
return [value]
|
|
352
|
+
if isinstance(value, list):
|
|
353
|
+
return [str(item) for item in value]
|
|
354
|
+
raise PydanticCustomError("devices", "devices must be a string or list")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _parse_dns(value: Any) -> str | list[str] | None:
|
|
358
|
+
"""Parse dns which can be a string or list of strings."""
|
|
359
|
+
if value is None:
|
|
360
|
+
return None
|
|
361
|
+
if isinstance(value, str):
|
|
362
|
+
return value
|
|
363
|
+
if isinstance(value, list):
|
|
364
|
+
return [str(item) for item in value]
|
|
365
|
+
raise PydanticCustomError("dns", "dns must be a string or list")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _parse_configs_or_secrets(value: Any) -> list[dict[str, Any]] | list[str] | None:
|
|
369
|
+
"""Parse configs/secrets which can be a list of strings or list of dicts."""
|
|
370
|
+
if value is None:
|
|
371
|
+
return None
|
|
372
|
+
if isinstance(value, list):
|
|
373
|
+
result: list[dict[str, Any] | str] = []
|
|
374
|
+
for item in value:
|
|
375
|
+
if isinstance(item, str):
|
|
376
|
+
result.append(item)
|
|
377
|
+
elif isinstance(item, dict):
|
|
378
|
+
result.append(dict(item))
|
|
379
|
+
return result
|
|
380
|
+
raise PydanticCustomError("configs_secrets", "Must be a list")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _parse_additional_contexts(value: Any) -> dict[str, str] | list[str] | None:
|
|
384
|
+
"""Parse additional_contexts which can be a dict or list of NAME=VALUE strings."""
|
|
385
|
+
if value is None:
|
|
386
|
+
return None
|
|
387
|
+
if isinstance(value, dict):
|
|
388
|
+
return {str(k): str(v) for k, v in value.items()}
|
|
389
|
+
if isinstance(value, list):
|
|
390
|
+
result: dict[str, str] = {}
|
|
391
|
+
for item in value:
|
|
392
|
+
if isinstance(item, str) and "=" in item:
|
|
393
|
+
k, v = item.split("=", 1)
|
|
394
|
+
result[k] = v
|
|
395
|
+
return result
|
|
396
|
+
raise PydanticCustomError("additional_contexts", "Must be a dict or list")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# Model base class shared by all compose models
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
class ComposeBaseModel(BaseModel):
|
|
404
|
+
"""Base model for all compose file models.
|
|
405
|
+
|
|
406
|
+
Allows extra fields for ``x-*`` extensions and unknown fields.
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
model_config = ConfigDict(
|
|
410
|
+
extra="allow",
|
|
411
|
+
populate_by_name=True,
|
|
412
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Top-level DockerComposeFile model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import model_validator
|
|
8
|
+
|
|
9
|
+
from .common import ComposeBaseModel
|
|
10
|
+
from .config import Config
|
|
11
|
+
from .model import Model
|
|
12
|
+
from .network import Network
|
|
13
|
+
from .secret import Secret
|
|
14
|
+
from .service import Service
|
|
15
|
+
from .volume import Volume
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Include(ComposeBaseModel):
|
|
19
|
+
"""Include configuration for composing multiple files."""
|
|
20
|
+
|
|
21
|
+
path: list[str] | str | None = None
|
|
22
|
+
project_directory: str | None = None
|
|
23
|
+
env_file: list[str] | str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DockerComposeFile(ComposeBaseModel):
|
|
27
|
+
"""Top-level Docker Compose file model."""
|
|
28
|
+
|
|
29
|
+
version: str | None = None
|
|
30
|
+
name: str | None = None
|
|
31
|
+
include: list[Include] | None = None
|
|
32
|
+
services: dict[str, Service] | None = None
|
|
33
|
+
networks: dict[str, Network] | None = None
|
|
34
|
+
volumes: dict[str, Volume] | None = None
|
|
35
|
+
configs: dict[str, Config] | None = None
|
|
36
|
+
secrets: dict[str, Secret] | None = None
|
|
37
|
+
models: dict[str, Model] | None = None
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="before")
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _normalize_none_values(data: Any) -> Any:
|
|
42
|
+
"""Replace None values in resource dicts with empty dicts.
|
|
43
|
+
|
|
44
|
+
Docker Compose allows declaring resources with no attributes
|
|
45
|
+
(e.g. ``volumes:\n frontend_build:``), which YAML parses as
|
|
46
|
+
``None``. Convert these to empty dicts for Pydantic validation.
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return data
|
|
50
|
+
for key in ("networks", "volumes", "configs", "secrets"):
|
|
51
|
+
if key not in data or data[key] is None:
|
|
52
|
+
continue
|
|
53
|
+
resource = data[key]
|
|
54
|
+
if isinstance(resource, dict):
|
|
55
|
+
for name, value in list(resource.items()):
|
|
56
|
+
if value is None:
|
|
57
|
+
resource[name] = {}
|
|
58
|
+
return data
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Config models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
|
|
7
|
+
from .common import ComposeBaseModel, _parse_external, _parse_list_or_dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Config(ComposeBaseModel):
|
|
11
|
+
"""Top-level config definition."""
|
|
12
|
+
|
|
13
|
+
file: str | None = None
|
|
14
|
+
environment: str | None = None
|
|
15
|
+
content: str | None = None
|
|
16
|
+
external: bool | dict[str, str] | None = Field(
|
|
17
|
+
default=None, validate_default=True
|
|
18
|
+
)
|
|
19
|
+
name: str | None = None
|
|
20
|
+
labels: dict[str, str] | list[str] | None = Field(
|
|
21
|
+
default=None, validate_default=True
|
|
22
|
+
)
|
|
23
|
+
driver: str | None = None
|
|
24
|
+
driver_opts: dict[str, str | int] | None = None
|
|
25
|
+
template_driver: str | None = None
|
|
26
|
+
|
|
27
|
+
@field_validator("labels", mode="before")
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _labels(v):
|
|
30
|
+
return _parse_list_or_dict(v) if v is not None else None
|
|
31
|
+
|
|
32
|
+
@field_validator("external", mode="before")
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _external(v):
|
|
35
|
+
return _parse_external(v) if v is not None else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ServiceConfig(ComposeBaseModel):
|
|
39
|
+
"""Service-level config reference."""
|
|
40
|
+
|
|
41
|
+
source: str
|
|
42
|
+
target: str | None = None
|
|
43
|
+
uid: str | None = None
|
|
44
|
+
gid: str | None = None
|
|
45
|
+
mode: str | None = None
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Deploy configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator
|
|
8
|
+
|
|
9
|
+
from .common import ComposeBaseModel, validate_byte_value, validate_duration
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlacementPreference(BaseModel):
|
|
13
|
+
"""Placement preference."""
|
|
14
|
+
|
|
15
|
+
spread: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Placement(ComposeBaseModel):
|
|
19
|
+
"""Placement constraints and preferences."""
|
|
20
|
+
|
|
21
|
+
constraints: list[str] | None = None
|
|
22
|
+
preferences: list[PlacementPreference] | None = None
|
|
23
|
+
max_replicas_per_node: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResourceLimits(ComposeBaseModel):
|
|
27
|
+
"""Resource limits."""
|
|
28
|
+
|
|
29
|
+
cpus: str | None = None
|
|
30
|
+
memory: str | None = Field(default=None, validate_default=True)
|
|
31
|
+
pids: int | None = None
|
|
32
|
+
|
|
33
|
+
@field_validator("memory", mode="before")
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _memory(v: Any) -> str | None:
|
|
36
|
+
return validate_byte_value(v)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ResourceReservations(ComposeBaseModel):
|
|
40
|
+
"""Resource reservations."""
|
|
41
|
+
|
|
42
|
+
cpus: str | None = None
|
|
43
|
+
memory: str | None = Field(default=None, validate_default=True)
|
|
44
|
+
pids: int | None = None
|
|
45
|
+
devices: list[dict[str, Any]] | None = None
|
|
46
|
+
|
|
47
|
+
@field_validator("memory", mode="before")
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _memory(v: Any) -> str | None:
|
|
50
|
+
return validate_byte_value(v)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Resources(ComposeBaseModel):
|
|
54
|
+
"""Resource configuration."""
|
|
55
|
+
|
|
56
|
+
limits: ResourceLimits | None = None
|
|
57
|
+
reservations: ResourceReservations | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RestartPolicy(ComposeBaseModel):
|
|
61
|
+
"""Restart policy for deploy."""
|
|
62
|
+
|
|
63
|
+
condition: str | None = None
|
|
64
|
+
delay: str | None = Field(default=None, validate_default=True)
|
|
65
|
+
max_attempts: int | None = None
|
|
66
|
+
window: str | None = Field(default=None, validate_default=True)
|
|
67
|
+
|
|
68
|
+
@field_validator("delay", "window", mode="before")
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _duration(v: Any) -> str | None:
|
|
71
|
+
return validate_duration(v)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UpdateConfig(ComposeBaseModel):
|
|
75
|
+
"""Update configuration."""
|
|
76
|
+
|
|
77
|
+
parallelism: int | None = None
|
|
78
|
+
delay: str | None = Field(default=None, validate_default=True)
|
|
79
|
+
failure_action: str | None = None
|
|
80
|
+
monitor: str | None = Field(default=None, validate_default=True)
|
|
81
|
+
max_failure_ratio: float | None = None
|
|
82
|
+
order: str | None = None
|
|
83
|
+
|
|
84
|
+
@field_validator("delay", "monitor", mode="before")
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _duration(v: Any) -> str | None:
|
|
87
|
+
return validate_duration(v)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RollbackConfig(ComposeBaseModel):
|
|
91
|
+
"""Rollback configuration."""
|
|
92
|
+
|
|
93
|
+
parallelism: int | None = None
|
|
94
|
+
delay: str | None = Field(default=None, validate_default=True)
|
|
95
|
+
failure_action: str | None = None
|
|
96
|
+
monitor: str | None = Field(default=None, validate_default=True)
|
|
97
|
+
max_failure_ratio: float | None = None
|
|
98
|
+
order: str | None = None
|
|
99
|
+
|
|
100
|
+
@field_validator("delay", "monitor", mode="before")
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _duration(v: Any) -> str | None:
|
|
103
|
+
return validate_duration(v)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DeployConfig(ComposeBaseModel):
|
|
107
|
+
"""Deploy configuration for a service."""
|
|
108
|
+
|
|
109
|
+
endpoint_mode: str | None = None
|
|
110
|
+
labels: dict[str, str] | list[str] | None = None
|
|
111
|
+
mode: str | None = None
|
|
112
|
+
replicas: int | None = None
|
|
113
|
+
resources: Resources | None = None
|
|
114
|
+
placement: Placement | None = None
|
|
115
|
+
restart_policy: RestartPolicy | None = None
|
|
116
|
+
rollback_config: RollbackConfig | None = None
|
|
117
|
+
update_config: UpdateConfig | None = None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Development configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .common import ComposeBaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WatchExec(ComposeBaseModel):
|
|
9
|
+
"""Exec configuration for watch sync+exec action."""
|
|
10
|
+
|
|
11
|
+
command: str | list[str]
|
|
12
|
+
user: str | None = None
|
|
13
|
+
privileged: bool | None = None
|
|
14
|
+
working_dir: str | None = None
|
|
15
|
+
environment: dict[str, str] | list[str] | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WatchRule(ComposeBaseModel):
|
|
19
|
+
"""Watch rule for development."""
|
|
20
|
+
|
|
21
|
+
path: str
|
|
22
|
+
action: str
|
|
23
|
+
target: str | None = None
|
|
24
|
+
ignore: list[str] | None = None
|
|
25
|
+
include: list[str] | None = None
|
|
26
|
+
initial_sync: bool | None = None
|
|
27
|
+
exec: WatchExec | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DevelopConfig(ComposeBaseModel):
|
|
31
|
+
"""Development configuration for a service."""
|
|
32
|
+
|
|
33
|
+
watch: list[WatchRule] | None = None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""AI model models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .common import ComposeBaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Model(ComposeBaseModel):
|
|
9
|
+
"""Top-level AI model definition."""
|
|
10
|
+
|
|
11
|
+
model: str
|
|
12
|
+
context_size: int | None = None
|
|
13
|
+
runtime_flags: list[str] | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ServiceModelConfig(ComposeBaseModel):
|
|
17
|
+
"""Service-level model reference (long syntax)."""
|
|
18
|
+
|
|
19
|
+
endpoint_var: str | None = None
|
|
20
|
+
model_var: str | None = None
|