docker-image-pin 0.4.1__tar.gz → 0.4.2__tar.gz
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.
- {docker_image_pin-0.4.1 → docker_image_pin-0.4.2}/PKG-INFO +1 -1
- {docker_image_pin-0.4.1 → docker_image_pin-0.4.2}/pyproject.toml +1 -1
- docker_image_pin-0.4.2/src/docker_image_pin/__init__.py +142 -0
- docker_image_pin-0.4.1/src/docker_image_pin/__init__.py +0 -145
- {docker_image_pin-0.4.1 → docker_image_pin-0.4.2}/README.md +0 -0
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.8.8,<0.9" ]
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "docker-image-pin"
|
7
|
-
version = "0.4.
|
7
|
+
version = "0.4.2"
|
8
8
|
description = "Checks if Docker images are properly pinned in docker-compose.yml and Dockerfile files"
|
9
9
|
readme = "README.md"
|
10
10
|
license = "GPL-3.0-only"
|
@@ -0,0 +1,142 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import string
|
5
|
+
from argparse import ArgumentParser
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import TYPE_CHECKING
|
8
|
+
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from collections.abc import Sequence
|
12
|
+
|
13
|
+
|
14
|
+
default_allows = {
|
15
|
+
"debian": "major-minor",
|
16
|
+
"postgres": "major-minor",
|
17
|
+
"atdr.meo.ws/archiveteam/warrior-dockerfile": "latest",
|
18
|
+
"lukaszlach/docker-tc": "latest",
|
19
|
+
}
|
20
|
+
|
21
|
+
|
22
|
+
class Args(argparse.Namespace):
|
23
|
+
files: Sequence[Path]
|
24
|
+
|
25
|
+
|
26
|
+
def parse_args() -> Args:
|
27
|
+
parser = ArgumentParser("docker-image-pin")
|
28
|
+
|
29
|
+
parser.add_argument(
|
30
|
+
"files",
|
31
|
+
nargs="+",
|
32
|
+
type=Path,
|
33
|
+
)
|
34
|
+
|
35
|
+
return parser.parse_args(namespace=Args())
|
36
|
+
|
37
|
+
|
38
|
+
def process_line(file: Path, lnr: int, line: str) -> int: # noqa: C901, PLR0911, PLR0912
|
39
|
+
def log(msg: str) -> None:
|
40
|
+
print(f"({file}:{lnr + 1}) {msg}")
|
41
|
+
|
42
|
+
def invalid(msg: str) -> int:
|
43
|
+
log(f"Invalid: {msg}")
|
44
|
+
return 1
|
45
|
+
|
46
|
+
def warn(msg: str) -> None:
|
47
|
+
log(f"Warning: {msg}")
|
48
|
+
|
49
|
+
line = line.strip()
|
50
|
+
if not (line.startswith(("image:", "FROM"))):
|
51
|
+
return 0
|
52
|
+
|
53
|
+
allow = None
|
54
|
+
if "#" in line:
|
55
|
+
line, comment = line.split("#")
|
56
|
+
line = line.strip()
|
57
|
+
comment = comment.strip()
|
58
|
+
if comment.startswith("allow-"):
|
59
|
+
allow = comment.removeprefix("allow-")
|
60
|
+
|
61
|
+
if allow == "all":
|
62
|
+
return 0
|
63
|
+
|
64
|
+
line = line.removeprefix("image:").strip()
|
65
|
+
line = line.removeprefix("FROM").strip()
|
66
|
+
try:
|
67
|
+
rest, sha = line.split("@")
|
68
|
+
except ValueError:
|
69
|
+
return invalid("no '@'")
|
70
|
+
try:
|
71
|
+
image, version = rest.split(":")
|
72
|
+
except ValueError:
|
73
|
+
return invalid("no ':' in leading part")
|
74
|
+
|
75
|
+
default_allow = default_allows.get(image)
|
76
|
+
if default_allow:
|
77
|
+
if allow:
|
78
|
+
warn(
|
79
|
+
"allow comment specified while "
|
80
|
+
"there is a default allow for this image. "
|
81
|
+
f"(specified '{allow}', default '{default_allow}')"
|
82
|
+
)
|
83
|
+
allow = default_allow
|
84
|
+
|
85
|
+
if version in {"latest", "stable"}:
|
86
|
+
if allow != version:
|
87
|
+
return invalid(
|
88
|
+
f"[{version}] uses dynamic tag '{version}' instead of pinned version"
|
89
|
+
)
|
90
|
+
else:
|
91
|
+
if "-" in version:
|
92
|
+
version, _extra = version.split("-")
|
93
|
+
version = version.removeprefix("v") # Optional prefix
|
94
|
+
parts = version.split(".")
|
95
|
+
if len(parts) > 3 and allow != "weird-version": # noqa: PLR2004
|
96
|
+
# major.minor.patch.???
|
97
|
+
return invalid(
|
98
|
+
"[weird-version] version contains more than three parts "
|
99
|
+
"(major.minor.patch.???)"
|
100
|
+
)
|
101
|
+
if len(parts) == 2 and allow != "major-minor": # noqa: PLR2004
|
102
|
+
# major.minor
|
103
|
+
return invalid(
|
104
|
+
"[major-minor] version contains only two parts (major.minor). "
|
105
|
+
"Can the version be pinned further?"
|
106
|
+
)
|
107
|
+
if len(parts) == 1 and allow != "major":
|
108
|
+
# major
|
109
|
+
return invalid(
|
110
|
+
"[major] version contains only one part (major). "
|
111
|
+
"Can the version be pinned further?"
|
112
|
+
)
|
113
|
+
if len(parts) == 0:
|
114
|
+
msg = "Unreachable"
|
115
|
+
raise AssertionError(msg)
|
116
|
+
|
117
|
+
if not sha.startswith("sha256:"):
|
118
|
+
return invalid("invalid hash (doesn't start with 'sha256:'")
|
119
|
+
sha = sha.removeprefix("sha256:")
|
120
|
+
if not is_valid_sha256(sha):
|
121
|
+
return invalid("invalid sha256 digest")
|
122
|
+
|
123
|
+
return 0
|
124
|
+
|
125
|
+
|
126
|
+
def main() -> int:
|
127
|
+
args = parse_args()
|
128
|
+
|
129
|
+
retval = 0
|
130
|
+
for file in args.files:
|
131
|
+
content = file.read_text()
|
132
|
+
|
133
|
+
for lnr, line in enumerate(content.splitlines()):
|
134
|
+
line_retval = process_line(file, lnr, line)
|
135
|
+
if line_retval == 1:
|
136
|
+
retval = 1
|
137
|
+
|
138
|
+
return retval
|
139
|
+
|
140
|
+
|
141
|
+
def is_valid_sha256(s: str) -> bool:
|
142
|
+
return len(s) == 64 and all(c in string.hexdigits for c in s) # noqa: PLR2004
|
@@ -1,145 +0,0 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import argparse
|
4
|
-
import string
|
5
|
-
from argparse import ArgumentParser
|
6
|
-
from pathlib import Path
|
7
|
-
from typing import TYPE_CHECKING
|
8
|
-
|
9
|
-
|
10
|
-
if TYPE_CHECKING:
|
11
|
-
from collections.abc import Sequence
|
12
|
-
|
13
|
-
|
14
|
-
default_allows = {
|
15
|
-
"debian": "major-minor",
|
16
|
-
"postgres": "major-minor",
|
17
|
-
"atdr.meo.ws/archiveteam/warrior-dockerfile": "latest",
|
18
|
-
"lukaszlach/docker-tc": "latest",
|
19
|
-
}
|
20
|
-
|
21
|
-
|
22
|
-
class Args(argparse.Namespace):
|
23
|
-
files: Sequence[Path]
|
24
|
-
|
25
|
-
|
26
|
-
def parse_args() -> Args:
|
27
|
-
parser = ArgumentParser("docker-image-pin")
|
28
|
-
|
29
|
-
parser.add_argument(
|
30
|
-
"files",
|
31
|
-
nargs="+",
|
32
|
-
type=Path,
|
33
|
-
)
|
34
|
-
|
35
|
-
return parser.parse_args(namespace=Args())
|
36
|
-
|
37
|
-
|
38
|
-
def main() -> int: # noqa: C901, PLR0912, PLR0915, FIX002, TD003 # TODO(GideonBear): extract line to function
|
39
|
-
args = parse_args()
|
40
|
-
|
41
|
-
retval = 0
|
42
|
-
for file in args.files:
|
43
|
-
content = file.read_text()
|
44
|
-
|
45
|
-
for lnr, line in enumerate(content.splitlines()):
|
46
|
-
|
47
|
-
def log(msg: str) -> None:
|
48
|
-
print(f"({file}:{lnr + 1}) {msg}") # noqa: B023
|
49
|
-
|
50
|
-
def invalid(msg: str) -> None:
|
51
|
-
nonlocal retval
|
52
|
-
retval = 1
|
53
|
-
log(f"Invalid: {msg}")
|
54
|
-
|
55
|
-
def warn(msg: str) -> None:
|
56
|
-
log(f"Warning: {msg}")
|
57
|
-
|
58
|
-
line = line.strip()
|
59
|
-
if not (line.startswith(("image:", "FROM"))):
|
60
|
-
continue
|
61
|
-
|
62
|
-
allow = None
|
63
|
-
if "#" in line:
|
64
|
-
line, comment = line.split("#")
|
65
|
-
line = line.strip()
|
66
|
-
comment = comment.strip()
|
67
|
-
if comment.startswith("allow-"):
|
68
|
-
allow = comment.removeprefix("allow-")
|
69
|
-
|
70
|
-
line = line.removeprefix("image:").strip()
|
71
|
-
line = line.removeprefix("FROM").strip()
|
72
|
-
try:
|
73
|
-
rest, sha = line.split("@")
|
74
|
-
except ValueError:
|
75
|
-
invalid("no '@'")
|
76
|
-
continue
|
77
|
-
try:
|
78
|
-
image, version = rest.split(":")
|
79
|
-
except ValueError:
|
80
|
-
invalid("no ':' in leading part")
|
81
|
-
continue
|
82
|
-
|
83
|
-
default_allow = default_allows.get(image)
|
84
|
-
if default_allow:
|
85
|
-
if allow:
|
86
|
-
warn(
|
87
|
-
"allow comment specified while "
|
88
|
-
"there is a default allow for this image. "
|
89
|
-
f"(specified '{allow}', default '{default_allow}')"
|
90
|
-
)
|
91
|
-
allow = default_allow
|
92
|
-
|
93
|
-
if version in {"latest", "stable"}:
|
94
|
-
if allow != version:
|
95
|
-
invalid(
|
96
|
-
f"[{version}] uses dynamic tag '{version}' "
|
97
|
-
f"instead of pinned version"
|
98
|
-
)
|
99
|
-
continue
|
100
|
-
else:
|
101
|
-
if "-" in version:
|
102
|
-
version, _extra = version.split("-")
|
103
|
-
version = version.removeprefix("v") # Optional prefix
|
104
|
-
parts = version.split(".")
|
105
|
-
if len(parts) == 3: # noqa: PLR2004
|
106
|
-
# major.minor.patch
|
107
|
-
continue
|
108
|
-
if len(parts) > 3 and allow != "weird-version": # noqa: PLR2004
|
109
|
-
# major.minor.patch.???0
|
110
|
-
invalid(
|
111
|
-
"[weird-version] version contains more than three parts "
|
112
|
-
"(major.minor.patch.???)"
|
113
|
-
)
|
114
|
-
continue
|
115
|
-
if len(parts) == 2 and allow != "major-minor": # noqa: PLR2004
|
116
|
-
# major.minor
|
117
|
-
invalid(
|
118
|
-
"[major-minor] version contains only two parts (major.minor). "
|
119
|
-
"Can the version be pinned further?"
|
120
|
-
)
|
121
|
-
continue
|
122
|
-
if len(parts) == 1 and allow != "major":
|
123
|
-
# major
|
124
|
-
invalid(
|
125
|
-
"[major] version contains only one part (major). "
|
126
|
-
"Can the version be pinned further?"
|
127
|
-
)
|
128
|
-
continue
|
129
|
-
if len(parts) == 0:
|
130
|
-
msg = "Unreachable"
|
131
|
-
raise AssertionError(msg)
|
132
|
-
|
133
|
-
if not sha.startswith("sha256:"):
|
134
|
-
invalid("invalid hash (doesn't start with 'sha256:'")
|
135
|
-
continue
|
136
|
-
sha = sha.removeprefix("sha256:")
|
137
|
-
if not is_valid_sha256(sha):
|
138
|
-
invalid("invalid sha256 digest")
|
139
|
-
continue
|
140
|
-
|
141
|
-
return retval
|
142
|
-
|
143
|
-
|
144
|
-
def is_valid_sha256(s: str) -> bool:
|
145
|
-
return len(s) == 64 and all(c in string.hexdigits for c in s) # noqa: PLR2004
|
File without changes
|