diff --git a/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt b/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt index 85f8084a58..33ba4a91c8 100644 --- a/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt +++ b/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt @@ -44,6 +44,7 @@ object Files { const val storageSftp = "cloudnet-sftp.jar" const val storageS3 = "cloudnet-s3.jar" const val influx = "cloudnet-influx.jar" + const val replacer = "cloudnet-replacer.jar" const val node = "cloudnet.jar" const val nodeCnl = "cloudnet.cnl" } diff --git a/modules/replacer/api/build.gradle.kts b/modules/replacer/api/build.gradle.kts new file mode 100644 index 0000000000..9780b406af --- /dev/null +++ b/modules/replacer/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * 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 + * + * 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. + */ + +plugins { + id("cloudnet-publish") + id("cloudnet-modules-api") +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/PlaceholderReplacement.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/PlaceholderReplacement.java new file mode 100644 index 0000000000..c194cbff11 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/PlaceholderReplacement.java @@ -0,0 +1,32 @@ +/* + * 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 + * + * 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.replacer.model; + +import eu.cloudnetservice.modules.replacer.model.condition.ConditionRule; +import eu.cloudnetservice.modules.replacer.type.ReplaceType; +import eu.cloudnetservice.modules.replacer.type.SearchType; +import java.util.List; +import org.jetbrains.annotations.Nullable; + +public record PlaceholderReplacement( + String token, + @Nullable SearchType searchType, + @Nullable ReplaceType replaceType, + @Nullable List values, + @Nullable List conditions +) { +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/Replacement.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/Replacement.java new file mode 100644 index 0000000000..5f8e02af63 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/Replacement.java @@ -0,0 +1,29 @@ +/* + * 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 + * + * 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.replacer.model; + +import java.util.List; +import org.jetbrains.annotations.Nullable; + +public record Replacement( + @Nullable String id, + @Nullable Boolean enabled, + @Nullable List targets, + @Nullable List files, + @Nullable List placeholders +) { +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/TargetDefinition.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/TargetDefinition.java new file mode 100644 index 0000000000..c7575de8f0 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/TargetDefinition.java @@ -0,0 +1,28 @@ +/* + * 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 + * + * 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.replacer.model; + +import org.jetbrains.annotations.Nullable; + +public record TargetDefinition( + @Nullable String task, + @Nullable String service, + @Nullable String environment, + @Nullable String group, + @Nullable String template +) { +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/condition/ConditionRule.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/condition/ConditionRule.java new file mode 100644 index 0000000000..ad029de623 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/condition/ConditionRule.java @@ -0,0 +1,23 @@ +/* + * 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 + * + * 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.replacer.model.condition; + +public record ConditionRule( + ConditionWhen when, + String value +) { +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/condition/ConditionWhen.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/condition/ConditionWhen.java new file mode 100644 index 0000000000..00e52452cb --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/condition/ConditionWhen.java @@ -0,0 +1,26 @@ +/* + * 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 + * + * 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.replacer.model.condition; + +import org.jetbrains.annotations.Nullable; + +public record ConditionWhen( + @Nullable String field, + @Nullable String equals, + @Nullable String regex +) { +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/config/Replacements.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/config/Replacements.java new file mode 100644 index 0000000000..07b34eff01 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/config/Replacements.java @@ -0,0 +1,29 @@ +/* + * 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 + * + * 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.replacer.model.config; + +import eu.cloudnetservice.modules.replacer.model.Replacement; +import java.util.List; +import org.jetbrains.annotations.Nullable; + +/** + * Container model for a replacement rule file. + * + * @param rules the rules parsed from the file. + */ +public record Replacements(@Nullable List rules) { +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/config/Replacer.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/config/Replacer.java new file mode 100644 index 0000000000..7a0ad9fe20 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/model/config/Replacer.java @@ -0,0 +1,38 @@ +/* + * 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 + * + * 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.replacer.model.config; + +import eu.cloudnetservice.modules.replacer.type.ReplaceType; +import eu.cloudnetservice.modules.replacer.type.SearchType; +import java.util.List; + +public record Replacer( + boolean builtInPlaceholdersEnabled, + DefaultSection defaults, + PathSection paths, + LimitSection limits +) { + + public record DefaultSection(SearchType searchType, ReplaceType replaceType) { + } + + public record PathSection(List filePatterns) { + } + + public record LimitSection(long maxFileSizeBytes) { + } +} diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/type/ReplaceType.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/type/ReplaceType.java new file mode 100644 index 0000000000..3f6834c9b8 --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/type/ReplaceType.java @@ -0,0 +1,43 @@ +/* + * 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 + * + * 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.replacer.type; + +/** + * Determines how a replacement value is chosen for a placeholder occurrence. + */ +public enum ReplaceType { + /** + * Always use the first value in the list. + */ + FIRST, + + /** + * Pick a random value for each occurrence. + */ + RANDOM, + + /** + * Cycle through the values in order (wrap-around). + */ + SEQUENTIAL, + + /** + * Choose a value based on matching condition rules. + */ + CONDITIONAL +} + diff --git a/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/type/SearchType.java b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/type/SearchType.java new file mode 100644 index 0000000000..bb622cd2dd --- /dev/null +++ b/modules/replacer/api/src/main/java/eu/cloudnetservice/modules/replacer/type/SearchType.java @@ -0,0 +1,32 @@ +/* + * 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 + * + * 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.replacer.type; + +/** + * Controls how many placeholder occurrences are replaced in a document. + */ +public enum SearchType { + /** + * Replace every occurrence of the token. + */ + ALL, + + /** + * Replace only the first occurrence of the token. + */ + FIRST +} diff --git a/modules/replacer/impl/build.gradle.kts b/modules/replacer/impl/build.gradle.kts new file mode 100644 index 0000000000..d69cc40eec --- /dev/null +++ b/modules/replacer/impl/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * 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 + * + * 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. + */ + +import eu.cloudnetservice.cloudnet.gradle.util.Files + +plugins { + id("cloudnet-modules") + id("cloudnet-publish") + alias(libs.plugins.shadow) +} + +dependencies { + compileOnlyApi(projects.node.nodeImpl) + compileOnlyApi(projects.driver.driverImpl) + compileOnlyApi(projects.utils.utilsBase) + + api(projects.modules.replacer.replacerApi) + + testImplementation(libs.mockito) + testImplementation(libs.bundles.junit) + testRuntimeOnly(libs.junitLauncher) + testImplementation(projects.driver.driverApi) +} + +tasks.shadowJar { + archiveFileName = Files.replacer +} + +moduleJson { + author = "CloudNetService" + name = "CloudNet-Replacer" + main = "eu.cloudnetservice.modules.replacer.ReplacerModule" + description = "Replaces configured placeholders in service files after templates/inclusions are applied" +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/ReplacerDefaults.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/ReplacerDefaults.java new file mode 100644 index 0000000000..fd14a4a942 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/ReplacerDefaults.java @@ -0,0 +1,219 @@ +/* + * 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 + * + * 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.replacer; + +import eu.cloudnetservice.modules.replacer.model.config.Replacer; +import eu.cloudnetservice.modules.replacer.type.ReplaceType; +import eu.cloudnetservice.modules.replacer.type.SearchType; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.slf4j.Logger; + +final class ReplacerDefaults { + + private static final String DEFAULT_REPLACEMENTS_DIR = "replacements"; + private static final String EXAMPLE_RULES = """ + { + "rules": [ + { + "id": "lobby-shared", + "enabled": true, + "targets": [ + { + "task": "Lobby" + } + ], + "files": [ + "config/**/*.yml", + "plugins/**/config.yml" + ], + "placeholders": [ + { + "token": "%motd%", + "searchType": "ALL", + "replaceType": "FIRST", + "values": [ + "Welcome to %taskName%" + ] + }, + { + "token": "%endpoint%", + "replaceType": "FIRST", + "values": [ + "http://%serviceHost%:%servicePort%" + ] + } + ] + }, + { + "id": "paper-forwarding-secret", + "enabled": true, + "targets": [ + { + "task": "Paper" + } + ], + "files": [ + "config/paper-global.yml", + "paper.yml" + ], + "placeholders": [ + { + "token": "%forwardingSecret%", + "replaceType": "FIRST", + "values": [ + "REPLACE_ME_WITH_SECRET" + ] + } + ] + }, + { + "id": "velocity-forwarding-secret", + "enabled": true, + "targets": [ + { + "task": "Velocity" + } + ], + "files": [ + "forwarding.secret" + ], + "placeholders": [ + { + "token": "%forwardingSecret%", + "replaceType": "FIRST", + "values": [ + "REPLACE_ME_WITH_SECRET" + ] + } + ] + }, + { + "id": "multi-target-shared", + "enabled": true, + "targets": [ + { + "task": "Lobby" + }, + { + "service": "Minigame-1", + "environment": "MINECRAFT_SERVER" + }, + { + "group": "SharedGroup" + } + ], + "files": [ + "config/shared.yml" + ], + "placeholders": [ + { + "token": "%sharedSecret%", + "replaceType": "FIRST", + "values": [ + "shared-%nodeId%" + ] + } + ] + } + ] + } + """; + + private static final String PROXY_RULES = """ + { + "rules": [ + { + "id": "velocity-endpoints", + "targets": [ + { "task": "Velocity" } + ], + "files": [ + "velocity.toml", + "config/velocity.toml" + ], + "placeholders": [ + { + "token": "%servicePort%", + "searchType": "FIRST", + "replaceType": "FIRST", + "values": [ + "25577" + ] + }, + { + "token": "%motd%", + "replaceType": "FIRST", + "values": [ + "Velocity %serviceName%" + ] + } + ] + } + ] + } + """; + + private ReplacerDefaults() { + } + + public static Replacer defaultConfiguration() { + return new Replacer( + true, + new Replacer.DefaultSection(SearchType.ALL, ReplaceType.FIRST), + new Replacer.PathSection(List.of( + "**/*.yml", + "**/*.yaml", + "**/*.json", + "**/*.conf", + "**/*.txt", + "**/*.cnl")), + new Replacer.LimitSection(524_288)); + } + + public static Path replacementsDirectory(Path dataDirectory) { + return dataDirectory.resolve(DEFAULT_REPLACEMENTS_DIR); + } + + public static void ensureExampleFiles(Path dir, Logger logger) { + if (!Files.isDirectory(dir)) { + return; + } + + try (var files = Files.list(dir)) { + if (files.anyMatch(path -> path.toString().endsWith(".json"))) { + return; + } + } catch (Exception exception) { + logger.debug("Unable to inspect replacements directory {}", dir, exception); + return; + } + + writeExample(dir.resolve("example.json"), EXAMPLE_RULES, logger); + writeExample(dir.resolve("proxy-1.json"), PROXY_RULES, logger); + } + + private static void writeExample(Path target, String content, Logger logger) { + try { + Files.writeString(target, content, StandardCharsets.UTF_8); + } catch (Exception exception) { + logger.warn("Unable to write example replacement file {}", target, exception); + } + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/ReplacerModule.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/ReplacerModule.java new file mode 100644 index 0000000000..e06a0fe344 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/ReplacerModule.java @@ -0,0 +1,113 @@ +/* + * 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 + * + * 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.replacer; + +import eu.cloudnetservice.driver.document.DocumentFactory; +import eu.cloudnetservice.driver.event.EventListener; +import eu.cloudnetservice.driver.event.EventManager; +import eu.cloudnetservice.driver.module.ModuleLifeCycle; +import eu.cloudnetservice.driver.module.ModuleTask; +import eu.cloudnetservice.driver.module.driver.DriverModule; +import eu.cloudnetservice.modules.replacer.model.Replacement; +import eu.cloudnetservice.modules.replacer.model.config.Replacements; +import eu.cloudnetservice.modules.replacer.model.config.Replacer; +import eu.cloudnetservice.node.event.service.CloudServicePostPrepareEvent; +import jakarta.inject.Singleton; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import lombok.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public final class ReplacerModule extends DriverModule { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReplacerModule.class); + + private Replacer configuration; + private TemplateReplacer replacer; + private Path replacementsDirectory; + + @ModuleTask(order = 127, lifecycle = ModuleLifeCycle.STARTED) + public void handleStart(@NonNull EventManager eventManager) { + this.reloadInternal(); + eventManager.registerListener(this); + } + + @ModuleTask(lifecycle = ModuleLifeCycle.RELOADING) + public void handleReload() { + this.reloadInternal(); + } + + @EventListener + public void handle(@NonNull CloudServicePostPrepareEvent event) { + if (this.replacer == null || this.configuration == null) { + return; + } + this.replacer.apply(event.serviceInfo(), event.service().directory(), null); + } + + private void reloadInternal() { + this.configuration = this.readConfig(Replacer.class, ReplacerDefaults::defaultConfiguration, DocumentFactory.json()); + + this.replacementsDirectory = ReplacerDefaults.replacementsDirectory(this.moduleWrapper().dataDirectory()); + try { + Files.createDirectories(this.replacementsDirectory); + } catch (IOException exception) { + LOGGER.warn("Unable to create replacements directory {}", this.replacementsDirectory, exception); + } + + ReplacerDefaults.ensureExampleFiles(this.replacementsDirectory, LOGGER); + this.replacer = new TemplateReplacer(this.configuration, this.loadRules()); + } + + private List loadRules() { + if (!Files.isDirectory(this.replacementsDirectory)) { + return Collections.emptyList(); + } + + var collectedRules = new ArrayList(); + try (var files = Files.walk(this.replacementsDirectory)) { + files.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".json")) + .forEach(path -> this.loadRuleFile(path).forEach(collectedRules::add)); + } catch (IOException exception) { + LOGGER.warn("Unable to walk replacements directory {}", this.replacementsDirectory, exception); + } + + return collectedRules; + } + + private Stream loadRuleFile(Path path) { + try { + var document = DocumentFactory.json().parse(path); + var ruleFile = document.toInstanceOf(Replacements.class); + if (ruleFile != null && ruleFile.rules() != null) { + return ruleFile.rules().stream(); + } + } catch (Exception exception) { + LOGGER.warn("Unable to parse replacement rule file {}", path, exception); + } + return Stream.empty(); + } +} + diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/TemplateReplacer.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/TemplateReplacer.java new file mode 100644 index 0000000000..669ec9271f --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/TemplateReplacer.java @@ -0,0 +1,125 @@ +/* + * 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 + * + * 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.replacer; + +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import eu.cloudnetservice.modules.replacer.files.ContentReader; +import eu.cloudnetservice.modules.replacer.files.ContentWriter; +import eu.cloudnetservice.modules.replacer.files.FileSelector; +import eu.cloudnetservice.modules.replacer.match.RuleMatcher; +import eu.cloudnetservice.modules.replacer.model.Replacement; +import eu.cloudnetservice.modules.replacer.model.config.Replacer; +import eu.cloudnetservice.modules.replacer.placeholder.BuiltInPlaceholderProvider; +import eu.cloudnetservice.modules.replacer.placeholder.RulePlaceholderApplier; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +public final class TemplateReplacer { + + private final Replacer configuration; + private final List rules; + + private final RuleMatcher ruleMatcher = new RuleMatcher(); + private final FileSelector fileSelector = new FileSelector(); + private final ContentReader contentReader = new ContentReader(); + private final ContentWriter contentWriter = new ContentWriter(); + private final BuiltInPlaceholderProvider builtInPlaceholderProvider = new BuiltInPlaceholderProvider(); + private final RulePlaceholderApplier rulePlaceholderApplier = new RulePlaceholderApplier(); + + public TemplateReplacer(@NonNull Replacer configuration, @NonNull List rules) { + this.configuration = configuration; + this.rules = List.copyOf(rules); + } + + public void apply(@NonNull ServiceInfoSnapshot serviceInfo, @NonNull Path serviceDirectory, @Nullable String template) { + var matchingRules = this.ruleMatcher.matchingRules(this.rules, serviceInfo, template); + var allGlobs = this.ruleMatcher.collectGlobs(this.configuration, matchingRules); + if (allGlobs.isEmpty()) { + return; + } + + var pathMatchers = this.ruleMatcher.toPathMatchers(allGlobs); + var rulePathMatchers = matchingRules.stream() + .collect(Collectors.toMap(Function.identity(), rule -> this.ruleMatcher.resolveGlobs(rule, this.configuration))); + + var builtInsEnabled = this.configuration.builtInPlaceholdersEnabled(); + var builtIns = builtInsEnabled ? this.builtInPlaceholderProvider.build(serviceInfo) : Map.of(); + + try (var files = this.fileSelector.findFiles(serviceDirectory, pathMatchers)) { + files.forEach(path -> this.applyToFile(path, serviceInfo, matchingRules, rulePathMatchers, builtInsEnabled, builtIns)); + } catch (IOException exception) { + // ignore discovery errors + } + } + + private void applyToFile( + Path path, + ServiceInfoSnapshot serviceInfo, + List matchingRules, + Map> rulePathMatchers, + boolean builtInsEnabled, + Map builtIns + ) { + if (!this.withinSizeLimit(path)) { + return; + } + + var content = this.contentReader.read(path); + if (content == null) { + return; + } + + var updated = content; + if (builtInsEnabled) { + updated = this.rulePlaceholderApplier.applyBuiltIns(updated, builtIns, serviceInfo); + } + + for (var rule : matchingRules) { + var matchers = rulePathMatchers.get(rule); + if (matchers != null && !matchers.isEmpty() && !this.ruleMatcher.matchesAny(path, matchers)) { + continue; + } + + updated = this.rulePlaceholderApplier.applyRule(updated, rule, this.configuration, serviceInfo); + } + + this.contentWriter.writeIfChanged(path, content, updated); + } + + private boolean withinSizeLimit(Path path) { + var limitSection = this.configuration.limits(); + long limit = limitSection == null ? 0L : limitSection.maxFileSizeBytes(); + if (limit <= 0L) { + return true; + } + + try { + return Files.size(path) <= limit; + } catch (IOException exception) { + return false; + } + } +} + diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/ContentReader.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/ContentReader.java new file mode 100644 index 0000000000..cac0c2cf21 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/ContentReader.java @@ -0,0 +1,40 @@ +/* + * 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 + * + * 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.replacer.files; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.jetbrains.annotations.Nullable; + +public final class ContentReader { + + public @Nullable String read(Path path) { + try { + var bytes = Files.readAllBytes(path); + var text = new String(bytes, StandardCharsets.UTF_8); + if (text.indexOf('\0') >= 0) { + return null; + } + + return text; + } catch (IOException exception) { + return null; + } + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/ContentWriter.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/ContentWriter.java new file mode 100644 index 0000000000..4feba26d36 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/ContentWriter.java @@ -0,0 +1,37 @@ +/* + * 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 + * + * 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.replacer.files; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class ContentWriter { + + public void writeIfChanged(Path path, String original, String updated) { + if (Objects.equals(original, updated)) { + return; + } + try { + Files.writeString(path, updated, StandardCharsets.UTF_8); + } catch (IOException exception) { + // ignore writes that fail + } + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/FileSelector.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/FileSelector.java new file mode 100644 index 0000000000..cd49d48f79 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/files/FileSelector.java @@ -0,0 +1,42 @@ +/* + * 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 + * + * 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.replacer.files; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.List; +import java.util.stream.Stream; + +public final class FileSelector { + + public Stream findFiles(Path root, List matchers) throws IOException { + return Files.walk(root) + .filter(Files::isRegularFile) + .filter(path -> this.matchesAny(path, matchers)); + } + + private boolean matchesAny(Path path, List matchers) { + for (var matcher : matchers) { + if (matcher.matches(path)) { + return true; + } + } + return false; + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/match/RuleMatcher.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/match/RuleMatcher.java new file mode 100644 index 0000000000..6182202f3d --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/match/RuleMatcher.java @@ -0,0 +1,160 @@ +/* + * 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 + * + * 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.replacer.match; + +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import eu.cloudnetservice.modules.replacer.model.Replacement; +import eu.cloudnetservice.modules.replacer.model.TargetDefinition; +import eu.cloudnetservice.modules.replacer.model.config.Replacer; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +public final class RuleMatcher { + + public List matchingRules( + @NonNull List rules, + @NonNull ServiceInfoSnapshot serviceInfo, + @Nullable String template + ) { + return rules.stream() + .filter(this::isRuleEnabled) + .filter(rule -> this.matches(rule, serviceInfo, template)) + .toList(); + } + + public List collectGlobs(@NonNull Replacer configuration, @NonNull List matchingRules) { + var globs = new ArrayList(); + if (configuration.paths() != null) { + globs.addAll(this.nonNullList(configuration.paths().filePatterns())); + } + for (var rule : matchingRules) { + if (rule.files() != null && !rule.files().isEmpty()) { + globs.addAll(rule.files()); + } + } + return globs.stream().distinct().toList(); + } + + public List resolveGlobs(@NonNull Replacement rule, @NonNull Replacer configuration) { + var globs = rule.files(); + if (globs == null || globs.isEmpty()) { + globs = configuration.paths() != null ? configuration.paths().filePatterns() : List.of(); + } + return this.nonNullList(globs).stream() + .map(glob -> FileSystems.getDefault().getPathMatcher("glob:**/" + glob)) + .collect(Collectors.toList()); + } + + public List toPathMatchers(@NonNull List globs) { + return globs.stream() + .map(glob -> FileSystems.getDefault().getPathMatcher("glob:**/" + glob)) + .toList(); + } + + public boolean matchesAny(@NonNull Path path, @NonNull List matchers) { + for (var matcher : matchers) { + if (matcher.matches(path)) { + return true; + } + } + return false; + } + + private boolean matches(Replacement rule, ServiceInfoSnapshot serviceInfo, @Nullable String template) { + List targets = rule.targets(); + if (targets == null || targets.isEmpty()) { + return true; + } + for (TargetDefinition target : targets) { + if (this.matchesTarget(target, serviceInfo, template)) { + return true; + } + } + return false; + } + + private boolean matchesTarget(TargetDefinition target, ServiceInfoSnapshot serviceInfo, @Nullable String template) { + if (target == null) { + return true; + } + if (target.task() != null && !Objects.equals(target.task(), serviceInfo.serviceId().taskName())) { + return false; + } + if (target.service() != null && !Objects.equals(target.service(), serviceInfo.serviceId().name())) { + return false; + } + if (target.environment() != null + && !this.environmentMatches(target.environment(), serviceInfo.serviceId().environmentName())) { + return false; + } + if (target.group() != null && !this.hasGroup(serviceInfo, target.group())) { + return false; + } + + return target.template() == null || this.matchesTemplate(target.template(), serviceInfo, template); + } + + private boolean hasGroup(ServiceInfoSnapshot serviceInfo, String desiredGroup) { + var configuration = serviceInfo.configuration(); + if (configuration.groups().isEmpty()) { + return false; + } + + for (var group : configuration.groups()) { + if (Objects.equals(group, desiredGroup)) { + return true; + } + } + + return false; + } + + private boolean isRuleEnabled(Replacement rule) { + return rule.enabled() == null || rule.enabled(); + } + + private boolean matchesTemplate(String targetTemplate, ServiceInfoSnapshot serviceInfo, @Nullable String template) { + if (template != null) { + return Objects.equals(targetTemplate, template); + } + + var templates = serviceInfo.configuration().templates(); + return templates.stream().anyMatch(entry -> Objects.equals(entry.name(), targetTemplate)); + } + + private boolean environmentMatches(String desired, @Nullable String actual) { + if (desired == null) { + return true; + } + if (actual == null) { + return false; + } + + return desired.equalsIgnoreCase(actual); + } + + private List nonNullList(@Nullable List input) { + return input == null ? List.of() : input; + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/placeholder/BuiltInPlaceholderProvider.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/placeholder/BuiltInPlaceholderProvider.java new file mode 100644 index 0000000000..6839a1a522 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/placeholder/BuiltInPlaceholderProvider.java @@ -0,0 +1,40 @@ +/* + * 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 + * + * 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.replacer.placeholder; + +import eu.cloudnetservice.driver.network.HostAndPort; +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import java.util.Map; +import java.util.Objects; + +public final class BuiltInPlaceholderProvider { + + public Map build(ServiceInfoSnapshot serviceInfo) { + var serviceId = serviceInfo.serviceId(); + var address = serviceInfo.address(); + return Map.of( + "%nodeId%", Objects.toString(serviceId.nodeUniqueId(), ""), + "%serviceName%", serviceId.name(), + "%taskName%", serviceId.taskName(), + "%serviceHost%", this.host(address), + "%servicePort%", Integer.toString(serviceInfo.configuration().port())); + } + + private String host(HostAndPort address) { + return address != null ? address.host() : ""; + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/placeholder/RulePlaceholderApplier.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/placeholder/RulePlaceholderApplier.java new file mode 100644 index 0000000000..e8e51eecb0 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/placeholder/RulePlaceholderApplier.java @@ -0,0 +1,105 @@ +/* + * 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 + * + * 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.replacer.placeholder; + +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import eu.cloudnetservice.modules.replacer.model.PlaceholderReplacement; +import eu.cloudnetservice.modules.replacer.model.Replacement; +import eu.cloudnetservice.modules.replacer.model.config.Replacer; +import eu.cloudnetservice.modules.replacer.replacement.SearchReplacer; +import eu.cloudnetservice.modules.replacer.replacement.ValueSelector; +import eu.cloudnetservice.modules.replacer.replacement.ValueSelectorFactory; +import eu.cloudnetservice.modules.replacer.type.ReplaceType; +import eu.cloudnetservice.modules.replacer.type.SearchType; +import java.util.List; +import java.util.Map; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +public final class RulePlaceholderApplier { + + private final SearchReplacer searchReplacer; + private final ValueSelectorFactory selectorFactory; + + public RulePlaceholderApplier() { + this(new SearchReplacer(), new ValueSelectorFactory()); + } + + public RulePlaceholderApplier(@NonNull SearchReplacer searchReplacer, @NonNull ValueSelectorFactory selectorFactory) { + this.searchReplacer = searchReplacer; + this.selectorFactory = selectorFactory; + } + + public String applyBuiltIns(String content, Map builtIns, ServiceInfoSnapshot serviceInfo) { + var updated = content; + for (var entry : builtIns.entrySet()) { + updated = this.applyPlaceholder( + updated, + entry.getKey(), + SearchType.ALL, + ReplaceType.FIRST, + new PlaceholderReplacement(entry.getKey(), SearchType.ALL, ReplaceType.FIRST, List.of(entry.getValue()), null), + serviceInfo); + } + + return updated; + } + + public String applyRule(String content, Replacement rule, Replacer configuration, ServiceInfoSnapshot serviceInfo) { + List replacements = rule.placeholders(); + if (replacements == null || replacements.isEmpty()) { + return content; + } + + var result = content; + var defaultSearchType = configuration.defaults() != null + ? configuration.defaults().searchType() + : SearchType.ALL; + var defaultReplaceType = configuration.defaults() != null + ? configuration.defaults().replaceType() + : ReplaceType.FIRST; + + for (PlaceholderReplacement replacement : replacements) { + var searchType = replacement.searchType() != null ? replacement.searchType() : defaultSearchType; + var replaceType = replacement.replaceType() != null ? replacement.replaceType() : defaultReplaceType; + + result = this.applyPlaceholder(result, replacement.token(), searchType, replaceType, replacement, serviceInfo); + } + + return result; + } + + private String applyPlaceholder( + String content, + @Nullable String token, + SearchType searchType, + ReplaceType replaceType, + PlaceholderReplacement replacement, + ServiceInfoSnapshot serviceInfo + ) { + if (token == null || token.isEmpty()) { + return content; + } + + ValueSelector selector = this.selectorFactory.selector(replaceType, replacement, serviceInfo); + if (selector == null) { + return content; + } + + return this.searchReplacer.apply(content, token, searchType, selector); + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ConditionalSelector.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ConditionalSelector.java new file mode 100644 index 0000000000..3ca8f552b2 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ConditionalSelector.java @@ -0,0 +1,77 @@ +/* + * 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 + * + * 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.replacer.replacement; + +import eu.cloudnetservice.driver.network.HostAndPort; +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import eu.cloudnetservice.modules.replacer.model.condition.ConditionRule; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import org.jetbrains.annotations.Nullable; + +public final class ConditionalSelector { + + public @Nullable ValueSelector selector(@Nullable List conditions, ServiceInfoSnapshot serviceInfo) { + if (conditions == null || conditions.isEmpty()) { + return null; + } + + var fieldValues = this.fieldValues(serviceInfo); + for (var condition : conditions) { + var when = condition.when(); + if (when == null || when.field() == null) { + continue; + } + + var actualValue = fieldValues.get(when.field().toLowerCase()); + if (actualValue == null) { + continue; + } + + if (when.equals() != null && actualValue.equals(when.equals())) { + var value = condition.value(); + return value == null ? null : _ -> value; + } + if (when.regex() != null && Pattern.compile(when.regex()).matcher(actualValue).matches()) { + var value = condition.value(); + return value == null ? null : _ -> value; + } + } + return null; + } + + private Map fieldValues(ServiceInfoSnapshot serviceInfo) { + var serviceId = serviceInfo.serviceId(); + + var values = new HashMap(); + values.put("task", serviceId.taskName()); + values.put("service", serviceId.name()); + values.put("environment", serviceId.environmentName()); + values.put("nodeid", Objects.toString(serviceId.nodeUniqueId(), "")); + values.put("host", this.host(serviceInfo.address())); + values.put("port", Integer.toString(serviceInfo.configuration().port())); + + return values; + } + + private String host(HostAndPort address) { + return address != null ? address.host() : ""; + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/SearchReplacer.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/SearchReplacer.java new file mode 100644 index 0000000000..f061b99516 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/SearchReplacer.java @@ -0,0 +1,67 @@ +/* + * 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 + * + * 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.replacer.replacement; + +import eu.cloudnetservice.modules.replacer.type.SearchType; +import org.jetbrains.annotations.Nullable; + +public final class SearchReplacer { + + public String apply(String content, String token, SearchType searchType, ValueSelector selector) { + return switch (searchType) { + case FIRST -> this.replaceFirst(content, token, selector.nextValue(0)); + case ALL -> this.replaceAll(content, token, selector); + }; + } + + public String replaceFirst(String content, String token, @Nullable String value) { + if (value == null) { + return content; + } + + var idx = content.indexOf(token); + if (idx < 0) { + return content; + } + + return content.substring(0, idx) + value + content.substring(idx + token.length()); + } + + public String replaceAll(String content, String token, ValueSelector selector) { + var idx = content.indexOf(token); + if (idx < 0) { + return content; + } + + var builder = new StringBuilder(); + var lastIndex = 0; + var occurrence = 0; + while (idx >= 0) { + var value = selector.nextValue(occurrence++); + if (value == null) { + builder.append(content, lastIndex, content.length()); + return builder.toString(); + } + + builder.append(content, lastIndex, idx).append(value); + lastIndex = idx + token.length(); + idx = content.indexOf(token, lastIndex); + } + builder.append(content, lastIndex, content.length()); + return builder.toString(); + } +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ValueSelector.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ValueSelector.java new file mode 100644 index 0000000000..1301d222c2 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ValueSelector.java @@ -0,0 +1,25 @@ +/* + * 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 + * + * 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.replacer.replacement; + +import org.jetbrains.annotations.Nullable; + +@FunctionalInterface +public interface ValueSelector { + + @Nullable String nextValue(int occurrence); +} diff --git a/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ValueSelectorFactory.java b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ValueSelectorFactory.java new file mode 100644 index 0000000000..3167727fd1 --- /dev/null +++ b/modules/replacer/impl/src/main/java/eu/cloudnetservice/modules/replacer/replacement/ValueSelectorFactory.java @@ -0,0 +1,52 @@ +/* + * 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 + * + * 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.replacer.replacement; + +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import eu.cloudnetservice.modules.replacer.model.PlaceholderReplacement; +import eu.cloudnetservice.modules.replacer.type.ReplaceType; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +public final class ValueSelectorFactory { + + private final ConditionalSelector conditionalSelector; + + public ValueSelectorFactory() { + this(new ConditionalSelector()); + } + + public ValueSelectorFactory(@NonNull ConditionalSelector conditionalSelector) { + this.conditionalSelector = conditionalSelector; + } + + public @Nullable ValueSelector selector( + ReplaceType replaceType, + PlaceholderReplacement replacement, + ServiceInfoSnapshot serviceInfo + ) { + var values = replacement.values() == null ? List.of() : replacement.values(); + return switch (replaceType) { + case FIRST -> values.isEmpty() ? null : _ -> values.getFirst(); + case RANDOM -> values.isEmpty() ? null : _ -> values.get(ThreadLocalRandom.current().nextInt(values.size())); + case SEQUENTIAL -> values.isEmpty() ? null : occurrence -> values.get(occurrence % values.size()); + case CONDITIONAL -> this.conditionalSelector.selector(replacement.conditions(), serviceInfo); + }; + } +} diff --git a/modules/replacer/impl/src/test/java/eu/cloudnetservice/modules/replacer/TemplateReplacerTest.java b/modules/replacer/impl/src/test/java/eu/cloudnetservice/modules/replacer/TemplateReplacerTest.java new file mode 100644 index 0000000000..2920fe4ff5 --- /dev/null +++ b/modules/replacer/impl/src/test/java/eu/cloudnetservice/modules/replacer/TemplateReplacerTest.java @@ -0,0 +1,221 @@ +/* + * 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 + * + * 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.replacer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import eu.cloudnetservice.driver.network.HostAndPort; +import eu.cloudnetservice.driver.service.ServiceConfiguration; +import eu.cloudnetservice.driver.service.ServiceId; +import eu.cloudnetservice.driver.service.ServiceInfoSnapshot; +import eu.cloudnetservice.driver.service.ServiceLifeCycle; +import eu.cloudnetservice.driver.service.ThreadSnapshot; +import eu.cloudnetservice.modules.replacer.model.PlaceholderReplacement; +import eu.cloudnetservice.modules.replacer.model.Replacement; +import eu.cloudnetservice.modules.replacer.model.TargetDefinition; +import eu.cloudnetservice.modules.replacer.model.condition.ConditionRule; +import eu.cloudnetservice.modules.replacer.model.condition.ConditionWhen; +import eu.cloudnetservice.modules.replacer.model.config.Replacer; +import eu.cloudnetservice.modules.replacer.type.ReplaceType; +import eu.cloudnetservice.modules.replacer.type.SearchType; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +class TemplateReplacerTest { + + @TempDir + private Path tempDir; + + private ServiceInfoSnapshot service; + + @BeforeEach + void setup() { + this.service = this.mockService( + "Node-1", + "Lobby", + "Lobby-1", + "MINECRAFT_SERVER", + "127.0.0.1", + 25565); + } + + @Test + void testSequentialWrappingAllMatches() throws Exception { + var file = this.write("config.yml", "A %token% B %token% C %token%"); + + var rule = new Replacement( + "seq", + true, + List.of(new TargetDefinition("Lobby", null, null, null, null)), + List.of("config.yml"), + List.of(new PlaceholderReplacement( + "%token%", + SearchType.ALL, + ReplaceType.SEQUENTIAL, + List.of("X", "Y"), + null))); + + var replacer = this.replacerWithRules(List.of(rule)); + replacer.apply(this.service, this.tempDir, null); + + assertEquals("A X B Y C X", Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void testConditionalReplacement() throws Exception { + var file = this.write("config.txt", "%token% waiting"); + + var rule = new Replacement( + "conditional", + true, + List.of(new TargetDefinition(null, null, "MINECRAFT_SERVER", null, null)), + List.of("config.txt"), + List.of(new PlaceholderReplacement( + "%token%", + SearchType.FIRST, + ReplaceType.CONDITIONAL, + null, + List.of( + new ConditionRule(new ConditionWhen("environment", "MINECRAFT_SERVER", null), "bridge"), + new ConditionRule(new ConditionWhen("environment", "BUNGEECORD", null), "proxy"))))); + + var replacer = this.replacerWithRules(List.of(rule)); + replacer.apply(this.service, this.tempDir, null); + + assertEquals("bridge waiting", Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void testBuiltInPlaceholdersApplied() throws Exception { + var file = this.write("config.txt", "%taskName% %serviceName% %nodeId% %serviceHost% %servicePort%"); + + var replacer = this.replacerWithRules(List.of()); + replacer.apply(this.service, this.tempDir, null); + + assertEquals("Lobby Lobby-1 Node-1 127.0.0.1 25565", Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void testDisabledRuleSkipped() throws Exception { + var file = this.write("config.txt", "value %token%"); + + var rule = new Replacement( + "disabled", + false, + List.of(new TargetDefinition("Lobby", null, null, null, null)), + List.of("config.txt"), + List.of(new PlaceholderReplacement("%token%", SearchType.ALL, ReplaceType.FIRST, List.of("X"), null))); + + var replacer = this.replacerWithRules(List.of(rule)); + replacer.apply(this.service, this.tempDir, null); + + assertEquals("value %token%", Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void testDefaultFileGlobsUsedWhenRuleMissingFiles() throws Exception { + var file = this.write("a.yml", "%token%"); + + var rule = new Replacement( + "glob-default", + true, + List.of(new TargetDefinition("Lobby", null, null, null, null)), + null, + List.of(new PlaceholderReplacement("%token%", SearchType.ALL, ReplaceType.FIRST, List.of("X"), null))); + + var replacer = this.replacerWithRules(List.of(rule)); + replacer.apply(this.service, this.tempDir, null); + + assertEquals("X", Files.readString(file, StandardCharsets.UTF_8)); + } + + @Test + void testGroupTargetMatches() throws Exception { + var file = this.write("group.txt", "%token%"); + + var rule = new Replacement( + "group-target", + true, + List.of(new TargetDefinition(null, null, null, "LobbyGroup", null)), + List.of("group.txt"), + List.of(new PlaceholderReplacement("%token%", SearchType.ALL, ReplaceType.FIRST, List.of("G"), null))); + + var replacer = this.replacerWithRules(List.of(rule)); + replacer.apply(this.service, this.tempDir, null); + + assertEquals("G", Files.readString(file, StandardCharsets.UTF_8)); + } + + private TemplateReplacer replacerWithRules(List rules) { + var config = new Replacer( + true, + new Replacer.DefaultSection(SearchType.ALL, ReplaceType.FIRST), + new Replacer.PathSection(List.of( + "**/*.yml", + "**/*.txt")), + new Replacer.LimitSection(524_288)); + return new TemplateReplacer(config, rules); + } + + private Path write(String name, String content) throws IOException { + var file = this.tempDir.resolve(name); + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } + + private ServiceInfoSnapshot mockService( + String nodeId, + String task, + String serviceName, + String environment, + String host, + int port + ) { + var serviceId = Mockito.mock(ServiceId.class); + Mockito.when(serviceId.taskName()).thenReturn(task); + Mockito.when(serviceId.name()).thenReturn(serviceName); + Mockito.when(serviceId.environmentName()).thenReturn(environment); + Mockito.when(serviceId.nodeUniqueId()).thenReturn(nodeId); + + var configuration = Mockito.mock(ServiceConfiguration.class); + Mockito.when(configuration.serviceId()).thenReturn(serviceId); + Mockito.when(configuration.port()).thenReturn(port); + Mockito.when(configuration.groups()).thenReturn(Set.of("LobbyGroup")); + + var serviceInfo = Mockito.mock(ServiceInfoSnapshot.class); + Mockito.when(serviceInfo.serviceId()).thenReturn(serviceId); + Mockito.when(serviceInfo.configuration()).thenReturn(configuration); + Mockito.when(serviceInfo.address()).thenReturn(new HostAndPort(host, port)); + Mockito.when(serviceInfo.lifeCycle()).thenReturn(ServiceLifeCycle.PREPARED); + Mockito.when(serviceInfo.processSnapshot()).thenReturn(new eu.cloudnetservice.driver.service.ProcessSnapshot( + 0, 0, 0, 0, 0, 0, 0, 0, 0, List.of(ThreadSnapshot.from(Thread.currentThread())))); + + // fallback for id -> config requires a task + Mockito.when(serviceId.environment()).thenReturn(null); + return serviceInfo; + } +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index e26cbaf916..4ef664e9ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -140,6 +140,11 @@ registerSubProjects( prefix = "syncproxy", subProjects = arrayOf("api", "impl"), ) +registerSubProjects( + root = "modules:replacer", + prefix = "replacer", + subProjects = arrayOf("api", "impl"), +) include("bom") include("plugins:luckperms")