Skip to content

Commit b644956

Browse files
committed
recipe: ModifyOptionValue
1 parent dda8e01 commit b644956

File tree

4 files changed

+349
-0
lines changed

4 files changed

+349
-0
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (c) 2025 Jim Schubert
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
package com.github.jimschubert.rewrite.docker;
16+
17+
import com.github.jimschubert.rewrite.docker.trait.Traits;
18+
import com.github.jimschubert.rewrite.docker.tree.Docker;
19+
import lombok.EqualsAndHashCode;
20+
import lombok.NoArgsConstructor;
21+
import lombok.RequiredArgsConstructor;
22+
import lombok.Value;
23+
import org.openrewrite.*;
24+
25+
@Value
26+
@EqualsAndHashCode(callSuper = false)
27+
@RequiredArgsConstructor
28+
@NoArgsConstructor(force = true)
29+
public class ModifyOptionValue extends Recipe {
30+
@Option(displayName = "Match key regex",
31+
description = "A regular expression to match against text of the key.",
32+
example = ".*/mount/.*")
33+
String matchKey;
34+
35+
@Option(displayName = "Match value regex",
36+
description = "A regular expression to match against text of the value.",
37+
example = ".*/var/lib/apt.*",
38+
required = false)
39+
String matchValue;
40+
41+
@Option(displayName = "Replacement value",
42+
description = "The text to set for the value of the matching option.",
43+
example = "java-21")
44+
String replacementText;
45+
46+
@Option(displayName = "The parent instruction",
47+
description = "An uppercase Docker instruction to match against exactly. " +
48+
"This is not a regex match, but a case-sensitive exact match. " +
49+
"Supported values are RUN, ADD, COPY." +
50+
"If matchInstructionRegex is set to true, this value may be a regex match against the _entire_ instruction text.",
51+
example = "RUN",
52+
required = false)
53+
String parent;
54+
55+
@Option(displayName = "Match instruction regex",
56+
description = "A regular expression to match against the full instruction text.",
57+
example = ".*https://.+",
58+
required = false)
59+
boolean matchInstructionRegex;
60+
61+
@Override
62+
public @NlsRewrite.DisplayName String getDisplayName() {
63+
return "Modify option value within a Dockerfile";
64+
}
65+
66+
@Override
67+
public @NlsRewrite.Description String getDescription() {
68+
return "Modify option value within a Dockerfile.";
69+
}
70+
71+
@Override
72+
public TreeVisitor<?, ExecutionContext> getVisitor() {
73+
if (parent == null || parent.isEmpty()) {
74+
return Traits.option(matchKey, matchValue, true)
75+
.asVisitor(n -> {
76+
Docker.KeyArgs args = n.getTree().getKeyArgs();
77+
return n.withArgs(args.withValue(args.getValue().withText(replacementText)))
78+
.getTree();
79+
});
80+
}
81+
82+
return Traits.option(matchKey, matchValue, true)
83+
.asVisitor(n -> {
84+
if (matchInstructionRegex) {
85+
Tree tree = n.getCursor().getParent().getValue();
86+
String text = tree.printTrimmed(new Cursor(n.getCursor().getParent(), tree));
87+
if (!text.matches(parent)) {
88+
return n.getTree();
89+
}
90+
} else {
91+
Class<? extends Docker.Instruction> parentTarget;
92+
switch (parent.toUpperCase()) {
93+
case "RUN":
94+
parentTarget = Docker.Run.class;
95+
break;
96+
case "ADD":
97+
parentTarget = Docker.Add.class;
98+
break;
99+
case "COPY":
100+
parentTarget = Docker.Copy.class;
101+
break;
102+
default:
103+
throw new IllegalArgumentException("Invalid parent instruction: " + parent);
104+
}
105+
106+
if (!parentTarget.isInstance(n.getCursor().getParent().getValue())) {
107+
return n.getTree();
108+
}
109+
}
110+
111+
112+
Docker.KeyArgs args = n.getTree().getKeyArgs();
113+
return n.withArgs(args.withValue(args.getValue().withText(replacementText)))
114+
.getTree();
115+
});
116+
}
117+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (c) 2025 Jim Schubert
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
package com.github.jimschubert.rewrite.docker.trait;
16+
17+
import com.github.jimschubert.rewrite.docker.tree.Docker;
18+
import lombok.AllArgsConstructor;
19+
import lombok.Getter;
20+
import org.jspecify.annotations.NonNull;
21+
import org.jspecify.annotations.Nullable;
22+
import org.openrewrite.Cursor;
23+
import org.openrewrite.trait.SimpleTraitMatcher;
24+
import org.openrewrite.trait.Trait;
25+
26+
import java.util.Optional;
27+
28+
@Getter
29+
@AllArgsConstructor
30+
public class DockerOption implements Trait<Docker.@NonNull Option> {
31+
Cursor cursor;
32+
33+
public Docker.KeyArgs getArgs() {
34+
return getTree().getKeyArgs();
35+
}
36+
37+
public DockerOption withArgs(Docker.KeyArgs newArgs) {
38+
Docker.Option option = getTree().withKeyArgs(newArgs);
39+
cursor = new Cursor(cursor.getParent(), option);
40+
return this;
41+
}
42+
43+
public static Matcher matcher(String key, @Nullable String value, boolean regexMatch) {
44+
return new Matcher(key, value, regexMatch);
45+
}
46+
47+
public static class Matcher extends SimpleTraitMatcher<@NonNull DockerOption> {
48+
private final String key;
49+
private final String value;
50+
private final boolean regexMatch;
51+
52+
public Matcher(String key, String value, boolean regexMatch) {
53+
this.key = key;
54+
this.value = value;
55+
this.regexMatch = regexMatch;
56+
}
57+
58+
@Override
59+
protected DockerOption test(Cursor cursor) {
60+
if (cursor.getValue() instanceof Docker.Option) {
61+
Docker.Option option = cursor.getValue();
62+
Docker.KeyArgs args = option.getKeyArgs();
63+
Optional<String> key = Optional.ofNullable(args.key());
64+
Optional<String> value = Optional.ofNullable(args.value());
65+
if (this.regexMatch) {
66+
if (key.isPresent() && !key.get().matches(this.key) && !key.get().replaceFirst("--", "").matches(this.key)) {
67+
return null;
68+
}
69+
if (this.value != null && value.isPresent() && !value.get().matches(this.value)) {
70+
return null;
71+
}
72+
} else {
73+
if (key.isPresent() && !key.get().equals(this.key)) {
74+
return null;
75+
}
76+
if (value.isPresent() && !value.get().equals(this.value)) {
77+
return null;
78+
}
79+
}
80+
81+
return new DockerOption(cursor);
82+
}
83+
84+
return null;
85+
}
86+
}
87+
}

src/main/java/com/github/jimschubert/rewrite/docker/trait/Traits.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ public static DockerLiteral.Matcher literal(String pattern) {
2424
public static DockerLiteral.Matcher literal(Pattern pattern) {
2525
return new DockerLiteral.Matcher(pattern);
2626
}
27+
28+
public static DockerOption.Matcher option(String key, String value, boolean regexMatch) {
29+
return new DockerOption.Matcher(key, value, regexMatch);
30+
}
2731
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) 2025 Jim Schubert
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
package com.github.jimschubert.rewrite.docker;
16+
17+
import org.junit.jupiter.api.Test;
18+
import org.openrewrite.internal.RecipeRunException;
19+
import org.openrewrite.test.RewriteTest;
20+
21+
import static com.github.jimschubert.rewrite.docker.Assertions.dockerfile;
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.junit.jupiter.api.Assertions.assertThrows;
24+
25+
class ModifyOptionValueTest implements RewriteTest {
26+
@Override
27+
public void defaults(org.openrewrite.test.RecipeSpec spec) {
28+
spec.recipe(new ModifyOptionValue());
29+
}
30+
31+
@Test
32+
void modifyOptionValueOrMatchValue() {
33+
rewriteRun(
34+
spec -> spec.recipe(new ModifyOptionValue("from", "build", "builder", null, false)),
35+
//language=dockerfile
36+
dockerfile(
37+
"COPY --from=build /myapp /usr/bin/",
38+
"COPY --from=builder /myapp /usr/bin/"
39+
)
40+
);
41+
}
42+
43+
@Test
44+
void modifyOptionValueWithoutParentIncludeKeyDashes() {
45+
rewriteRun(
46+
spec -> spec.recipe(new ModifyOptionValue("--from", "build", "builder", null, false)),
47+
//language=dockerfile
48+
dockerfile(
49+
"COPY --from=build /myapp /usr/bin/",
50+
"COPY --from=builder /myapp /usr/bin/"
51+
)
52+
);
53+
}
54+
55+
@Test
56+
void modifyOptionValueWrongParent() {
57+
rewriteRun(
58+
spec -> spec.recipe(new ModifyOptionValue("--from", "build", "builder", "ADD", false)),
59+
//language=dockerfile
60+
dockerfile(
61+
"COPY --from=build /myapp /usr/bin/"
62+
)
63+
);
64+
}
65+
66+
@Test
67+
void modifyOptionValueWithParentAndRegex() {
68+
rewriteRun(
69+
spec -> spec.recipe(new ModifyOptionValue(
70+
"from",
71+
"build",
72+
"other",
73+
"COPY.+/usr/bin/",
74+
true)),
75+
//language=dockerfile
76+
dockerfile(
77+
"""
78+
COPY --from=build /myapp /usr/bin/
79+
COPY --from=builder /myapp /usr/local/bin/
80+
""",
81+
"""
82+
COPY --from=other /myapp /usr/bin/
83+
COPY --from=builder /myapp /usr/local/bin/
84+
"""
85+
)
86+
);
87+
}
88+
89+
@Test
90+
void modifyOptionValueMatchSingleOption() {
91+
rewriteRun(
92+
spec -> spec.recipe(new ModifyOptionValue(
93+
"mount",
94+
".+/var/cache.+",
95+
"type=tmpfs,destination=/tmp,size=300M",
96+
"RUN",
97+
false)),
98+
//language=dockerfile
99+
dockerfile(
100+
"""
101+
FROM ubuntu
102+
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
103+
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
104+
--mount=type=cache,target=/var/lib/apt,sharing=locked \
105+
apt update && apt-get --no-install-recommends install -y gcc
106+
""",
107+
"""
108+
FROM ubuntu
109+
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
110+
RUN --mount=type=tmpfs,destination=/tmp,size=300M \
111+
--mount=type=cache,target=/var/lib/apt,sharing=locked \
112+
apt update && apt-get --no-install-recommends install -y gcc
113+
"""
114+
)
115+
);
116+
}
117+
118+
@Test
119+
void modifyOptionValueWithInvalidParent() {
120+
AssertionError assertionError = assertThrows(AssertionError.class, () -> rewriteRun(
121+
spec -> spec.recipe(new ModifyOptionValue(
122+
"from",
123+
null,
124+
"other",
125+
"FROM",
126+
false)),
127+
//language=dockerfile
128+
dockerfile(
129+
"""
130+
COPY --from=build /myapp /usr/bin/
131+
COPY --from=builder /myapp /usr/local/bin/
132+
"""
133+
)
134+
));
135+
136+
assertThat(assertionError).cause().isInstanceOf(RecipeRunException.class);
137+
RecipeRunException e = (RecipeRunException) assertionError.getCause();
138+
assertThat(e.getMessage())
139+
.contains("Invalid parent instruction: FROM");
140+
}
141+
}

0 commit comments

Comments
 (0)