-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_values.py
More file actions
593 lines (442 loc) · 25.4 KB
/
Copy pathtest_values.py
File metadata and controls
593 lines (442 loc) · 25.4 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
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
import pytest
from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.values import (
as_bool,
has_variable,
is_bool_like,
validate_count,
validate_duration,
validate_integer,
validate_native_number,
validate_number,
validate_ports,
validate_size,
validate_string,
)
@pytest.mark.parametrize("value", ["${MEM}", "$MEM", "a${X}b", "${X:-1}"])
def test_has_variable_true(value: str) -> None:
assert has_variable(value) is True
@pytest.mark.parametrize("value", ["512m", "", "$$literal", 512, None])
def test_has_variable_false(value: object) -> None:
assert has_variable(value) is False
# A `${VAR}` value is never grammar-checked: its runtime value is unknowable here.
@pytest.mark.parametrize(
"validate",
[validate_size, validate_number, validate_integer, validate_duration, validate_count],
)
def test_variable_value_skips_grammar(validate) -> None: # noqa: ANN001
validate("app", "k", "${VAR}")
@pytest.mark.parametrize("value", [512, 1.5, "512", "512m", "512M", "512mb", "512k", "1g", "1gb", "0.5g", "1e3"])
def test_validate_size_accepts(value: object) -> None:
validate_size("app", "mem_limit", value)
@pytest.mark.parametrize("value", ["", "abc", [], {}, True])
def test_validate_size_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="mem_limit"):
validate_size("app", "mem_limit", value)
def test_validate_size_int_only_rejects_fractional_float() -> None:
with pytest.raises(UnsupportedComposeError, match="mem_swappiness"):
validate_size("app", "mem_swappiness", 1.5, allow_fractional=False)
def test_validate_size_int_only_accepts_int() -> None:
validate_size("app", "mem_swappiness", 60, allow_fractional=False)
def test_validate_size_int_only_accepts_int_string() -> None:
validate_size("app", "mem_swappiness", "60", allow_fractional=False)
# Measured against `docker compose config` v5.1.2: `mem_swappiness: 60.0` and
# `mem_reservation: 60.0` are ACCEPTED (a whole-valued float casts cleanly to
# Docker's int64 field); only a fractional float ("must be a integer") is
# refused. The constraint is an integral *value*, not the absence of a float type.
def test_validate_size_int_only_accepts_whole_float() -> None:
validate_size("app", "mem_swappiness", 60.0, allow_fractional=False)
def test_validate_size_int_only_rejects_fractional_float_string_unaffected() -> None:
# The string branch is ungated by allow_fractional -- "1.5" is a valid size
# string regardless (matches the pre-existing allow_fractional=True case).
validate_size("app", "mem_swappiness", "1.5", allow_fractional=False)
# C1: every Docker size unit accepts, but Docker has no exabyte unit -- 'e'/'eb' is not
# a suffix, it collides with scientific notation ('1e3') and must not be swallowed as one.
@pytest.mark.parametrize("value", ["512b", "512k", "512m", "512g", "512t", "512p"])
def test_validate_size_accepts_every_real_unit(value: str) -> None:
validate_size("app", "mem_limit", value)
@pytest.mark.parametrize("value", ["1e", "1eb", "512e", "512eb"])
def test_validate_size_rejects_invented_exabyte_unit(value: str) -> None:
with pytest.raises(UnsupportedComposeError, match="mem_limit"):
validate_size("app", "mem_limit", value)
# C2: Docker's JSON number decoder refuses NaN/Infinity outright ("unsupported value"),
# and the size grammar refuses the string spellings too ("invalid size").
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf"), "nan", "inf", "-inf"])
def test_validate_size_rejects_non_finite(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="mem_limit"):
validate_size("app", "mem_limit", value)
# I4: Go's float parser (used for size/number strings) permits digit-grouping
# underscores between digits, so Docker accepts these -- unlike the integer parser.
@pytest.mark.parametrize("value", ["1_000", "1_0"])
def test_validate_size_accepts_digit_grouping(value: str) -> None:
validate_size("app", "mem_limit", value)
def test_validate_size_int_only_accepts_digit_grouping() -> None:
validate_size("app", "mem_swappiness", "1_0", allow_fractional=False)
# Whitespace boundary, measured against `docker compose config` v5.1.2: a single
# space is legal in exactly one position -- immediately after the digits, before an
# optional unit ("512 m" and the unit-less "512 " both ACCEPT). Any other placement
# -- leading, doubled, a tab, or trailing *after* a unit letter -- is "invalid
# size"/"invalid syntax" and must be refused. Confirmed with a 4x4x3x4
# (lead x mid x unit x trail) probe against the live CLI, not just this table.
@pytest.mark.parametrize("value", ["512 m", "512 mb", "512 "])
def test_validate_size_accepts_single_space_before_unit(value: str) -> None:
validate_size("app", "mem_limit", value)
@pytest.mark.parametrize(
"value",
[" 512m", "512m ", "512 m", "512\tm", " 512", "512 ", " 512 m", "512\t"],
)
def test_validate_size_rejects_whitespace_padding(value: str) -> None:
with pytest.raises(UnsupportedComposeError, match="mem_limit"):
validate_size("app", "mem_limit", value)
# Python's `$` regex anchor matches before a trailing newline, not just at the true
# end of string -- so a `$`-anchored grammar would over-accept a value carrying one
# (reachable via a YAML block scalar like `mem_limit: |\n 512m\n`), which Docker
# refuses. Each grammar below is anchored with `\Z` (true end-of-string only) to
# close that gap.
@pytest.mark.parametrize(
("validate", "key", "good", "bad"),
[
(validate_size, "mem_limit", "512m", "512m\n"),
(validate_count, "cpu_shares", "1024", "1024\n"),
(validate_duration, "stop_grace_period", "5s", "5s\n"),
],
)
def test_grammar_rejects_trailing_newline(validate, key, good, bad) -> None: # noqa: ANN001
validate("app", key, good) # newline-free form still validates
with pytest.raises(UnsupportedComposeError):
validate("app", key, bad)
# string_only: deploy.resources.limits.memory / reservations.memory are typed
# as a Go string field, unlike mem_limit/mem_reservation -- a native number is
# refused outright, only a size *string* is accepted. Measured against
# `docker compose config` v5.1.2.
@pytest.mark.parametrize("value", [512, 1073741824, 1.5, 60, True])
def test_validate_size_string_only_rejects_native_number(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match=r"limits\.memory"):
validate_size("app", "limits.memory", value, string_only=True)
@pytest.mark.parametrize("value", ["512", "512m", "512M", "512mb", "1.5", "1e3"])
def test_validate_size_string_only_accepts_size_string(value: object) -> None:
validate_size("app", "limits.memory", value, string_only=True)
@pytest.mark.parametrize("value", ["", "somevalue", "abc"])
def test_validate_size_string_only_rejects_non_size_string(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match=r"limits\.memory"):
validate_size("app", "limits.memory", value, string_only=True)
def test_validate_size_string_only_skips_variable() -> None:
validate_size("app", "limits.memory", "${MEM}", string_only=True)
@pytest.mark.parametrize("value", [0.5, 2, -1, "0.5", "2", 1.5])
def test_validate_number_accepts(value: object) -> None:
validate_number("app", "cpus", value)
@pytest.mark.parametrize("value", ["", "abc", [], {}, True])
def test_validate_number_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="cpus"):
validate_number("app", "cpus", value)
# C2: Docker's JSON decoder refuses NaN/Infinity outright ("unsupported value: NaN" /
# "unsupported value: +Inf"), both as native floats and as the string spellings.
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf"), "nan", "inf", "-inf"])
def test_validate_number_rejects_non_finite(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="cpus"):
validate_number("app", "cpus", value)
# I4: Go's float parser permits digit-grouping underscores between digits.
def test_validate_number_accepts_digit_grouping() -> None:
validate_number("app", "cpus", "1_000")
# Whitespace, measured against `docker compose config` v5.1.2: unlike the size
# grammar, `cpus` (Go's bare strconv.ParseFloat, no unit) permits NO surrounding
# whitespace at all -- Python's own float() is more lenient (float(" 1.5 ") == 1.5)
# and must be guarded against explicitly, or this over-accepts what Docker refuses
# ("strconv.ParseFloat: parsing \" 1.5 \": invalid syntax").
@pytest.mark.parametrize("value", [" 1.5 ", "1.5 ", " 1.5", "1.5\t", "\t1.5"])
def test_validate_number_rejects_whitespace_padding(value: str) -> None:
with pytest.raises(UnsupportedComposeError, match="cpus"):
validate_number("app", "cpus", value)
# validate_native_number: networks entry priority/gw_priority. Measured against
# `docker compose config` v5.1.2 -- unlike validate_number, there is no
# number-or-string union: a numeric string is refused just like any other one.
@pytest.mark.parametrize("value", [0, 5, -1, 1.5, 400.0])
def test_validate_native_number_accepts(value: object) -> None:
validate_native_number("app", "priority", value)
@pytest.mark.parametrize("value", ["5", "1.5", "", "abc", [], {}, True])
def test_validate_native_number_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="priority"):
validate_native_number("app", "priority", value)
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
def test_validate_native_number_rejects_non_finite(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="priority"):
validate_native_number("app", "priority", value)
def test_validate_native_number_does_not_carve_out_variable_reference() -> None:
# Deliberately unlike every other grammar in this module: `${VAR}` always
# interpolates to a string, and this grammar has no string branch, so no
# possible variable value could make Docker accept the document -- the
# verdict is a fact about the document, not the host, and must not be
# carved out. Measured: `priority: "${MYPRI}"` is refused even with
# MYPRI set to a valid number ("5") in the reading shell.
with pytest.raises(UnsupportedComposeError, match="priority"):
validate_native_number("app", "priority", "${MYPRI}")
# validate_count: cpu_shares/cpu_quota/cpu_period/pids_limit. Measured against
# `docker compose config` v5.1.2 -- the *native* number is cast leniently (a
# float is fine), but the *string* form goes through Go's strconv.ParseInt,
# which is a strict integer: no decimal point, no exponent, no digit-grouping
# underscore. `cpus` does not share this grammar (it stays on validate_number,
# a genuine ParseFloat field) -- measured `cpus: "0.5"` is accepted.
@pytest.mark.parametrize("value", [2, 0.5, -1, "2", "-3", "0"])
def test_validate_count_accepts(value: object) -> None:
validate_count("app", "cpu_shares", value)
@pytest.mark.parametrize("value", ["0.5", "1e3", "1_000", "", "abc", [], {}, True])
def test_validate_count_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="cpu_shares"):
validate_count("app", "cpu_shares", value)
# C2: Docker's JSON decoder refuses NaN/Infinity outright, same as validate_number.
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
def test_validate_count_rejects_non_finite(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="cpu_shares"):
validate_count("app", "cpu_shares", value)
@pytest.mark.parametrize("value", [100, -500, "100"])
def test_validate_integer_accepts(value: object) -> None:
validate_integer("app", "oom_score_adj", value)
@pytest.mark.parametrize("value", [1.5, "abc", "", True, []])
def test_validate_integer_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="oom_score_adj"):
validate_integer("app", "oom_score_adj", value)
# I4: unlike the float grammars above, Go's integer parser (ParseInt, used for
# oom_score_adj) does not permit digit-grouping underscores -- "invalid syntax".
@pytest.mark.parametrize("value", ["1_00", "1_0", "1_000"])
def test_validate_integer_rejects_digit_grouping(value: str) -> None:
with pytest.raises(UnsupportedComposeError, match="oom_score_adj"):
validate_integer("app", "oom_score_adj", value)
# Measured against `docker compose config` v5.1.2: ulimits' int64 field rejects
# any float outright ("invalid type float64 for external"), whole or fractional
# -- unlike oom_score_adj/mem_swappiness/mem_reservation. Strict is the default
# so a bare `validate_integer` call (as `validate_ulimits` makes) stays exact.
def test_validate_integer_default_rejects_whole_float() -> None:
with pytest.raises(UnsupportedComposeError, match="nofile"):
validate_integer("app", "nofile", 60.0)
# oom_score_adj opts in: `oom_score_adj: 1000.0` is ACCEPTED by Docker, only a
# fractional float ("must be a integer") is refused.
def test_validate_integer_allow_whole_float_accepts_whole() -> None:
validate_integer("app", "oom_score_adj", 1000.0, allow_whole_float=True)
def test_validate_integer_allow_whole_float_still_rejects_fractional() -> None:
with pytest.raises(UnsupportedComposeError, match="oom_score_adj"):
validate_integer("app", "oom_score_adj", 0.5, allow_whole_float=True)
# Whitespace, measured against `docker compose config` v5.1.2: the lenient (non
# strict_string) branch delegates to Go's strconv.ParseInt (via oom_score_adj),
# which permits NO surrounding whitespace -- "strconv.ParseInt: parsing \" 5 \":
# invalid syntax". Python's int() is more lenient (int(" 5 ") == 5) and must be
# guarded against explicitly here, distinct from the already-anchored
# strict_string=True branch (_STRICT_INT_STRING), which this does not touch.
@pytest.mark.parametrize("value", [" 5 ", "5 ", " 5", "5\t", "\t5"])
def test_validate_integer_rejects_whitespace_padding(value: str) -> None:
with pytest.raises(UnsupportedComposeError, match="oom_score_adj"):
validate_integer("app", "oom_score_adj", value)
@pytest.mark.parametrize("value", ["0-3", "0,1", "", "abc"])
def test_validate_string_accepts(value: object) -> None:
validate_string("app", "cpuset", value)
@pytest.mark.parametrize("value", [0, 2, 1.5, True, [], {}])
def test_validate_string_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="cpuset"):
validate_string("app", "cpuset", value)
@pytest.mark.parametrize("value", ["1m30s", "90s", "1h", "500ms", "1h30m", "0s", "0"])
def test_validate_duration_accepts(value: str) -> None:
validate_duration("app", "stop_grace_period", value)
# A bare number and a unitless string are both refused -- Docker: "missing unit in duration".
# '0' is the one exception (Go's time.ParseDuration special-cases the zero literal); '30' is not.
@pytest.mark.parametrize("value", [90, "90", "30", "", "abc", 1.5, True, []])
def test_validate_duration_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="stop_grace_period"):
validate_duration("app", "stop_grace_period", value)
# Measured against `docker compose config` v5.1.2: 'stop_grace_period: "0"' is accepted
# (a pre-existing over-rejection this fix incidentally closes), while a native 0 and the
# unitless '30' stay refused -- only the bare '0' string is special-cased.
def test_validate_duration_accepts_bare_zero_string() -> None:
validate_duration("app", "stop_grace_period", "0")
@pytest.mark.parametrize("value", [0, 30])
def test_validate_duration_rejects_native_number(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="stop_grace_period"):
validate_duration("app", "stop_grace_period", value)
@pytest.mark.parametrize(
"value",
[
[8080],
["8080"],
["8080:80"],
["8080:80/tcp"],
["3000-3005:3000-3005"],
["127.0.0.1:8080:80"],
[{"target": 80, "published": "8080"}],
["${PORT}:80"],
[],
],
)
def test_validate_ports_accepts(value: object) -> None:
validate_ports("app", "ports", value)
@pytest.mark.parametrize("value", ["8080:80", ["abc"], ["80:80:80"], 8080, {}, [None], [1.5]])
def test_validate_ports_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="ports"):
validate_ports("app", "ports", value)
def test_validate_ports_rejects_trailing_newline() -> None:
validate_ports("app", "ports", ["8080"]) # newline-free form still validates
with pytest.raises(UnsupportedComposeError):
validate_ports("app", "ports", ["8080\n"])
# C3: a host range is fine on its own, or matched 1:1 against a container range, but
# a bare/differently-sized container range is "invalid ranges specified for container
# and host Ports", and a descending range (start > end) is invalid regardless of side.
@pytest.mark.parametrize("value", [["3000-3005:3000-3005"], ["3000-3005:3000"]])
def test_validate_ports_accepts_compatible_ranges(value: object) -> None:
validate_ports("app", "ports", value)
@pytest.mark.parametrize(
"value",
[
["3000:3000-3005"], # container is a range, host is a single port
["3000-3005:3000-3002"], # both ranges, but different lengths (6 vs 3)
["3005-3000:3005-3000"], # descending range on both sides
["3005-3000:3000"], # descending range on the host side only
],
)
def test_validate_ports_rejects_incompatible_ranges(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="ports"):
validate_ports("app", "ports", value)
# Measured against `docker compose config` v5.1.2: the container (target) port
# must be 1-65535 -- 0 is rejected as "missing a target port", not accepted as
# a wildcard. The host (published) port allows 0 (meaning "pick a free port"),
# so its floor is 0, not 1 -- the two sides are not symmetric.
@pytest.mark.parametrize(
"value",
[
["1:1"],
["65535:65535"],
["0:80"],
[1],
[65535],
],
)
def test_validate_ports_accepts_bounds(value: object) -> None:
validate_ports("app", "ports", value)
@pytest.mark.parametrize(
"value",
[
["0:0"], # missing target port
["8080:0"], # missing target port
["65536:80"], # invalid hostPort: 65536
["80:65536"], # invalid containerPort: 65536
["1-65536:80"], # invalid hostPort: 1-65536 (range upper bound out of range)
[0], # bare container port: missing target port
[65536], # bare container port: invalid containerPort: 65536
],
)
def test_validate_ports_rejects_out_of_bounds(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="ports"):
validate_ports("app", "ports", value)
# Measured against `docker compose config` v5.1.2: a long-form entry is Docker's
# business except for one hard requirement -- a 'target' key -- which "docker
# compose config" refuses to omit ("is missing a target port"). Everything else
# inside the mapping stays unchecked; compose2pod never reads `ports` at all.
@pytest.mark.parametrize(
"value",
[
[{"target": 80}],
[{"target": 80, "published": "8080"}],
],
)
def test_validate_ports_accepts_long_form_with_target(value: object) -> None:
validate_ports("app", "ports", value)
@pytest.mark.parametrize(
"value",
[
[{"published": "8080"}],
[{"a": 1}],
],
)
def test_validate_ports_rejects_long_form_missing_target(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="missing a target port"):
validate_ports("app", "ports", value)
# --- long-form ports fields (Task 10, Class 4): `_validate_port_entry`'s dict
# branch used to return right after checking 'target' is present -- every other
# field's value was Docker's business, not ours. Each grammar below was
# measured against `docker compose config` v5.1.2 with the same YAML text fed
# to both oracles.
def _port(**fields: object) -> list[dict[str, object]]:
return [{"target": 80, **fields}]
def test_validate_ports_rejects_long_form_unknown_field() -> None:
with pytest.raises(UnsupportedComposeError, match="unsupported keys"):
validate_ports("app", "ports", _port(bogus_field=1))
def test_validate_ports_accepts_every_known_long_form_field_name() -> None:
# app_protocol is a real Compose Spec field, not a typo -- measured, it is
# accepted, unlike a genuinely unknown key (see the test above).
validate_ports(
"app",
"ports",
_port(published="8080", host_ip="1.2.3.4", protocol="tcp", mode="host", name="web", app_protocol="http"),
)
@pytest.mark.parametrize("value", [80, 80.0, "80", 0])
def test_validate_ports_long_form_target_accepts(value: object) -> None:
validate_ports("app", "ports", [{"target": value}])
@pytest.mark.parametrize("value", [80.5, -1, "-5", "abc", True, "80-90", "1_000", " 80 "])
def test_validate_ports_long_form_target_rejects(value: object) -> None:
# Docker casts a string target through Go's uint32 decoder: no fractional
# float, no negative (native or string), no non-numeric string, no bool,
# and no range (unlike the string form's short-form grammar). "1_000" and
# " 80 " are digit-grouping/whitespace forms Python's own int() would
# accept but Go's strconv.Atoi refuses -- measured against `docker compose
# config` v5.1.2.
with pytest.raises(UnsupportedComposeError, match="target"):
validate_ports("app", "ports", [{"target": value}])
def test_validate_ports_long_form_target_has_no_upper_bound() -> None:
# Measured: unlike the short-form container port (bound to 1-65535), the
# long-form target field is accepted well past 65535 at config-validate time.
validate_ports("app", "ports", [{"target": 99999}])
@pytest.mark.parametrize("value", [8080, -1, "8080", "abc", "8080-8090", ""])
def test_validate_ports_long_form_published_accepts(value: object) -> None:
# Docker's own Published field is a plain string with no further
# validation at config time -- content is entirely unchecked.
validate_ports("app", "ports", _port(published=value))
@pytest.mark.parametrize("value", [True, 80.5])
def test_validate_ports_long_form_published_rejects(value: object) -> None:
with pytest.raises(UnsupportedComposeError, match="published"):
validate_ports("app", "ports", _port(published=value))
@pytest.mark.parametrize(
"value",
["1.2.3.4", "255.255.255.255", "0.0.0.0", "::1", "2001:db8::1", "::ffff:1.2.3.4"], # noqa: S104 - a real Compose value, not a bind address
)
def test_validate_ports_long_form_host_ip_accepts(value: object) -> None:
# ::ffff:1.2.3.4 is an IPv4-mapped IPv6 literal -- measured, Docker accepts
# it against `docker compose config` v5.1.2, same as stdlib `ipaddress`.
validate_ports("app", "ports", _port(host_ip=value))
@pytest.mark.parametrize(
"value",
["localhost", "1.2.3", "1.2.3.4.5", "[::1]", "", 3, "fe80::1%eth0", " 1.2.3.4 "],
)
def test_validate_ports_long_form_host_ip_rejects(value: object) -> None:
# fe80::1%eth0 is an RFC 4007 zone-id literal: stdlib `ipaddress` parses it
# (Python 3.9+ scope_id support), but Docker's Go `net.ParseIP` has no
# zone-id support and refuses it ("invalid ip address") -- measured against
# `docker compose config` v5.1.2. " 1.2.3.4 " (leading/trailing
# whitespace) is refused by both oracles already.
with pytest.raises(UnsupportedComposeError, match="host_ip"):
validate_ports("app", "ports", _port(host_ip=value))
@pytest.mark.parametrize("field", ["protocol", "mode", "name", "app_protocol"])
def test_validate_ports_long_form_string_fields_accept_any_string(field: str) -> None:
# Measured: content is entirely unchecked -- 'protocol: bogus' and
# 'mode: bogus' both pass `docker compose config`; only the type matters.
validate_ports("app", "ports", _port(**{field: "bogus"}))
validate_ports("app", "ports", _port(**{field: ""}))
@pytest.mark.parametrize("field", ["protocol", "mode", "name", "app_protocol"])
def test_validate_ports_long_form_string_fields_reject_non_string(field: str) -> None:
with pytest.raises(UnsupportedComposeError, match=field):
validate_ports("app", "ports", _port(**{field: 3}))
def test_validate_ports_long_form_fields_accept_variable_reference() -> None:
validate_ports("app", "ports", _port(published="${P}", host_ip="${H}", protocol="${PR}"))
validate_ports("app", "ports", [{"target": "${T}"}])
BOOL_TRUE_SPELLINGS = ["y", "Y", "yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"]
BOOL_FALSE_SPELLINGS = ["n", "N", "no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"]
@pytest.mark.parametrize("value", [True, False, *BOOL_TRUE_SPELLINGS, *BOOL_FALSE_SPELLINGS])
def test_is_bool_like_true(value: object) -> None:
assert is_bool_like(value) is True
# "t"/"f"/"1"/"0"/"banana"/"" and non-strings are NOT the YAML-1.1 boolean set
# (measured against `docker compose config` v5.1.2). Unhashable dict/list must
# return False, not crash the `in` membership test.
@pytest.mark.parametrize("value", ["t", "f", "1", "0", "banana", "", 3, 1.5, None, {"a": 1}, ["a"]])
def test_is_bool_like_false(value: object) -> None:
assert is_bool_like(value) is False
@pytest.mark.parametrize("value", [True, *BOOL_TRUE_SPELLINGS])
def test_as_bool_true(value: object) -> None:
assert as_bool(value) is True
@pytest.mark.parametrize("value", [False, *BOOL_FALSE_SPELLINGS])
def test_as_bool_false(value: object) -> None:
assert as_bool(value) is False