diff --git a/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerMountParser.java b/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerMountParser.java new file mode 100644 index 0000000000..bd04beb77a --- /dev/null +++ b/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerMountParser.java @@ -0,0 +1,210 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License + * You may obtain a copy of the License at + * + * [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package eu.cloudnetservice.modules.docker.impl; + +import java.util.Arrays; +import java.util.Objects; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +public final class DockerMountParser { + + private DockerMountParser() { + throw new UnsupportedOperationException(); + } + + /* + * Parses a docker volume definition from short syntax + */ + public static @NonNull ParsedVolume parseVolume(@NonNull String definition) { + var raw = requireText(definition, "volume definition"); + var parts = splitDockerShortSyntax(raw, false); + + return switch (parts.length) { + case 1 -> new ParsedVolume(null, normalizeContainerPath(parts[0]), false); + case 2 -> new ParsedVolume(emptyToNull(parts[0]), normalizeContainerPath(parts[1]), false); + case 3 -> new ParsedVolume( + emptyToNull(parts[0]), + normalizeContainerPath(parts[1]), + isReadOnly(parts[2]) + ); + default -> throw new IllegalArgumentException("Invalid volume definition: " + definition); + }; + } + + /* + * Parses a docker bind mount definition from short syntax + */ + public static @NonNull ParsedBindMount parseBindMount(@NonNull String definition) { + var raw = requireText(definition, "bind definition"); + var parts = splitDockerShortSyntax(raw, true); + + return switch (parts.length) { + case 1 -> new ParsedBindMount(normalizeHostPath(parts[0]), normalizeHostPath(parts[0]), false); + case 2 -> new ParsedBindMount(normalizeHostPath(parts[0]), normalizeContainerPath(parts[1]), false); + case 3 -> new ParsedBindMount( + normalizeHostPath(parts[0]), + normalizeContainerPath(parts[1]), + isReadOnly(parts[2]) + ); + default -> throw new IllegalArgumentException("Invalid bind definition: " + definition); + }; + } + + /* + * Splits a docker short syntax string into source, target and options + */ + private static @NonNull String[] splitDockerShortSyntax(@NonNull String input, boolean handleWindowsSource) { + var firstSeparator = findSeparator(input, 0, handleWindowsSource); + if (firstSeparator == -1) { + return new String[]{input}; + } + + var secondSeparator = findSeparator(input, firstSeparator + 1, false); + if (secondSeparator == -1) { + return new String[]{ + input.substring(0, firstSeparator), + input.substring(firstSeparator + 1) + }; + } + + return new String[]{ + input.substring(0, firstSeparator), + input.substring(firstSeparator + 1, secondSeparator), + input.substring(secondSeparator + 1) + }; + } + + /* + * Finds the next valid colon separator in the definition + */ + private static int findSeparator(@NonNull String input, int start, boolean handleWindowsSource) { + for (var index = start; index < input.length(); index++) { + if (input.charAt(index) == ':' && (!handleWindowsSource || !hasWindowsDriveSeparator(input, index))) { + return index; + } + } + + return -1; + } + + /* + * Checks whether the colon belongs to a Windows drive path like C:\ or C:/ + */ + private static boolean hasWindowsDriveSeparator(@NonNull String input, int index) { + if (index != 1 || input.length() < 3) { + return false; + } + + var driveLetter = input.charAt(0); + var next = input.charAt(2); + return Character.isLetter(driveLetter) && (next == '\\' || next == '/'); + } + + /* + * Checks if the mount options mark the mount as read only + */ + private static boolean isReadOnly(@NonNull String options) { + return Arrays.stream(options.split(",")) + .map(String::trim) + .anyMatch(opt -> opt.equalsIgnoreCase("ro")); + } + + /* + * Validates and normalizes the container target path + */ + private static @NonNull String normalizeContainerPath(@NonNull String path) { + var trimmed = requireText(path, "container target"); + if (isRelativePath(trimmed)) { + throw new IllegalArgumentException("Container target path must be absolute: " + path); + } + + return trimmed; + } + + /* + * Validates and normalizes the host source path + */ + private static @NonNull String normalizeHostPath(@NonNull String path) { + var trimmed = requireText(path, "bind source"); + if (isRelativePath(trimmed)) { + throw new IllegalArgumentException("Bind source path must be absolute: " + path); + } + + return trimmed; + } + + /* + * Checks if a path is relative for Unix or Windows + */ + private static boolean isRelativePath(@NonNull String value) { + if (value.startsWith("/")) { + return false; + } + + return value.length() < 3 + || !Character.isLetter(value.charAt(0)) + || value.charAt(1) != ':' + || (value.charAt(2) != '\\' && value.charAt(2) != '/'); + } + + /* + * Ensures a string is not null empty or blank + */ + private static @NonNull String requireText(@Nullable String value, @NonNull String name) { + Objects.requireNonNull(name, "name"); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be empty"); + } + + return value.trim(); + } + + /* + * Returns null for blank strings, otherwise returns the trimmed value + */ + private static @Nullable String emptyToNull(@NonNull String value) { + return value.isBlank() ? null : value.trim(); + } + + /* + * Parsed named or anonymous volume definition + */ + public record ParsedVolume( + @Nullable String source, + @NonNull String target, + boolean readOnly + ) { + + /* + * Returns true when the volume has a source path or name + */ + public boolean hasSource() { + return this.source != null && !this.source.isBlank(); + } + } + + /* + * Parsed bind mount definition + */ + public record ParsedBindMount( + @NonNull String source, + @NonNull String target, + boolean readOnly + ) { + } +} diff --git a/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerizedService.java b/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerizedService.java index d4ada22d14..9a4352922d 100644 --- a/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerizedService.java +++ b/modules/dockerized-services/impl/src/main/java/eu/cloudnetservice/modules/docker/impl/DockerizedService.java @@ -340,15 +340,33 @@ public void doDelete() { binds.add(this.bindFromPath(wrapperFilePath.toAbsolutePath().toString(), AccessMode.ro)); binds.add(this.bindFromPath(this.serviceDirectory.toAbsolutePath().toString(), AccessMode.rw)); - // get the task specific volumes and concat them with the default volumes + // get the task specific binds and concat them with the default binds var taskBinds = Objects.requireNonNullElse( this.readFromTaskConfig(config, TaskDockerConfig::binds), Set.of()); binds.addAll(Stream.concat(taskBinds.stream(), this.configuration.binds().stream()) - .map(path -> this.serviceDirectory.resolve(path).toAbsolutePath().toString()) - .map(path -> this.bindFromPath(path, AccessMode.rw)) + .map(DockerMountParser::parseBindMount) + .map(parsedBindMount -> new Bind( + parsedBindMount.source(), + new Volume(parsedBindMount.target()), + parsedBindMount.readOnly() ? AccessMode.ro : AccessMode.rw + )) .toList()); + // get the task specific named volumes and concat them with the default named volumes + var taskVolumes = Objects.requireNonNullElse( + this.readFromTaskConfig(config, TaskDockerConfig::volumes), + Set.of()); + Stream.concat(taskVolumes.stream(), this.configuration.volumes().stream()) + .map(DockerMountParser::parseVolume) + .filter(DockerMountParser.ParsedVolume::hasSource) + .map(parsed -> new Bind( + parsed.source(), + new Volume(parsed.target()), + parsed.readOnly() ? AccessMode.ro : AccessMode.rw + )) + .forEach(binds::add); + // uses array instead of list to ensure that there are no duplicate binds return binds.toArray(Bind[]::new); } @@ -358,8 +376,13 @@ public void doDelete() { this.readFromTaskConfig(config, TaskDockerConfig::volumes), Set.of()); return Stream.concat(this.configuration.volumes().stream(), taskVolumes.stream()) - .map(Volume::new) + .map(DockerMountParser::parseVolume) + // only add volumes which do not have a source as those are the only ones which need to be declared as anonymous volumes. + // the rest is handled via bind mounts and does not need to be declared as a volume here. + .filter(parsed -> !parsed.hasSource()) + .map(DockerMountParser.ParsedVolume::target) .distinct() + .map(Volume::new) .toArray(Volume[]::new); }