From e7234c3ed6b648b8718ae68b7333bf09cbc33db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Ho=C3=9F?= Date: Wed, 3 Jun 2026 17:06:42 +0200 Subject: [PATCH 1/2] add more fields from the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sebastian Hoß --- dev/Containerfile | 6 +- .../java/wtf/metio/devcontainer/Build.java | 6 +- .../wtf/metio/devcontainer/Devcontainer.java | 14 ++-- src/main/java/wtf/metio/devcontainer/Gpu.java | 32 +++++++++ .../metio/devcontainer/GpuDeserializer.java | 38 ++++++++++ .../metio/devcontainer/GpuRequirements.java | 27 +++++++ .../metio/devcontainer/HostRequirements.java | 6 +- .../java/wtf/metio/devcontainer/Mount.java | 29 ++++++++ .../metio/devcontainer/MountDeserializer.java | 36 ++++++++++ .../wtf/metio/devcontainer/MountObject.java | 29 ++++++++ .../wtf/metio/devcontainer/MountType.java | 12 ++++ .../java/wtf/metio/devcontainer/Secret.java | 26 +++++++ .../devcontainer.java/reflect-config.json | 72 +++++++++++++++++++ .../devcontainer/NativeImageSmokeTest.java | 10 ++- .../devcontainer/DevcontainerBuilderTest.java | 46 ++++++++++++ .../devcontainer/DevcontainerParsingTest.java | 35 +++++++-- src/test/resources/build.json | 3 +- .../host-requirements-gpu-optional.json | 5 ++ .../host-requirements-gpu-requirements.json | 8 +++ src/test/resources/host-requirements-gpu.json | 5 ++ src/test/resources/mounts-string.json | 5 ++ src/test/resources/secrets.json | 8 +++ 22 files changed, 442 insertions(+), 16 deletions(-) create mode 100644 src/main/java/wtf/metio/devcontainer/Gpu.java create mode 100644 src/main/java/wtf/metio/devcontainer/GpuDeserializer.java create mode 100644 src/main/java/wtf/metio/devcontainer/GpuRequirements.java create mode 100644 src/main/java/wtf/metio/devcontainer/Mount.java create mode 100644 src/main/java/wtf/metio/devcontainer/MountDeserializer.java create mode 100644 src/main/java/wtf/metio/devcontainer/MountObject.java create mode 100644 src/main/java/wtf/metio/devcontainer/MountType.java create mode 100644 src/main/java/wtf/metio/devcontainer/Secret.java create mode 100644 src/test/resources/host-requirements-gpu-optional.json create mode 100644 src/test/resources/host-requirements-gpu-requirements.json create mode 100644 src/test/resources/host-requirements-gpu.json create mode 100644 src/test/resources/mounts-string.json create mode 100644 src/test/resources/secrets.json diff --git a/dev/Containerfile b/dev/Containerfile index e3609bf..fac2c80 100644 --- a/dev/Containerfile +++ b/dev/Containerfile @@ -12,7 +12,11 @@ COPY --from=docker.io/library/maven:3-eclipse-temurin-21 /usr/share/maven/ref/se RUN ln -s ${MAVEN_HOME}/bin/mvn /usr/bin/mvn -ENV MAVEN_CONFIG "/root/.m2" +# The entrypoint copied from the maven image runs under `set -u` and dereferences +# $MAVEN_CONFIG with no default, so it must be set or the container fails to start. +# It is unset before mvn runs, so the value only governs where the entrypoint stages +# its reference files — point it at a path writable by any uid (ilo runs rootless). +ENV MAVEN_CONFIG "/tmp/.m2" ENTRYPOINT ["/usr/local/bin/mvn-entrypoint.sh"] CMD ["mvn"] diff --git a/src/main/java/wtf/metio/devcontainer/Build.java b/src/main/java/wtf/metio/devcontainer/Build.java index 12ba184..ee2983f 100644 --- a/src/main/java/wtf/metio/devcontainer/Build.java +++ b/src/main/java/wtf/metio/devcontainer/Build.java @@ -23,6 +23,9 @@ * image. Cached image identifiers are passed to the docker build command with --cache-from. Note that * the array syntax will execute the command without a shell. You can learn more about formatting * string vs array properties. + * @param options An array of additional arguments that should be passed to the docker build command when building a + * Dockerfile. Defaults to not set. For example: "build": { "options": [ "--add-host=host.docker + * .internal:host-gateway" ] } */ @RecordBuilder @RecordBuilder.Options(buildMethodName = "create") @@ -31,7 +34,8 @@ public record Build( String context, Map args, String target, - List cacheFrom) implements BuildBuilder.With { + List cacheFrom, + List options) implements BuildBuilder.With { public static BuildBuilder builder() { return BuildBuilder.builder(); diff --git a/src/main/java/wtf/metio/devcontainer/Devcontainer.java b/src/main/java/wtf/metio/devcontainer/Devcontainer.java index e301b1f..889b6ae 100644 --- a/src/main/java/wtf/metio/devcontainer/Devcontainer.java +++ b/src/main/java/wtf/metio/devcontainer/Devcontainer.java @@ -77,10 +77,10 @@ * @param securityOpt Defaults to []. Cross-orchestrator way to set container security options. For * example: "securityOpt": [ "seccomp=unconfined" ] * @param mounts Defaults to unset. Cross-orchestrator way to add additional mounts to a container. - * Each value is a string that accepts the same values as the Docker CLI --mount - * flag. Environment and pre-defined variables may be referenced in the value. For - * example: "mounts": [{ "source": "dind-var-lib-docker", "target": - * "/var/lib/docker", "type": "volume" }] + * Each value is either a string that accepts the same values as the Docker CLI + * --mount flag or an object with the equivalent named fields. Environment and + * pre-defined variables may be referenced in the value. For example: "mounts": [{ + * "source": "dind-var-lib-docker", "target": "/var/lib/docker", "type": "volume" }] * @param features An object of Dev Container Feature IDs and related options to be added into your * primary container. The specific options that are available varies by feature, so * see its documentation for additional details. For example: "features": { @@ -90,6 +90,9 @@ * allows you to override the Feature install order when needed. For example: * "overrideFeatureInstallorder": [ "ghcr.io/devcontainers/features/common-utils", * "ghcr.io/devcontainers/features/github-cli" ] + * @param secrets Recommended secrets for this dev container. Recommendations are provided as + * environment variable keys, each mapped to optional metadata such as a description + * and documentation URL. * @param customizations Product specific properties, defined in supporting tools * @param image Required when using an image. The name of an image in a container registry * (DockerHub, GitHub Container Registry, Azure Container Registry) that @@ -200,9 +203,10 @@ public record Devcontainer( Boolean privileged, List capAdd, List securityOpt, - List> mounts, + List mounts, Map> features, List overrideFeatureInstallOrder, + Map secrets, Map> customizations, String image, Build build, diff --git a/src/main/java/wtf/metio/devcontainer/Gpu.java b/src/main/java/wtf/metio/devcontainer/Gpu.java new file mode 100644 index 0000000..f2f4e39 --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/Gpu.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import io.soabase.recordbuilder.core.RecordBuilder; +import tools.jackson.databind.annotation.JsonDeserialize; + +/** + * Wrapper for the various ways to specify the {@code hostRequirements.gpu} property. At most one of the parameters is + * set. + * + * @param enabled The boolean form: {@code true} requires a GPU, {@code false} does not. + * @param optional The string form. The only accepted value is {@code optional}, indicating a GPU is used when + * available but not required. + * @param requirements The object form, expressing detailed GPU requirements. + * @see schema reference + */ +@RecordBuilder +@RecordBuilder.Options(buildMethodName = "create") +@JsonDeserialize(using = GpuDeserializer.class) +public record Gpu( + Boolean enabled, + String optional, + GpuRequirements requirements) implements GpuBuilder.With { + + public static GpuBuilder builder() { + return GpuBuilder.builder(); + } + +} diff --git a/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java b/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java new file mode 100644 index 0000000..03eaf17 --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.deser.std.StdDeserializer; + +public final class GpuDeserializer extends StdDeserializer { + + public GpuDeserializer() { + this(Gpu.class); + } + + public GpuDeserializer(final Class vc) { + super(vc); + } + + @Override + public Gpu deserialize(final JsonParser parser, final DeserializationContext context) + throws JacksonException { + final JsonNode node = context.readTree(parser); + if (node.isBoolean()) { + return new Gpu(node.booleanValue(), null, null); + } else if (node.isString()) { + return new Gpu(null, node.stringValue(), null); + } else if (node.isObject()) { + return new Gpu(null, null, context.readTreeAsValue(node, GpuRequirements.class)); + } + + return context.reportInputMismatch(Gpu.class, "Cannot deserialize given input to Gpu"); + } + +} diff --git a/src/main/java/wtf/metio/devcontainer/GpuRequirements.java b/src/main/java/wtf/metio/devcontainer/GpuRequirements.java new file mode 100644 index 0000000..aae1e15 --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/GpuRequirements.java @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import io.soabase.recordbuilder.core.RecordBuilder; + +/** + * The object form of the {@code hostRequirements.gpu} property, used to express detailed GPU requirements. + * + * @param cores Indicates the minimum required number of cores. For example: "gpu": {"cores": 2} + * @param memory A string indicating minimum memory requirements with a tb, gb, mb, or kb suffix. For example, "gpu": + * {"memory": "8gb"} + * @see schema reference + */ +@RecordBuilder +@RecordBuilder.Options(buildMethodName = "create") +public record GpuRequirements( + Integer cores, + String memory) implements GpuRequirementsBuilder.With { + + public static GpuRequirementsBuilder builder() { + return GpuRequirementsBuilder.builder(); + } + +} diff --git a/src/main/java/wtf/metio/devcontainer/HostRequirements.java b/src/main/java/wtf/metio/devcontainer/HostRequirements.java index f6ce6ad..8a2fb91 100644 --- a/src/main/java/wtf/metio/devcontainer/HostRequirements.java +++ b/src/main/java/wtf/metio/devcontainer/HostRequirements.java @@ -13,13 +13,17 @@ * "hostRequirements": {"memory": "4gb"} * @param storage A string indicating minimum storage requirements with a tb, gb, mb, or kb suffix. For example, * "hostRequirements": {"storage": "32gb"} + * @param gpu Indicates whether a GPU is required. The string "optional" indicates that a GPU is used if available. + * An object value allows detailed GPU requirements to be expressed. For example: "hostRequirements": + * {"gpu": "optional"} */ @RecordBuilder @RecordBuilder.Options(buildMethodName = "create") public record HostRequirements( Integer cpus, String memory, - String storage) implements HostRequirementsBuilder.With { + String storage, + Gpu gpu) implements HostRequirementsBuilder.With { public static HostRequirementsBuilder builder() { return HostRequirementsBuilder.builder(); diff --git a/src/main/java/wtf/metio/devcontainer/Mount.java b/src/main/java/wtf/metio/devcontainer/Mount.java new file mode 100644 index 0000000..3b143cb --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/Mount.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import io.soabase.recordbuilder.core.RecordBuilder; +import tools.jackson.databind.annotation.JsonDeserialize; + +/** + * Wrapper for the two ways to specify a {@code mounts} entry. At most one of the parameters is set. + * + * @param string The string form, accepting the same syntax as the Docker CLI {@code --mount} flag (e.g. + * {@code source=dind-var-lib-docker,target=/var/lib/docker,type=volume}). + * @param object The object form, expressing the mount with named fields. + * @see schema reference + */ +@RecordBuilder +@RecordBuilder.Options(buildMethodName = "create") +@JsonDeserialize(using = MountDeserializer.class) +public record Mount( + String string, + MountObject object) implements MountBuilder.With { + + public static MountBuilder builder() { + return MountBuilder.builder(); + } + +} diff --git a/src/main/java/wtf/metio/devcontainer/MountDeserializer.java b/src/main/java/wtf/metio/devcontainer/MountDeserializer.java new file mode 100644 index 0000000..885f609 --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/MountDeserializer.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.deser.std.StdDeserializer; + +public final class MountDeserializer extends StdDeserializer { + + public MountDeserializer() { + this(Mount.class); + } + + public MountDeserializer(final Class vc) { + super(vc); + } + + @Override + public Mount deserialize(final JsonParser parser, final DeserializationContext context) + throws JacksonException { + final JsonNode node = context.readTree(parser); + if (node.isString()) { + return new Mount(node.stringValue(), null); + } else if (node.isObject()) { + return new Mount(null, context.readTreeAsValue(node, MountObject.class)); + } + + return context.reportInputMismatch(Mount.class, "Cannot deserialize given input to Mount"); + } + +} diff --git a/src/main/java/wtf/metio/devcontainer/MountObject.java b/src/main/java/wtf/metio/devcontainer/MountObject.java new file mode 100644 index 0000000..5d4d50b --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/MountObject.java @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import io.soabase.recordbuilder.core.RecordBuilder; + +/** + * The object form of a {@code mounts} entry, mirroring the fields of the Docker CLI {@code --mount} flag. + * + * @param type The kind of mount. Required. + * @param source The source of the mount. Optional for a {@code volume} type, where Docker may create an anonymous + * volume. + * @param target The path the mount is created at inside the container. Required. + * @see schema reference + */ +@RecordBuilder +@RecordBuilder.Options(buildMethodName = "create") +public record MountObject( + MountType type, + String source, + String target) implements MountObjectBuilder.With { + + public static MountObjectBuilder builder() { + return MountObjectBuilder.builder(); + } + +} diff --git a/src/main/java/wtf/metio/devcontainer/MountType.java b/src/main/java/wtf/metio/devcontainer/MountType.java new file mode 100644 index 0000000..781e41b --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/MountType.java @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +public enum MountType { + + bind, + volume, + +} diff --git a/src/main/java/wtf/metio/devcontainer/Secret.java b/src/main/java/wtf/metio/devcontainer/Secret.java new file mode 100644 index 0000000..fc673bb --- /dev/null +++ b/src/main/java/wtf/metio/devcontainer/Secret.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import io.soabase.recordbuilder.core.RecordBuilder; + +/** + * Metadata for a recommended secret, keyed by the environment variable name under {@code secrets}. + * + * @param description A description of the secret. + * @param documentationUrl A URL to documentation about the secret. + * @see schema reference + */ +@RecordBuilder +@RecordBuilder.Options(buildMethodName = "create") +public record Secret( + String description, + String documentationUrl) implements SecretBuilder.With { + + public static SecretBuilder builder() { + return SecretBuilder.builder(); + } + +} diff --git a/src/main/resources/META-INF/native-image/wtf.metio.devcontainer/devcontainer.java/reflect-config.json b/src/main/resources/META-INF/native-image/wtf.metio.devcontainer/devcontainer.java/reflect-config.json index 8d03e8d..64c0208 100644 --- a/src/main/resources/META-INF/native-image/wtf.metio.devcontainer/devcontainer.java/reflect-config.json +++ b/src/main/resources/META-INF/native-image/wtf.metio.devcontainer/devcontainer.java/reflect-config.json @@ -35,6 +35,33 @@ "allDeclaredClasses": true, "allPublicClasses": true }, + { + "name": "wtf.metio.devcontainer.Gpu", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "wtf.metio.devcontainer.GpuDeserializer", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "wtf.metio.devcontainer.GpuRequirements", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, { "name": "wtf.metio.devcontainer.HostRequirements", "allDeclaredConstructors": true, @@ -44,6 +71,42 @@ "allDeclaredClasses": true, "allPublicClasses": true }, + { + "name": "wtf.metio.devcontainer.Mount", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "wtf.metio.devcontainer.MountDeserializer", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "wtf.metio.devcontainer.MountObject", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, + { + "name": "wtf.metio.devcontainer.MountType", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, { "name": "wtf.metio.devcontainer.OnAutoForward", "allDeclaredConstructors": true, @@ -71,6 +134,15 @@ "allDeclaredClasses": true, "allPublicClasses": true }, + { + "name": "wtf.metio.devcontainer.Secret", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredClasses": true, + "allPublicClasses": true + }, { "name": "wtf.metio.devcontainer.ShutdownAction", "allDeclaredConstructors": true, diff --git a/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java b/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java index 8debc40..bbaef5e 100644 --- a/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java +++ b/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java @@ -24,12 +24,20 @@ public static void main(final String[] args) throws Exception { "name": "smoke", "image": "example:123", "forwardPorts": [3000, "db:5432"], - "postCreateCommand": "npm install" + "postCreateCommand": "npm install", + "build": { "options": ["--no-cache"] }, + "hostRequirements": { "gpu": { "cores": 2, "memory": "8gb" } }, + "secrets": { "TOKEN": { "description": "a token" } }, + "mounts": [{ "source": "vol", "target": "/data", "type": "volume" }] } """); require("example:123".equals(parsed.image()), "image"); require(List.of("3000", "db:5432").equals(parsed.forwardPorts()), "forwardPorts"); require("npm install".equals(parsed.postCreateCommand().string()), "postCreateCommand"); + require(List.of("--no-cache").equals(parsed.build().options()), "build.options"); + require(Integer.valueOf(2).equals(parsed.hostRequirements().gpu().requirements().cores()), "gpu.requirements.cores"); + require("a token".equals(parsed.secrets().get("TOKEN").description()), "secrets.description"); + require(MountType.volume.equals(parsed.mounts().get(0).object().type()), "mounts.type"); final var built = Devcontainer.builder().name("built").image("quay.io/x:1").create(); require("built".equals(built.name()), "builder"); diff --git a/src/test/java/wtf/metio/devcontainer/DevcontainerBuilderTest.java b/src/test/java/wtf/metio/devcontainer/DevcontainerBuilderTest.java index bd775ee..7c91024 100644 --- a/src/test/java/wtf/metio/devcontainer/DevcontainerBuilderTest.java +++ b/src/test/java/wtf/metio/devcontainer/DevcontainerBuilderTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; class DevcontainerBuilderTest { @@ -66,6 +67,51 @@ void createNestedPortAttributeComponent() { Assertions.assertEquals(true, devcontainer.portsAttributes().get("http").elevateIfNeeded()); } + @Test + void createNestedGpuComponent() { + final var devcontainer = Devcontainer.builder() + .name("test") + .hostRequirements(HostRequirements.builder() + .gpu(Gpu.builder() + .requirements(GpuRequirements.builder().cores(2).memory("8gb").create()) + .create()) + .create()) + .create(); + + Assertions.assertEquals("test", devcontainer.name()); + Assertions.assertEquals(2, devcontainer.hostRequirements().gpu().requirements().cores()); + Assertions.assertEquals("8gb", devcontainer.hostRequirements().gpu().requirements().memory()); + } + + @Test + void createNestedSecretComponent() { + final var devcontainer = Devcontainer.builder() + .name("test") + .secrets(Map.of("TOKEN", Secret.builder().description("a token").create())) + .create(); + + Assertions.assertEquals("test", devcontainer.name()); + Assertions.assertEquals("a token", devcontainer.secrets().get("TOKEN").description()); + } + + @Test + void createNestedMountComponent() { + final var devcontainer = Devcontainer.builder() + .name("test") + .mounts(List.of(Mount.builder() + .object(MountObject.builder() + .type(MountType.volume) + .source("dind-var-lib-docker") + .target("/var/lib/docker") + .create()) + .create())) + .create(); + + Assertions.assertEquals("test", devcontainer.name()); + Assertions.assertEquals(MountType.volume, devcontainer.mounts().get(0).object().type()); + Assertions.assertEquals("/var/lib/docker", devcontainer.mounts().get(0).object().target()); + } + @Test void adjustDevcontainerWithWither() { final var original = Devcontainer.builder() diff --git a/src/test/java/wtf/metio/devcontainer/DevcontainerParsingTest.java b/src/test/java/wtf/metio/devcontainer/DevcontainerParsingTest.java index 690e4ef..df71e18 100644 --- a/src/test/java/wtf/metio/devcontainer/DevcontainerParsingTest.java +++ b/src/test/java/wtf/metio/devcontainer/DevcontainerParsingTest.java @@ -55,6 +55,25 @@ Stream shouldParseJsonFiles() { () -> assertEquals("4gb", devcontainer.hostRequirements().memory(), "hostRequirements.memory"), () -> assertEquals("32gb", devcontainer.hostRequirements().storage(), "hostRequirements.storage") )), + Map.entry("host-requirements-gpu.json", devcontainer -> () -> assertAll( + () -> assertEquals(true, devcontainer.hostRequirements().gpu().enabled(), "hostRequirements.gpu.enabled") + )), + Map.entry("host-requirements-gpu-optional.json", devcontainer -> () -> assertAll( + () -> assertEquals("optional", devcontainer.hostRequirements().gpu().optional(), + "hostRequirements.gpu.optional") + )), + Map.entry("host-requirements-gpu-requirements.json", devcontainer -> () -> assertAll( + () -> assertEquals(2, devcontainer.hostRequirements().gpu().requirements().cores(), + "hostRequirements.gpu.requirements.cores"), + () -> assertEquals("8gb", devcontainer.hostRequirements().gpu().requirements().memory(), + "hostRequirements.gpu.requirements.memory") + )), + Map.entry("secrets.json", devcontainer -> () -> assertAll( + () -> assertEquals("Token used to authenticate against the GitHub API.", + devcontainer.secrets().get("GITHUB_TOKEN").description(), "secrets.description"), + () -> assertEquals("https://example.com/docs/github-token", + devcontainer.secrets().get("GITHUB_TOKEN").documentationUrl(), "secrets.documentationUrl") + )), Map.entry("ports-attributes.json", devcontainer -> () -> assertAll( () -> assertEquals("Application Port", devcontainer.portsAttributes().get("3000").label(), "label"), () -> assertEquals(Protocol.http, devcontainer.portsAttributes().get("3000").protocol(), "protocol"), @@ -193,7 +212,9 @@ Stream shouldParseJsonFiles() { () -> assertEquals("dev", devcontainer.build().target(), "build.target"), () -> assertEquals(Map.of("some", "value"), devcontainer.build().args(), "build.args"), () -> assertIterableEquals(List.of("some-cache:latest"), devcontainer.build().cacheFrom(), - "build.cacheFrom") + "build.cacheFrom"), + () -> assertIterableEquals(List.of("--add-host=host.docker.internal:host-gateway"), + devcontainer.build().options(), "build.options") )), Map.entry("customizations.json", devcontainer -> () -> assertAll( () -> assertEquals(Map.of("vscode", Map.of("settings", Map.of(), "extensions", List.of())), @@ -209,11 +230,13 @@ Stream shouldParseJsonFiles() { devcontainer.features(), "features") )), Map.entry("mounts.json", devcontainer -> () -> assertAll( - () -> assertIterableEquals(List.of(Map.of( - "source", "dind-var-lib-docker", - "target", "/var/lib/docker", - "type", "volume")), - devcontainer.mounts(), "mounts") + () -> assertEquals(MountType.volume, devcontainer.mounts().get(0).object().type(), "mounts.type"), + () -> assertEquals("dind-var-lib-docker", devcontainer.mounts().get(0).object().source(), "mounts.source"), + () -> assertEquals("/var/lib/docker", devcontainer.mounts().get(0).object().target(), "mounts.target") + )), + Map.entry("mounts-string.json", devcontainer -> () -> assertAll( + () -> assertEquals("source=dind-var-lib-docker,target=/var/lib/docker,type=volume", + devcontainer.mounts().get(0).string(), "mounts.string") )), Map.entry("security-opt-empty.json", devcontainer -> () -> assertAll( () -> assertIterableEquals(List.of(), devcontainer.securityOpt(), "securityOpt") diff --git a/src/test/resources/build.json b/src/test/resources/build.json index 8e23682..3061833 100644 --- a/src/test/resources/build.json +++ b/src/test/resources/build.json @@ -6,6 +6,7 @@ "args": { "some": "value" }, - "cacheFrom": ["some-cache:latest"] + "cacheFrom": ["some-cache:latest"], + "options": ["--add-host=host.docker.internal:host-gateway"] } } diff --git a/src/test/resources/host-requirements-gpu-optional.json b/src/test/resources/host-requirements-gpu-optional.json new file mode 100644 index 0000000..c8b7281 --- /dev/null +++ b/src/test/resources/host-requirements-gpu-optional.json @@ -0,0 +1,5 @@ +{ + "hostRequirements": { + "gpu": "optional" + } +} diff --git a/src/test/resources/host-requirements-gpu-requirements.json b/src/test/resources/host-requirements-gpu-requirements.json new file mode 100644 index 0000000..7ba8e1c --- /dev/null +++ b/src/test/resources/host-requirements-gpu-requirements.json @@ -0,0 +1,8 @@ +{ + "hostRequirements": { + "gpu": { + "cores": 2, + "memory": "8gb" + } + } +} diff --git a/src/test/resources/host-requirements-gpu.json b/src/test/resources/host-requirements-gpu.json new file mode 100644 index 0000000..80d2dda --- /dev/null +++ b/src/test/resources/host-requirements-gpu.json @@ -0,0 +1,5 @@ +{ + "hostRequirements": { + "gpu": true + } +} diff --git a/src/test/resources/mounts-string.json b/src/test/resources/mounts-string.json new file mode 100644 index 0000000..882355e --- /dev/null +++ b/src/test/resources/mounts-string.json @@ -0,0 +1,5 @@ +{ + "mounts": [ + "source=dind-var-lib-docker,target=/var/lib/docker,type=volume" + ] +} diff --git a/src/test/resources/secrets.json b/src/test/resources/secrets.json new file mode 100644 index 0000000..16d1964 --- /dev/null +++ b/src/test/resources/secrets.json @@ -0,0 +1,8 @@ +{ + "secrets": { + "GITHUB_TOKEN": { + "description": "Token used to authenticate against the GitHub API.", + "documentationUrl": "https://example.com/docs/github-token" + } + } +} From dbbd5b2587a3599081b73bbcab2a75ece81b65c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Ho=C3=9F?= Date: Wed, 3 Jun 2026 17:40:32 +0200 Subject: [PATCH 2/2] improve test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sebastian Hoß --- pom.xml | 16 ++++++ .../devcontainer/CommandDeserializer.java | 3 +- .../metio/devcontainer/GpuDeserializer.java | 3 +- .../metio/devcontainer/MountDeserializer.java | 3 +- .../devcontainer/CommandDeserializerTest.java | 41 +++++++++++++++ .../devcontainer/DevcontainerParseTest.java | 50 +++++++++++++++++++ .../devcontainer/GpuDeserializerTest.java | 41 +++++++++++++++ .../devcontainer/MountDeserializerTest.java | 36 +++++++++++++ 8 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/test/java/wtf/metio/devcontainer/CommandDeserializerTest.java create mode 100644 src/test/java/wtf/metio/devcontainer/DevcontainerParseTest.java create mode 100644 src/test/java/wtf/metio/devcontainer/GpuDeserializerTest.java create mode 100644 src/test/java/wtf/metio/devcontainer/MountDeserializerTest.java diff --git a/pom.xml b/pom.xml index b89d4f4..276d15b 100644 --- a/pom.xml +++ b/pom.xml @@ -184,6 +184,22 @@ + + + org.jacoco + jacoco-maven-plugin + + + **/*Builder.class + **/*Builder$*.class + **/NativeImageSmokeTest.class + + + diff --git a/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java b/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java index ecef659..9590085 100644 --- a/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java +++ b/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java @@ -12,6 +12,7 @@ import tools.jackson.databind.JavaType; import tools.jackson.databind.JsonNode; import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.node.StringNode; public final class CommandDeserializer extends StdDeserializer { @@ -38,7 +39,7 @@ public Command deserialize(final JsonParser parser, final DeserializationContext return new Command(null, null, context.readTreeAsValue(node, type)); } - return context.reportInputMismatch(Command.class, "Cannot deserialize given input to Command"); + throw MismatchedInputException.from(parser, Command.class, "Cannot deserialize given input to Command"); } } diff --git a/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java b/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java index 03eaf17..3e5076e 100644 --- a/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java +++ b/src/main/java/wtf/metio/devcontainer/GpuDeserializer.java @@ -9,6 +9,7 @@ import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.JsonNode; import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.exc.MismatchedInputException; public final class GpuDeserializer extends StdDeserializer { @@ -32,7 +33,7 @@ public Gpu deserialize(final JsonParser parser, final DeserializationContext con return new Gpu(null, null, context.readTreeAsValue(node, GpuRequirements.class)); } - return context.reportInputMismatch(Gpu.class, "Cannot deserialize given input to Gpu"); + throw MismatchedInputException.from(parser, Gpu.class, "Cannot deserialize given input to Gpu"); } } diff --git a/src/main/java/wtf/metio/devcontainer/MountDeserializer.java b/src/main/java/wtf/metio/devcontainer/MountDeserializer.java index 885f609..213c7f4 100644 --- a/src/main/java/wtf/metio/devcontainer/MountDeserializer.java +++ b/src/main/java/wtf/metio/devcontainer/MountDeserializer.java @@ -9,6 +9,7 @@ import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.JsonNode; import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.exc.MismatchedInputException; public final class MountDeserializer extends StdDeserializer { @@ -30,7 +31,7 @@ public Mount deserialize(final JsonParser parser, final DeserializationContext c return new Mount(null, context.readTreeAsValue(node, MountObject.class)); } - return context.reportInputMismatch(Mount.class, "Cannot deserialize given input to Mount"); + throw MismatchedInputException.from(parser, Mount.class, "Cannot deserialize given input to Mount"); } } diff --git a/src/test/java/wtf/metio/devcontainer/CommandDeserializerTest.java b/src/test/java/wtf/metio/devcontainer/CommandDeserializerTest.java new file mode 100644 index 0000000..3df2e6c --- /dev/null +++ b/src/test/java/wtf/metio/devcontainer/CommandDeserializerTest.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +class CommandDeserializerTest { + + private final ObjectMapper mapper = Devcontainer.defaultObjectMapper(); + + @Test + void deserializesString() { + assertEquals("echo hi", mapper.readValue("\"echo hi\"", Command.class).string()); + } + + @Test + void deserializesArray() { + assertIterableEquals(List.of("echo", "hi"), mapper.readValue("[\"echo\", \"hi\"]", Command.class).array()); + } + + @Test + void deserializesObject() { + final Command command = mapper.readValue("{\"server\": \"npm start\"}", Command.class); + assertEquals("npm start", command.object().get("server").string()); + } + + @Test + void rejectsNumber() { + assertThrows(JacksonException.class, () -> mapper.readValue("123", Command.class)); + } + +} diff --git a/src/test/java/wtf/metio/devcontainer/DevcontainerParseTest.java b/src/test/java/wtf/metio/devcontainer/DevcontainerParseTest.java new file mode 100644 index 0000000..72f929f --- /dev/null +++ b/src/test/java/wtf/metio/devcontainer/DevcontainerParseTest.java @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class DevcontainerParseTest { + + private static final String JSON = "{\"name\":\"example\"}"; + private static final File FILE = new File("src/test/resources/name.json"); + + @Test + void parseFromString() { + assertEquals("example", Devcontainer.parse(JSON).name()); + } + + @Test + void parseFromStringWithMapper() { + assertEquals("example", Devcontainer.parse(JSON, Devcontainer.defaultObjectMapper()).name()); + } + + @Test + void parseFromFile() { + assertEquals("example", Devcontainer.parse(FILE).name()); + } + + @Test + void parseFromFileWithMapper() { + assertEquals("example", Devcontainer.parse(FILE, Devcontainer.defaultObjectMapper()).name()); + } + + @Test + void parseFromPath() { + assertEquals("example", Devcontainer.parse(Paths.get("src/test/resources/name.json")).name()); + } + + @Test + void parseFromPathWithMapper() { + final ObjectMapper mapper = Devcontainer.defaultObjectMapper(); + assertEquals("example", Devcontainer.parse(Paths.get("src/test/resources/name.json"), mapper).name()); + } + +} diff --git a/src/test/java/wtf/metio/devcontainer/GpuDeserializerTest.java b/src/test/java/wtf/metio/devcontainer/GpuDeserializerTest.java new file mode 100644 index 0000000..faa1eb9 --- /dev/null +++ b/src/test/java/wtf/metio/devcontainer/GpuDeserializerTest.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +class GpuDeserializerTest { + + private final ObjectMapper mapper = Devcontainer.defaultObjectMapper(); + + @Test + void deserializesBoolean() { + assertTrue(mapper.readValue("true", Gpu.class).enabled()); + } + + @Test + void deserializesString() { + assertEquals("optional", mapper.readValue("\"optional\"", Gpu.class).optional()); + } + + @Test + void deserializesObject() { + final Gpu gpu = mapper.readValue("{\"cores\": 2, \"memory\": \"8gb\"}", Gpu.class); + assertEquals(2, gpu.requirements().cores()); + assertEquals("8gb", gpu.requirements().memory()); + } + + @Test + void rejectsNumber() { + assertThrows(JacksonException.class, () -> mapper.readValue("123", Gpu.class)); + } + +} diff --git a/src/test/java/wtf/metio/devcontainer/MountDeserializerTest.java b/src/test/java/wtf/metio/devcontainer/MountDeserializerTest.java new file mode 100644 index 0000000..b35d768 --- /dev/null +++ b/src/test/java/wtf/metio/devcontainer/MountDeserializerTest.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +class MountDeserializerTest { + + private final ObjectMapper mapper = Devcontainer.defaultObjectMapper(); + + @Test + void deserializesString() { + assertEquals("source=vol,target=/data,type=volume", + mapper.readValue("\"source=vol,target=/data,type=volume\"", Mount.class).string()); + } + + @Test + void deserializesObject() { + final Mount mount = mapper.readValue("{\"type\": \"volume\", \"target\": \"/data\"}", Mount.class); + assertEquals(MountType.volume, mount.object().type()); + assertEquals("/data", mount.object().target()); + } + + @Test + void rejectsNumber() { + assertThrows(JacksonException.class, () -> mapper.readValue("123", Mount.class)); + } + +}