dockerfile-analyzer 0.3.25__tar.gz → 0.4.0__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.
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/Cargo.lock +1 -1
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/Cargo.toml +1 -1
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/PKG-INFO +1 -1
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/README.md +106 -1
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/dockerfile_analyzer.pyi +42 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/analyzer.rs +393 -8
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/constants.rs +1 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/lib.rs +5 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/models.rs +112 -0
- dockerfile_analyzer-0.4.0/tests/test_stages.py +192 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.cargo/config.toml +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.github/dependabot.yml +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.github/workflows/CI.yml +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/.gitignore +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/LICENSE +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/example.py +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/py.typed +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/pyproject.toml +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/requirements-dev.txt +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/src/parse_utils.rs +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/tests/fixtures/multistage.Dockerfile +0 -0
- {dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/tests/test_analyze_dockerfile.py +0 -0
|
@@ -10,9 +10,28 @@ This Python package analyzes a Dockerfile and returns detailed analysis informat
|
|
|
10
10
|
- Instructions statistics
|
|
11
11
|
- Environment variables, labels, and arguments
|
|
12
12
|
- Exposed ports
|
|
13
|
+
- A per-stage breakdown (`stages`, `final_stage_info`): each stage's base image, parent stage, `--platform`, and its own `ARG`, `ENV`, `LABEL`, `EXPOSE`, `USER`, and `WORKDIR` values
|
|
13
14
|
|
|
14
15
|
Image references that contain `$` are not expanded. Other image references are lowercased. `EXPOSE` values are returned as written, including protocol suffixes and ranges. A later `ARG NAME` without a default keeps an earlier default.
|
|
15
16
|
|
|
17
|
+
The top-level `args`, `env_vars`, `labels`, and `exposed_ports` are merged across all stages (and `args` also includes global `ARG`s declared before the first `FROM`), so a later stage's value for a key replaces an earlier one. To see which stage set what, use the per-stage breakdown described below.
|
|
18
|
+
|
|
19
|
+
## Per-stage breakdown
|
|
20
|
+
|
|
21
|
+
`analysis.stages` is a list of `Stage` objects in Dockerfile order. Each `Stage` has:
|
|
22
|
+
|
|
23
|
+
| Attribute | Description |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `index` | Zero-based position of the stage. |
|
|
26
|
+
| `name` | The lowercased `AS` name, or `None` when the stage is unnamed. |
|
|
27
|
+
| `base_image` | The `FROM` reference, normalized like `images` (lowercased unless it contains `$`). |
|
|
28
|
+
| `parent_stage` | The name of the earlier stage this `FROM` builds on, or `None`. Docker only resolves earlier stages, so a name defined later, or an unknown name, is treated as an image and has no parent. |
|
|
29
|
+
| `platform` | The `FROM --platform` value as written, or `None`. |
|
|
30
|
+
| `args`, `env_vars`, `labels`, `exposed_ports` | Values set by this stage's own instructions. Global `ARG`s before the first `FROM` are not attributed to any stage, and nothing is inherited from a parent stage. |
|
|
31
|
+
| `user`, `workdir` | The last `USER` and `WORKDIR` in this stage, or `None`. |
|
|
32
|
+
|
|
33
|
+
`Stage` has `to_dict()` and a readable `repr()`. `analysis.final_stage_info` returns the last `Stage`, or `None` when there are no stages. The existing `analysis.final_stage` is unchanged and still returns only the last stage's name (or `None` when it is unnamed). `analysis.to_dict()` includes the stages under a `stages` key.
|
|
34
|
+
|
|
16
35
|
The code leverages the highly useful Rust crates `parse-dockerfile` and `docker-image`.
|
|
17
36
|
|
|
18
37
|
## Example
|
|
@@ -88,6 +107,12 @@ print(analysis.final_stage) # None
|
|
|
88
107
|
# The second ARG VERSION has no default, so 1.0.0 is kept.
|
|
89
108
|
print(analysis.args["VERSION"]) # 1.0.0
|
|
90
109
|
|
|
110
|
+
# Per-stage values. Global ARGs before the first FROM belong to no stage.
|
|
111
|
+
print(analysis.stages[0].args) # {}
|
|
112
|
+
print(analysis.stages[2].parent_stage) # build
|
|
113
|
+
final = analysis.final_stage_info
|
|
114
|
+
print(final.index, final.user, final.exposed_ports) # 4 nonroot ['8080/tcp', '9090/udp']
|
|
115
|
+
|
|
91
116
|
print(json.dumps(analysis.to_dict(), indent=2))
|
|
92
117
|
```
|
|
93
118
|
|
|
@@ -99,6 +124,9 @@ ghcr.io
|
|
|
99
124
|
['debug']
|
|
100
125
|
None
|
|
101
126
|
1.0.0
|
|
127
|
+
{}
|
|
128
|
+
build
|
|
129
|
+
4 nonroot ['8080/tcp', '9090/udp']
|
|
102
130
|
|
|
103
131
|
```json
|
|
104
132
|
{
|
|
@@ -205,7 +233,84 @@ None
|
|
|
205
233
|
},
|
|
206
234
|
"env_vars": {
|
|
207
235
|
"APP_VERSION": "$VERSION"
|
|
208
|
-
}
|
|
236
|
+
},
|
|
237
|
+
"stages": [
|
|
238
|
+
{
|
|
239
|
+
"index": 0,
|
|
240
|
+
"name": "tools",
|
|
241
|
+
"base_image": "alpine:${ALPINE_VERSION}",
|
|
242
|
+
"parent_stage": null,
|
|
243
|
+
"platform": null,
|
|
244
|
+
"args": {},
|
|
245
|
+
"env_vars": {},
|
|
246
|
+
"labels": {},
|
|
247
|
+
"exposed_ports": [],
|
|
248
|
+
"user": null,
|
|
249
|
+
"workdir": null
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"index": 1,
|
|
253
|
+
"name": "build",
|
|
254
|
+
"base_image": "golang:${GO_VERSION}-alpine",
|
|
255
|
+
"parent_stage": null,
|
|
256
|
+
"platform": null,
|
|
257
|
+
"args": {},
|
|
258
|
+
"env_vars": {},
|
|
259
|
+
"labels": {},
|
|
260
|
+
"exposed_ports": [],
|
|
261
|
+
"user": null,
|
|
262
|
+
"workdir": "/src"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"index": 2,
|
|
266
|
+
"name": "assets",
|
|
267
|
+
"base_image": "build",
|
|
268
|
+
"parent_stage": "build",
|
|
269
|
+
"platform": null,
|
|
270
|
+
"args": {},
|
|
271
|
+
"env_vars": {},
|
|
272
|
+
"labels": {},
|
|
273
|
+
"exposed_ports": [],
|
|
274
|
+
"user": null,
|
|
275
|
+
"workdir": null
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"index": 3,
|
|
279
|
+
"name": "debug",
|
|
280
|
+
"base_image": "alpine:${ALPINE_VERSION}",
|
|
281
|
+
"parent_stage": null,
|
|
282
|
+
"platform": null,
|
|
283
|
+
"args": {},
|
|
284
|
+
"env_vars": {},
|
|
285
|
+
"labels": {},
|
|
286
|
+
"exposed_ports": [],
|
|
287
|
+
"user": null,
|
|
288
|
+
"workdir": null
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"index": 4,
|
|
292
|
+
"name": null,
|
|
293
|
+
"base_image": "gcr.io/distroless/static-debian12:nonroot@sha256:2c8e1f5d9a3b7e6c4d1f0a8b5e2c9d7f3a6b1c4e8d2f5a9b3c7e1d4f6a8b0c2e",
|
|
294
|
+
"parent_stage": null,
|
|
295
|
+
"platform": null,
|
|
296
|
+
"args": {
|
|
297
|
+
"VERSION": null
|
|
298
|
+
},
|
|
299
|
+
"env_vars": {
|
|
300
|
+
"APP_VERSION": "$VERSION"
|
|
301
|
+
},
|
|
302
|
+
"labels": {
|
|
303
|
+
"org.opencontainers.image.title": "app",
|
|
304
|
+
"org.opencontainers.image.version": "${VERSION}"
|
|
305
|
+
},
|
|
306
|
+
"exposed_ports": [
|
|
307
|
+
"8080/tcp",
|
|
308
|
+
"9090/udp"
|
|
309
|
+
],
|
|
310
|
+
"user": "nonroot",
|
|
311
|
+
"workdir": null
|
|
312
|
+
}
|
|
313
|
+
]
|
|
209
314
|
}
|
|
210
315
|
```
|
|
211
316
|
|
|
@@ -19,6 +19,11 @@ def analyze_dockerfile(body: str) -> Analysis:
|
|
|
19
19
|
when it is unnamed. A later ``ARG NAME`` without a value keeps an
|
|
20
20
|
earlier default.
|
|
21
21
|
|
|
22
|
+
``args``, ``labels``, ``env_vars``, and ``exposed_ports`` are merged
|
|
23
|
+
across all stages. ``stages`` lists each stage with the values it sets
|
|
24
|
+
itself, and ``final_stage_info`` is the last ``Stage`` (None when
|
|
25
|
+
there are no stages).
|
|
26
|
+
|
|
22
27
|
Raises:
|
|
23
28
|
ValueError: If the dockerfile content is empty or invalid
|
|
24
29
|
|
|
@@ -32,6 +37,16 @@ def analyze_dockerfile(body: str) -> Analysis:
|
|
|
32
37
|
from typing import Dict, List, Optional, Any
|
|
33
38
|
|
|
34
39
|
class Analysis:
|
|
40
|
+
"""Analysis of one Dockerfile.
|
|
41
|
+
|
|
42
|
+
``args``, ``labels``, ``env_vars``, and ``exposed_ports`` are merged across
|
|
43
|
+
all stages (``args`` also includes global ``ARG``s before the first
|
|
44
|
+
``FROM``); a later stage's value for a key replaces an earlier one. Use
|
|
45
|
+
``stages`` for the values each stage sets. ``final_stage`` is the last
|
|
46
|
+
stage's name (None when unnamed); ``final_stage_info`` is the last
|
|
47
|
+
``Stage`` object, or None when there are no stages.
|
|
48
|
+
"""
|
|
49
|
+
|
|
35
50
|
num_stages: int
|
|
36
51
|
images: List[Image]
|
|
37
52
|
stage_names: List[str]
|
|
@@ -46,6 +61,33 @@ class Analysis:
|
|
|
46
61
|
args: Dict[str, Optional[str]]
|
|
47
62
|
labels: Dict[str, str]
|
|
48
63
|
env_vars: Dict[str, str]
|
|
64
|
+
stages: List[Stage]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def final_stage_info(self) -> Optional[Stage]: ...
|
|
68
|
+
def to_dict(self) -> Dict[str, Any]: ...
|
|
69
|
+
def __repr__(self) -> str: ...
|
|
70
|
+
|
|
71
|
+
class Stage:
|
|
72
|
+
"""One build stage, from its ``FROM`` line to the next ``FROM``.
|
|
73
|
+
|
|
74
|
+
Values are the stage's own. Nothing is inherited from a parent stage, and
|
|
75
|
+
global ``ARG``s declared before the first ``FROM`` are not attributed to
|
|
76
|
+
any stage. ``parent_stage`` is set only when ``FROM`` names an earlier
|
|
77
|
+
stage; a later or unknown name is treated as an image.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
index: int
|
|
81
|
+
name: Optional[str]
|
|
82
|
+
base_image: str
|
|
83
|
+
parent_stage: Optional[str]
|
|
84
|
+
platform: Optional[str]
|
|
85
|
+
args: Dict[str, Optional[str]]
|
|
86
|
+
env_vars: Dict[str, str]
|
|
87
|
+
labels: Dict[str, str]
|
|
88
|
+
exposed_ports: List[str]
|
|
89
|
+
user: Optional[str]
|
|
90
|
+
workdir: Optional[str]
|
|
49
91
|
|
|
50
92
|
def to_dict(self) -> Dict[str, Any]: ...
|
|
51
93
|
def __repr__(self) -> str: ...
|
|
@@ -24,6 +24,8 @@ struct InstructionFacts {
|
|
|
24
24
|
args: BTreeMap<String, Option<String>>,
|
|
25
25
|
labels: BTreeMap<String, String>,
|
|
26
26
|
env_vars: BTreeMap<String, String>,
|
|
27
|
+
user: Option<String>,
|
|
28
|
+
workdir: Option<String>,
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
enum FromTarget {
|
|
@@ -35,7 +37,12 @@ pub fn analyze_dockerfile(body: &str) -> Result<models::Analysis, Box<dyn Error>
|
|
|
35
37
|
let dockerfile = parse(body)?;
|
|
36
38
|
let stages: Vec<_> = dockerfile.stages().collect();
|
|
37
39
|
let stage_info = extract_stage_info(&stages);
|
|
38
|
-
let facts = collect_instructions(&dockerfile.instructions, &stage_info.ordered_names);
|
|
40
|
+
let facts = collect_instructions(&dockerfile.instructions, &stage_info.ordered_names, true);
|
|
41
|
+
let stage_details = stages
|
|
42
|
+
.iter()
|
|
43
|
+
.enumerate()
|
|
44
|
+
.map(|(index, stage)| build_stage(index, stage, &stage_info.ordered_names))
|
|
45
|
+
.collect();
|
|
39
46
|
let multistage_analysis = analyze_multistage(
|
|
40
47
|
stages.len(),
|
|
41
48
|
&stage_info.images,
|
|
@@ -62,9 +69,43 @@ pub fn analyze_dockerfile(body: &str) -> Result<models::Analysis, Box<dyn Error>
|
|
|
62
69
|
args: facts.args,
|
|
63
70
|
labels: facts.labels,
|
|
64
71
|
env_vars: facts.env_vars,
|
|
72
|
+
stages: stage_details,
|
|
65
73
|
})
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/// Describe one stage using only the instructions inside it.
|
|
77
|
+
///
|
|
78
|
+
/// The `FROM` instruction is not part of `stage.instructions`, so global
|
|
79
|
+
/// `ARG`s before the first `FROM` never land in stage 0.
|
|
80
|
+
fn build_stage(index: usize, stage: &Stage, ordered_names: &[Option<String>]) -> models::Stage {
|
|
81
|
+
let facts = collect_instructions(stage.instructions, ordered_names, false);
|
|
82
|
+
let base_image = normalize_image_ref(&stage.from.image.value);
|
|
83
|
+
let parent_stage = earlier_stage_named(&base_image, &ordered_names[..index]);
|
|
84
|
+
models::Stage {
|
|
85
|
+
index,
|
|
86
|
+
name: ordered_names[index].clone(),
|
|
87
|
+
base_image,
|
|
88
|
+
parent_stage,
|
|
89
|
+
platform: flag_value(&stage.from.options, constants::PLATFORM),
|
|
90
|
+
args: facts.args,
|
|
91
|
+
env_vars: facts.env_vars,
|
|
92
|
+
labels: facts.labels,
|
|
93
|
+
exposed_ports: facts.exposed_ports.into_iter().collect(),
|
|
94
|
+
user: facts.user,
|
|
95
|
+
workdir: facts.workdir,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Docker resolves a `FROM` name against earlier stages only. A name defined
|
|
100
|
+
/// later, or not at all, is an image reference.
|
|
101
|
+
fn earlier_stage_named(image: &str, earlier_names: &[Option<String>]) -> Option<String> {
|
|
102
|
+
earlier_names
|
|
103
|
+
.iter()
|
|
104
|
+
.flatten()
|
|
105
|
+
.find(|name| name.as_str() == image)
|
|
106
|
+
.cloned()
|
|
107
|
+
}
|
|
108
|
+
|
|
68
109
|
fn analyze_multistage(
|
|
69
110
|
num_stages: usize,
|
|
70
111
|
images: &BTreeSet<String>,
|
|
@@ -125,6 +166,7 @@ fn extract_stage_info(stages: &[Stage]) -> StageInfo {
|
|
|
125
166
|
fn collect_instructions(
|
|
126
167
|
instructions: &[Instruction],
|
|
127
168
|
ordered_stage_names: &[Option<String>],
|
|
169
|
+
preserve_arg_defaults: bool,
|
|
128
170
|
) -> InstructionFacts {
|
|
129
171
|
let mut facts = InstructionFacts {
|
|
130
172
|
copy_from_stages: BTreeSet::new(),
|
|
@@ -137,6 +179,8 @@ fn collect_instructions(
|
|
|
137
179
|
args: BTreeMap::new(),
|
|
138
180
|
labels: BTreeMap::new(),
|
|
139
181
|
env_vars: BTreeMap::new(),
|
|
182
|
+
user: None,
|
|
183
|
+
workdir: None,
|
|
140
184
|
};
|
|
141
185
|
|
|
142
186
|
for instruction in instructions {
|
|
@@ -146,10 +190,14 @@ fn collect_instructions(
|
|
|
146
190
|
|
|
147
191
|
match instruction {
|
|
148
192
|
Instruction::Arg(arg) => {
|
|
149
|
-
parse_utils::
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
193
|
+
let pairs = parse_utils::parse_kv_pairs(arg.arguments.value.as_ref(), false);
|
|
194
|
+
if preserve_arg_defaults {
|
|
195
|
+
// A later stage's bare `ARG NAME` redeclares a global default.
|
|
196
|
+
parse_utils::merge_kv_pairs(&mut facts.args, pairs);
|
|
197
|
+
} else {
|
|
198
|
+
// Inside one stage, a later bare `ARG NAME` clears the default.
|
|
199
|
+
facts.args.extend(pairs);
|
|
200
|
+
}
|
|
153
201
|
}
|
|
154
202
|
Instruction::Env(env) => {
|
|
155
203
|
insert_required_values(
|
|
@@ -168,6 +216,12 @@ fn collect_instructions(
|
|
|
168
216
|
.exposed_ports
|
|
169
217
|
.extend(expose.arguments.iter().map(|port| port.value.to_string()));
|
|
170
218
|
}
|
|
219
|
+
Instruction::User(user) => {
|
|
220
|
+
facts.user = Some(user.arguments.value.to_string());
|
|
221
|
+
}
|
|
222
|
+
Instruction::Workdir(workdir) => {
|
|
223
|
+
facts.workdir = Some(workdir.arguments.value.to_string());
|
|
224
|
+
}
|
|
171
225
|
Instruction::Copy(copy) => {
|
|
172
226
|
record_from_flag(
|
|
173
227
|
©.options,
|
|
@@ -206,7 +260,7 @@ fn record_from_flag(
|
|
|
206
260
|
stages: &mut BTreeSet<String>,
|
|
207
261
|
images: &mut BTreeMap<String, models::Image>,
|
|
208
262
|
) {
|
|
209
|
-
let Some(raw) =
|
|
263
|
+
let Some(raw) = flag_value(options, constants::FROM) else {
|
|
210
264
|
return;
|
|
211
265
|
};
|
|
212
266
|
match resolve_from_target(&raw, ordered_stage_names) {
|
|
@@ -221,11 +275,11 @@ fn record_from_flag(
|
|
|
221
275
|
}
|
|
222
276
|
}
|
|
223
277
|
|
|
224
|
-
fn
|
|
278
|
+
fn flag_value(options: &[Flag], name: &str) -> Option<String> {
|
|
225
279
|
options.iter().find_map(|flag| {
|
|
226
280
|
flag.name
|
|
227
281
|
.value
|
|
228
|
-
.eq_ignore_ascii_case(
|
|
282
|
+
.eq_ignore_ascii_case(name)
|
|
229
283
|
.then(|| flag.value.as_ref().map(|value| value.value.to_string()))
|
|
230
284
|
.flatten()
|
|
231
285
|
})
|
|
@@ -446,6 +500,31 @@ mod tests {
|
|
|
446
500
|
}
|
|
447
501
|
}
|
|
448
502
|
|
|
503
|
+
fn stage(
|
|
504
|
+
index: usize,
|
|
505
|
+
name: Option<&str>,
|
|
506
|
+
base_image: &str,
|
|
507
|
+
parent_stage: Option<&str>,
|
|
508
|
+
) -> models::Stage {
|
|
509
|
+
models::Stage {
|
|
510
|
+
index,
|
|
511
|
+
name: name.map(str::to_string),
|
|
512
|
+
base_image: base_image.to_string(),
|
|
513
|
+
parent_stage: parent_stage.map(str::to_string),
|
|
514
|
+
platform: None,
|
|
515
|
+
args: BTreeMap::new(),
|
|
516
|
+
env_vars: BTreeMap::new(),
|
|
517
|
+
labels: BTreeMap::new(),
|
|
518
|
+
exposed_ports: vec![],
|
|
519
|
+
user: None,
|
|
520
|
+
workdir: None,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
fn strings(values: &[&str]) -> Vec<String> {
|
|
525
|
+
values.iter().map(|value| value.to_string()).collect()
|
|
526
|
+
}
|
|
527
|
+
|
|
449
528
|
#[test]
|
|
450
529
|
fn test_multistage() {
|
|
451
530
|
let dockerfile = r#"
|
|
@@ -557,6 +636,46 @@ CMD ["uvicorn", "--host", "0.0.0.0", "--port", "5000", "app.main:app"]"#;
|
|
|
557
636
|
args,
|
|
558
637
|
labels,
|
|
559
638
|
env_vars,
|
|
639
|
+
stages: vec![
|
|
640
|
+
models::Stage {
|
|
641
|
+
labels: BTreeMap::from([
|
|
642
|
+
("org.opencontainers.image.title".into(), "My App".into()),
|
|
643
|
+
("org.opencontainers.image.version".into(), "1.0".into()),
|
|
644
|
+
(
|
|
645
|
+
"org.opencontainers.image.authors".into(),
|
|
646
|
+
"john@example.com".into(),
|
|
647
|
+
),
|
|
648
|
+
]),
|
|
649
|
+
env_vars: BTreeMap::from([
|
|
650
|
+
("PYTHONPATH".into(), "/src".into()),
|
|
651
|
+
("PYTHONUNBUFFERED".into(), "1".into()),
|
|
652
|
+
(
|
|
653
|
+
"REQUESTS_CA_BUNDLE".into(),
|
|
654
|
+
"/etc/ssl/certs/ca-certificates.crt".into(),
|
|
655
|
+
),
|
|
656
|
+
("PATH".into(), "/home/appuser/.local/bin:$PATH".into()),
|
|
657
|
+
]),
|
|
658
|
+
workdir: Some("/src".into()),
|
|
659
|
+
user: Some("root:root".into()),
|
|
660
|
+
..stage(
|
|
661
|
+
0,
|
|
662
|
+
Some("base"),
|
|
663
|
+
"docker.abc.com/base-images/python:3.13-debian@sha256:55f1d15ef4c37870e23c03e89ad238940b55c8ede9f13fac4b7d71c7955f1053",
|
|
664
|
+
None,
|
|
665
|
+
)
|
|
666
|
+
},
|
|
667
|
+
models::Stage {
|
|
668
|
+
user: Some("1000:1000".into()),
|
|
669
|
+
..stage(1, Some("test"), "base", Some("base"))
|
|
670
|
+
},
|
|
671
|
+
models::Stage {
|
|
672
|
+
user: Some("1000:1000".into()),
|
|
673
|
+
args: BTreeMap::from([("GIT_COMMIT".into(), None)]),
|
|
674
|
+
env_vars: BTreeMap::from([("GIT_COMMIT".into(), "$GIT_COMMIT".into())]),
|
|
675
|
+
exposed_ports: strings(&["5000"]),
|
|
676
|
+
..stage(2, None, "base", Some("base"))
|
|
677
|
+
},
|
|
678
|
+
],
|
|
560
679
|
};
|
|
561
680
|
|
|
562
681
|
let res = analyze_dockerfile(dockerfile);
|
|
@@ -645,6 +764,13 @@ CMD ["npm", "start"]
|
|
|
645
764
|
args: BTreeMap::new(),
|
|
646
765
|
labels: BTreeMap::new(),
|
|
647
766
|
env_vars: BTreeMap::from([("NODE_ENV".into(), "production".into())]),
|
|
767
|
+
stages: vec![models::Stage {
|
|
768
|
+
workdir: Some("/app".into()),
|
|
769
|
+
user: Some("nextjs".into()),
|
|
770
|
+
exposed_ports: strings(&["3000"]),
|
|
771
|
+
env_vars: BTreeMap::from([("NODE_ENV".into(), "production".into())]),
|
|
772
|
+
..stage(0, None, "node:20-alpine", None)
|
|
773
|
+
}],
|
|
648
774
|
};
|
|
649
775
|
let res = analyze_dockerfile(dockerfile);
|
|
650
776
|
assert!(res.is_ok());
|
|
@@ -765,6 +891,26 @@ CMD ["node", "server.js"]
|
|
|
765
891
|
args: BTreeMap::new(),
|
|
766
892
|
labels: BTreeMap::new(),
|
|
767
893
|
env_vars: BTreeMap::new(),
|
|
894
|
+
stages: vec![
|
|
895
|
+
models::Stage {
|
|
896
|
+
workdir: Some("/app".into()),
|
|
897
|
+
..stage(0, Some("dependencies"), "node:20-alpine", None)
|
|
898
|
+
},
|
|
899
|
+
models::Stage {
|
|
900
|
+
workdir: Some("/app".into()),
|
|
901
|
+
..stage(1, Some("builder"), "node:20-alpine", None)
|
|
902
|
+
},
|
|
903
|
+
models::Stage {
|
|
904
|
+
workdir: Some("/configs".into()),
|
|
905
|
+
..stage(2, Some("config-builder"), "alpine:3.18", None)
|
|
906
|
+
},
|
|
907
|
+
models::Stage {
|
|
908
|
+
workdir: Some("/app".into()),
|
|
909
|
+
user: Some("nextjs".into()),
|
|
910
|
+
exposed_ports: strings(&["8080"]),
|
|
911
|
+
..stage(3, Some("production"), "node:20-alpine", None)
|
|
912
|
+
},
|
|
913
|
+
],
|
|
768
914
|
};
|
|
769
915
|
let res = analyze_dockerfile(dockerfile);
|
|
770
916
|
assert!(res.is_ok());
|
|
@@ -868,6 +1014,26 @@ CMD ["./app"]
|
|
|
868
1014
|
args: BTreeMap::new(),
|
|
869
1015
|
labels: BTreeMap::new(),
|
|
870
1016
|
env_vars: BTreeMap::new(),
|
|
1017
|
+
stages: vec![
|
|
1018
|
+
models::Stage {
|
|
1019
|
+
workdir: Some("/downloads".into()),
|
|
1020
|
+
..stage(0, Some("downloader"), "alpine:3.18", None)
|
|
1021
|
+
},
|
|
1022
|
+
models::Stage {
|
|
1023
|
+
workdir: Some("/src".into()),
|
|
1024
|
+
..stage(1, Some("go-builder"), "golang:1.21-alpine", None)
|
|
1025
|
+
},
|
|
1026
|
+
models::Stage {
|
|
1027
|
+
workdir: Some("/certs".into()),
|
|
1028
|
+
..stage(2, Some("cert-generator"), "alpine:3.18", None)
|
|
1029
|
+
},
|
|
1030
|
+
models::Stage {
|
|
1031
|
+
workdir: Some("/app".into()),
|
|
1032
|
+
user: Some("appuser".into()),
|
|
1033
|
+
exposed_ports: strings(&["8080", "8443"]),
|
|
1034
|
+
..stage(3, None, "alpine:3.18", None)
|
|
1035
|
+
},
|
|
1036
|
+
],
|
|
871
1037
|
};
|
|
872
1038
|
let res = analyze_dockerfile(dockerfile);
|
|
873
1039
|
assert!(res.is_ok());
|
|
@@ -1115,4 +1281,223 @@ FROM ${REG}/library/ubuntu:${TAG:-22.04} AS build
|
|
|
1115
1281
|
assert!(!rendered.contains("components=\"ImageComponents"));
|
|
1116
1282
|
assert!(!rendered.contains("Some("));
|
|
1117
1283
|
}
|
|
1284
|
+
|
|
1285
|
+
#[test]
|
|
1286
|
+
fn test_stage_parent_resolves_only_earlier_stages() {
|
|
1287
|
+
let dockerfile = r#"
|
|
1288
|
+
FROM alpine:3.20 AS Base
|
|
1289
|
+
FROM base AS child
|
|
1290
|
+
FROM later AS early
|
|
1291
|
+
FROM node:20 AS later
|
|
1292
|
+
FROM unknown-image
|
|
1293
|
+
FROM child
|
|
1294
|
+
"#;
|
|
1295
|
+
let analysis = analyze_dockerfile(dockerfile).unwrap();
|
|
1296
|
+
assert_eq!(
|
|
1297
|
+
analysis.stages,
|
|
1298
|
+
vec![
|
|
1299
|
+
stage(0, Some("base"), "alpine:3.20", None),
|
|
1300
|
+
stage(1, Some("child"), "base", Some("base")),
|
|
1301
|
+
// `later` is defined after this stage, so it is an image.
|
|
1302
|
+
stage(2, Some("early"), "later", None),
|
|
1303
|
+
stage(3, Some("later"), "node:20", None),
|
|
1304
|
+
stage(4, None, "unknown-image", None),
|
|
1305
|
+
stage(5, None, "child", Some("child")),
|
|
1306
|
+
]
|
|
1307
|
+
);
|
|
1308
|
+
assert_eq!(analysis.num_stages, analysis.stages.len());
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
#[test]
|
|
1312
|
+
fn test_stage_platform() {
|
|
1313
|
+
let dockerfile = r#"
|
|
1314
|
+
FROM --platform=$BUILDPLATFORM golang:1.22 AS build
|
|
1315
|
+
FROM --platform=linux/amd64 alpine:3.20 AS runtime
|
|
1316
|
+
FROM scratch
|
|
1317
|
+
"#;
|
|
1318
|
+
let analysis = analyze_dockerfile(dockerfile).unwrap();
|
|
1319
|
+
let platforms: Vec<_> = analysis
|
|
1320
|
+
.stages
|
|
1321
|
+
.iter()
|
|
1322
|
+
.map(|stage| stage.platform.clone())
|
|
1323
|
+
.collect();
|
|
1324
|
+
assert_eq!(
|
|
1325
|
+
platforms,
|
|
1326
|
+
vec![
|
|
1327
|
+
Some("$BUILDPLATFORM".to_string()),
|
|
1328
|
+
Some("linux/amd64".to_string()),
|
|
1329
|
+
None
|
|
1330
|
+
]
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
#[test]
|
|
1335
|
+
fn test_stage_scoped_values_and_merged_top_level() {
|
|
1336
|
+
let dockerfile = r#"
|
|
1337
|
+
FROM golang:1.22 AS build
|
|
1338
|
+
ARG TARGETOS=linux
|
|
1339
|
+
ENV CGO_ENABLED=0 MODE=build
|
|
1340
|
+
LABEL stage="build"
|
|
1341
|
+
EXPOSE 6060
|
|
1342
|
+
WORKDIR /src
|
|
1343
|
+
USER builder
|
|
1344
|
+
WORKDIR /src/cmd
|
|
1345
|
+
|
|
1346
|
+
FROM alpine:3.20
|
|
1347
|
+
ARG TARGETOS
|
|
1348
|
+
ENV MODE=runtime
|
|
1349
|
+
LABEL stage="runtime" org.opencontainers.image.title="app"
|
|
1350
|
+
EXPOSE 8080/tcp 9090/udp
|
|
1351
|
+
USER root
|
|
1352
|
+
USER 65532:65532
|
|
1353
|
+
"#;
|
|
1354
|
+
let analysis = analyze_dockerfile(dockerfile).unwrap();
|
|
1355
|
+
let build = &analysis.stages[0];
|
|
1356
|
+
let runtime = &analysis.stages[1];
|
|
1357
|
+
|
|
1358
|
+
assert_eq!(
|
|
1359
|
+
build.args,
|
|
1360
|
+
BTreeMap::from([("TARGETOS".into(), Some("linux".into()))])
|
|
1361
|
+
);
|
|
1362
|
+
assert_eq!(
|
|
1363
|
+
build.env_vars,
|
|
1364
|
+
BTreeMap::from([
|
|
1365
|
+
("CGO_ENABLED".into(), "0".into()),
|
|
1366
|
+
("MODE".into(), "build".into()),
|
|
1367
|
+
])
|
|
1368
|
+
);
|
|
1369
|
+
assert_eq!(
|
|
1370
|
+
build.labels,
|
|
1371
|
+
BTreeMap::from([("stage".into(), "build".into())])
|
|
1372
|
+
);
|
|
1373
|
+
assert_eq!(build.exposed_ports, strings(&["6060"]));
|
|
1374
|
+
assert_eq!(build.user.as_deref(), Some("builder"));
|
|
1375
|
+
assert_eq!(build.workdir.as_deref(), Some("/src/cmd"));
|
|
1376
|
+
|
|
1377
|
+
assert_eq!(runtime.args, BTreeMap::from([("TARGETOS".into(), None)]));
|
|
1378
|
+
assert_eq!(
|
|
1379
|
+
runtime.env_vars,
|
|
1380
|
+
BTreeMap::from([("MODE".into(), "runtime".into())])
|
|
1381
|
+
);
|
|
1382
|
+
assert_eq!(
|
|
1383
|
+
runtime.labels,
|
|
1384
|
+
BTreeMap::from([
|
|
1385
|
+
("org.opencontainers.image.title".into(), "app".into()),
|
|
1386
|
+
("stage".into(), "runtime".into()),
|
|
1387
|
+
])
|
|
1388
|
+
);
|
|
1389
|
+
assert_eq!(runtime.exposed_ports, strings(&["8080/tcp", "9090/udp"]));
|
|
1390
|
+
assert_eq!(runtime.user.as_deref(), Some("65532:65532"));
|
|
1391
|
+
assert_eq!(runtime.workdir, None);
|
|
1392
|
+
|
|
1393
|
+
// Top-level values stay merged across stages.
|
|
1394
|
+
assert_eq!(
|
|
1395
|
+
analysis.args,
|
|
1396
|
+
BTreeMap::from([("TARGETOS".into(), Some("linux".into()))])
|
|
1397
|
+
);
|
|
1398
|
+
assert_eq!(
|
|
1399
|
+
analysis.env_vars,
|
|
1400
|
+
BTreeMap::from([
|
|
1401
|
+
("CGO_ENABLED".into(), "0".into()),
|
|
1402
|
+
("MODE".into(), "runtime".into()),
|
|
1403
|
+
])
|
|
1404
|
+
);
|
|
1405
|
+
assert_eq!(
|
|
1406
|
+
analysis.labels,
|
|
1407
|
+
BTreeMap::from([
|
|
1408
|
+
("org.opencontainers.image.title".into(), "app".into()),
|
|
1409
|
+
("stage".into(), "runtime".into()),
|
|
1410
|
+
])
|
|
1411
|
+
);
|
|
1412
|
+
assert_eq!(
|
|
1413
|
+
analysis.exposed_ports,
|
|
1414
|
+
strings(&["6060", "8080/tcp", "9090/udp"])
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
#[test]
|
|
1419
|
+
fn test_global_args_are_not_attributed_to_first_stage() {
|
|
1420
|
+
let dockerfile = r#"
|
|
1421
|
+
ARG VERSION=1
|
|
1422
|
+
ARG REGISTRY=docker.io
|
|
1423
|
+
FROM ${REGISTRY}/alpine:${VERSION} AS base
|
|
1424
|
+
RUN true
|
|
1425
|
+
|
|
1426
|
+
FROM base
|
|
1427
|
+
ARG VERSION
|
|
1428
|
+
"#;
|
|
1429
|
+
let analysis = analyze_dockerfile(dockerfile).unwrap();
|
|
1430
|
+
assert!(analysis.stages[0].args.is_empty());
|
|
1431
|
+
assert_eq!(
|
|
1432
|
+
analysis.stages[0].base_image,
|
|
1433
|
+
"${REGISTRY}/alpine:${VERSION}"
|
|
1434
|
+
);
|
|
1435
|
+
assert_eq!(
|
|
1436
|
+
analysis.stages[1].args,
|
|
1437
|
+
BTreeMap::from([("VERSION".into(), None)])
|
|
1438
|
+
);
|
|
1439
|
+
assert_eq!(
|
|
1440
|
+
analysis.args,
|
|
1441
|
+
BTreeMap::from([
|
|
1442
|
+
("REGISTRY".into(), Some("docker.io".into())),
|
|
1443
|
+
("VERSION".into(), Some("1".into())),
|
|
1444
|
+
])
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
#[test]
|
|
1449
|
+
fn test_final_stage_info_is_last_stage() {
|
|
1450
|
+
let dockerfile = r#"
|
|
1451
|
+
FROM golang:1.22 AS build
|
|
1452
|
+
FROM build AS release
|
|
1453
|
+
USER app
|
|
1454
|
+
"#;
|
|
1455
|
+
let analysis = analyze_dockerfile(dockerfile).unwrap();
|
|
1456
|
+
assert_eq!(analysis.final_stage, Some("release".to_string()));
|
|
1457
|
+
assert_eq!(
|
|
1458
|
+
analysis.final_stage_info(),
|
|
1459
|
+
Some(models::Stage {
|
|
1460
|
+
user: Some("app".into()),
|
|
1461
|
+
..stage(1, Some("release"), "build", Some("build"))
|
|
1462
|
+
})
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
let unnamed = analyze_dockerfile("FROM alpine AS a\nFROM scratch\n").unwrap();
|
|
1466
|
+
assert_eq!(unnamed.final_stage, None);
|
|
1467
|
+
assert_eq!(
|
|
1468
|
+
unnamed.final_stage_info(),
|
|
1469
|
+
Some(stage(1, None, "scratch", None))
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
#[test]
|
|
1474
|
+
fn test_final_stage_info_without_stages_is_none() {
|
|
1475
|
+
let mut analysis = analyze_dockerfile("FROM alpine\n").unwrap();
|
|
1476
|
+
analysis.stages.clear();
|
|
1477
|
+
assert_eq!(analysis.final_stage_info(), None);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
#[test]
|
|
1481
|
+
fn test_bare_arg_in_same_stage_clears_default() {
|
|
1482
|
+
let dockerfile = "FROM alpine\nARG VERSION=1\nARG VERSION\n";
|
|
1483
|
+
let analysis = analyze_dockerfile(dockerfile).unwrap();
|
|
1484
|
+
assert_eq!(
|
|
1485
|
+
analysis.stages[0].args,
|
|
1486
|
+
BTreeMap::from([("VERSION".into(), None)])
|
|
1487
|
+
);
|
|
1488
|
+
assert_eq!(
|
|
1489
|
+
analysis.args,
|
|
1490
|
+
BTreeMap::from([("VERSION".into(), Some("1".into()))])
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
#[test]
|
|
1495
|
+
fn test_stage_repr_uses_python_none() {
|
|
1496
|
+
let analysis = analyze_dockerfile("FROM alpine\nARG VERSION\n").unwrap();
|
|
1497
|
+
assert_eq!(
|
|
1498
|
+
analysis.stages[0].repr(),
|
|
1499
|
+
"Stage(index=0, name=None, base_image=\"alpine\", parent_stage=None, platform=None, args={\"VERSION\": None}, env_vars={}, labels={}, exposed_ports=[], user=None, workdir=None)"
|
|
1500
|
+
);
|
|
1501
|
+
assert!(!analysis.repr().contains("Stage("));
|
|
1502
|
+
}
|
|
1118
1503
|
}
|
|
@@ -11,6 +11,7 @@ pub const HEALTHCHECK: &str = "HEALTHCHECK";
|
|
|
11
11
|
pub const LABEL: &str = "LABEL";
|
|
12
12
|
pub const MAINTAINER: &str = "MAINTAINER";
|
|
13
13
|
pub const ONBUILD: &str = "ONBUILD";
|
|
14
|
+
pub const PLATFORM: &str = "platform";
|
|
14
15
|
pub const RUN: &str = "RUN";
|
|
15
16
|
pub const SHELL: &str = "SHELL";
|
|
16
17
|
pub const STOPSIGNAL: &str = "STOPSIGNAL";
|
|
@@ -22,6 +22,10 @@ Returns:
|
|
|
22
22
|
only; `final_stage` is the last stage name, or None when it is unnamed.
|
|
23
23
|
A later `ARG NAME` without a value keeps an earlier default.
|
|
24
24
|
|
|
25
|
+
`args`, `labels`, `env_vars`, and `exposed_ports` are merged across all
|
|
26
|
+
stages. `stages` lists each stage with the values it sets itself, and
|
|
27
|
+
`final_stage_info` is the last `Stage` (None when there are no stages).
|
|
28
|
+
|
|
25
29
|
Raises:
|
|
26
30
|
ValueError: If the dockerfile content is empty or invalid
|
|
27
31
|
|
|
@@ -47,5 +51,6 @@ fn dockerfile_analyzer(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
47
51
|
m.add_class::<models::Image>()?;
|
|
48
52
|
m.add_class::<models::ImageComponents>()?;
|
|
49
53
|
m.add_class::<models::InstructionStats>()?;
|
|
54
|
+
m.add_class::<models::Stage>()?;
|
|
50
55
|
Ok(())
|
|
51
56
|
}
|
|
@@ -181,6 +181,98 @@ impl MultistageAnalysis {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
#[pyclass(skip_from_py_object)]
|
|
185
|
+
#[doc = "One build stage, from its `FROM` line to the next `FROM`.
|
|
186
|
+
|
|
187
|
+
Values are the stage's own, as written in that stage. Nothing is inherited
|
|
188
|
+
from a parent stage, and global `ARG`s declared before the first `FROM` are
|
|
189
|
+
not attributed to any stage.
|
|
190
|
+
|
|
191
|
+
Attributes:
|
|
192
|
+
index (int): Zero-based position of the stage in the Dockerfile.
|
|
193
|
+
name (str | None): The lowercased `AS` name, or None when unnamed.
|
|
194
|
+
base_image (str): The `FROM` reference. It is lowercased unless it
|
|
195
|
+
contains `$`, matching `Analysis.images`.
|
|
196
|
+
parent_stage (str | None): The name of the earlier stage this `FROM`
|
|
197
|
+
builds on, or None. Only earlier stages are matched, as in Docker; a
|
|
198
|
+
name defined later, or any other name, is treated as an image.
|
|
199
|
+
platform (str | None): The `--platform` value as written, or None.
|
|
200
|
+
args (dict[str, str | None]): `ARG`s declared in this stage. A
|
|
201
|
+
redeclaration without a default is None here.
|
|
202
|
+
env_vars (dict[str, str]): `ENV` values set in this stage.
|
|
203
|
+
labels (dict[str, str]): `LABEL` values set in this stage.
|
|
204
|
+
exposed_ports (list[str]): `EXPOSE` tokens in this stage, sorted.
|
|
205
|
+
user (str | None): The last `USER` in this stage, or None.
|
|
206
|
+
workdir (str | None): The last `WORKDIR` in this stage, or None.
|
|
207
|
+
"]
|
|
208
|
+
#[derive(Debug, PartialEq, Clone)]
|
|
209
|
+
pub struct Stage {
|
|
210
|
+
#[pyo3(get)]
|
|
211
|
+
pub index: usize,
|
|
212
|
+
#[pyo3(get)]
|
|
213
|
+
pub name: Option<String>,
|
|
214
|
+
#[pyo3(get)]
|
|
215
|
+
pub base_image: String,
|
|
216
|
+
#[pyo3(get)]
|
|
217
|
+
pub parent_stage: Option<String>,
|
|
218
|
+
#[pyo3(get)]
|
|
219
|
+
pub platform: Option<String>,
|
|
220
|
+
#[pyo3(get)]
|
|
221
|
+
pub args: BTreeMap<String, Option<String>>,
|
|
222
|
+
#[pyo3(get)]
|
|
223
|
+
pub env_vars: BTreeMap<String, String>,
|
|
224
|
+
#[pyo3(get)]
|
|
225
|
+
pub labels: BTreeMap<String, String>,
|
|
226
|
+
#[pyo3(get)]
|
|
227
|
+
pub exposed_ports: Vec<String>,
|
|
228
|
+
#[pyo3(get)]
|
|
229
|
+
pub user: Option<String>,
|
|
230
|
+
#[pyo3(get)]
|
|
231
|
+
pub workdir: Option<String>,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
impl Stage {
|
|
235
|
+
pub fn repr(&self) -> String {
|
|
236
|
+
self.__repr__()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[pymethods]
|
|
241
|
+
impl Stage {
|
|
242
|
+
fn __repr__(&self) -> String {
|
|
243
|
+
format!(
|
|
244
|
+
"Stage(index={}, name={}, base_image={:?}, parent_stage={}, platform={}, args={}, env_vars={:?}, labels={:?}, exposed_ports={:?}, user={}, workdir={})",
|
|
245
|
+
self.index,
|
|
246
|
+
fmt_opt(&self.name),
|
|
247
|
+
self.base_image,
|
|
248
|
+
fmt_opt(&self.parent_stage),
|
|
249
|
+
fmt_opt(&self.platform),
|
|
250
|
+
fmt_opt_map(&self.args),
|
|
251
|
+
self.env_vars,
|
|
252
|
+
self.labels,
|
|
253
|
+
self.exposed_ports,
|
|
254
|
+
fmt_opt(&self.user),
|
|
255
|
+
fmt_opt(&self.workdir)
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn to_dict(&self, py: Python) -> PyResult<Py<PyAny>> {
|
|
260
|
+
let dict = PyDict::new(py);
|
|
261
|
+
dict.set_item("index", self.index)?;
|
|
262
|
+
dict.set_item("name", &self.name)?;
|
|
263
|
+
dict.set_item("base_image", &self.base_image)?;
|
|
264
|
+
dict.set_item("parent_stage", &self.parent_stage)?;
|
|
265
|
+
dict.set_item("platform", &self.platform)?;
|
|
266
|
+
dict.set_item("args", &self.args)?;
|
|
267
|
+
dict.set_item("env_vars", &self.env_vars)?;
|
|
268
|
+
dict.set_item("labels", &self.labels)?;
|
|
269
|
+
dict.set_item("exposed_ports", &self.exposed_ports)?;
|
|
270
|
+
dict.set_item("user", &self.user)?;
|
|
271
|
+
dict.set_item("workdir", &self.workdir)?;
|
|
272
|
+
Ok(dict.into())
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
184
276
|
#[pyclass(skip_from_py_object)]
|
|
185
277
|
#[doc = "Analysis of one Dockerfile.
|
|
186
278
|
|
|
@@ -199,6 +291,13 @@ in-range index of an unnamed stage, the index is reported in place of a name.
|
|
|
199
291
|
images are `copy_from_images` and `add_from_images`. A `--from` value that
|
|
200
292
|
names a stage, or an in-range stage index, is a stage reference rather than
|
|
201
293
|
an image.
|
|
294
|
+
|
|
295
|
+
`args`, `labels`, `env_vars`, and `exposed_ports` are merged across all
|
|
296
|
+
stages (and, for `args`, global `ARG`s before the first `FROM`); a later
|
|
297
|
+
stage's value for a key replaces an earlier one. Use `stages` for the values
|
|
298
|
+
each stage sets. `stages` lists every stage in Dockerfile order.
|
|
299
|
+
`final_stage_info` is the last `Stage`, or None when there are no stages;
|
|
300
|
+
`final_stage` keeps returning only the last stage's name.
|
|
202
301
|
"]
|
|
203
302
|
#[derive(Debug, PartialEq, Clone)]
|
|
204
303
|
pub struct Analysis {
|
|
@@ -230,6 +329,8 @@ pub struct Analysis {
|
|
|
230
329
|
pub labels: BTreeMap<String, String>,
|
|
231
330
|
#[pyo3(get)]
|
|
232
331
|
pub env_vars: BTreeMap<String, String>,
|
|
332
|
+
#[pyo3(get)]
|
|
333
|
+
pub stages: Vec<Stage>,
|
|
233
334
|
}
|
|
234
335
|
|
|
235
336
|
impl Analysis {
|
|
@@ -240,6 +341,11 @@ impl Analysis {
|
|
|
240
341
|
|
|
241
342
|
#[pymethods]
|
|
242
343
|
impl Analysis {
|
|
344
|
+
#[getter]
|
|
345
|
+
pub fn final_stage_info(&self) -> Option<Stage> {
|
|
346
|
+
self.stages.last().cloned()
|
|
347
|
+
}
|
|
348
|
+
|
|
243
349
|
fn __repr__(&self) -> String {
|
|
244
350
|
let images = join_images(&self.images);
|
|
245
351
|
let copy_from_images = join_images(&self.copy_from_images);
|
|
@@ -282,6 +388,12 @@ impl Analysis {
|
|
|
282
388
|
dict.set_item("args", &self.args)?;
|
|
283
389
|
dict.set_item("labels", &self.labels)?;
|
|
284
390
|
dict.set_item("env_vars", &self.env_vars)?;
|
|
391
|
+
let stages = self
|
|
392
|
+
.stages
|
|
393
|
+
.iter()
|
|
394
|
+
.map(|stage| stage.to_dict(py))
|
|
395
|
+
.collect::<PyResult<Vec<_>>>()?;
|
|
396
|
+
dict.set_item("stages", stages)?;
|
|
285
397
|
Ok(dict.into())
|
|
286
398
|
}
|
|
287
399
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Tests for the per-stage breakdown (Analysis.stages and final_stage_info)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import dockerfile_analyzer as da
|
|
6
|
+
|
|
7
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
8
|
+
|
|
9
|
+
STAGE_KEYS = {
|
|
10
|
+
"index",
|
|
11
|
+
"name",
|
|
12
|
+
"base_image",
|
|
13
|
+
"parent_stage",
|
|
14
|
+
"platform",
|
|
15
|
+
"args",
|
|
16
|
+
"env_vars",
|
|
17
|
+
"labels",
|
|
18
|
+
"exposed_ports",
|
|
19
|
+
"user",
|
|
20
|
+
"workdir",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_multistage_fixture_stages():
|
|
25
|
+
analysis = da.analyze_dockerfile((FIXTURES / "multistage.Dockerfile").read_text())
|
|
26
|
+
base, test, final = analysis.stages
|
|
27
|
+
|
|
28
|
+
assert [stage.index for stage in analysis.stages] == [0, 1, 2]
|
|
29
|
+
assert [stage.name for stage in analysis.stages] == ["base", "test", None]
|
|
30
|
+
assert base.base_image.startswith("docker.abc.com/base-images/python:3.13-debian@")
|
|
31
|
+
assert base.parent_stage is None
|
|
32
|
+
assert test.base_image == "base"
|
|
33
|
+
assert test.parent_stage == "base"
|
|
34
|
+
assert final.parent_stage == "base"
|
|
35
|
+
|
|
36
|
+
assert base.labels["org.opencontainers.image.title"] == "My App"
|
|
37
|
+
assert base.env_vars["PYTHONPATH"] == "/src"
|
|
38
|
+
assert "GIT_COMMIT" not in base.env_vars
|
|
39
|
+
assert base.workdir == "/src"
|
|
40
|
+
assert base.user == "root:root"
|
|
41
|
+
|
|
42
|
+
assert test.env_vars == {}
|
|
43
|
+
assert test.labels == {}
|
|
44
|
+
assert test.user == "1000:1000"
|
|
45
|
+
assert test.workdir is None
|
|
46
|
+
|
|
47
|
+
assert final.args == {"GIT_COMMIT": None}
|
|
48
|
+
assert final.env_vars == {"GIT_COMMIT": "$GIT_COMMIT"}
|
|
49
|
+
assert final.exposed_ports == ["5000"]
|
|
50
|
+
assert final.platform is None
|
|
51
|
+
|
|
52
|
+
# Top-level values remain merged across stages.
|
|
53
|
+
assert set(analysis.env_vars) == set(base.env_vars) | set(final.env_vars)
|
|
54
|
+
assert analysis.final_stage is None
|
|
55
|
+
assert analysis.final_stage_info.to_dict() == final.to_dict()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parent_stage_only_resolves_earlier_stages():
|
|
59
|
+
dockerfile = """\
|
|
60
|
+
FROM alpine:3.20 AS Base
|
|
61
|
+
FROM base AS child
|
|
62
|
+
FROM later AS early
|
|
63
|
+
FROM node:20 AS later
|
|
64
|
+
FROM unknown-image
|
|
65
|
+
FROM child
|
|
66
|
+
"""
|
|
67
|
+
stages = da.analyze_dockerfile(dockerfile).stages
|
|
68
|
+
|
|
69
|
+
assert [(s.name, s.base_image, s.parent_stage) for s in stages] == [
|
|
70
|
+
("base", "alpine:3.20", None),
|
|
71
|
+
("child", "base", "base"),
|
|
72
|
+
("early", "later", None),
|
|
73
|
+
("later", "node:20", None),
|
|
74
|
+
(None, "unknown-image", None),
|
|
75
|
+
(None, "child", "child"),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_platform_per_stage():
|
|
80
|
+
dockerfile = """\
|
|
81
|
+
FROM --platform=$BUILDPLATFORM golang:1.22 AS build
|
|
82
|
+
FROM --platform=linux/amd64 alpine:3.20
|
|
83
|
+
"""
|
|
84
|
+
stages = da.analyze_dockerfile(dockerfile).stages
|
|
85
|
+
assert [s.platform for s in stages] == ["$BUILDPLATFORM", "linux/amd64"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_per_stage_values_and_overrides():
|
|
89
|
+
dockerfile = """\
|
|
90
|
+
FROM golang:1.22 AS build
|
|
91
|
+
ARG TARGETOS=linux
|
|
92
|
+
ENV MODE=build
|
|
93
|
+
LABEL stage=build
|
|
94
|
+
EXPOSE 6060
|
|
95
|
+
WORKDIR /src
|
|
96
|
+
WORKDIR /src/cmd
|
|
97
|
+
USER builder
|
|
98
|
+
|
|
99
|
+
FROM alpine:3.20
|
|
100
|
+
ENV MODE=runtime
|
|
101
|
+
LABEL stage=runtime
|
|
102
|
+
EXPOSE 8080/tcp
|
|
103
|
+
USER root
|
|
104
|
+
USER 65532
|
|
105
|
+
"""
|
|
106
|
+
analysis = da.analyze_dockerfile(dockerfile)
|
|
107
|
+
build, runtime = analysis.stages
|
|
108
|
+
|
|
109
|
+
assert build.args == {"TARGETOS": "linux"}
|
|
110
|
+
assert build.env_vars == {"MODE": "build"}
|
|
111
|
+
assert build.labels == {"stage": "build"}
|
|
112
|
+
assert build.exposed_ports == ["6060"]
|
|
113
|
+
assert build.workdir == "/src/cmd"
|
|
114
|
+
assert build.user == "builder"
|
|
115
|
+
|
|
116
|
+
assert runtime.args == {}
|
|
117
|
+
assert runtime.env_vars == {"MODE": "runtime"}
|
|
118
|
+
assert runtime.labels == {"stage": "runtime"}
|
|
119
|
+
assert runtime.exposed_ports == ["8080/tcp"]
|
|
120
|
+
assert runtime.workdir is None
|
|
121
|
+
assert runtime.user == "65532"
|
|
122
|
+
|
|
123
|
+
assert analysis.env_vars == {"MODE": "runtime"}
|
|
124
|
+
assert analysis.labels == {"stage": "runtime"}
|
|
125
|
+
assert analysis.exposed_ports == ["6060", "8080/tcp"]
|
|
126
|
+
assert analysis.args == {"TARGETOS": "linux"}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_global_args_not_attributed_to_first_stage():
|
|
130
|
+
dockerfile = """\
|
|
131
|
+
ARG VERSION=1
|
|
132
|
+
FROM alpine:${VERSION} AS base
|
|
133
|
+
FROM base
|
|
134
|
+
ARG VERSION
|
|
135
|
+
"""
|
|
136
|
+
analysis = da.analyze_dockerfile(dockerfile)
|
|
137
|
+
assert analysis.stages[0].args == {}
|
|
138
|
+
assert analysis.stages[1].args == {"VERSION": None}
|
|
139
|
+
assert analysis.args == {"VERSION": "1"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_bare_arg_in_same_stage_clears_default():
|
|
143
|
+
analysis = da.analyze_dockerfile("FROM alpine\nARG VERSION=1\nARG VERSION\n")
|
|
144
|
+
assert analysis.stages[0].args == {"VERSION": None}
|
|
145
|
+
assert analysis.args == {"VERSION": "1"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_final_stage_info_and_final_stage_name():
|
|
149
|
+
analysis = da.analyze_dockerfile("FROM golang:1.22 AS build\nFROM build AS release\n")
|
|
150
|
+
assert analysis.final_stage == "release"
|
|
151
|
+
final = analysis.final_stage_info
|
|
152
|
+
assert isinstance(final, da.Stage)
|
|
153
|
+
assert final.index == 1
|
|
154
|
+
assert final.name == "release"
|
|
155
|
+
assert final.parent_stage == "build"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_to_dict_adds_only_stages_key():
|
|
159
|
+
analysis = da.analyze_dockerfile((FIXTURES / "multistage.Dockerfile").read_text())
|
|
160
|
+
data = analysis.to_dict()
|
|
161
|
+
|
|
162
|
+
assert list(data) == [
|
|
163
|
+
"num_stages",
|
|
164
|
+
"images",
|
|
165
|
+
"stage_names",
|
|
166
|
+
"final_stage",
|
|
167
|
+
"copy_from_stages",
|
|
168
|
+
"add_from_stages",
|
|
169
|
+
"copy_from_images",
|
|
170
|
+
"add_from_images",
|
|
171
|
+
"multistage_analysis",
|
|
172
|
+
"exposed_ports",
|
|
173
|
+
"instructions",
|
|
174
|
+
"args",
|
|
175
|
+
"labels",
|
|
176
|
+
"env_vars",
|
|
177
|
+
"stages",
|
|
178
|
+
]
|
|
179
|
+
assert len(data["stages"]) == 3
|
|
180
|
+
assert all(set(stage) == STAGE_KEYS for stage in data["stages"])
|
|
181
|
+
assert data["stages"][2] == analysis.stages[2].to_dict()
|
|
182
|
+
assert data["stages"][1]["parent_stage"] == "base"
|
|
183
|
+
assert data["stages"][2]["name"] is None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_stage_repr():
|
|
187
|
+
stage = da.analyze_dockerfile("FROM alpine\nARG VERSION\n").stages[0]
|
|
188
|
+
assert repr(stage) == (
|
|
189
|
+
'Stage(index=0, name=None, base_image="alpine", parent_stage=None, '
|
|
190
|
+
'platform=None, args={"VERSION": None}, env_vars={}, labels={}, '
|
|
191
|
+
"exposed_ports=[], user=None, workdir=None)"
|
|
192
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dockerfile_analyzer-0.3.25 → dockerfile_analyzer-0.4.0}/tests/fixtures/multistage.Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|