From 820b4060f2839786bcb7a7c389a357ed65e25387 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 11:24:23 +0100 Subject: [PATCH 01/26] Env manager module (#11) * [env-manager] bootstrap env manager module (yem) --- _documentation/pom.xml | 30 +- .../java/io/yupiik/tools/doc/Generate.java | 17 +- .../java/io/yupiik/tools/doc/YemCommands.java | 62 +++ .../src/main/minisite/content/yem.adoc | 62 +++ env-manager/pom.xml | 154 ++++++++ .../java/io/yupiik/dev/command/Config.java | 79 ++++ .../java/io/yupiik/dev/command/Delete.java | 52 +++ .../main/java/io/yupiik/dev/command/Env.java | 277 ++++++++++++++ .../java/io/yupiik/dev/command/Install.java | 66 ++++ .../main/java/io/yupiik/dev/command/List.java | 69 ++++ .../java/io/yupiik/dev/command/ListLocal.java | 61 +++ .../io/yupiik/dev/command/ListProviders.java | 48 +++ .../java/io/yupiik/dev/command/Resolve.java | 54 +++ .../EnableSimpleOptionsArgs.java | 55 +++ .../ImplicitKeysConfiguration.java | 31 ++ .../java/io/yupiik/dev/provider/Provider.java | 53 +++ .../yupiik/dev/provider/ProviderRegistry.java | 122 ++++++ .../central/ApacheMavenConfiguration.java | 23 ++ .../provider/central/ApacheMavenProvider.java | 28 ++ .../provider/central/CentralBaseProvider.java | 276 ++++++++++++++ .../central/CentralConfiguration.java | 25 ++ .../SingletonCentralConfiguration.java | 31 ++ .../provider/github/GithubConfiguration.java | 25 ++ .../github/MinikubeConfiguration.java | 24 ++ .../provider/github/MinikubeGithubClient.java | 245 ++++++++++++ .../yupiik/dev/provider/github/Release.java | 28 ++ .../github/SingletonGithubConfiguration.java | 31 ++ .../io/yupiik/dev/provider/model/Archive.java | 21 ++ .../yupiik/dev/provider/model/Candidate.java | 19 + .../io/yupiik/dev/provider/model/Version.java | 45 +++ .../dev/provider/sdkman/SdkManClient.java | 355 ++++++++++++++++++ .../provider/sdkman/SdkManConfiguration.java | 31 ++ .../dev/provider/zulu/ZuluCdnClient.java | 219 +++++++++++ .../provider/zulu/ZuluCdnConfiguration.java | 29 ++ .../java/io/yupiik/dev/shared/Archives.java | 208 ++++++++++ .../main/java/io/yupiik/dev/shared/Os.java | 57 +++ .../dev/shared/http/HttpConfiguration.java | 29 ++ .../yupiik/dev/shared/http/YemHttpClient.java | 289 ++++++++++++++ .../io/yupiik/dev/command/CommandsTest.java | 207 ++++++++++ .../central/CentralBaseProviderTest.java | 112 ++++++ .../dev/provider/sdkman/SdkManClientTest.java | 204 ++++++++++ .../dev/provider/zulu/ZuluCdnClientTest.java | 179 +++++++++ .../io/yupiik/dev/shared/ArchivesTest.java | 114 ++++++ .../io/yupiik/dev/test/HttpMockExtension.java | 133 +++++++ .../test/java/io/yupiik/dev/test/Mock.java | 46 +++ pom.xml | 4 +- 46 files changed, 4326 insertions(+), 3 deletions(-) create mode 100644 _documentation/src/main/java/io/yupiik/tools/doc/YemCommands.java create mode 100644 _documentation/src/main/minisite/content/yem.adoc create mode 100644 env-manager/pom.xml create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/Config.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/Delete.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/Env.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/Install.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/List.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/ListProviders.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/Resolve.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/Provider.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/github/GithubConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/github/Release.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/github/SingletonGithubConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/model/Archive.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/model/Version.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/Archives.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/Os.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java create mode 100644 env-manager/src/test/java/io/yupiik/dev/test/Mock.java diff --git a/_documentation/pom.xml b/_documentation/pom.xml index cefa20ac..ecfb884e 100644 --- a/_documentation/pom.xml +++ b/_documentation/pom.xml @@ -16,7 +16,8 @@ under the License. --> - + yupiik-tools-maven-plugin-parent io.yupiik.maven @@ -67,6 +68,12 @@ ${project.version} provided + + io.yupiik.dev + env-manager + ${project.version} + provided + org.apache.maven maven-settings @@ -85,8 +92,29 @@ 1.7.36 provided + + io.yupiik.fusion + fusion-documentation + ${fusion.version} + provided + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + 17 + + + + + doc diff --git a/_documentation/src/main/java/io/yupiik/tools/doc/Generate.java b/_documentation/src/main/java/io/yupiik/tools/doc/Generate.java index ff22bb71..8eae37c0 100644 --- a/_documentation/src/main/java/io/yupiik/tools/doc/Generate.java +++ b/_documentation/src/main/java/io/yupiik/tools/doc/Generate.java @@ -15,6 +15,7 @@ */ package io.yupiik.tools.doc; +import io.yupiik.fusion.documentation.DocumentationGenerator; import io.yupiik.maven.service.git.Git; import io.yupiik.maven.service.git.GitService; import io.yupiik.tools.common.asciidoctor.AsciidoctorConfiguration; @@ -53,6 +54,19 @@ public static void main(final String... args) throws Exception { "toBase", doc.resolve("content/mojo").toString(), "pluginXml", doc.resolve("../../../../yupiik-tools-maven-plugin/target/classes/META-INF/maven/plugin.xml").toString())); + final var generateEnvManagerConfiguration = new PreAction(); + generateEnvManagerConfiguration.setType(DocumentationGenerator.class.getName()); + generateEnvManagerConfiguration.setConfiguration(Map.of( + "includeEnvironmentNames", "true", + "module", "yem", + "urls", doc + .resolve("../../../../env-manager/target/classes/META-INF/fusion/configuration/documentation.json") + .normalize() + .toUri().toURL().toExternalForm())); + + final var generateEnvManagerCommands = new PreAction(); + generateEnvManagerCommands.setType(YemCommands.class.getName()); + final var configuration = new MiniSiteConfiguration(); configuration.setIndexText("Yupiik Tools"); configuration.setIndexSubTitle("adoc:" + @@ -77,7 +91,7 @@ public static void main(final String... args) throws Exception { configuration.setSource(doc); configuration.setTarget(out); configuration.setSiteBase("/tools-maven-plugin"); - configuration.setPreActions(List.of(mojoAction)); + configuration.setPreActions(List.of(mojoAction, generateEnvManagerConfiguration, generateEnvManagerCommands)); configuration.setGenerateSiteMap(true); configuration.setGenerateIndex(true); configuration.setProjectName(projectName); @@ -90,6 +104,7 @@ public static void main(final String... args) throws Exception { configuration.setActionClassLoader(() -> new ClassLoader(Thread.currentThread().getContextClassLoader()) { // avoid it to be closed too early by wrapping it in a not URLCLassLoader }); + configuration.setAttributes(Map.of("partialsdir", doc + "/content/_partials")); configuration.setAsciidoc(new YupiikAsciidoc()); configuration.setAsciidoctorConfiguration(new AsciidoctorConfiguration() { @Override diff --git a/_documentation/src/main/java/io/yupiik/tools/doc/YemCommands.java b/_documentation/src/main/java/io/yupiik/tools/doc/YemCommands.java new file mode 100644 index 00000000..4bd40040 --- /dev/null +++ b/_documentation/src/main/java/io/yupiik/tools/doc/YemCommands.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.tools.doc; + +import io.yupiik.dev.YemModule; +import io.yupiik.fusion.cli.CliAwaiter; +import io.yupiik.fusion.cli.internal.CliModule; +import io.yupiik.fusion.framework.api.ConfiguringContainer; +import io.yupiik.fusion.framework.api.container.bean.ProvidedInstanceBean; +import io.yupiik.fusion.framework.api.main.Args; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; +import io.yupiik.fusion.json.internal.framework.JsonModule; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class YemCommands implements Runnable { + private final Path sourceBase; + + public YemCommands(final Path sourceBase) { + this.sourceBase = sourceBase; + } + + @Override + public void run() { // cheap way to generate the help, todo: make it sexier and contribute it to fusion-documentation? + try (final var container = ConfiguringContainer.of() + .disableAutoDiscovery(true) + .register(new ProvidedInstanceBean<>(DefaultScoped.class, Args.class, () -> new Args(List.of()))) + .register(new JsonModule(), new YemModule(), new CliModule()) + .start(); + final var awaiter = container.lookup(CliAwaiter.class)) { + awaiter.instance().await(); + throw new IllegalStateException("Should have failed since CliAwaiter didn't find any command"); + } catch (final IllegalArgumentException iae) { + try { + Files.writeString( + Files.createDirectories(sourceBase.resolve("content/_partials/generated")).resolve("commands.yem.adoc"), + iae.getMessage().substring(iae.getMessage().indexOf(':') + 1).strip() + // structure next lines + .replace(" Parameters:", "** Parameters:") + .replace(" --", "*** --")); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + } +} diff --git a/_documentation/src/main/minisite/content/yem.adoc b/_documentation/src/main/minisite/content/yem.adoc new file mode 100644 index 00000000..f0b020d1 --- /dev/null +++ b/_documentation/src/main/minisite/content/yem.adoc @@ -0,0 +1,62 @@ += YEM (Environment Manager) +:minisite-index: 600 +:minisite-index-title: YEM +:minisite-index-icon: robot +:minisite-index-description: Yupiik Environment manager + +[abstract] +Setting up dev environment can be a nightmare. +_yem_ intends to make it easier. + +== Inspiration + +_yem_ is inspired from SDKMan, Chocolatey and other alternatives. +The main differences are: + +* _yem_ does not come with a remote storage but it tries to reuse existing ones - priviledging immutable one when possible (compared to SDKMan where you can loose the version you picked), +* _yem_ intends to be portable (linux/windows at least), +* _yem_ is extensible if needed (new source or tool/distribution). + +== Configuration + +IMPORTANT: the atomic configuration is listed there but used on the command line you must ensure to prefix any option by `-` and replace dots by `-`. Example: `central.base` becomes `--central-base`. + +include::{partialsdir}/generated/documentation.yem.adoc[lines=4..-1] + +== CLI + +The command line uses spaces between option and value: `yem install --tool java --version 21.0.2`. + +=== Commands + +include::{partialsdir}/generated/commands.yem.adoc[] + +== Auto-path + +A bit like SDKMan, _yem_ supports to initialize an environment from a file but with some differences. + +The file must be a `properties` file. +Each tool/distribution setup has several properties: + +[source,properties] +---- +prefix.version = 1.2.3 <1> +prefix.provider = xxxx <2> +prefix.relaxed = [true|false] <3> +prefix.envVarName = xxxx <4> +prefix.addToPath = [true|false] <5> +prefix.failOnMissing = [true|false] <6> +prefix.installIfMissing = [true|false] <7> +prefix.toolName = 1.2.3 <8> +---- +<.> Version of the tool to install, using `relaxed` option it can be a version prefix (`21.` for ex), +<.> Provider to use to resolve the tool, if you want to force `zulu` provider instead of using SDKMan to install Java versions for example, +<.> Should version be matched exactly or the first matching one be used, +<.> When `addToPath` is `true` (default) the environment name to setup for this tool - deduced from tool name otherwise, generally `uppercase(tool)_HOME` with dot replaced by underscores, +<.> Should the `PATH` be set too - note that when it is the case `YEM_ORIGINAL_PATH` is set too allowing to reset `PATH` when exiting the folder, +<.> Should the execution fail if a tool is missing (mainly for debug purposes), +<.> Should tools be installed automatically when missing - CI friendly, +<.> If your prefix does not match the tool name, the tool name to use. + +Only the `version` property is required of `prefix` matches a tool name. +You can get as much group of properties as needed tools (one for java 11, one for java 17, one for maven 4 etc...). diff --git a/env-manager/pom.xml b/env-manager/pom.xml new file mode 100644 index 00000000..21dd19ed --- /dev/null +++ b/env-manager/pom.xml @@ -0,0 +1,154 @@ + + + + 4.0.0 + + io.yupiik.maven + yupiik-tools-maven-plugin-parent + 1.2.1-SNAPSHOT + + + io.yupiik.dev + env-manager + Yupiik Tools :: Dev Env + Simple library to manage its dev tools. + + + + io.yupiik.fusion + fusion-build-api + ${fusion.version} + provided + + + io.yupiik.fusion + fusion-processor + ${fusion.version} + provided + + + io.yupiik.fusion + fusion-api + ${fusion.version} + + + io.yupiik.fusion + fusion-cli + ${fusion.version} + + + io.yupiik.fusion + fusion-json + ${fusion.version} + + + io.yupiik.fusion + fusion-httpclient + ${fusion.version} + + + io.yupiik.logging + yupiik-logging-jul + 1.0.7 + + + + org.apache.commons + commons-compress + 1.25.0 + + + + io.yupiik.fusion + fusion-testing + ${fusion.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + + + -Afusion.moduleFqn=io.yupiik.dev.YemModule + + + + + default-testCompile + + + -Afusion.moduleFqn=io.yupiik.dev.test.YemTestModule + + + + + + 17 + 17 + 17 + + + + org.apache.maven.plugins + maven-surefire-plugin + + + io.yupiik.logging.jul.YupiikLogManager + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 17 + + + + + + org.apache.geronimo.arthur + arthur-maven-plugin + 1.0.8 + +
io.yupiik.fusion.framework.api.main.Launcher
+ 21.0.2-graalce + ${project.build.directory}/yem + false + + -H:+StaticExecutableWithDynamicLibC + -Djava.util.logging.manager=io.yupiik.logging.jul.YupiikLogManager + +
+
+
+
+
\ No newline at end of file diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Config.java b/env-manager/src/main/java/io/yupiik/dev/command/Config.java new file mode 100644 index 00000000..d2368f66 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/Config.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.central.ApacheMavenConfiguration; +import io.yupiik.dev.provider.central.SingletonCentralConfiguration; +import io.yupiik.dev.provider.github.MinikubeConfiguration; +import io.yupiik.dev.provider.github.SingletonGithubConfiguration; +import io.yupiik.dev.provider.sdkman.SdkManConfiguration; +import io.yupiik.dev.provider.zulu.ZuluCdnConfiguration; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.Map; +import java.util.logging.Logger; + +import static java.util.stream.Collectors.joining; + +@Command(name = "config", description = "Show configuration.") +public class Config implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final SingletonCentralConfiguration central; + private final SdkManConfiguration sdkman; + private final SingletonGithubConfiguration github; + private final ZuluCdnConfiguration zulu; + private final MinikubeConfiguration minikube; + private final ApacheMavenConfiguration maven; + + public Config(final Conf conf, + final SingletonCentralConfiguration central, + final SdkManConfiguration sdkman, + final SingletonGithubConfiguration github, + final ZuluCdnConfiguration zulu, + final MinikubeConfiguration minikube, + final ApacheMavenConfiguration maven) { + this.central = central; + this.sdkman = sdkman; + this.github = github; + this.zulu = zulu; + this.minikube = minikube; + this.maven = maven; + } + + @Override + public void run() { + logger.info(() -> Map.of( + "central", central.configuration(), + "sdkman", sdkman, + "github", github.configuration(), + "zulu", zulu, + "minikube", minikube, + "maven", maven) + .entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> { + final var value = e.getValue().toString(); + return "- " + e.getKey() + ": " + value.substring(value.indexOf('[') + 1, value.lastIndexOf(']')); + }) + .collect(joining("\n"))); + } + + + @RootConfiguration("config") + public record Conf(/* no option yet */) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java new file mode 100644 index 00000000..3b0f5b20 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.logging.Logger; + +@Command(name = "delete", description = "Delete a distribution.") +public class Delete implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final Conf conf; + private final ProviderRegistry registry; + + public Delete(final Conf conf, + final ProviderRegistry registry) { + this.conf = conf; + this.registry = registry; + } + + @Override + public void run() { + final var providerAndVersion = registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false); + providerAndVersion.getKey().delete(conf.tool(), providerAndVersion.getValue().identifier()); + logger.info(() -> "Deleted " + conf.tool() + "@" + providerAndVersion.getValue().version()); + + } + + @RootConfiguration("delete") + public record Conf( + @Property(documentation = "Tool to delete.", required = true) String tool, + @Property(documentation = "Version of `tool` to delete - we recommend to use the actual identifier to avoid to delete more than the expected instance.", required = true) String version, + @Property(documentation = "Provider to use to delete the version (if not t is deduced from the tool/version parameters).") String provider + ) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java new file mode 100644 index 00000000..891b4f17 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.shared.Os; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import static java.io.File.pathSeparator; +import static java.util.Locale.ROOT; +import static java.util.Map.entry; +import static java.util.Optional.ofNullable; +import static java.util.logging.Level.FINE; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; + +@Command(name = "env", description = "Creates a script you can eval in a shell to prepare the environment from a file. Often used as `eval $(yem env--env-rc .yemrc)`") +public class Env implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final Conf conf; + private final ProviderRegistry registry; + private final Os os; + + public Env(final Conf conf, + final Os os, + final ProviderRegistry registry) { + this.conf = conf; + this.os = os; + this.registry = registry; + } + + @Override + public void run() { + final var windows = "windows".equals(os.findOs()); + final var export = windows ? "set " : "export "; + final var comment = windows ? "%% " : "# "; + final var pathName = windows ? "Path" : "PATH"; + final var pathVar = windows ? "%" + pathName + "%" : ("$" + pathName); + + final var isAuto = "auto".equals(conf.rc()); + var rcLocation = isAuto ? auto() : Path.of(conf.rc()); + if (Files.notExists(rcLocation)) { // enable to navigate in the project without loosing the env + while (Files.notExists(rcLocation)) { + var parent = rcLocation.toAbsolutePath().getParent(); + if (parent == null || !Files.isReadable(parent)) { + break; + } + parent = parent.getParent(); + if (parent == null || !Files.isReadable(parent)) { + break; + } + rcLocation = parent.resolve(isAuto ? rcLocation.getFileName().toString() : conf.rc()); + } + } + + if (Files.notExists(rcLocation) || !Files.isReadable(rcLocation)) { + // just check we have YEM_ORIGINAL_PATH and reset PATH if needed + ofNullable(System.getenv("YEM_ORIGINAL_PATH")) + .ifPresent(value -> { + if (windows) { + System.out.println("set YEM_ORIGINAL_PATH="); + } else { + System.out.println("unset YEM_ORIGINAL_PATH"); + } + System.out.println(export + " " + pathName + "=\"" + value + '"'); + }); + return; + } + + final var props = new Properties(); + try (final var reader = Files.newBufferedReader(rcLocation)) { + props.load(reader); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + if (".sdkmanrc".equals(rcLocation.getFileName().toString())) { + rewritePropertiesFromSdkManRc(props); + } + + final var logger = this.logger.getParent().getParent(); + final var useParentHandlers = logger.getUseParentHandlers(); + final var messages = new ArrayList(); + final var tempHandler = new Handler() { // forward all standard messages to stderr and at debug level to avoid to break default behavior + @Override + public void publish(final LogRecord record) { + // capture to forward messages in the shell when init is done (thanks eval call) + if (logger.isLoggable(record.getLevel())) { + messages.add(record.getMessage()); + } + + // enable to log at fine level for debug purposes + record.setLevel(FINE); + if (useParentHandlers) { + logger.getParent().log(record); + } + } + + @Override + public void flush() { + // no-op + } + + @Override + public void close() throws SecurityException { + flush(); + } + }; + logger.setUseParentHandlers(false); + logger.addHandler(tempHandler); + + try { + final var resolved = props.stringPropertyNames().stream() + .filter(it -> it.endsWith(".version")) + .map(versionKey -> { + final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); + return new ToolProperties( + props.getProperty(name + ".toolName", name), + props.getProperty(versionKey), + props.getProperty(name + ".provider"), + Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), + props.getProperty(name + ".envVarName", name.toUpperCase(ROOT).replace('.', '_') + "_HOME"), + Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), + Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), + Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); + }) + .flatMap(tool -> registry.tryFindByToolVersionAndProvider( + tool.toolName(), tool.version(), + tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), + new ProviderRegistry.Cache(new IdentityHashMap<>(), new IdentityHashMap<>())) + .or(() -> { + if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); + } + return Optional.empty(); + }) + .flatMap(providerAndVersion -> { + final var provider = providerAndVersion.getKey(); + final var version = providerAndVersion.getValue().identifier(); + return provider.resolve(tool.toolName(), tool.version()) + .or(() -> { + if (tool.installIfMissing()) { + logger.info(() -> "Installing " + tool.toolName() + '@' + version); + provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP); + } else if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); + } + return provider.resolve(tool.toolName(), version); + }); + }) + .stream() + .map(home -> entry(tool, home))) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + final var toolVars = resolved.entrySet().stream() + .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\"") + .sorted() + .collect(joining("\n", "", "\n")); + + final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) + .or(() -> ofNullable(System.getenv(pathName))) + .orElse(""); + final var pathVars = resolved.keySet().stream().anyMatch(ToolProperties::addToPath) ? + export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\"\n" + + export + pathName + "=\"" + resolved.entrySet().stream() + .filter(r -> r.getKey().addToPath()) + .map(r -> quoted(toBin(r.getValue()))) + .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\"\n" : + ""; + final var echos = Boolean.parseBoolean(props.getProperty("echo", "true")) ? + resolved.entrySet().stream() + .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\"") + .collect(joining("\n", "", "\n")) : + ""; + + final var script = messages.stream().map(m -> "echo \"[yem] " + m + "\"").collect(joining("\n", "", "\n\n")) + + pathVars + toolVars + echos + "\n" + + comment + "To load a .yemrc configuration run:\n" + + comment + "[ -f .yemrc ] && eval $(yem env--env-file .yemrc)\n" + + comment + "\n" + + comment + "yemrc format is based on properties\n" + + comment + "(only version one is required, version being either a plain version or version identifier, see versions command)\n" + + comment + "$toolName is just a marker or if .toolName is not set it is the actual tool name:\n" + + comment + "$toolName.toolName = xxxx\n" + + comment + "\n" + + comment + "$toolName.version = 1.2.3\n" + + comment + "$toolName.provider = xxxx\n" + + comment + "$toolName.relaxed = [true|false]\n" + + comment + "$toolName.envVarName = xxxx\n" + + comment + "$toolName.addToPath = [true|false]\n" + + comment + "$toolName.failOnMissing = [true|false]\n" + + comment + "$toolName.installIfMissing = [true|false]\n" + + "\n"; + System.out.println(script); + } finally { + logger.setUseParentHandlers(useParentHandlers); + logger.removeHandler(tempHandler); + } + } + + private void rewritePropertiesFromSdkManRc(final Properties props) { + final var original = new Properties(); + original.putAll(props); + + props.clear(); + props.setProperty("addToPath", "true"); + props.putAll(original.stringPropertyNames().stream() + .collect(toMap(p -> p + ".version", original::getProperty))); + } + + private Path auto() { + return Stream.of(".yemrc", ".sdkmanrc") + .map(Path::of) + .filter(Files::exists) + .findFirst() + .orElseGet(() -> Path.of(".yemrc")); + } + + private String quoted(final Path path) { + return path + .toAbsolutePath() + .normalize() + .toString() + .replace("\"", "\\\""); + } + + private Path toBin(final Path value) { + return Stream.of("bin" /* add other potential folders */) + .map(value::resolve) + .filter(Files::exists) + .findFirst() + .orElse(value); + } + + @RootConfiguration("env") + public record Conf( + @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", required = true) String rc) { + } + + private record ToolProperties( + String toolName, + String version, + String provider, + boolean relaxed, + String envVarName, + boolean addToPath, + boolean failOnMissing, + boolean installIfMissing) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Install.java b/env-manager/src/main/java/io/yupiik/dev/command/Install.java new file mode 100644 index 00000000..91b2f2dc --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/Install.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.logging.Logger; +import java.util.stream.IntStream; + +import static java.util.stream.Collectors.joining; + +@Command(name = "install", description = "Install a distribution.") +public class Install implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final Conf conf; + private final ProviderRegistry registry; + + public Install(final Conf conf, + final ProviderRegistry registry) { + this.conf = conf; + this.registry = registry; + } + + @Override + public void run() { + final var providerAndVersion = registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed()); + final var result = providerAndVersion.getKey().install(conf.tool(), providerAndVersion.getValue().identifier(), new Provider.ProgressListener() { + @Override + public void onProcess(final String name, final double percent) { + final int plain = (int) (10 * percent); + System.out.printf("%s [%s]\r", + name, + (plain == 0 ? "" : IntStream.range(0, plain).mapToObj(i -> "X").collect(joining(""))) + + (plain == 10 ? "" : IntStream.range(0, 10 - plain).mapToObj(i -> "_").collect(joining("")))); + } + }); + logger.info(() -> "Installed " + conf.tool() + "@" + providerAndVersion.getValue().version() + " at '" + result + "'"); + } + + @RootConfiguration("install") + public record Conf( + @Property(documentation = "Should progress bar be skipped (can be useful on CI for example).", defaultValue = "System.getenv(\"CI\") != null") boolean skipProgress, + @Property(documentation = "Tool to install.", required = true) String tool, + @Property(documentation = "Version of `tool` to install.", required = true) String version, + @Property(documentation = "Provider to use to install the version (if not t is deduced from the tool/version parameters).") String provider, + @Property(documentation = "Should version be matched with a `startsWith` logic (ex: `install --install-tool java --install-relaxed true --install-version 21.`).", defaultValue = "false") boolean relaxed + ) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/List.java b/env-manager/src/main/java/io/yupiik/dev/command/List.java new file mode 100644 index 00000000..e526b2ef --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/List.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.Map; +import java.util.logging.Logger; + +import static java.util.function.Function.identity; +import static java.util.logging.Level.FINEST; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; + +@Command(name = "list", description = "List remote (available) distributions.") +public class List implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final ProviderRegistry registry; + + public List(final Conf conf, + final ProviderRegistry registry) { + this.registry = registry; + } + + @Override + public void run() { + final var collect = registry.providers().stream() + .map(p -> { + try { + return p.listTools().stream() + .collect(toMap(identity(), tool -> p.listVersions(tool.tool()))); + } catch (final RuntimeException re) { + logger.log(FINEST, re, re::getMessage); + return Map.>of(); + } + }) + .flatMap(m -> m.entrySet().stream()) + .map(e -> "- " + e.getKey().tool() + ":" + (e.getValue().isEmpty() ? + " no version available" : + e.getValue().stream() + .sorted((a, b) -> -a.compareTo(b)) + .map(v -> "-- " + v.version()) + .collect(joining("\n", "\n", "\n")))) + .sorted() + .collect(joining("\n")); + logger.info(() -> collect.isBlank() ? "No distribution available." : collect); + } + + @RootConfiguration("list") + public record Conf(/* no option yet */) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java b/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java new file mode 100644 index 00000000..f3d68e1b --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.Objects; +import java.util.logging.Logger; + +import static java.util.stream.Collectors.joining; + +@Command(name = "list-local", description = "List local available distributions.") +public class ListLocal implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + + private final ProviderRegistry registry; + private final Conf conf; + + public ListLocal(final Conf conf, + final ProviderRegistry registry) { + this.conf = conf; + this.registry = registry; + } + + @Override + public void run() { + final var collect = registry.providers().stream() + .flatMap(p -> p.listLocal().entrySet().stream() + .filter(it -> (conf.tool() == null || Objects.equals(conf.tool(), it.getKey().tool())) && + !it.getValue().isEmpty()) + .map(e -> "- [" + p.name() + "] " + e.getKey().tool() + ":" + (e.getValue().isEmpty() ? + " no version" : + e.getValue().stream() + .sorted((a, b) -> -a.compareTo(b)) // more recent first + .map(v -> "-- " + v.version()) + .collect(joining("\n", "\n", "\n"))))) + .sorted() + .collect(joining("\n")); + logger.info(() -> collect.isBlank() ? "No distribution available." : collect); + } + + @RootConfiguration("list-local") + public record Conf(@Property(documentation = "Tool to filter.") String tool) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/ListProviders.java b/env-manager/src/main/java/io/yupiik/dev/command/ListProviders.java new file mode 100644 index 00000000..28561d6f --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/ListProviders.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.logging.Logger; + +import static java.util.stream.Collectors.joining; + +@Command(name = "list-providers", description = "List available providers.") +public class ListProviders implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final ProviderRegistry registry; + + public ListProviders(final Conf conf, + final ProviderRegistry registry) { + this.registry = registry; + } + + @Override + public void run() { + final var collect = registry.providers().stream() + .map(p -> "- " + p.name()) + .sorted() + .collect(joining("\n")); + logger.info(() -> collect.isBlank() ? "No provider available." : collect); + } + + @RootConfiguration("list-providers") + public record Conf(/* no option yet */) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java new file mode 100644 index 00000000..1371f3d1 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.util.logging.Logger; + +@Command(name = "resolve", description = "Resolve a distribution.") +public class Resolve implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final Conf conf; + private final ProviderRegistry registry; + + public Resolve(final Conf conf, + final ProviderRegistry registry) { + this.conf = conf; + this.registry = registry; + } + + @Override + public void run() { + final var providerAndVersion = registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false); + final var resolved = providerAndVersion.getKey().resolve(conf.tool(), providerAndVersion.getValue().identifier()) + .orElseThrow(() -> new IllegalArgumentException("No matching instance for " + conf.tool() + "@" + conf.version() + ", ensure to install it before resolving it.")); + logger.info(() -> "Resolved " + conf.tool() + "@" + providerAndVersion.getValue().version() + ": '" + resolved + "'"); + + } + + @RootConfiguration("resolve") + public record Conf( + @Property(documentation = "Tool to resolve.", required = true) String tool, + @Property(documentation = "Version of `tool` to resolve.", required = true) String version, + @Property(documentation = "Provider to use to resolve the version (if not t is deduced from the tool/version parameters).") String provider, + @Property(documentation = "Should version be matched with a `startsWith` logic (ex: `resolve --resolve-tool java --resolve-relaxed true --resolve-version 21.`).", defaultValue = "false") boolean relaxed + ) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java b/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java new file mode 100644 index 00000000..a5bd9fab --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.configuration; + +import io.yupiik.fusion.framework.api.RuntimeContainer; +import io.yupiik.fusion.framework.api.container.bean.ProvidedInstanceBean; +import io.yupiik.fusion.framework.api.lifecycle.Start; +import io.yupiik.fusion.framework.api.main.Args; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; +import io.yupiik.fusion.framework.build.api.event.OnEvent; +import io.yupiik.fusion.framework.build.api.order.Order; + +import java.util.List; +import java.util.stream.Stream; + +import static java.util.Optional.ofNullable; + +@DefaultScoped +public class EnableSimpleOptionsArgs { // here the goal is to auto-complete short options (--tool) by prefixing it with the command name. + public void onStart(@OnEvent @Order(Integer.MIN_VALUE) final Start start, final RuntimeContainer container) { + ofNullable(container.getBeans().getBeans().get(Args.class)) + .ifPresent(args -> { + final var enriched = enrich(((Args) args.get(0).create(container, null)).args()); + container.getBeans().getBeans() + .put(Args.class, List.of(new ProvidedInstanceBean<>(DefaultScoped.class, Args.class, () -> enriched))); + }); + } + + private Args enrich(final List args) { + if (args == null || args.isEmpty() || args.get(0).startsWith("-") /* not a command */) { + return new Args(args); + } + final var prefix = "--" + args.get(0) + '-'; + return new Args(args.stream() + .flatMap(i -> i.startsWith("--") && !i.startsWith(prefix) ? + (i.substring("--".length()).contains("-") ? + Stream.of(prefix + i.substring("--".length()), i) : + Stream.of(prefix + i.substring("--".length()))) : + Stream.of(i)) + .toList()); + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java new file mode 100644 index 00000000..40574ce4 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.configuration; + +import io.yupiik.fusion.framework.api.configuration.ConfigurationSource; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; +import io.yupiik.fusion.framework.build.api.order.Order; + +@DefaultScoped +@Order(Integer.MAX_VALUE) +public class ImplicitKeysConfiguration implements ConfigurationSource { + @Override + public String get(final String key) { + return "fusion.json.maxStringLength".equals(key) ? + System.getProperty(key, "8388608") /* zulu payload is huge and would be slow to keep allocating mem */ : + null; + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java b/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java new file mode 100644 index 00000000..17aab7d9 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider; + +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Represents a source of distribution/tool and integrates with a external+local (cache) storage. + */ +public interface Provider { // NOTE: normally we don't need a reactive impl since we resolve most of tools locally + String name(); + + List listTools(); + + List listVersions(String tool); + + Archive download(String tool, String version, Path target, ProgressListener progressListener); + + void delete(String tool, String version); + + Path install(String tool, String version, ProgressListener progressListener); + + Map> listLocal(); + + Optional resolve(String tool, String version); + + interface ProgressListener { + ProgressListener NOOP = (n, p) -> { + }; + + void onProcess(String name, double percent); + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java new file mode 100644 index 00000000..666bc840 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider; + +import io.yupiik.dev.provider.central.CentralBaseProvider; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.provider.sdkman.SdkManClient; +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static java.util.Locale.ROOT; +import static java.util.Map.entry; +import static java.util.stream.Collectors.joining; + +@ApplicationScoped +public class ProviderRegistry { + private final List providers; + + public ProviderRegistry(final List providers) { + this.providers = providers == null ? null : providers.stream() + .sorted((a, b) -> { // mainly push sdkman last since it does more remoting than othes + if (a == b) { + return 0; + } + if (a instanceof CentralBaseProvider p1 && b instanceof CentralBaseProvider p2) { + return p1.gav().compareTo(p2.gav()); + } + if (a instanceof SdkManClient && !(b instanceof SdkManClient)) { + return 1; + } + if (b instanceof SdkManClient && !(a instanceof SdkManClient)) { + return -1; + } + return a.getClass().getName().compareTo(b.getClass().getName()); + }) + .toList(); + } + + public List providers() { + return providers; + } + + public Map.Entry findByToolVersionAndProvider(final String tool, final String version, final String provider, + final boolean relaxed) { + return tryFindByToolVersionAndProvider(tool, version, provider, relaxed, new Cache(new IdentityHashMap<>(), new IdentityHashMap<>())) + .orElseThrow(() -> new IllegalArgumentException("No provider for tool '" + tool + "' in version '" + version + "', available tools:\n" + + providers().stream() + .flatMap(it -> it.listTools().stream() + .map(Candidate::tool) + .map(t -> "- " + t + "\n" + it.listVersions(t).stream() + .map(v -> "-- " + v.identifier() + " (" + v.version() + ")") + .collect(joining("\n")))) + .sorted() + .collect(joining("\n")))); + } + + public Optional> tryFindByToolVersionAndProvider( + final String tool, final String version, final String provider, final boolean relaxed, + final Cache cache) { + return providers().stream() + .filter(it -> provider == null || + // enable "--install-provider zulu" for example + Objects.equals(provider, it.name()) || + it.getClass().getSimpleName().toLowerCase(ROOT).startsWith(provider.toLowerCase(ROOT))) + .map(it -> { + final var candidates = it.listTools(); + if (candidates.stream().anyMatch(t -> tool.equals(t.tool()))) { + return cache.local.computeIfAbsent(it, Provider::listLocal) + .entrySet().stream() + .filter(e -> Objects.equals(e.getKey().tool(), tool)) + .flatMap(e -> e.getValue().stream() + .filter(v -> matchVersion(v, version, relaxed)) + .findFirst() + .stream()) + .map(v -> entry(it, v)) + .findFirst() + .map(Optional::of) + .orElseGet(() -> { + final var versions = cache.versions + .computeIfAbsent(it, p -> new HashMap<>()) + .computeIfAbsent(tool, it::listVersions); + return versions.stream() + .filter(v -> matchVersion(v, version, relaxed)) + .findFirst() + .map(v -> entry(it, v)); + }); + } + return Optional.>empty(); + }) + .flatMap(Optional::stream) + .findFirst(); + } + + private boolean matchVersion(final Version v, final String version, final boolean relaxed) { + return version.equals(v.version()) || version.equals(v.identifier()) || + (relaxed && v.version().startsWith(version)); + } + + public record Cache(Map>> local, + Map>> versions) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java new file mode 100644 index 00000000..5528817c --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("maven") +public record ApacheMavenConfiguration(@Property(documentation = "Is Apache Maven provider enabled - from central.", defaultValue = "true") boolean enabled) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java new file mode 100644 index 00000000..d12d43e7 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; + +@DefaultScoped +public class ApacheMavenProvider extends CentralBaseProvider { + public ApacheMavenProvider(final YemHttpClient client, final SingletonCentralConfiguration conf, + final Archives archives, final ApacheMavenConfiguration configuration) { + super(client, conf.configuration(), archives, "org.apache.maven:apache-maven:tar.gz:bin", configuration.enabled()); + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java new file mode 100644 index 00000000..d6aa568c --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.http.YemHttpClient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; + +public abstract class CentralBaseProvider implements Provider { + private final YemHttpClient client; + private final Archives archives; + private final URI base; + private final Gav gav; + private final Path local; + private final boolean enabled; + + protected CentralBaseProvider(final YemHttpClient client, + final CentralConfiguration conf, // children must use SingletonCentralConfiguration to avoid multiple creations + final Archives archives, + final String gav, + final boolean enabled) { + this.client = client; + this.archives = archives; + this.base = URI.create(conf.base()); + this.local = Path.of(conf.local()); + this.gav = Gav.of(gav); + this.enabled = enabled; + } + + public Gav gav() { + return gav; + } + + @Override + public String name() { + return gav.groupId() + ":" + gav.artifactId(); // assume it is sufficient for now, else it can be overriden + } + + @Override + public void delete(final String tool, final String version) { + final var archivePath = local.resolve(relativePath(version)); + if (Files.notExists(archivePath)) { + return; + } + final var exploded = archivePath.getParent().resolve(archivePath.getFileName() + "_exploded"); + if (Files.notExists(exploded)) { + return; + } + + if (!enabled) { + throw new IllegalStateException(gav + " support not enabled (by configuration)"); + } + + try { + Files.delete(archivePath); + archives.delete(exploded); + } catch (final IOException e) { + throw new IllegalStateException("Can't delete " + tool + "@" + version, e); + } + } + + @Override + public Path install(final String tool, final String version, final ProgressListener progressListener) { + final var archivePath = local.resolve(relativePath(version)); + final var exploded = archivePath.getParent().resolve(archivePath.getFileName() + "_exploded"); + if (Files.exists(exploded)) { + return exploded; + } + + if (!enabled) { + throw new IllegalStateException(gav + " support not enabled (by configuration)"); + } + + try { + Files.createDirectories(archivePath.getParent()); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + final var archive = Files.notExists(archivePath) ? + download(tool, version, archivePath, progressListener) : + new Archive(gav.type(), archivePath); + return archives.unpack(archive, exploded); + } + + @Override + public Map> listLocal() { + return listTools().stream() + .collect(toMap(identity(), it -> { + final var artifactDir = local.resolve(relativePath("ignored")).getParent().getParent(); + if (Files.notExists(artifactDir)) { + return List.of(); + } + try (final var versions = Files.list(artifactDir)) { + return versions + .filter(Files::isDirectory) + .flatMap(f -> { + try (final var exploded = Files.list(f)) { + return exploded + .filter(Files::isDirectory) + .filter(child -> child.getFileName().toString().endsWith("_exploded")) + .map(distro -> { + final var filename = distro.getFileName().toString(); + final var version = filename.substring(0, filename.length() - "_exploded".length()); + return new Version(gav.groupId(), version, gav.artifactId(), version); + }) + .toList() // materialize otherwise exploded will be closed and lazy evaluation will fail + .stream(); + } catch (final IOException e) { + return Stream.of(); + } + }) + .toList(); + } catch (final IOException e) { + return List.of(); + } + })); + } + + @Override + public Optional resolve(final String tool, final String version) { + final var location = local.resolve(relativePath(version)); + if (Files.notExists(location)) { + return Optional.empty(); + } + + final var exploded = location.getParent().resolve(location.getFileName() + "_exploded"); + if (Files.notExists(exploded)) { + return Optional.empty(); + } + + final var maybeMac = exploded.resolve("Contents/Home"); + if (Files.isDirectory(maybeMac)) { + return Optional.of(maybeMac); + } + + return Optional.of(exploded); + } + + @Override + public List listTools() { + if (!enabled) { + return List.of(); + } + + final var gavString = Stream.of(gav.groupId(), gav.artifactId(), gav.type(), gav.classifier()) + .filter(Objects::nonNull) + .collect(joining(":")); + return List.of(new Candidate(gavString, gav.artifactId(), gavString + " downloaded from central.", base.toASCIIString())); + } + + @Override + public Archive download(final String tool, final String version, final Path target, final ProgressListener progressListener) { + if (!enabled) { + throw new IllegalStateException(gav + " support not enabled (by configuration)"); + } + + final var res = client.getFile(HttpRequest.newBuilder() + .uri(base.resolve(relativePath(version))) + .build(), + target, progressListener); + ensure200(res); + return new Archive(gav.type().endsWith(".zip") || gav.type().endsWith(".jar") ? "zip" : "tar.gz", target); + } + + @Override + public List listVersions(final String tool) { + if (!enabled) { + return List.of(); + } + + final var res = client.send(HttpRequest.newBuilder() + .GET() + .uri(base + .resolve(gav.groupId().replace('.', '/') + '/') + .resolve(gav.artifactId() + '/') + .resolve("maven-metadata.xml")) + .build()); + ensure200(res); + return parseVersions(res.body()); + } + + private String relativePath(final String version) { + return gav.groupId().replace('.', '/') + '/' + + gav.artifactId() + '/' + + version + '/' + + gav.artifactId() + '-' + version + (gav.classifier() != null ? '-' + gav.classifier() : "") + "." + gav.type(); + } + + private List parseVersions(final String body) { + final var out = new ArrayList(2); + int from = body.indexOf(""); + while (from > 0) { + from += "".length(); + final int end = body.indexOf("", from); + if (end < 0) { + break; + } + + final var version = body.substring(from, end).strip(); + out.add(new Version(gav.groupId(), version, gav.artifactId(), version)); + from = body.indexOf("", end + "".length()); + } + return out; + } + + private void ensure200(final HttpResponse res) { + if (res.statusCode() != 200) { + throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); + } + } + + public record Gav(String groupId, String artifactId, String type, String classifier) implements Comparable { + private static Gav of(final String gav) { + final var segments = gav.split(":"); + return switch (segments.length) { + case 2 -> new Gav(segments[0], segments[1], "jar", null); + case 3 -> new Gav(segments[0], segments[1], segments[2], null); + case 4 -> new Gav(segments[0], segments[1], segments[2], segments[3]); + default -> throw new IllegalArgumentException("Invalid gav: '" + gav + "'"); + }; + } + + @Override + public int compareTo(final Gav o) { + if (this == o) { + return 0; + } + final int g = groupId().compareTo(o.groupId()); + if (g != 0) { + return g; + } + final int a = artifactId().compareTo(o.artifactId()); + if (a != 0) { + return a; + } + final int t = type().compareTo(o.type()); + if (t != 0) { + return t; + } + return (classifier == null ? "" : classifier).compareTo(o.classifier() == null ? "" : o.classifier()); + } + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java new file mode 100644 index 00000000..4faf5ec5 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("central") +public record CentralConfiguration( + @Property(documentation = "Base repository URL.", defaultValue = "\"https://repo.maven.apache.org/maven2/\"") String base, + @Property(documentation = "Local repository path.", defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.m2/repository\"") String local) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java new file mode 100644 index 00000000..4baebb99 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; + +@ApplicationScoped +public class SingletonCentralConfiguration { + private final CentralConfiguration configuration; + + public SingletonCentralConfiguration(final CentralConfiguration configuration) { + this.configuration = configuration; + } + + public CentralConfiguration configuration() { + return configuration; + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/GithubConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/GithubConfiguration.java new file mode 100644 index 00000000..0d0b0fa1 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/GithubConfiguration.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.github; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("github") +public record GithubConfiguration( + @Property(documentation = "Base repository URL.", defaultValue = "\"https://api.github.com\"") String base, + @Property(documentation = "Local repository path.", defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.yupiik/yem/github\"") String local) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeConfiguration.java new file mode 100644 index 00000000..bc65f438 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeConfiguration.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.github; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("minikube") +public record MinikubeConfiguration( + @Property(documentation = "Is Minikube (Github) support enabled. It is disabled by default cause it also depends on local dependencies (docker or cri depending how you plan to run - driver).", defaultValue = "false") boolean enabled) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java new file mode 100644 index 00000000..918dc03e --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.github; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.fusion.framework.api.container.Types; +import io.yupiik.fusion.json.JsonMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import static java.nio.file.attribute.PosixFilePermission.GROUP_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.GROUP_READ; +import static java.nio.file.attribute.PosixFilePermission.OTHERS_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.OTHERS_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; + +// todo: complete to make it functional with --driver=none? +public class MinikubeGithubClient implements Provider { + private final YemHttpClient client; + private final JsonMapper jsonMapper; + private final String assetName; + private final URI base; + private final Path local; + private final boolean enabled; + private final Archives archives; + + public MinikubeGithubClient(final SingletonGithubConfiguration githubConfiguration, final MinikubeConfiguration conf, + final YemHttpClient client, final JsonMapper jsonMapper, final Os os, final Archives archives) { + this.client = client; + this.enabled = conf.enabled(); + this.jsonMapper = jsonMapper; + this.archives = archives; + this.base = URI.create(githubConfiguration.configuration().base()); + this.local = Path.of(githubConfiguration.configuration().local()); + this.assetName = "minikube-" + switch (os.findOs()) { + case "windows" -> "windows"; + case "macos" -> "darwin"; + default -> "linux"; + } + '-' + (os.isArm() ? "arm64" : "amd64") + ".tar.gz"; + } + + @Override + public String name() { + return "minikube-github"; + } + + @Override + public List listTools() { + if (!enabled) { + return List.of(); + } + return List.of(new Candidate( + "minikube", "Minikube", "Local development Kubernetes binary.", "https://minikube.sigs.k8s.io/docs/")); + } + + @Override + public List listVersions(final String tool) { + if (!enabled) { + return List.of(); + } + + final var releases = findReleases(); + return releases.stream() + .filter(r -> r.name().startsWith("v") && r.assets() != null && r.assets().stream() + .anyMatch(a -> Objects.equals(a.name(), assetName))) + .map(r -> { + var version = r.name(); + if (version.startsWith("v")) { + version = version.substring(1); + } + return new Version("Kubernetes", version, "minikube", version); + }) + .toList(); + } + + @Override + public Archive download(final String tool, final String version, final Path target, final ProgressListener progressListener) { + if (!enabled) { + throw new IllegalStateException("Minikube support not enabled (by configuration)"); + } + + // todo: we can simplify that normally + final var versions = findReleases(); + final var assets = versions.stream() + .filter(it -> Objects.equals(version, it.name()) || + (it.name().startsWith("v") && Objects.equals(version, it.name().substring(1)))) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No version '" + version + "' matched, availables:" + versions.stream() + .map(Release::name) + .map(v -> "- " + v) + .collect(joining("\n", "", "\n")))) + .assets(); + final var uri = URI.create(assets.stream() + .filter(a -> Objects.equals(a.name(), assetName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No matching asset for this version:" + assets.stream() + .map(Release.Asset::name) + .map(a -> "- " + a) + .collect(joining("\n", "\n", "\n")))) + .browserDownloadUrl()); + final var res = client.getFile(HttpRequest.newBuilder().uri(uri).build(), target, progressListener); + if (res.statusCode() != 200) { + throw new IllegalArgumentException("Can't download " + uri + ": " + res + "\n" + res.body()); + } + return new Archive("tar.gz", target); + } + + @Override + public Path install(final String tool, final String version, final ProgressListener progressListener) { + final var archivePath = local.resolve("minikube").resolve(version).resolve(assetName); + final var exploded = archivePath.getParent().resolve("distribution_exploded"); + if (Files.exists(exploded)) { + return exploded; + } + + if (!enabled) { + throw new IllegalStateException("Minikube support not enabled (by configuration)"); + } + + try { + Files.createDirectories(archivePath.getParent()); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + final var archive = Files.notExists(archivePath) ? + download(tool, version, archivePath, progressListener) : + new Archive("tar.gz", archivePath); + final var unpacked = archives.unpack(archive, exploded); + try (final var list = Files.list(unpacked)) { + final var bin = assetName.substring(0, assetName.length() - ".tar.gz".length()); + list + .filter(Files::isRegularFile) + .filter(it -> Objects.equals(it.getFileName().toString(), bin)) + .forEach(f -> { + try { + Files.setPosixFilePermissions( + Files.move(f, f.getParent().resolve("minikube")), // rename to minikube + Stream.of( + OWNER_READ, OWNER_EXECUTE, OWNER_WRITE, + GROUP_READ, GROUP_EXECUTE, + OTHERS_READ, OTHERS_EXECUTE) + .collect(toSet())); + } catch (final IOException e) { + // no-op + } + }); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + return unpacked; + } + + @Override + public void delete(final String tool, final String version) { + if (!enabled) { + throw new IllegalStateException("Minikube support not enabled (by configuration)"); + } + + final var base = local.resolve("minikube").resolve(version); + if (Files.exists(base)) { + archives.delete(base); + } + } + + @Override + public Map> listLocal() { + final var root = local.resolve("minikube"); + if (Files.notExists(root)) { + return Map.of(); + } + return listTools().stream().collect(toMap(identity(), t -> { + try (final var children = Files.list(root)) { + return children + .filter(r -> Files.exists(r.resolve("distribution_exploded"))) + .map(v -> { + final var version = v.getFileName().toString(); + return new Version("Kubernetes", version, "minikube", version); + }) + .toList(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + })); + } + + @Override + public Optional resolve(final String tool, final String version) { + final var distribution = local.resolve("minikube").resolve(version).resolve("distribution_exploded"); + if (Files.notExists(distribution)) { + return Optional.empty(); + } + return Optional.of(distribution); + } + + private List findReleases() { + final var res = client.send( + HttpRequest.newBuilder() + .GET() + .header("accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .uri(base.resolve("/repos/kubernetes/minikube/releases")) + .build()); + if (res.statusCode() != 200) { + throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); + } + + final var type = new Types.ParameterizedTypeImpl(List.class, Release.class); + final List releases = jsonMapper.fromString(type, res.body()); + return releases; + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/Release.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/Release.java new file mode 100644 index 00000000..857b6582 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/Release.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.github; + +import io.yupiik.fusion.framework.build.api.json.JsonModel; +import io.yupiik.fusion.framework.build.api.json.JsonProperty; + +import java.util.List; + +@JsonModel +public record Release(String name, List assets) { + @JsonModel + public record Asset(String name, @JsonProperty("browser_download_url") String browserDownloadUrl) { + } +} \ No newline at end of file diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/SingletonGithubConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/SingletonGithubConfiguration.java new file mode 100644 index 00000000..2a75998e --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/SingletonGithubConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.github; + +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; + +@ApplicationScoped +public class SingletonGithubConfiguration { + private final GithubConfiguration configuration; + + public SingletonGithubConfiguration(final GithubConfiguration configuration) { + this.configuration = configuration; + } + + public GithubConfiguration configuration() { + return configuration; + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/model/Archive.java b/env-manager/src/main/java/io/yupiik/dev/provider/model/Archive.java new file mode 100644 index 00000000..43160994 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/model/Archive.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.model; + +import java.nio.file.Path; + +public record Archive(String type, Path location) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java b/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java new file mode 100644 index 00000000..535c9505 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.model; + +public record Candidate(String tool, String name, String description, String url) { +} \ No newline at end of file diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/model/Version.java b/env-manager/src/main/java/io/yupiik/dev/provider/model/Version.java new file mode 100644 index 00000000..40a80adf --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/model/Version.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.model; + +import java.util.regex.Pattern; + +public record Version(String vendor, String version, String dist, String identifier) implements Comparable { + private static final Pattern DOT_SPLITTER = Pattern.compile("\\."); + + @Override + public int compareTo(final Version other) { + final var s1 = DOT_SPLITTER.split(version().replace('-', '.')); + final var s2 = DOT_SPLITTER.split(other.version().replace('-', '.')); + for (int i = 0; i < s1.length; i++) { + if (s2.length <= i) { + return 1; + } + try { + final var segment1 = s1[i]; + final var segment2 = s2[i]; + if (segment1.equals(segment2)) { // enables to handle alpha case for ex + continue; + } + + return Integer.parseInt(segment1) - Integer.parseInt(segment2); + } catch (final NumberFormatException nfe) { + // alphabetical comparison + } + } + return version().compareTo(other.version()); + } +} \ No newline at end of file diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java new file mode 100644 index 00000000..0480c5e9 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.sdkman; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static java.util.Optional.of; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.toMap; + +@DefaultScoped +public class SdkManClient implements Provider { + private final String platform; + private final Archives archives; + private final YemHttpClient client; + private final URI base; + private final Path local; + private final boolean enabled; + private final Pattern oldVersionsSplitter = Pattern.compile("\\ +"); + + public SdkManClient(final YemHttpClient client, final SdkManConfiguration configuration, final Os os, final Archives archives) { + this.client = client; + this.archives = archives; + this.base = URI.create(configuration.base()); + this.local = Path.of(configuration.local()); + this.enabled = configuration.enabled(); + this.platform = ofNullable(configuration.platform()) + .filter(i -> !"auto".equalsIgnoreCase(i)) + .orElseGet(() -> switch (os.findOs()) { + case "windows" -> "windowsx64"; + case "linux" -> os.is32Bits() ? + (os.isArm() ? "linuxarm32hf" : "linuxx32") : + (os.isAarch64() ? "linuxarm64" : "linuxx64"); + case "mac" -> os.isArm() ? "darwinarm64" : "darwinx64"; + case "solaris" -> "linuxx64"; + default -> "exotic"; + }); + } + + @Override + public String name() { + return "sdkman"; + } + + @Override + public void delete(final String tool, final String version) { + if (!enabled) { + throw new IllegalStateException("SDKMan support not enabled (by configuration)"); + } + + final var target = local.resolve(tool).resolve(version); + if (Files.exists(target)) { + archives.delete(target); + } + // todo: check current symbolic link? + } + + @Override + public Path install(final String tool, final String version, final ProgressListener progressListener) { + final var target = local.resolve(tool).resolve(version); + if (Files.exists(target)) { + final var maybeMac = target.resolve("Contents/Home"); // todo: check it is the case + if (Files.isDirectory(maybeMac)) { + return maybeMac; + } + return target; + } + + if (!enabled) { + throw new IllegalStateException("SDKMan support not enabled (by configuration)"); + } + + try { + final var toDelete = Files.createDirectories(local.resolve(tool).resolve(version + ".yem.tmp")); + Files.createDirectories(local.resolve(tool).resolve(version)); + try { + final var archive = download(tool, version, toDelete.resolve("distro.archive"), progressListener); + return archives.unpack(archive, target); + } finally { + archives.delete(toDelete); + } + } catch (final IOException e) { + archives.delete(local.resolve(tool).resolve(version)); + throw new IllegalStateException(e); + } + } + + @Override + public Map> listLocal() { + if (Files.notExists(local)) { + return Map.of(); + } + try (final var tool = Files.list(local)) { + // todo: if we cache in listTools we could reuse the meta there + return tool.collect(toMap( + it -> { + final var name = it.getFileName().toString(); + return new Candidate(name, name, "", ""); + }, + it -> { + if (Files.notExists(it)) { + return List.of(); + } + try (final var versions = Files.list(it)) { + return versions + .filter(v -> !Files.isSymbolicLink(v) && !"current".equals(v.getFileName().toString())) + .map(v -> { // todo: same + final var version = v.getFileName().toString(); + return new Version("sdkman", version, "", version); + }) + .toList(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + })); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + + @Override + public Optional resolve(final String tool, final String version) { // don't disable since it is 100% local + final var location = local.resolve(tool).resolve(version); + if (Files.notExists(location)) { + return Optional.empty(); + } + return of(location); + } + + @Override // warn: zip for windows often and tar.gz for linux + public Archive download(final String tool, final String version, final Path target, final ProgressListener progressListener) { // todo: checksum (x-sdkman headers) etc + if (!enabled) { + throw new IllegalStateException("SDKMan support not enabled (by configuration)"); + } + final var res = client.getFile(HttpRequest.newBuilder() + .uri(base.resolve("broker/download/" + tool + "/" + version + "/" + platform)) + .build(), + target, progressListener); + ensure200(res); + return new Archive( + StreamSupport.stream(Spliterators.spliteratorUnknownSize(new Iterator>() { + private HttpResponse current = res; + + @Override + public boolean hasNext() { + return current != null; + } + + @Override + public HttpResponse next() { + try { + return current; + } finally { + current = current.previousResponse().orElse(null); + } + } + }, Spliterator.IMMUTABLE), false) + .filter(Objects::nonNull) + .map(r -> r.headers().firstValue("x-sdkman-archivetype").orElse(null)) + .filter(Objects::nonNull) + .findFirst() + .orElse("tar.gz"), + target); + } + + @Override + public List listTools() { // todo: cache in sdkman folder a sdkman.yem.properties? refresh once per day? + if (!enabled) { + return List.of(); + } + + // note: we could use ~/.sdkman/var/candidates too but + // this would assume sdkman keeps updating this list which is likely not true + + final var res = client.send(HttpRequest.newBuilder() + .uri(base.resolve("candidates/list")) + .build()); + ensure200(res); + return parseList(res.body()); + } + + @Override + public List listVersions(final String tool) { + if (!enabled) { + return List.of(); + } + final var res = client.send(HttpRequest.newBuilder() + .uri(base.resolve("candidates/" + tool + "/" + platform + "/versions/list?current=&installed=")) + .build()); + ensure200(res); + return parseVersions(tool, res.body()); + } + + private List parseVersions(final String tool, final String body) { + final var markerStart = "--------------------------------------------------------------------------------"; + final var markerEnd = "================================================================================"; + + final var allLines = lines(body); + final var lines = allLines.subList(allLines.indexOf(markerStart) + 1, allLines.size()); + final var versions = new ArrayList(16); + + { + String lastVendor = null; + final var from = lines.iterator(); + while (from.hasNext()) { + final var next = from.next(); + if (Objects.equals(markerEnd, next)) { + break; + } + + final var segments = next.strip().split("\\|"); + if (segments.length == 6) { + // Vendor | Use | Version | Dist | Status | Identifier + if (!segments[0].isBlank()) { + lastVendor = segments[0].strip(); + } + versions.add(new Version(lastVendor, segments[2].strip(), segments[3].strip(), segments[5].strip())); + } + } + } + + if (versions.isEmpty()) { // try old style + var data = lines; + for (int i = 0; i < 2; i++) { + final int marker = data.indexOf(markerEnd); + if (marker < 0) { + break; + } + data = data.subList(marker + 1, data.size()); + } + + final var from = data.iterator(); + while (from.hasNext()) { + final var next = from.next(); + if (next.isBlank() || next.charAt(0) != ' ') { + continue; + } + if (Objects.equals(markerEnd, next)) { + break; + } + + final var segments = oldVersionsSplitter.split(next.strip()); + if (segments.length > 0) { + versions.addAll(Stream.of(segments) + .filter(Predicate.not(String::isBlank)) + .map(v -> new Version(tool, v, "sdkman", v)) + .toList()); + } + } + } + + return versions; + } + + private List parseList(final String body) { + final var allLines = lines(body); + + final var marker = "--------------------------------------------------------------------------------"; + + final var from = allLines.iterator(); + final var candidates = new ArrayList(16); + while (from.hasNext()) { + if (!Objects.equals(marker, from.next()) || !from.hasNext()) { + continue; + } + + // first line: Java (21.0.2-tem) https://projects.eclipse.org/projects/adoptium.temurin/ + final var line1 = from.next(); + final int sep1 = line1.lastIndexOf(" ("); + final int sep2 = line1.indexOf(')', sep1); + if (sep1 < 0 || sep2 < 0) { + throw new IllegalArgumentException("Invalid first line: '" + line1 + "'"); + } + final int link = line1.indexOf("h", sep2); + + String tool = null; + final var description = new StringBuilder(); + while (from.hasNext()) { + final var next = from.next(); + if (next.strip().startsWith("$ sdk install ")) { + tool = next.substring(next.lastIndexOf(' ') + 1).strip(); + break; + } + if (next.isBlank()) { + continue; + } + if (!description.isEmpty()) { + description.append(' '); + } + description.append(next.strip()); + } + candidates.add(new Candidate( + tool, line1.substring(0, sep1), // version=line1.substring(sep1 + 2, sep2), + description.toString(), link > 0 ? line1.substring(link) : "")); + } + return candidates; + } + + private List lines(final String body) { + final List allLines; + try (final var reader = new BufferedReader(new StringReader(body))) { + allLines = reader.lines().toList(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + return allLines; + } + + private void ensure200(final HttpResponse res) { + if (res.statusCode() != 200) { + throw new IllegalArgumentException("Invalid response: " + res + "\n" + res.body()); + } + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManConfiguration.java new file mode 100644 index 00000000..8ed5a194 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.sdkman; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("sdkman") +public record SdkManConfiguration( + @Property(documentation = "Is SDKMan support enabled.", defaultValue = "true") boolean enabled, + @Property(documentation = "Base URL for SDKMan API.", defaultValue = "\"https://api.sdkman.io/2/\"") String base, + @Property(documentation = "SDKMan platform value - if `auto` it will be computed.", defaultValue = "\"auto\"") String platform, + @Property(documentation = "SDKMan local candidates directory, generally `$HOME/.sdkman/candidates`.", + defaultValue = "java.util.Optional.ofNullable(System.getenv(\"SDKMAN_DIR\"))" + + ".map(b -> java.nio.file.Path.of(b).resolve(\"candidates\").toString())" + + ".orElseGet(() -> System.getProperty(\"user.home\", \"\") + \"/.sdkman/candidates\")") String local +) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java new file mode 100644 index 00000000..78dbc35f --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.zulu; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static java.util.Optional.ofNullable; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; + +@DefaultScoped +public class ZuluCdnClient implements Provider { + private final String suffix; + private final Archives archives; + private final YemHttpClient client; + private final URI base; + private final Path local; + private final boolean enabled; + private final boolean preferJre; + + public ZuluCdnClient(final YemHttpClient client, final ZuluCdnConfiguration configuration, final Os os, final Archives archives) { + this.client = client; + this.archives = archives; + this.base = URI.create(configuration.base()); + this.local = Path.of(configuration.local()); + this.enabled = configuration.enabled(); + this.preferJre = configuration.preferJre(); + this.suffix = ofNullable(configuration.platform()) + .filter(i -> !"auto".equalsIgnoreCase(i)) + .orElseGet(() -> switch (os.findOs()) { // not all cases are managed compared to sdkman + case "windows" -> "win_x64.zip"; + case "linux" -> os.isAarch64() ? "linux_aarch64.tar.gz" : "linux_x64.tar.gz"; + case "mac" -> os.isArm() ? "macosx_aarch64.tar.gz" : "macosx_x64.tar.gz"; + default -> "linux_x64.tar.gz"; + }); + } + + @Override + public String name() { + return "zulu"; + } + + @Override + public void delete(final String tool, final String version) { + final var archivePath = local.resolve(version).resolve(version + '-' + suffix); + if (Files.notExists(archivePath)) { + return; + } + final var exploded = archivePath.getParent().resolve("distribution_exploded"); + if (Files.notExists(exploded)) { + return; + } + + if (!enabled) { + throw new IllegalStateException("Zulu support not enabled (by configuration)"); + } + archives.delete(exploded.getParent()); + } + + @Override + public Path install(final String tool, final String version, final ProgressListener progressListener) { + final var archivePath = local.resolve(version).resolve(version + '-' + suffix); + final var exploded = archivePath.getParent().resolve("distribution_exploded"); + if (Files.exists(exploded)) { + return exploded; + } + + if (!enabled) { + throw new IllegalStateException("Zulu support not enabled (by configuration)"); + } + + try { + Files.createDirectories(archivePath.getParent()); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + final var archive = Files.notExists(archivePath) ? + download(tool, version, archivePath, progressListener) : + new Archive(suffix.endsWith(".zip") ? "zip" : "tar.gz", archivePath); + return archives.unpack(archive, exploded); + } + + @Override + public Map> listLocal() { + if (Files.notExists(local)) { + return Map.of(); + } + return listTools().stream().collect(toMap(identity(), tool -> { + try (final var versions = Files.list(local)) { + return versions + .map(v -> { + final var version = v.getFileName().toString(); + final int start = preferJre ? version.indexOf("-jre") : version.indexOf("-jdk"); + if (start > 0) { + return new Version("Azul", version.substring(start + 4), "zulu", version); + } + return new Version("Azul", version, "zulu", version); + }) + .toList(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + })); + } + + @Override + public Optional resolve(final String tool, final String version) { + final var location = local.resolve(version).resolve(version + '-' + suffix); + if (Files.notExists(location)) { + return Optional.empty(); + } + + final var exploded = location.getParent().resolve("distribution_exploded"); + if (Files.notExists(exploded)) { + return Optional.empty(); + } + + final var maybeMac = exploded.resolve("Contents/Home"); + if (Files.isDirectory(maybeMac)) { + return Optional.of(maybeMac); + } + + return Optional.of(exploded); + } + + @Override + public Archive download(final String tool, final String id, final Path target, final ProgressListener progressListener) { + if (!enabled) { + throw new IllegalStateException("Zulu support not enabled (by configuration)"); + } + + final var res = client.getFile(HttpRequest.newBuilder() + .uri(base.resolve("zulu" + id + '-' + suffix)) + .build(), + target, progressListener); + ensure200(res); + return new Archive(suffix.endsWith(".zip") ? "zip" : "tar.gz", target); + } + + @Override + public List listTools() { + if (!enabled) { + return List.of(); + } + return List.of(new Candidate("java", "java", "Java JRE or JDK downloaded from Azul CDN.", base.toASCIIString())); + } + + @Override + public List listVersions(final String tool) { + if (!enabled) { + return List.of(); + } + final var res = client.send(HttpRequest.newBuilder().GET().uri(base).build()); + ensure200(res); + return parseVersions(res.body()); + } + + private void ensure200(final HttpResponse res) { + if (res.statusCode() != 200) { + throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); + } + } + + private List parseVersions(final String body) { + try (final var reader = new BufferedReader(new StringReader(body))) { + return reader.lines() + .filter(it -> it.contains(" { + final var from = it.indexOf(" it.endsWith(suffix) && (preferJre ? it.contains("jre") : it.contains("jdk"))) + .map(v -> { // ex: "zulu21.32.17-ca-jre21.0.2-linux_x64.zip" + // path for the download directly without the prefix and suffix + final var identifier = v.substring("zulu".length(), v.length() - suffix.length() - 1); + final var distroType = preferJre ? "-jre" : "-jdk"; + final int versionStart = identifier.lastIndexOf(distroType); + final var version = identifier.substring(versionStart + distroType.length()).strip(); + return new Version("Azul", version, "zulu", identifier); + }) + .distinct() + .toList(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java new file mode 100644 index 00000000..3444bb82 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.zulu; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("zulu") +public record ZuluCdnConfiguration( + @Property(documentation = "Is Zulu CDN support enabled.", defaultValue = "true") boolean enabled, + @Property(documentation = "Should JRE be preferred over JDK.", defaultValue = "false") boolean preferJre, + @Property(documentation = "Base URL for zulu CDN archives.", defaultValue = "\"https://cdn.azul.com/zulu/bin/\"") String base, + @Property(documentation = "Zulu platform value - if `auto` it will be computed.", defaultValue = "\"auto\"") String platform, + @Property(documentation = "Local cache of distributions.", defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.yupiik/yem/zulu\"") String local +) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java b/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java new file mode 100644 index 00000000..d31455e1 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared; + +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import org.apache.commons.compress.archivers.ArchiveEntry; +import org.apache.commons.compress.archivers.ArchiveInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.HashMap; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static java.nio.file.attribute.PosixFilePermission.GROUP_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.GROUP_READ; +import static java.nio.file.attribute.PosixFilePermission.OTHERS_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.OTHERS_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; +import static java.util.stream.Collectors.toSet; + +@ApplicationScoped +public class Archives { + public Path unpack(final Archive from, final Path exploded) { + boolean created = false; + try { + if (exploded.getParent() != null && Files.notExists(exploded)) { + Files.createDirectories(exploded); + created = true; + } + + switch (from.type()) { + case "zip" -> unzip(from.location(), exploded); + case "tar.gz" -> unTarGz(from.location(), exploded); + default -> { + if (created) { + Files.delete(exploded); + } + throw new IllegalArgumentException("unknown archive type: " + from); + } + } + + return exploded; + } catch (final IOException e) { + final var ex = new IllegalStateException(e); + if (created) { + delete(exploded); + } + throw ex; + } + } + + private void unzip(final Path location, final Path exploded) throws IOException { + try (final var zip = new ZipArchiveInputStream( + new BufferedInputStream(Files.newInputStream(location)))) { + doExtract(exploded, zip, true); + } + } + + private void unTarGz(final Path location, final Path exploded) throws IOException { + try (final var archive = new TarArchiveInputStream(new GzipCompressorInputStream( + new BufferedInputStream(Files.newInputStream(location))))) { + doExtract(exploded, archive, false); + } + } + + // highly inspired from Apache Geronimo Arthur as of today + private void doExtract(final Path exploded, final ArchiveInputStream archive, final boolean isZip) throws IOException { + final Predicate isLink = isZip ? + e -> ((ZipArchiveEntry) e).isUnixSymlink() : + e -> ((TarArchiveEntry) e).isSymbolicLink(); + final BiFunction, ArchiveEntry, String> linkPath = isZip ? + (a, e) -> { // todo: validate this with cygwin + try { + return new BufferedReader(new InputStreamReader(a)).readLine(); + } catch (final IOException ex) { + throw new IllegalStateException(ex); + } + } : + (a, e) -> TarArchiveEntry.class.cast(e).getLinkName(); + + final var linksToCopy = new HashMap(); + final var linksToRetry = new HashMap(); + + ArchiveEntry entry; + while ((entry = archive.getNextEntry()) != null && !entry.getName().contains("..")) { + if (!archive.canReadEntryData(entry)) { + continue; + } + + final var name = entry.getName(); + final int rootFolderEnd = name.indexOf('/'); + if (rootFolderEnd < 0 || rootFolderEnd == name.length() - 1) { + continue; + } + final var out = exploded.resolve(name.substring(rootFolderEnd + 1)); + if (entry.isDirectory()) { + Files.createDirectories(out); + } else if (isLink.test(entry)) { + final Path targetLinked = Paths.get(linkPath.apply(archive, entry)); + if (Files.exists(out.getParent().resolve(targetLinked))) { + try { + Files.createSymbolicLink(out, targetLinked); + setExecutableIfNeeded(out); + } catch (final IOException ioe) { + linksToCopy.put(out, targetLinked); + } + } else { + linksToRetry.put(out, targetLinked); + } + } else { + Files.copy(archive, out, StandardCopyOption.REPLACE_EXISTING); + Files.setLastModifiedTime(out, FileTime.fromMillis(entry.getLastModifiedDate().getTime())); + setExecutableIfNeeded(out); + } + } + + linksToRetry.forEach((target, targetLinked) -> { + try { + Files.createSymbolicLink(target, targetLinked); + setExecutableIfNeeded(target); + } catch (final IOException ioe) { + linksToCopy.put(target, targetLinked); + } + }); + linksToCopy.forEach((target, targetLinked) -> { + final Path actualTarget = target.getParent().resolve(targetLinked); + if (!Files.exists(actualTarget)) { + return; + } + try { + Files.copy(actualTarget, target, StandardCopyOption.REPLACE_EXISTING); + setExecutableIfNeeded(target); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + }); + } + + public void delete(final Path exploded) { + try { + Files.walkFileTree(exploded, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return super.visitFile(file, attrs); + } + + @Override + public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) throws IOException { + Files.delete(dir); + return super.postVisitDirectory(dir, exc); + } + }); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + + private void setExecutableIfNeeded(final Path target) throws IOException { + final String parentFilename = target.getParent().getFileName().toString(); + final String filename = target.getFileName().toString(); + if ((parentFilename.equals("bin") && !Files.isExecutable(target)) || + (parentFilename.equals("lib") && ( + filename.contains("exec") || filename.startsWith("j") || + (filename.startsWith("lib") && filename.contains(".so"))))) { + Files.setPosixFilePermissions( + target, + Stream.of( + OWNER_READ, OWNER_EXECUTE, OWNER_WRITE, + GROUP_READ, GROUP_EXECUTE, + OTHERS_READ, OTHERS_EXECUTE) + .collect(toSet())); + } + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/Os.java b/env-manager/src/main/java/io/yupiik/dev/shared/Os.java new file mode 100644 index 00000000..35561268 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/Os.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared; + +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; + +import java.nio.ByteOrder; + +import static java.nio.ByteOrder.LITTLE_ENDIAN; +import static java.util.Locale.ROOT; + +@ApplicationScoped +public class Os { + private final String arch = System.getProperty("os.arch", ""); + + public String findOs() { + final var os = System.getProperty("os.name", "linux").toLowerCase(ROOT); + if (os.contains("win")) { + return "windows"; + } + if (os.contains("mac")) { + return "mac"; + } + if (os.contains("sunos")) { + return "solaris"; + } + if (os.contains("lin") || os.contains("nix") || os.contains("aix")) { + return "linux"; + } + return "exotic"; + } + + public boolean is32Bits() { + return ByteOrder.nativeOrder() == LITTLE_ENDIAN; + } + + public boolean isArm() { + return arch.startsWith("arm"); + } + + public boolean isAarch64() { + return arch.contains("aarch64"); + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java new file mode 100644 index 00000000..a33c29d4 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared.http; + +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +@RootConfiguration("http") +public record HttpConfiguration( + @Property(defaultValue = "false", documentation = "Should HTTP calls be logged.") boolean log, + @Property(defaultValue = "60_000L", documentation = "Connection timeout in milliseconds.") long connectTimeout, + @Property(defaultValue = "900_000L", documentation = "Request timeout in milliseconds.") long requestTimeout, + @Property(defaultValue = "86_400_000L", documentation = "Cache validity of requests (1 day by default) in milliseconds. A negative or zero value will disable cache.") long cacheValidity, + @Property(defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.yupiik/yem/cache/http\"", documentation = "Where to cache slow updates (version fetching). `none` will disable cache.") String cache +) { +} diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java new file mode 100644 index 00000000..a04a1bdc --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared.http; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import io.yupiik.fusion.framework.build.api.json.JsonModel; +import io.yupiik.fusion.httpclient.core.ExtendedHttpClient; +import io.yupiik.fusion.httpclient.core.ExtendedHttpClientConfiguration; +import io.yupiik.fusion.httpclient.core.listener.RequestListener; +import io.yupiik.fusion.httpclient.core.listener.impl.DefaultTimeout; +import io.yupiik.fusion.httpclient.core.listener.impl.ExchangeLogger; +import io.yupiik.fusion.httpclient.core.request.UnlockedHttpRequest; +import io.yupiik.fusion.httpclient.core.response.StaticHttpResponse; +import io.yupiik.fusion.json.JsonMapper; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Flow; +import java.util.logging.Logger; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; + +import static java.net.http.HttpClient.Redirect.ALWAYS; +import static java.net.http.HttpClient.Version.HTTP_1_1; +import static java.net.http.HttpResponse.BodyHandlers.ofByteArray; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.Clock.systemDefaultZone; +import static java.util.stream.Collectors.toMap; + +@ApplicationScoped +public class YemHttpClient implements AutoCloseable { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final ExtendedHttpClient client; + private final Path cache; + private final JsonMapper jsonMapper; + private final Clock clock; + private final long cacheValidity; + + protected YemHttpClient() { // for subclassing proxy + this.client = null; + this.cache = null; + this.jsonMapper = null; + this.clock = null; + this.cacheValidity = 0L; + } + + public YemHttpClient(final HttpConfiguration configuration, final JsonMapper jsonMapper) { + final var listeners = new ArrayList>(); + if (configuration.log()) { + listeners.add((new ExchangeLogger( + Logger.getLogger("io.yupiik.dev.shared.http.HttpClient"), + systemDefaultZone(), + true))); + } + if (configuration.requestTimeout() > 0) { + listeners.add(new DefaultTimeout(Duration.ofMillis(configuration.requestTimeout()))); + } + listeners.add(new RequestListener() { // prefer gzip if enabled + @Override + public RequestListener.State before(final long count, final HttpRequest request) { + final var headers = request.headers(); + return new RequestListener.State<>(new UnlockedHttpRequest( + request.bodyPublisher(), request.method(), + request.timeout(), request.expectContinue(), + request.uri(), request.version(), + headers.firstValue("accept-encoding").isEmpty() ? + HttpHeaders.of( + Stream.concat( + headers.map().entrySet().stream(), + Map.of("accept-encoding", List.of("gzip")).entrySet().stream()) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)), + (k, v) -> true) : + headers), + null); + } + }); + + final var conf = new ExtendedHttpClientConfiguration() + .setDelegate(HttpClient.newBuilder() + .followRedirects(ALWAYS) + .version(HTTP_1_1) + .connectTimeout(Duration.ofMillis(configuration.connectTimeout())) + .build()) + .setRequestListeners(listeners); + + try { + this.cache = "none".equals(configuration.cache()) || configuration.cacheValidity() <= 0 ? + null : + Files.createDirectories(Path.of(configuration.cache())); + } catch (final IOException e) { + throw new IllegalArgumentException("Can't create HTTP cache directory : '" + configuration.cache() + "', adjust --http-cache parameter"); + } + this.cacheValidity = configuration.cacheValidity(); + this.client = new ExtendedHttpClient(conf); + this.jsonMapper = jsonMapper; + this.clock = systemDefaultZone(); + } + + @Override + public void close() { + this.client.close(); + } + + public HttpResponse getFile(final HttpRequest request, final Path target, final Provider.ProgressListener listener) { + logger.finest(() -> "Calling " + request); + final var response = sendWithProgress(request, listener, HttpResponse.BodyHandlers.ofFile(target)); + if (isGzip(response) && Files.exists(response.body())) { + final var tmp = response.body().getParent().resolve(response.body().getFileName() + ".degzip.tmp"); + try (final var in = new GZIPInputStream(new BufferedInputStream(Files.newInputStream(response.body())))) { + Files.copy(in, tmp); + } catch (final IOException ioe) { + return response; + } finally { + if (Files.exists(tmp)) { + try { + Files.delete(tmp); + } catch (final IOException e) { + // no-op + } + } + } + try { + Files.move(tmp, response.body()); + } catch (final IOException e) { + return response; + } + return new StaticHttpResponse<>( + response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), + response.body()); + } + return response; + } + + public HttpResponse send(final HttpRequest request) { + final Path cacheLocation; + if (cache != null) { + cacheLocation = cache.resolve(Base64.getUrlEncoder().withoutPadding().encodeToString(request.uri().toASCIIString().getBytes(UTF_8))); + if (Files.exists(cacheLocation)) { + try { + final var cached = jsonMapper.fromString(Response.class, Files.readString(cacheLocation)); + if (cached.validUntil() > clock.instant().toEpochMilli()) { + return new StaticHttpResponse<>( + request, request.uri(), HTTP_1_1, 200, + HttpHeaders.of( + cached.headers().entrySet().stream() + .collect(toMap(Map.Entry::getKey, e -> List.of(e.getValue()))), + (a, b) -> true), + cached.payload()); + } + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + } else { + cacheLocation = null; + } + + logger.finest(() -> "Calling " + request); + try { + final var response = client.send(request, ofByteArray()); + HttpResponse result = null; + if (isGzip(response) && response.body() != null) { + try (final var in = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { + result = new StaticHttpResponse<>( + response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), + new String(in.readAllBytes(), UTF_8)); + } catch (final IOException ioe) { + // no-op, use original response + } + } + result = result != null ? result : new StaticHttpResponse<>( + response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), + new String(response.body(), UTF_8)); + if (cacheLocation != null && result.statusCode() == 200) { + final var cachedData = jsonMapper.toString(new Response( + result.headers().map().entrySet().stream() + .filter(it -> !"content-encoding".equalsIgnoreCase(it.getKey())) + .collect(toMap(Map.Entry::getKey, l -> String.join(",", l.getValue()))), + result.body(), + clock.instant().plusMillis(cacheValidity).toEpochMilli())); + try { + Files.writeString(cacheLocation, cachedData); + } catch (final IOException e) { + try { + Files.deleteIfExists(cacheLocation); + } catch (final IOException ex) { + // no-op + } + } + } + return result; + } catch (final InterruptedException var4) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(var4); + } catch (final RuntimeException run) { + throw run; + } catch (final Exception others) { + throw new IllegalStateException(others); + } + } + + private boolean isGzip(final HttpResponse response) { + return response.headers().allValues("content-encoding").stream().anyMatch(it -> it.contains("gzip")); + } + + /* not needed yet + public HttpResponse send(final HttpRequest request, final Provider.ProgressListener listener) { + return sendWithProgress(request, listener, ofString()); + } + */ + + private HttpResponse sendWithProgress(final HttpRequest request, final Provider.ProgressListener listener, + final HttpResponse.BodyHandler delegateHandler) { + try { + return client.send(request, listener == Provider.ProgressListener.NOOP ? delegateHandler : responseInfo -> { + final long contentLength = Long.parseLong(responseInfo.headers().firstValue("content-length").orElse("-1")); + final var delegate = delegateHandler.apply(responseInfo); + if (contentLength > 0) { + final var name = request.uri().getPath(); + return HttpResponse.BodySubscribers.fromSubscriber(new Flow.Subscriber>() { + private long current = 0; + + @Override + public void onSubscribe(final Flow.Subscription subscription) { + delegate.onSubscribe(subscription); + } + + @Override + public void onNext(final List item) { + current += item.stream().mapToLong(ByteBuffer::remaining).sum(); + listener.onProcess(name, current * 1. / contentLength); + delegate.onNext(item); + } + + @Override + public void onError(final Throwable throwable) { + delegate.onError(throwable); + } + + @Override + public void onComplete() { + delegate.onComplete(); + } + }, subscriber -> delegate.getBody().toCompletableFuture().getNow(null)); + } + return delegate; + }); + } catch (final InterruptedException var3) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(var3); + } catch (final RuntimeException var4) { + throw var4; + } catch (final Exception var5) { + throw new IllegalStateException(var5); + } + } + + @JsonModel + public record Response(Map headers, String payload, long validUntil) { + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java new file mode 100644 index 00000000..c381280a --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; + +import io.yupiik.dev.test.Mock; +import io.yupiik.fusion.framework.api.ConfiguringContainer; +import io.yupiik.fusion.framework.api.Instance; +import io.yupiik.fusion.framework.api.configuration.ConfigurationSource; +import io.yupiik.fusion.framework.api.container.bean.ProvidedInstanceBean; +import io.yupiik.fusion.framework.api.main.Args; +import io.yupiik.fusion.framework.api.main.ArgsConfigSource; +import io.yupiik.fusion.framework.api.main.Awaiter; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Mock(uri = "/2/", payload = "zulu21.32.17-ca-jdk21.0.2-linux64.tar.gz") +@Mock(uri = "/2/zulu21.32.17-ca-jdk21.0.2-linux64.tar.gz", payload = "this is Java", format = "tar.gz") +class CommandsTest { + @Test + void config(@TempDir final Path work, final URI uri) { + assertEquals(""" + - central: base=http://localhost:$port/2//m2/, local=$work/.m2/repository + - github: base=http://localhost:$port/2//github/, local=/github + - maven: enabled=true + - minikube: enabled=false + - sdkman: enabled=true, base=http://localhost:$port/2/, platform=linuxx64.tar.gz, local=$work/sdkman/candidates + - zulu: enabled=true, preferJre=false, base=http://localhost:$port/2/, platform=linux64.tar.gz, local=$work/zulu""" + .replace("$work", work.toString()) + .replace("$port", Integer.toString(uri.getPort())), + captureOutput(work, uri, "config")); + } + + @Test + void simplifiedOptions(@TempDir final Path work, final URI uri) throws IOException { + execute(work, uri, + "install", + "--tool", "java", + "--version", "21.", + "--relaxed", "true"); + assertEquals("this is Java", Files.readString(work.resolve("zulu/21.32.17-ca-jdk21.0.2/distribution_exploded/entry.txt"))); + } + + @Test + void list(@TempDir final Path work, final URI uri) { + assertEquals(""" + - java: + -- 21.0.2""", captureOutput(work, uri, "list")); + } + + @Test + void listLocal(@TempDir final Path work, final URI uri) { + assertEquals("No distribution available.", captureOutput(work, uri, "list-local")); + doInstall(work, uri); + assertEquals(""" + - [zulu] java: + -- 21.0.2""", captureOutput(work, uri, "list-local")); + } + + @Test + void resolve(@TempDir final Path work, final URI uri) { + doInstall(work, uri); + assertEquals( + "Resolved java@21.0.2: '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'" + .replace("$work", work.toString()), + captureOutput(work, uri, "resolve", "--tool", "java", "--version", "21.0.2")); + } + + @Test + void install(@TempDir final Path work, final URI uri) throws IOException { + doInstall(work, uri); + assertEquals("this is Java", Files.readString(work.resolve("zulu/21.32.17-ca-jdk21.0.2/distribution_exploded/entry.txt"))); + } + + @Test + void delete(@TempDir final Path work, final URI uri) throws IOException { + doInstall(work, uri); + assertEquals("this is Java", Files.readString(work.resolve("zulu/21.32.17-ca-jdk21.0.2/distribution_exploded/entry.txt"))); + + execute(work, uri, + "delete", + "--delete-tool", "java", + "--delete-version", "21.0.2"); + assertTrue(Files.notExists(work.resolve("zulu/21.32.17-ca-jdk21.0.2/distribution_exploded/entry.txt"))); + assertTrue(Files.notExists(work.resolve("zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"))); + } + + @Test + void env(@TempDir final Path work, final URI uri) throws IOException { + final var rc = Files.writeString(work.resolve("rc"), "java.version = 21.\njava.relaxed = true\naddToPath = true\ninstallIfMissing = true"); + final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString()); + assertEquals((""" + echo "[yem] Installing java@21.32.17-ca-jdk21.0.2" + + export YEM_ORIGINAL_PATH="$original_path" + export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH" + export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded" + echo "[yem] Resolved java@21. to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\"""") + .replace("$original_path", System.getenv("PATH")) + .replace("$work", work.toString()), + out + .replaceAll("#.*", "") + .strip()); + } + + @Test + void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { + doInstall(work, uri); + + final var rc = Files.writeString(work.resolve(".sdkmanrc"), "java = 21.0.2"); + final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString()); + assertEquals((""" + export YEM_ORIGINAL_PATH="$original_path" + export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH" + export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded" + echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\"""") + .replace("$original_path", System.getenv("PATH")) + .replace("$work", work.toString()), + out + .replaceAll("#.*", "") + .strip()); + } + + private String captureOutput(final Path work, final URI uri, final String... command) { + final var out = new ByteArrayOutputStream(); + final var oldOut = System.out; + try (final var stdout = new PrintStream(out)) { + System.setOut(stdout); + execute(work, uri, command); + } finally { + System.setOut(oldOut); + } + final var stdout = out.toString(UTF_8); + return stdout.replaceAll("\\p{Digit}+.* \\[INFO]\\[io.yupiik.dev.command.[^]]+] ", "").strip(); + } + + private void doInstall(final Path work, final URI uri) { + execute(work, uri, + "install", + "--install-tool", "java", + "--install-version", "21.", + "--install-relaxed", "true"); + } + + private void execute(final Path work, final URI mockHttp, final String... args) { + try (final var container = ConfiguringContainer.of() + .register(new ProvidedInstanceBean<>(DefaultScoped.class, LocalSource.class, () -> new LocalSource(work, mockHttp.toASCIIString()))) + .register(new ProvidedInstanceBean<>(DefaultScoped.class, Args.class, () -> new Args(List.of(args)))) + .register(new ProvidedInstanceBean<>(DefaultScoped.class, ArgsConfigSource.class, () -> new ArgsConfigSource(List.of(args)))) + .start(); + final var awaiters = container.lookups(Awaiter.class, list -> list.stream().map(Instance::instance).toList())) { + awaiters.instance().forEach(Awaiter::await); + } + } + + private static class LocalSource implements ConfigurationSource { + private final Path work; + private final String baseHttp; + + private LocalSource(final Path work, final String mockHttp) { + this.work = work; + this.baseHttp = mockHttp; + } + + @Override + public String get(final String key) { + return switch (key) { + case "http.cache" -> "none"; + case "github.base" -> baseHttp + "/github/"; + case "github.local" -> work.resolve("/github").toString(); + case "central.base" -> baseHttp + "/m2/"; + case "central.local" -> work.resolve(".m2/repository").toString(); + case "sdkman.local" -> work.resolve("sdkman/candidates").toString(); + case "sdkman.platform" -> "linuxx64.tar.gz"; + case "sdkman.base", "zulu.base" -> baseHttp; + case "zulu.local" -> work.resolve("zulu").toString(); + case "zulu.platform" -> "linux64.tar.gz"; + default -> null; + }; + } + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java new file mode 100644 index 00000000..b3af026c --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.dev.test.Mock; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CentralBaseProviderTest { + @Test + @Mock(uri = "/2/org/foo/bar/maven-metadata.xml", payload = """ + + + org.foo + bar + + 1.0.25 + 1.0.25 + + 1.0.0 + 1.0.2 + 1.0.3 + 1.0.10 + 1.0.24 + 1.0.25 + + 20240108140053 + + + """) + void lastVersions(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var actual = newProvider(uri, client, work).listVersions(""); + assertEquals( + List.of(new Version("org.foo", "1.0.0", "bar", "1.0.0"), + new Version("org.foo", "1.0.2", "bar", "1.0.2"), + new Version("org.foo", "1.0.3", "bar", "1.0.3"), + new Version("org.foo", "1.0.10", "bar", "1.0.10"), + new Version("org.foo", "1.0.24", "bar", "1.0.24"), + new Version("org.foo", "1.0.25", "bar", "1.0.25")), + actual); + } + + @Test + @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz") + void download(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + final var out = work.resolve("download.tar.gz"); + assertEquals(new Archive("tar.gz", out), newProvider(uri, client, work.resolve("local")).download("", "1.0.2", out, Provider.ProgressListener.NOOP)); + assertEquals("you got a tar.gz", Files.readString(out)); + } + + @Test + @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz", format = "tar.gz") + void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + final var installationDir = work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz_exploded"); + assertEquals(installationDir, newProvider(uri, client, work.resolve("m2")).install("", "1.0.2", Provider.ProgressListener.NOOP)); + assertTrue(Files.isDirectory(installationDir)); + assertEquals("you got a tar.gz", Files.readString(installationDir.resolve("entry.txt"))); + } + + @Test + @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz", format = "tar.gz") + void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var installationDir = work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz_exploded"); + final var provider = newProvider(uri, client, work.resolve("m2")); + provider.install("", "1.0.2", Provider.ProgressListener.NOOP); + assertEquals(installationDir, provider.resolve("", "1.0.2").orElseThrow()); + } + + @Test + @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz", format = "tar.gz") + void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var installationDir = work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz_exploded"); + final var provider = newProvider(uri, client, work.resolve("m2")); + provider.install("", "1.0.2", Provider.ProgressListener.NOOP); + provider.delete("", "1.0.2"); + assertFalse(Files.exists(installationDir)); + assertFalse(Files.exists(work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz"))); + } + + private CentralBaseProvider newProvider(final URI uri, final YemHttpClient client, final Path local) { + return new CentralBaseProvider(client, new CentralConfiguration(uri.toASCIIString(), local.toString()), new Archives(), "org.foo:bar:tar.gz:simple", true) { + }; + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java new file mode 100644 index 00000000..1167cdeb --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.sdkman; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Archive; +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.dev.test.Mock; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SdkManClientTest { + @Test + @Mock(uri = "/2/candidates/list", payload = """ + ================================================================================ + Available Candidates + ================================================================================ + q-quit /-search down + j-down ?-search up + k-up h-help + + -------------------------------------------------------------------------------- + Apache ActiveMQ (Classic) (5.17.1) https://activemq.apache.org/ + + Apache ActiveMQ® is a popular open source, multi-protocol, Java-based message + broker. It supports industry standard protocols so users get the benefits of + client choices across a broad range of languages and platforms. Connect from + clients written in JavaScript, C, C++, Python, .Net, and more. Integrate your + multi-platform applications using the ubiquitous AMQP protocol. Exchange + messages between your web applications using STOMP over websockets. Manage your + IoT devices using MQTT. Support your existing JMS infrastructure and beyond. + ActiveMQ offers the power and flexibility to support any messaging use-case. + + $ sdk install activemq + -------------------------------------------------------------------------------- + Java (221-zulu-tem) https://projects.eclipse.org/projects/adoptium.temurin/ + + Java Platform, Standard Edition (or Java SE) is a widely used platform for + development and deployment of portable code for desktop and server environments. + Java SE uses the object-oriented Java programming language. It is part of the + Java software-platform family. Java SE defines a wide range of general-purpose + APIs – such as Java APIs for the Java Class Library – and also includes the Java + Language Specification and the Java Virtual Machine Specification. + + $ sdk install java + -------------------------------------------------------------------------------- + Maven (3.9.6) https://maven.apache.org/ + + Apache Maven is a software project management and comprehension tool. Based on + the concept of a project object model (POM), Maven can manage a project's build, + reporting and documentation from a central piece of information. + + $ sdk install maven + -------------------------------------------------------------------------------- + """) + void listTools(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var actual = sdkMan(client, uri, work).listTools(); + final var expected = List.of( + new Candidate( + "activemq", "Apache ActiveMQ (Classic)", // "5.17.1", + "Apache ActiveMQ® is a popular open source, multi-protocol, Java-based message broker. It supports industry standard protocols so users get the benefits of client choices across a broad range of languages and platforms. Connect from clients written in JavaScript, C, C++, Python, .Net, and more. Integrate your multi-platform applications using the ubiquitous AMQP protocol. Exchange messages between your web applications using STOMP over websockets. Manage your IoT devices using MQTT. Support your existing JMS infrastructure and beyond. ActiveMQ offers the power and flexibility to support any messaging use-case.", + "https://activemq.apache.org/"), + new Candidate( + "java", "Java", // "221-zulu-tem", + "Java Platform, Standard Edition (or Java SE) is a widely used platform for development and deployment of portable code for desktop and server environments. Java SE uses the object-oriented Java programming language. It is part of the Java software-platform family. Java SE defines a wide range of general-purpose APIs – such as Java APIs for the Java Class Library – and also includes the Java Language Specification and the Java Virtual Machine Specification.", + "https://projects.eclipse.org/projects/adoptium.temurin/"), + new Candidate( + "maven", "Maven", // "3.9.6", + "Apache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project's build, reporting and documentation from a central piece of information.", + "https://maven.apache.org/")); + assertEquals(expected, actual); + } + + @Test + @Mock(uri = "/2/candidates/java/linuxx64/versions/list?current=&installed=", payload = """ + ================================================================================ + Available Java Versions for Linux 64bit + ================================================================================ + Vendor | Use | Version | Dist | Status | Identifier + -------------------------------------------------------------------------------- + Gluon | | 22.1.0.1.r17 | gln | | 22.1.0.1.r17-gln \s + | | 22.1.0.1.r11 | gln | | 22.1.0.1.r11-gln \s + GraalVM CE | | 221-zulu | graalce | | 221-zulu-graalce \s + | | 17.0.9 | graalce | | 17.0.9-graalce \s + Trava | | 11.0.15 | trava | | 11.0.15-trava \s + Zulu | | 221-zulu | zulu | | 221-zulu-zulu \s + | | 21.0.1.crac | zulu | | 21.0.1.crac-zulu \s + | | 17.0.10 | zulu | | 17.0.10-zulu \s + | | 17.0.10.fx | zulu | | 17.0.10.fx-zulu \s + ================================================================================ + Omit Identifier to install default version 221-zulu-tem: + $ sdk install java + Use TAB completion to discover available versions + $ sdk install java [TAB] + Or install a specific version by Identifier: + $ sdk install java 221-zulu-tem + Hit Q to exit this list view + ================================================================================""") + void listToolVersions(final URI uri, @TempDir final Path work, final YemHttpClient client) { + assertEquals( + List.of( + new Version("Gluon", "22.1.0.1.r17", "gln", "22.1.0.1.r17-gln"), + new Version("Gluon", "22.1.0.1.r11", "gln", "22.1.0.1.r11-gln"), + new Version("GraalVM CE", "221-zulu", "graalce", "221-zulu-graalce"), + new Version("GraalVM CE", "17.0.9", "graalce", "17.0.9-graalce"), + new Version("Trava", "11.0.15", "trava", "11.0.15-trava"), + new Version("Zulu", "221-zulu", "zulu", "221-zulu-zulu"), + new Version("Zulu", "21.0.1.crac", "zulu", "21.0.1.crac-zulu"), + new Version("Zulu", "17.0.10", "zulu", "17.0.10-zulu"), + new Version("Zulu", "17.0.10.fx", "zulu", "17.0.10.fx-zulu")), + sdkMan(client, uri, work).listVersions("java")); + } + + @Test + @Mock(uri = "/2/candidates/activemq/linuxx64/versions/list?current=&installed=", payload = """ + ================================================================================ + Available Activemq Versions + ================================================================================ + 5.17.1 5.15.8 5.13.4 5.19.1 \s + 5.15.9 5.14.0 5.10.0 \s + + ================================================================================ + + - local version + * - installed + > - currently in use + ================================================================================""") + void listToolVersionsSimple(final URI uri, @TempDir final Path work, final YemHttpClient client) { + assertEquals( + Stream.of("5.19.1", "5.17.1", "5.15.9", "5.15.8", "5.14.0", "5.13.4", "5.10.0") + .map(v -> new Version("activemq", v, "sdkman", v)) + .toList(), + sdkMan(client, uri, work).listVersions("activemq").stream() + .sorted((a, b) -> -a.compareTo(b)) + .toList()); + } + + @Test + @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz") + void download(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + final var out = work.resolve("download.tar.gz"); + assertEquals(new Archive("tar.gz", out), sdkMan(client, uri, work.resolve("local")).download("java", "21-zulu", out, Provider.ProgressListener.NOOP)); + assertEquals("you got a tar.gz", Files.readString(out)); + } + + @Test + @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz", format = "tar.gz") + void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + final var installationDir = work.resolve("candidates/java/21-zulu"); + assertEquals(installationDir, sdkMan(client, uri, work.resolve("candidates")).install("java", "21-zulu", Provider.ProgressListener.NOOP)); + assertTrue(Files.isDirectory(installationDir)); + assertEquals("you got a tar.gz", Files.readString(installationDir.resolve("entry.txt"))); + } + + @Test + @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz", format = "tar.gz") + void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var installationDir = work.resolve("candidates/java/21-zulu"); + final var provider = sdkMan(client, uri, work.resolve("candidates")); + provider.install("java", "21-zulu", Provider.ProgressListener.NOOP); + assertEquals(installationDir, provider.resolve("java", "21-zulu").orElseThrow()); + } + + @Test + @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz", format = "tar.gz") + void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var installationDir = work.resolve("candidates/java/21-zulu"); + final var provider = sdkMan(client, uri, work.resolve("candidates")); + provider.install("java", "21-zulu", Provider.ProgressListener.NOOP); + provider.delete("java", "21-zulu"); + assertFalse(Files.exists(installationDir)); + } + + private SdkManClient sdkMan(final YemHttpClient client, final URI base, final Path local) { + return new SdkManClient(client, new SdkManConfiguration(true, base.toASCIIString(), "linuxx64", local.toString()), new Os(), new Archives()); + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java new file mode 100644 index 00000000..de4c4c47 --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.zulu; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.model.Version; +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.dev.test.Mock; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZuluCdnClientTest { + @Test + @Mock(uri = "/2/", payload = """ + + + + Index of /zulu/bin + + +

Index of /zulu/bin

+

+            		
+            			
+            			
+            			
+            			
+            			
+            			
+            		
+            		
+            		
+            			
+            			
+            			
+            			
+            			
+            			
+            		
+            				
+            			
+            			
+            			
+            			
+            			
+            			
+            		
+            		
+            			
+            			
+            			
+            			
+            			
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+            		
+            			
+            		
+                    
+            			
+            		
+            	
[   ]NameLast modifiedSizeDescription

[PARENTDIR] Parent Directory-
[   ]index.yml2023-12-14 03:483.4K
[   ]zre1.7.0_65-7.6.0.2-headless-x86lx32.zip2023-08-17 17:4333M
zulu21.32.17-ca-fx-jre21.0.2-macosx_x64.zip
zulu21.32.17-ca-fx-jre21.0.2-win_x64.zip
zulu21.32.17-ca-jdk21.0.2-linux.aarch64.rpm
zulu21.32.17-ca-jdk21.0.2-linux.x86_64.rpm
zulu21.32.17-ca-jre21.0.2-linux_aarch64.tar.gz
zulu21.32.17-ca-jre21.0.2-linux_amd64.deb
zulu21.32.17-ca-jre21.0.2-linux_arm64.deb
zulu21.32.17-ca-jre21.0.2-linux_musl_aarch64.tar.gz
zulu21.32.17-ca-jre21.0.2-linux_musl_x64.tar.gz
zulu21.32.17-ca-jre21.0.2-linux_x64.tar.gz
zulu21.32.17-ca-jre21.0.2-linux_x64.zip
zulu21.32.17-ca-jre21.0.2-macosx_aarch64.dmg
zulu21.32.17-ca-jre21.0.2-macosx_aarch64.tar.gz
zulu21.32.17-ca-jre21.0.2-macosx_aarch64.zip
zulu21.32.17-ca-jre21.0.2-macosx_x64.tar.gz
zulu21.32.17-ca-jre21.0.2-macosx_x64.zip
zulu21.32.17-ca-jre21.0.2-win_x64.zip
zulu22.0.43-beta-jdk22.0.0-beta.16-linux_aarch64.tar.gz
zulu9.0.7.1-jdk9.0.7-win_x64.zip

+ + """) + void listJavaVersions(final URI uri, @TempDir final Path local, final YemHttpClient client) { + final var actual = newProvider(uri, client, local).listVersions(""); + assertEquals( + List.of(new Version("Azul", "21.0.2", "zulu", "21.32.17-ca-jre21.0.2")), + actual); + } + + @Test + @Mock(uri = "/2/zulu21.0.2-linux_x64.zip", payload = "you got a zip", format = "zip") + void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + final var installationDir = work.resolve("yem/21.0.2/distribution_exploded"); + assertEquals(installationDir, newProvider(uri, client, work.resolve("yem")).install("java", "21.0.2", Provider.ProgressListener.NOOP)); + assertTrue(Files.isDirectory(installationDir)); + assertEquals("you got a zip", Files.readString(installationDir.resolve("entry.txt"))); + } + + @Test + @Mock(uri = "/2/zulu21.0.2-linux_x64.zip", payload = "you got a zip", format = "zip") + void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var installationDir = work.resolve("yem/21.0.2/distribution_exploded"); + final var provider = newProvider(uri, client, work.resolve("yem")); + provider.install("java", "21.0.2", Provider.ProgressListener.NOOP); + assertEquals(installationDir, provider.resolve("java", "21.0.2").orElseThrow()); + } + + @Test + @Mock(uri = "/2/zulu21.0.2-linux_x64.zip", payload = "you got a zip", format = "zip") + void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) { + final var installationDir = work.resolve("yem/21.0.2/distribution_exploded"); + final var provider = newProvider(uri, client, work.resolve("yem")); + provider.install("java", "21.0.2", Provider.ProgressListener.NOOP); + assertTrue(Files.exists(installationDir.getParent())); + provider.delete("java", "21.0.2"); + assertTrue(Files.notExists(installationDir.getParent())); + } + + private ZuluCdnClient newProvider(final URI uri, final YemHttpClient client, final Path local) { + return new ZuluCdnClient(client, new ZuluCdnConfiguration(true, true, uri.toASCIIString(), "linux_x64.zip", local.toString()), new Os(), new Archives()); + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java b/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java new file mode 100644 index 00000000..1aafac31 --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared; + +import io.yupiik.dev.provider.model.Archive; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ArchivesTest { + @Test + void zip(@TempDir final Path work) throws IOException { + final var zip = Files.createDirectories(work).resolve("ar.zip"); + try (final var out = new ZipOutputStream(Files.newOutputStream(zip))) { // we use JVM impl to ensure interop + out.putNextEntry(new ZipEntry("foo/")); + out.closeEntry(); + out.putNextEntry(new ZipEntry("foo/README.adoc")); + out.write("test".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + out.putNextEntry(new ZipEntry("foo/dummy/")); + out.closeEntry(); + out.putNextEntry(new ZipEntry("foo/dummy/thing.txt")); + out.write("".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + out.finish(); + } + + final var exploded = work.resolve("exploded"); + new Archives().unpack(new Archive("zip", zip), exploded); + + assertFiles( + Map.of("README.adoc", "test", "dummy/", "", "dummy/thing.txt", ""), + exploded); + } + + @Test + void tarGz(@TempDir final Path work) throws IOException { + final var zip = Files.createDirectories(work).resolve("ar.zip"); + try (final var out = new TarArchiveOutputStream(new GzipCompressorOutputStream(Files.newOutputStream(zip)))) { + out.putArchiveEntry(new TarArchiveEntry("foo/")); + out.closeArchiveEntry(); + out.putArchiveEntry(new TarArchiveEntry("foo/README.adoc") {{ + setSize(4); + }}); + out.write("test".getBytes(StandardCharsets.UTF_8)); + out.closeArchiveEntry(); + out.putArchiveEntry(new TarArchiveEntry("foo/dummy/")); + out.closeArchiveEntry(); + out.putArchiveEntry(new TarArchiveEntry("foo/dummy/thing.txt") {{ + setSize(7); + }}); + out.write("".getBytes(StandardCharsets.UTF_8)); + out.closeArchiveEntry(); + out.finish(); + } + + final var exploded = work.resolve("exploded"); + new Archives().unpack(new Archive("tar.gz", zip), exploded); + + assertFiles( + Map.of("README.adoc", "test", "dummy/", "", "dummy/thing.txt", ""), + exploded); + } + + private void assertFiles(final Map files, final Path exploded) throws IOException { + final var actual = new HashMap(); + Files.walkFileTree(exploded, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException { + if (!Objects.equals(dir, exploded)) { + actual.put(exploded.relativize(dir).toString() + '/', ""); + } + return super.preVisitDirectory(dir, attrs); + } + + @Override + public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { + actual.put(exploded.relativize(file).toString(), Files.readString(file)); + return super.visitFile(file, attrs); + } + }); + assertEquals(files, actual); + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java new file mode 100644 index 00000000..a5696e26 --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.test; + +import com.sun.net.httpserver.HttpServer; +import io.yupiik.dev.shared.http.HttpConfiguration; +import io.yupiik.dev.shared.http.YemHttpClient; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Optional.ofNullable; +import static org.junit.platform.commons.support.AnnotationSupport.findRepeatableAnnotations; + +public class HttpMockExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(HttpMockExtension.class); + + @Override + public void beforeEach(final ExtensionContext context) throws Exception { + final var annotations = Stream.concat( + findRepeatableAnnotations(context.getTestMethod(), Mock.class).stream(), // first to override class ones if needed + findRepeatableAnnotations(context.getTestClass(), Mock.class).stream()) + .toList(); + if (annotations.isEmpty()) { + return; + } + + final var server = HttpServer.create(new InetSocketAddress("localhost", 0), 16); + server.createContext("/").setHandler(ex -> { + try (ex) { + final var method = ex.getRequestMethod(); + final var uri = ex.getRequestURI().toASCIIString(); + final var resp = annotations.stream() + .filter(m -> Objects.equals(method, m.method()) && Objects.equals(uri, m.uri())) + .findFirst() + .orElse(null); + if (resp == null) { + ex.sendResponseHeaders(404, 0); + } else { + final var bytes = process(resp.payload().getBytes(UTF_8), resp.format()); + ex.sendResponseHeaders(200, bytes.length); + ex.getResponseBody().write(bytes); + } + } + }); + server.start(); + context.getStore(NAMESPACE).put(HttpServer.class, server); + } + + @Override + public void afterEach(final ExtensionContext context) { + ofNullable(context.getStore(NAMESPACE).get(HttpServer.class, HttpServer.class)).ifPresent(s -> s.stop(0)); + } + + @Override + public boolean supportsParameter(final ParameterContext parameterContext, final ExtensionContext extensionContext) throws ParameterResolutionException { + return URI.class == parameterContext.getParameter().getType() || YemHttpClient.class == parameterContext.getParameter().getType(); + } + + @Override + public Object resolveParameter(final ParameterContext parameterContext, final ExtensionContext context) throws ParameterResolutionException { + if (URI.class == parameterContext.getParameter().getType()) { + return URI.create("http://localhost:" + context.getStore(NAMESPACE).get(HttpServer.class, HttpServer.class).getAddress().getPort() + "/2/"); + } + if (YemHttpClient.class == parameterContext.getParameter().getType()) { + return new YemHttpClient(new HttpConfiguration(false, 30_000L, 30_000L, 0, "none"), null); + } + throw new ParameterResolutionException("Can't resolve " + parameterContext.getParameter().getType()); + } + + private byte[] process(final byte[] bytes, final String format) throws IOException { + return switch (format) { + case "tar.gz" -> { + final var out = new ByteArrayOutputStream(); + try (final var archive = new TarArchiveOutputStream(new GzipCompressorOutputStream(out))) { + archive.putArchiveEntry(new TarArchiveEntry("root-1.2.3/")); + archive.closeArchiveEntry(); + + final var archiveEntry = new TarArchiveEntry("root-1.2.3/entry.txt"); + archiveEntry.setSize(bytes.length); + archive.putArchiveEntry(archiveEntry); + archive.write(bytes); + archive.closeArchiveEntry(); + + archive.finish(); + } + yield out.toByteArray(); + } + case "zip" -> { + final var out = new ByteArrayOutputStream(); + try (final var archive = new ZipOutputStream(out)) { + archive.putNextEntry(new ZipEntry("root-1.2.3/")); + archive.closeEntry(); + archive.putNextEntry(new ZipEntry("root-1.2.3/entry.txt")); + archive.write(bytes); + archive.closeEntry(); + archive.finish(); + } + yield out.toByteArray(); + } + default -> bytes; + }; + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/test/Mock.java b/env-manager/src/test/java/io/yupiik/dev/test/Mock.java new file mode 100644 index 00000000..51d9ab83 --- /dev/null +++ b/env-manager/src/test/java/io/yupiik/dev/test/Mock.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.test; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Repeatable(Mock.List.class) +@Target({METHOD, TYPE}) +@Retention(RUNTIME) +@ExtendWith(HttpMockExtension.class) +public @interface Mock { + String method() default "GET"; + + String uri(); + + String payload(); + + String format() default "text"; + + @Target({METHOD, TYPE}) + @Retention(RUNTIME) + @interface List { + Mock[] value(); + } +} diff --git a/pom.xml b/pom.xml index 254da49e..7aefd835 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ UTF-8 + 1.0.14 3.6.3 9.3.6.0 4.0.1 @@ -46,10 +47,11 @@ slides-core yupiik-tools-cli html-versioning-injector - _documentation codec-core ascii2svg asciidoc-java + env-manager + _documentation From 6b53318fcefba9cec498ca66f8be514fadbf030d Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 18:02:05 +0100 Subject: [PATCH 02/26] [env manager] add run command --- .../src/main/minisite/content/yem.adoc | 12 ++ .../main/java/io/yupiik/dev/command/Env.java | 172 +++--------------- .../main/java/io/yupiik/dev/command/Run.java | 155 ++++++++++++++++ .../EnableSimpleOptionsArgs.java | 6 +- .../main/java/io/yupiik/dev/shared/Os.java | 4 + .../java/io/yupiik/dev/shared/RcService.java | 157 ++++++++++++++++ .../io/yupiik/dev/command/CommandsTest.java | 4 +- 7 files changed, 359 insertions(+), 151 deletions(-) create mode 100644 env-manager/src/main/java/io/yupiik/dev/command/Run.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/RcService.java diff --git a/_documentation/src/main/minisite/content/yem.adoc b/_documentation/src/main/minisite/content/yem.adoc index f0b020d1..f4737349 100644 --- a/_documentation/src/main/minisite/content/yem.adoc +++ b/_documentation/src/main/minisite/content/yem.adoc @@ -60,3 +60,15 @@ prefix.toolName = 1.2.3 <8> Only the `version` property is required of `prefix` matches a tool name. You can get as much group of properties as needed tools (one for java 11, one for java 17, one for maven 4 etc...). + +== Alias support + +The `run` command supports aliases. +They globally use a `.yemrc` file as for `env` command but support additional properties. + +These additional properties must match the pattern `xxx.alias = yyyy` where `xxx` is an alias name and `yyyy` a command. +The usage of `yem run -- xxxx` will be equivalent to run `yyyy` command (can have arguments predefined) in the context of the `.yemrc` file, including the environment - Java, Apache Maven etc... + +== TIP + +If you need to see more logs from _yem_ you can add the following system properties: `-Djava.util.logging.manager=io.yupiik.logging.jul.YupiikLogManager -Dio.yupiik.logging.jul.handler.StandardHandler.level=FINEST -Dio.yupiik.level=FINEST`. diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 891b4f17..b18c388c 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -15,97 +15,51 @@ */ package io.yupiik.dev.command; -import io.yupiik.dev.provider.Provider; -import io.yupiik.dev.provider.ProviderRegistry; import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.RcService; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.IdentityHashMap; -import java.util.Map; -import java.util.Optional; -import java.util.Properties; import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; -import java.util.stream.Stream; import static java.io.File.pathSeparator; -import static java.util.Locale.ROOT; -import static java.util.Map.entry; import static java.util.Optional.ofNullable; import static java.util.logging.Level.FINE; import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toMap; @Command(name = "env", description = "Creates a script you can eval in a shell to prepare the environment from a file. Often used as `eval $(yem env--env-rc .yemrc)`") public class Env implements Runnable { private final Logger logger = Logger.getLogger(getClass().getName()); private final Conf conf; - private final ProviderRegistry registry; + private final RcService rc; private final Os os; - public Env(final Conf conf, - final Os os, - final ProviderRegistry registry) { + public Env(final Conf conf, final Os os, final RcService rc) { this.conf = conf; this.os = os; - this.registry = registry; + this.rc = rc; } @Override public void run() { - final var windows = "windows".equals(os.findOs()); + final var windows = os.isWindows(); final var export = windows ? "set " : "export "; final var comment = windows ? "%% " : "# "; final var pathName = windows ? "Path" : "PATH"; final var pathVar = windows ? "%" + pathName + "%" : ("$" + pathName); - final var isAuto = "auto".equals(conf.rc()); - var rcLocation = isAuto ? auto() : Path.of(conf.rc()); - if (Files.notExists(rcLocation)) { // enable to navigate in the project without loosing the env - while (Files.notExists(rcLocation)) { - var parent = rcLocation.toAbsolutePath().getParent(); - if (parent == null || !Files.isReadable(parent)) { - break; - } - parent = parent.getParent(); - if (parent == null || !Files.isReadable(parent)) { - break; - } - rcLocation = parent.resolve(isAuto ? rcLocation.getFileName().toString() : conf.rc()); - } - } + resetOriginalPath(export, pathName); - if (Files.notExists(rcLocation) || !Files.isReadable(rcLocation)) { - // just check we have YEM_ORIGINAL_PATH and reset PATH if needed - ofNullable(System.getenv("YEM_ORIGINAL_PATH")) - .ifPresent(value -> { - if (windows) { - System.out.println("set YEM_ORIGINAL_PATH="); - } else { - System.out.println("unset YEM_ORIGINAL_PATH"); - } - System.out.println(export + " " + pathName + "=\"" + value + '"'); - }); + final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); + if (tools == null || tools.isEmpty()) { // nothing to do return; } - final var props = new Properties(); - try (final var reader = Files.newBufferedReader(rcLocation)) { - props.load(reader); - } catch (final IOException e) { - throw new IllegalStateException(e); - } - if (".sdkmanrc".equals(rcLocation.getFileName().toString())) { - rewritePropertiesFromSdkManRc(props); - } - final var logger = this.logger.getParent().getParent(); final var useParentHandlers = logger.getUseParentHandlers(); final var messages = new ArrayList(); @@ -138,47 +92,7 @@ public void close() throws SecurityException { logger.addHandler(tempHandler); try { - final var resolved = props.stringPropertyNames().stream() - .filter(it -> it.endsWith(".version")) - .map(versionKey -> { - final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); - return new ToolProperties( - props.getProperty(name + ".toolName", name), - props.getProperty(versionKey), - props.getProperty(name + ".provider"), - Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), - props.getProperty(name + ".envVarName", name.toUpperCase(ROOT).replace('.', '_') + "_HOME"), - Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), - Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), - Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); - }) - .flatMap(tool -> registry.tryFindByToolVersionAndProvider( - tool.toolName(), tool.version(), - tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), - new ProviderRegistry.Cache(new IdentityHashMap<>(), new IdentityHashMap<>())) - .or(() -> { - if (tool.failOnMissing()) { - throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); - } - return Optional.empty(); - }) - .flatMap(providerAndVersion -> { - final var provider = providerAndVersion.getKey(); - final var version = providerAndVersion.getValue().identifier(); - return provider.resolve(tool.toolName(), tool.version()) - .or(() -> { - if (tool.installIfMissing()) { - logger.info(() -> "Installing " + tool.toolName() + '@' + version); - provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP); - } else if (tool.failOnMissing()) { - throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); - } - return provider.resolve(tool.toolName(), version); - }); - }) - .stream() - .map(home -> entry(tool, home))) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + final var resolved = rc.toToolProperties(tools); final var toolVars = resolved.entrySet().stream() .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\"") .sorted() @@ -187,14 +101,14 @@ public void close() throws SecurityException { final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) .or(() -> ofNullable(System.getenv(pathName))) .orElse(""); - final var pathVars = resolved.keySet().stream().anyMatch(ToolProperties::addToPath) ? + final var pathVars = resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath) ? export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\"\n" + export + pathName + "=\"" + resolved.entrySet().stream() .filter(r -> r.getKey().addToPath()) - .map(r -> quoted(toBin(r.getValue()))) + .map(r -> quoted(rc.toBin(r.getValue()))) .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\"\n" : ""; - final var echos = Boolean.parseBoolean(props.getProperty("echo", "true")) ? + final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? resolved.entrySet().stream() .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\"") .collect(joining("\n", "", "\n")) : @@ -205,18 +119,7 @@ public void close() throws SecurityException { comment + "To load a .yemrc configuration run:\n" + comment + "[ -f .yemrc ] && eval $(yem env--env-file .yemrc)\n" + comment + "\n" + - comment + "yemrc format is based on properties\n" + - comment + "(only version one is required, version being either a plain version or version identifier, see versions command)\n" + - comment + "$toolName is just a marker or if .toolName is not set it is the actual tool name:\n" + - comment + "$toolName.toolName = xxxx\n" + - comment + "\n" + - comment + "$toolName.version = 1.2.3\n" + - comment + "$toolName.provider = xxxx\n" + - comment + "$toolName.relaxed = [true|false]\n" + - comment + "$toolName.envVarName = xxxx\n" + - comment + "$toolName.addToPath = [true|false]\n" + - comment + "$toolName.failOnMissing = [true|false]\n" + - comment + "$toolName.installIfMissing = [true|false]\n" + + comment + "See https://www.yupiik.io/tools-maven-plugin/yem.html#autopath for details\n" + "\n"; System.out.println(script); } finally { @@ -225,24 +128,6 @@ public void close() throws SecurityException { } } - private void rewritePropertiesFromSdkManRc(final Properties props) { - final var original = new Properties(); - original.putAll(props); - - props.clear(); - props.setProperty("addToPath", "true"); - props.putAll(original.stringPropertyNames().stream() - .collect(toMap(p -> p + ".version", original::getProperty))); - } - - private Path auto() { - return Stream.of(".yemrc", ".sdkmanrc") - .map(Path::of) - .filter(Files::exists) - .findFirst() - .orElseGet(() -> Path.of(".yemrc")); - } - private String quoted(final Path path) { return path .toAbsolutePath() @@ -251,27 +136,22 @@ private String quoted(final Path path) { .replace("\"", "\\\""); } - private Path toBin(final Path value) { - return Stream.of("bin" /* add other potential folders */) - .map(value::resolve) - .filter(Files::exists) - .findFirst() - .orElse(value); + private void resetOriginalPath(final String export, final String pathName) { + // just check we have YEM_ORIGINAL_PATH and reset PATH if needed + ofNullable(System.getenv("YEM_ORIGINAL_PATH")) + .ifPresent(value -> { + if (os.isWindows()) { + System.out.println("set YEM_ORIGINAL_PATH="); + } else { + System.out.println("unset YEM_ORIGINAL_PATH"); + } + System.out.println(export + " " + pathName + "=\"" + value + '"'); + }); } @RootConfiguration("env") public record Conf( - @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", required = true) String rc) { - } - - private record ToolProperties( - String toolName, - String version, - String provider, - boolean relaxed, - String envVarName, - boolean addToPath, - boolean failOnMissing, - boolean installIfMissing) { + @Property(documentation = "Should `~/.yupiik/yem/rc` be ignored or not. If present it defines default versions and uses the same syntax than `yemrc`.", defaultValue = "System.getProperty(\"user.home\") + \"/.yupiik/yem/rc\"") String defaultRc, + @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\".yemrc\"") String rc) { } } diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java new file mode 100644 index 00000000..1c1648d2 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -0,0 +1,155 @@ +package io.yupiik.dev.command; + +import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.RcService; +import io.yupiik.fusion.framework.api.main.Args; +import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import static java.io.File.pathSeparator; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.joining; + +@Command(name = "run", description = "Similar to `env` spirit but for aliases or execution in the shell context of the `.yemrc` file.`." + + " Important: it is recommended to use `--` to separate yem args from the command: `yem run --rc .yemrc -- mvn clean package`." + + " Note that the first arg after `--` will be tested against an alias in the `.yemrc` (or global one) so you can pre-define complex commands there." + + " Ex: `build.alias = mvn package` in `.yemrc` will enable to run `yem run -- build` which will actually execute `mvn package` and if you configure maven version (`maven.version = 3.9.6` for example) the execution environment will use the right distribution.") +public class Run implements Runnable { + private final Logger logger = Logger.getLogger(getClass().getName()); + + private final Conf conf; + private final RcService rc; + private final Os os; + private final Args args; + + public Run(final Conf conf, final RcService rc, final Os os, final Args args) { + this.conf = conf; + this.rc = rc; + this.os = os; + this.args = args; + } + + @Override + public void run() { + final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); + final var resolved = rc.toToolProperties(tools); + + final int idx = args.args().indexOf("--"); + final var command = new ArrayList(8); + if (idx > 0) { + command.addAll(args.args().subList(idx + 1, args.args().size())); + } else { + command.addAll(args.args().subList(1, args.args().size())); + } + + if (!command.isEmpty()) { // handle aliasing + final var alias = tools.getProperty(command.get(0) + ".alias"); + if (alias != null) { + command.remove(0); + command.addAll(0, parseArgs(alias)); + } + } + + logger.finest(() -> "Resolved command: " + command); + + final var process = new ProcessBuilder(command); + process.inheritIO(); + + final var environment = process.environment(); + setEnv(resolved, environment); + + final var path = environment.getOrDefault("PATH", environment.getOrDefault("Path", "")); + if (!command.isEmpty() && !path.isBlank()) { + final var exec = command.get(0); + if (Files.notExists(Path.of(exec))) { + Stream.of(path.split(pathSeparator)) + .map(Path::of) + .filter(Files::exists) + .map(d -> d.resolve(exec)) + .filter(Files::exists) + .findFirst() + .ifPresent(bin -> command.set(0, bin.toString())); + } + } + + try { + final var processExec = process.start(); + final int exitCode = processExec.waitFor(); + logger.finest(() -> "Process exited with status=" + exitCode); + } catch (final IOException e) { + throw new IllegalStateException(e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private void setEnv(final Map resolved, final Map environment) { + resolved.forEach((tool, home) -> { + final var homeStr = home.toString(); + logger.finest(() -> "Setting '" + tool.envVarName() + "' to '" + homeStr + "'"); + environment.put(tool.envVarName(), homeStr); + }); + if (resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath)) { + final var pathName = os.isWindows() ? "Path" : "PATH"; + final var path = resolved.entrySet().stream() + .filter(r -> r.getKey().addToPath()) + .map(r -> rc.toBin(r.getValue()).toString()) + .collect(joining(pathSeparator, "", pathSeparator)) + + ofNullable(System.getenv(pathName)).orElse(""); + logger.finest(() -> "Setting 'PATH' to '" + path + "'"); + environment.put(pathName, path); + } + } + + private List parseArgs(final String alias) { + final var out = new ArrayList(4); + final var builder = new StringBuilder(alias.length()); + char await = 0; + boolean escaped = false; + for (final char c : alias.toCharArray()) { + if (await == 0 && c == ' ') { + if (!builder.isEmpty()) { + out.add(builder.toString().strip()); + builder.setLength(0); + } + } else if (c == await) { + out.add(builder.toString().strip()); + builder.setLength(0); + await = 0; + } else if (escaped) { + builder.append(c); + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"' && builder.isEmpty()) { + await = '"'; + } else if (c == '\'' && builder.isEmpty()) { + await = '\''; + } else { + builder.append(c); + } + } + if (!builder.isEmpty()) { + out.add(builder.toString().strip()); + } + return out; + } + + @RootConfiguration("run") + public record Conf( + @Property(documentation = "Should `~/.yupiik/yem/rc` be ignored or not. If present it defines default versions and uses the same syntax than `yemrc`.", defaultValue = "System.getProperty(\"user.home\") + \"/.yupiik/yem/rc\"") String defaultRc, + @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\".yemrc\"") String rc) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java b/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java index a5bd9fab..47fb407f 100644 --- a/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java +++ b/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java @@ -32,8 +32,8 @@ public class EnableSimpleOptionsArgs { // here the goal is to auto-complete short options (--tool) by prefixing it with the command name. public void onStart(@OnEvent @Order(Integer.MIN_VALUE) final Start start, final RuntimeContainer container) { ofNullable(container.getBeans().getBeans().get(Args.class)) - .ifPresent(args -> { - final var enriched = enrich(((Args) args.get(0).create(container, null)).args()); + .ifPresent(beans -> { + final var enriched = enrich(((Args) beans.get(0).create(container, null)).args()); container.getBeans().getBeans() .put(Args.class, List.of(new ProvidedInstanceBean<>(DefaultScoped.class, Args.class, () -> enriched))); }); @@ -45,7 +45,7 @@ private Args enrich(final List args) { } final var prefix = "--" + args.get(0) + '-'; return new Args(args.stream() - .flatMap(i -> i.startsWith("--") && !i.startsWith(prefix) ? + .flatMap(i -> !"--".equals(i) && i.startsWith("--") && !i.startsWith(prefix) ? (i.substring("--".length()).contains("-") ? Stream.of(prefix + i.substring("--".length()), i) : Stream.of(prefix + i.substring("--".length()))) : diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/Os.java b/env-manager/src/main/java/io/yupiik/dev/shared/Os.java index 35561268..2d2f4f53 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/Os.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/Os.java @@ -54,4 +54,8 @@ public boolean isArm() { public boolean isAarch64() { return arch.contains("aarch64"); } + + public boolean isWindows() { + return "windows".equals(findOs()); + } } diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java new file mode 100644 index 00000000..27eb3655 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -0,0 +1,157 @@ +package io.yupiik.dev.shared; + +import io.yupiik.dev.provider.Provider; +import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import static java.util.Locale.ROOT; +import static java.util.Map.entry; +import static java.util.stream.Collectors.toMap; + +@ApplicationScoped +public class RcService { + private final Logger logger = Logger.getLogger(getClass().getName()); + private final Os os; + private final ProviderRegistry registry; + + public RcService(final Os os, final ProviderRegistry registry) { + this.os = os; + this.registry = registry; + } + + public Map toToolProperties(final Properties props) { + return props.stringPropertyNames().stream() + .filter(it -> it.endsWith(".version")) + .map(versionKey -> { + final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); + return new ToolProperties( + props.getProperty(name + ".toolName", name), + props.getProperty(versionKey), + props.getProperty(name + ".provider"), + Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), + props.getProperty(name + ".envVarName", name.toUpperCase(ROOT).replace('.', '_') + "_HOME"), + Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), + Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), + Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); + }) + .flatMap(tool -> registry.tryFindByToolVersionAndProvider( + tool.toolName(), tool.version(), + tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), + new ProviderRegistry.Cache(new IdentityHashMap<>(), new IdentityHashMap<>())) + .or(() -> { + if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); + } + return Optional.empty(); + }) + .flatMap(providerAndVersion -> { + final var provider = providerAndVersion.getKey(); + final var version = providerAndVersion.getValue().identifier(); + return provider.resolve(tool.toolName(), tool.version()) + .or(() -> { + if (tool.installIfMissing()) { + logger.info(() -> "Installing " + tool.toolName() + '@' + version); + provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP); + } else if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); + } + return provider.resolve(tool.toolName(), version); + }); + }) + .stream() + .map(home -> entry(tool, home))) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + public Properties loadPropertiesFrom(final String rcPath, final String defaultRcPath) { + final var defaultRc = Path.of(defaultRcPath); + final var defaultProps = new Properties(); + if (Files.exists(defaultRc)) { + readRc(defaultRc, defaultProps); + } + + final var isAuto = "auto".equals(rcPath); + var rcLocation = isAuto ? auto() : Path.of(rcPath); + final boolean isAbsolute = rcLocation.isAbsolute(); + if (Files.notExists(rcLocation)) { // enable to navigate in the project without loosing the env + while (!isAbsolute && Files.notExists(rcLocation)) { + var parent = rcLocation.toAbsolutePath().getParent(); + if (parent == null || !Files.isReadable(parent)) { + break; + } + parent = parent.getParent(); + if (parent == null || !Files.isReadable(parent)) { + break; + } + rcLocation = parent.resolve(isAuto ? rcLocation.getFileName().toString() : rcPath); + } + } + + final var props = new Properties(); + if (Files.exists(rcLocation)) { + readRc(rcLocation, props); + if (".sdkmanrc".equals(rcLocation.getFileName().toString())) { + rewritePropertiesFromSdkManRc(props); + } + } else if (Files.notExists(defaultRc)) { + return null; // no config at all + } + + return props; + } + + public Path toBin(final Path value) { + return Stream.of("bin" /* add other potential folders */) + .map(value::resolve) + .filter(Files::exists) + .findFirst() + .orElse(value); + } + + private void readRc(final Path rcLocation, final Properties props) { + try (final var reader = Files.newBufferedReader(rcLocation)) { + props.load(reader); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + + private void rewritePropertiesFromSdkManRc(final Properties props) { + final var original = new Properties(); + original.putAll(props); + + props.clear(); + props.setProperty("addToPath", "true"); + props.putAll(original.stringPropertyNames().stream() + .collect(toMap(p -> p + ".version", original::getProperty))); + } + + private Path auto() { + return Stream.of(".yemrc", ".sdkmanrc") + .map(Path::of) + .filter(Files::exists) + .findFirst() + .orElseGet(() -> Path.of(".yemrc")); + } + + public record ToolProperties( + String toolName, + String version, + String provider, + boolean relaxed, + String envVarName, + boolean addToPath, + boolean failOnMissing, + boolean installIfMissing) { + } +} diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index c381280a..0c55ab5d 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -113,7 +113,7 @@ void delete(@TempDir final Path work, final URI uri) throws IOException { @Test void env(@TempDir final Path work, final URI uri) throws IOException { final var rc = Files.writeString(work.resolve("rc"), "java.version = 21.\njava.relaxed = true\naddToPath = true\ninstallIfMissing = true"); - final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString()); + final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); assertEquals((""" echo "[yem] Installing java@21.32.17-ca-jdk21.0.2" @@ -133,7 +133,7 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { doInstall(work, uri); final var rc = Files.writeString(work.resolve(".sdkmanrc"), "java = 21.0.2"); - final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString()); + final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); assertEquals((""" export YEM_ORIGINAL_PATH="$original_path" export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH" From 447504be63f880f0fc463ce6d783277ac92ebde2 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 18:10:16 +0100 Subject: [PATCH 03/26] [style] fix missing headers --- .../src/main/java/io/yupiik/dev/command/Run.java | 16 +++++++++++++++- .../java/io/yupiik/dev/shared/RcService.java | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index 1c1648d2..af56ee40 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.command; import io.yupiik.dev.shared.Os; @@ -7,7 +22,6 @@ import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; -import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index 27eb3655..4d4d8f0f 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared; import io.yupiik.dev.provider.Provider; From 3cad043b718dd04c5b59fdfaf5e150cec91a6f73 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 18:10:26 +0100 Subject: [PATCH 04/26] [asciidoc] avoid wrapping p in callouts --- .../html/AsciidoctorLikeHtmlRenderer.java | 2 +- .../html/AsciidoctorLikeHtmlRendererTest.java | 40 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java b/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java index a30fb466..6fe412d4 100644 --- a/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java +++ b/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java @@ -517,7 +517,7 @@ public void visitText(final Text element) { final boolean parentNeedsP = state.lastElement.size() > 1 && isList(state.lastElement.get(state.lastElement.size() - 2).type()); final boolean wrap = useWrappers && (parentNeedsP || (element.style().size() != 1 && (isParagraph || state.inCallOut || !element.options().isEmpty()))); - final boolean useP = parentNeedsP || isParagraph || state.inCallOut; + final boolean useP = parentNeedsP || isParagraph || !state.inCallOut; if (wrap) { builder.append(" <").append(useP ? "p" : "span"); writeCommonAttributes(element.options(), null); diff --git a/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java b/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java index b02071ca..4d0aeff3 100644 --- a/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java +++ b/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java @@ -576,8 +576,44 @@ void codeInSectionTitleComplex() { void linkWithImage() { assertRenderingContent( "link:http://foo.bar[this is image:foo.png[alt]]", - " this is \"alt\"\n" + - "\n"); + """ + this is alt + + """); + } + + @Test + void callouts() { + assertRenderingContent(""" + [source,properties] + ---- + prefix.version = 1.2.3 <1> + ---- + <.> Version of the tool to install, using `relaxed` option it can be a version prefix (`21.` for ex),""", + """ +
+
+
prefix.version = 1.2.3 (1)
+
+
+
+
    +
  1. +
    + + Version of the tool to install, using\s + + relaxed + option it can be a version prefix ( + + 21. + for ex), + +
    +
  2. +
+
+ """); } private void assertRendering(final String adoc, final String html) { From 1c1df81074d3ae805b2655936f4c57d697f2ca17 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 18:21:45 +0100 Subject: [PATCH 05/26] [asciidoc] even less wrapping in callouts --- .../asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java | 3 +++ .../renderer/html/AsciidoctorLikeHtmlRendererTest.java | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java b/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java index 6fe412d4..994cd646 100644 --- a/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java +++ b/asciidoc-java/src/main/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRenderer.java @@ -594,10 +594,13 @@ public void visitCode(final Code element) { builder.append("
\n"); builder.append("
    \n"); element.callOuts().forEach(c -> { + final boolean nowrap = state.nowrap; builder.append("
  1. \n"); state.inCallOut = true; + state.nowrap = true; visitElement(c.text()); state.inCallOut = false; + state.nowrap = nowrap; builder.append("
  2. \n"); }); builder.append("
\n"); diff --git a/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java b/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java index 4d0aeff3..49dc82a1 100644 --- a/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java +++ b/asciidoc-java/src/test/java/io/yupiik/asciidoc/renderer/html/AsciidoctorLikeHtmlRendererTest.java @@ -599,7 +599,6 @@ void callouts() {
  1. -
    Version of the tool to install, using\s @@ -609,7 +608,6 @@ option it can be a version prefix ( 21. for ex), -
From 0f4c23260663b08cbbd8931a523898ea60b4e7eb Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 21:02:11 +0100 Subject: [PATCH 06/26] [env manager] add run command test and support windows PathExt --- .../main/java/io/yupiik/dev/command/Run.java | 16 ++++++++++-- .../io/yupiik/dev/command/CommandsTest.java | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index af56ee40..b5d0e673 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import java.util.logging.Logger; import java.util.stream.Stream; @@ -87,10 +88,21 @@ public void run() { if (!command.isEmpty() && !path.isBlank()) { final var exec = command.get(0); if (Files.notExists(Path.of(exec))) { - Stream.of(path.split(pathSeparator)) + final var paths = path.split(pathSeparator); + final var exts = os.isWindows() ? + Stream.concat( + Stream.of(""), + Stream.ofNullable(System.getenv("PathExt")) + .map(i -> Stream.of(i.split(";")) + .map(String::strip) + .filter(Predicate.not(String::isBlank)))) + .toList() : + List.of(""); + Stream.of(paths) .map(Path::of) .filter(Files::exists) - .map(d -> d.resolve(exec)) + .flatMap(d -> exts.stream() + .map(e -> d.resolve(exec + e))) .filter(Files::exists) .findFirst() .ifPresent(bin -> command.set(0, bin.toString())); diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 0c55ab5d..d453f7fd 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -34,6 +34,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Stream; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -146,6 +147,20 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { .strip()); } + @Test + void run(@TempDir final Path work, final URI uri) throws IOException { + final var output = work.resolve("output"); + final var yem = Files.writeString( + work.resolve(".yemrc"), + "demo.alias = " + System.getProperty("java.home") + "/bin/java " + + "-cp target/test-classes " + + "io.yupiik.dev.command.CommandsTest$SampleMain " + + "\"" + output + "\"" + + "hello YEM!"); + captureOutput(work, uri, "run", "--rc", yem.toString(), "--", "demo"); + assertEquals(">> [hello, YEM!]", Files.readString(output)); + } + private String captureOutput(final Path work, final URI uri, final String... command) { final var out = new ByteArrayOutputStream(); final var oldOut = System.out; @@ -204,4 +219,14 @@ public String get(final String key) { }; } } + + public static final class SampleMain { + private SampleMain() { + // no-op + } + + public static void main(final String... args) throws IOException { + Files.writeString(Path.of(args[0]), ">> " + Stream.of(args).skip(1).toList()); + } + } } From eb222a482b91b3e1c4af8cda94955f1bc912ebfe Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Mon, 5 Feb 2024 21:02:47 +0100 Subject: [PATCH 07/26] [env manager] fix run executed command debug statement --- env-manager/src/main/java/io/yupiik/dev/command/Run.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index b5d0e673..c431c006 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -76,8 +76,6 @@ public void run() { } } - logger.finest(() -> "Resolved command: " + command); - final var process = new ProcessBuilder(command); process.inheritIO(); @@ -109,6 +107,8 @@ public void run() { } } + logger.finest(() -> "Resolved command: " + command); + try { final var processExec = process.start(); final int exitCode = processExec.waitFor(); From 32797821e1e414c8b771c5b23d102f2c26c81f25 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Tue, 6 Feb 2024 11:04:36 +0100 Subject: [PATCH 08/26] [env manager] more efficient zulu cache and add filters to list command --- .../main/java/io/yupiik/dev/command/List.java | 37 +++++-- .../dev/provider/zulu/ZuluCdnClient.java | 23 ++++- .../java/io/yupiik/dev/shared/http/Cache.java | 96 +++++++++++++++++++ .../dev/shared/http/HttpConfiguration.java | 3 + .../yupiik/dev/shared/http/YemHttpClient.java | 78 +++------------ .../io/yupiik/dev/command/CommandsTest.java | 2 +- .../dev/provider/zulu/ZuluCdnClientTest.java | 7 +- .../io/yupiik/dev/test/HttpMockExtension.java | 4 +- 8 files changed, 174 insertions(+), 76 deletions(-) create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java diff --git a/env-manager/src/main/java/io/yupiik/dev/command/List.java b/env-manager/src/main/java/io/yupiik/dev/command/List.java index e526b2ef..d1ca091d 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/List.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/List.java @@ -19,11 +19,15 @@ import io.yupiik.dev.provider.model.Candidate; import io.yupiik.dev.provider.model.Version; import io.yupiik.fusion.framework.build.api.cli.Command; +import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; import java.util.Map; +import java.util.function.Predicate; import java.util.logging.Logger; +import java.util.stream.Stream; +import static java.util.Locale.ROOT; import static java.util.function.Function.identity; import static java.util.logging.Level.FINEST; import static java.util.stream.Collectors.joining; @@ -32,38 +36,55 @@ @Command(name = "list", description = "List remote (available) distributions.") public class List implements Runnable { private final Logger logger = Logger.getLogger(getClass().getName()); + + private final Conf conf; private final ProviderRegistry registry; public List(final Conf conf, final ProviderRegistry registry) { + this.conf = conf; this.registry = registry; } @Override public void run() { + final var toolFilter = toFilter(conf.tools()); + final var providerFilter = toFilter(conf.providers()); final var collect = registry.providers().stream() + .filter(p -> providerFilter.test(p.name()) || providerFilter.test(p.getClass().getSimpleName().toLowerCase(ROOT))) .map(p -> { try { return p.listTools().stream() - .collect(toMap(identity(), tool -> p.listVersions(tool.tool()))); + .filter(t -> toolFilter.test(t.tool()) || toolFilter.test(t.name())) + .collect(toMap(c -> "[" + p.name() + "] " + c.tool(), tool -> p.listVersions(tool.tool()))); } catch (final RuntimeException re) { logger.log(FINEST, re, re::getMessage); return Map.>of(); } }) .flatMap(m -> m.entrySet().stream()) - .map(e -> "- " + e.getKey().tool() + ":" + (e.getValue().isEmpty() ? - " no version available" : - e.getValue().stream() - .sorted((a, b) -> -a.compareTo(b)) - .map(v -> "-- " + v.version()) - .collect(joining("\n", "\n", "\n")))) + .filter(Predicate.not(m -> m.getValue().isEmpty())) + .map(e -> "- " + e.getKey() + ":" + e.getValue().stream() + .sorted((a, b) -> -a.compareTo(b)) + .map(v -> "-- " + v.version()) + .collect(joining("\n", "\n", "\n"))) .sorted() .collect(joining("\n")); logger.info(() -> collect.isBlank() ? "No distribution available." : collect); } + private Predicate toFilter(final String values) { + return values == null || values.isBlank() ? + t -> true : + Stream.of(values.split(",")) + .map(String::strip) + .filter(Predicate.not(String::isBlank)) + .map(t -> (Predicate) t::equals) + .reduce(t -> false, Predicate::or); + } + @RootConfiguration("list") - public record Conf(/* no option yet */) { + public record Conf(@Property(documentation = "List of tools to list (comma separated).") String tools, + @Property(documentation = "List of providers to use (comma separated).") String providers) { } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java index 78dbc35f..d8d37b6b 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java @@ -21,6 +21,7 @@ import io.yupiik.dev.provider.model.Version; import io.yupiik.dev.shared.Archives; import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.Cache; import io.yupiik.dev.shared.http.YemHttpClient; import io.yupiik.fusion.framework.api.scope.DefaultScoped; @@ -38,6 +39,7 @@ import static java.util.Optional.ofNullable; import static java.util.function.Function.identity; +import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; @DefaultScoped @@ -45,14 +47,17 @@ public class ZuluCdnClient implements Provider { private final String suffix; private final Archives archives; private final YemHttpClient client; + private final Cache cache; private final URI base; private final Path local; private final boolean enabled; private final boolean preferJre; - public ZuluCdnClient(final YemHttpClient client, final ZuluCdnConfiguration configuration, final Os os, final Archives archives) { + public ZuluCdnClient(final YemHttpClient client, final ZuluCdnConfiguration configuration, final Os os, final Archives archives, + final Cache cache) { this.client = client; this.archives = archives; + this.cache = cache; this.base = URI.create(configuration.base()); this.local = Path.of(configuration.local()); this.enabled = configuration.enabled(); @@ -182,9 +187,23 @@ public List listVersions(final String tool) { if (!enabled) { return List.of(); } + + // here the payload is >5M so we can let the client cache it but saving the result will be way more efficient on the JSON side + final var entry = cache.lookup(base.toASCIIString()); + if (entry != null && entry.hit() != null) { + return parseVersions(entry.hit().payload()); + } + final var res = client.send(HttpRequest.newBuilder().GET().uri(base).build()); ensure200(res); - return parseVersions(res.body()); + + final var filtered = parseVersions(res.body()); + if (entry != null) { + cache.save(entry.key(), Map.of(), filtered.stream() + .map(it -> "zulu" + it.identifier() + '-' + suffix + "") + .collect(joining("\n", "", "\n"))); + } + return filtered; } private void ensure200(final HttpResponse res) { diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java new file mode 100644 index 00000000..4cf0376f --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java @@ -0,0 +1,96 @@ +package io.yupiik.dev.shared.http; + +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import io.yupiik.fusion.framework.build.api.json.JsonModel; +import io.yupiik.fusion.json.JsonMapper; + +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.util.Base64; +import java.util.Map; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.Clock.systemDefaultZone; +import static java.util.stream.Collectors.toMap; + +@ApplicationScoped +public class Cache { + private final Path cache; + private final long cacheValidity; + private final JsonMapper jsonMapper; + private final Clock clock; + + protected Cache() { + this.cache = null; + this.jsonMapper = null; + this.clock = null; + this.cacheValidity = 0L; + } + + public Cache(final HttpConfiguration configuration, final JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + try { + this.cache = configuration.isCacheEnabled() ? null : Files.createDirectories(Path.of(configuration.cache())); + } catch (final IOException e) { + throw new IllegalArgumentException("Can't create HTTP cache directory : '" + configuration.cache() + "', adjust --http-cache parameter"); + } + this.cacheValidity = configuration.cacheValidity(); + this.clock = systemDefaultZone(); + } + + public void save(final Path key, final HttpResponse result) { + save( + key, + result.headers().map().entrySet().stream() + .filter(it -> !"content-encoding".equalsIgnoreCase(it.getKey())) + .collect(toMap(Map.Entry::getKey, l -> String.join(",", l.getValue()))), + result.body()); + } + + public void save(final Path cacheLocation, final Map headers, final String body) { + final var cachedData = jsonMapper.toString(new Response(headers, body, clock.instant().plusMillis(cacheValidity).toEpochMilli())); + try { + Files.writeString(cacheLocation, cachedData); + } catch (final IOException e) { + try { + Files.deleteIfExists(cacheLocation); + } catch (final IOException ex) { + // no-op + } + } + } + + public CachedEntry lookup(final HttpRequest request) { + return lookup(request.uri().toASCIIString()); + } + + public CachedEntry lookup(final String key) { + if (cache == null) { + return null; + } + + final var cacheLocation = cache.resolve(Base64.getUrlEncoder().withoutPadding().encodeToString(key.getBytes(UTF_8))); + if (Files.exists(cacheLocation)) { + try { + final var cached = jsonMapper.fromString(Response.class, Files.readString(cacheLocation)); + if (cached.validUntil() > clock.instant().toEpochMilli()) { + return new CachedEntry(cacheLocation, cached); + } + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + return new CachedEntry(cacheLocation, null); + } + + public record CachedEntry(Path key, Response hit) { + } + + @JsonModel + public record Response(Map headers, String payload, long validUntil) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java index a33c29d4..e88ae826 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java @@ -26,4 +26,7 @@ public record HttpConfiguration( @Property(defaultValue = "86_400_000L", documentation = "Cache validity of requests (1 day by default) in milliseconds. A negative or zero value will disable cache.") long cacheValidity, @Property(defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.yupiik/yem/cache/http\"", documentation = "Where to cache slow updates (version fetching). `none` will disable cache.") String cache ) { + public boolean isCacheEnabled() { + return "none".equals(cache()) || cacheValidity() <= 0; + } } diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java index a04a1bdc..2cf4d235 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java @@ -17,7 +17,6 @@ import io.yupiik.dev.provider.Provider; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; -import io.yupiik.fusion.framework.build.api.json.JsonModel; import io.yupiik.fusion.httpclient.core.ExtendedHttpClient; import io.yupiik.fusion.httpclient.core.ExtendedHttpClientConfiguration; import io.yupiik.fusion.httpclient.core.listener.RequestListener; @@ -25,7 +24,6 @@ import io.yupiik.fusion.httpclient.core.listener.impl.ExchangeLogger; import io.yupiik.fusion.httpclient.core.request.UnlockedHttpRequest; import io.yupiik.fusion.httpclient.core.response.StaticHttpResponse; -import io.yupiik.fusion.json.JsonMapper; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; @@ -37,10 +35,8 @@ import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Clock; import java.time.Duration; import java.util.ArrayList; -import java.util.Base64; import java.util.List; import java.util.Map; import java.util.concurrent.Flow; @@ -58,21 +54,16 @@ @ApplicationScoped public class YemHttpClient implements AutoCloseable { private final Logger logger = Logger.getLogger(getClass().getName()); + private final ExtendedHttpClient client; - private final Path cache; - private final JsonMapper jsonMapper; - private final Clock clock; - private final long cacheValidity; + private final Cache cache; protected YemHttpClient() { // for subclassing proxy this.client = null; this.cache = null; - this.jsonMapper = null; - this.clock = null; - this.cacheValidity = 0L; } - public YemHttpClient(final HttpConfiguration configuration, final JsonMapper jsonMapper) { + public YemHttpClient(final HttpConfiguration configuration, final Cache cache) { final var listeners = new ArrayList>(); if (configuration.log()) { listeners.add((new ExchangeLogger( @@ -111,17 +102,8 @@ public RequestListener.State before(final long count, final HttpRequest re .build()) .setRequestListeners(listeners); - try { - this.cache = "none".equals(configuration.cache()) || configuration.cacheValidity() <= 0 ? - null : - Files.createDirectories(Path.of(configuration.cache())); - } catch (final IOException e) { - throw new IllegalArgumentException("Can't create HTTP cache directory : '" + configuration.cache() + "', adjust --http-cache parameter"); - } - this.cacheValidity = configuration.cacheValidity(); + this.cache = cache; this.client = new ExtendedHttpClient(conf); - this.jsonMapper = jsonMapper; - this.clock = systemDefaultZone(); } @Override @@ -160,27 +142,15 @@ public HttpResponse getFile(final HttpRequest request, final Path target, } public HttpResponse send(final HttpRequest request) { - final Path cacheLocation; - if (cache != null) { - cacheLocation = cache.resolve(Base64.getUrlEncoder().withoutPadding().encodeToString(request.uri().toASCIIString().getBytes(UTF_8))); - if (Files.exists(cacheLocation)) { - try { - final var cached = jsonMapper.fromString(Response.class, Files.readString(cacheLocation)); - if (cached.validUntil() > clock.instant().toEpochMilli()) { - return new StaticHttpResponse<>( - request, request.uri(), HTTP_1_1, 200, - HttpHeaders.of( - cached.headers().entrySet().stream() - .collect(toMap(Map.Entry::getKey, e -> List.of(e.getValue()))), - (a, b) -> true), - cached.payload()); - } - } catch (final IOException e) { - throw new IllegalStateException(e); - } - } - } else { - cacheLocation = null; + final var entry = cache.lookup(request); + if (entry != null && entry.hit() != null) { + return new StaticHttpResponse<>( + request, request.uri(), HTTP_1_1, 200, + HttpHeaders.of( + entry.hit().headers().entrySet().stream() + .collect(toMap(Map.Entry::getKey, e -> List.of(e.getValue()))), + (a, b) -> true), + entry.hit().payload()); } logger.finest(() -> "Calling " + request); @@ -199,22 +169,8 @@ public HttpResponse send(final HttpRequest request) { result = result != null ? result : new StaticHttpResponse<>( response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), new String(response.body(), UTF_8)); - if (cacheLocation != null && result.statusCode() == 200) { - final var cachedData = jsonMapper.toString(new Response( - result.headers().map().entrySet().stream() - .filter(it -> !"content-encoding".equalsIgnoreCase(it.getKey())) - .collect(toMap(Map.Entry::getKey, l -> String.join(",", l.getValue()))), - result.body(), - clock.instant().plusMillis(cacheValidity).toEpochMilli())); - try { - Files.writeString(cacheLocation, cachedData); - } catch (final IOException e) { - try { - Files.deleteIfExists(cacheLocation); - } catch (final IOException ex) { - // no-op - } - } + if (entry != null && result.statusCode() == 200) { + cache.save(entry.key(), result); } return result; } catch (final InterruptedException var4) { @@ -282,8 +238,4 @@ public void onComplete() { throw new IllegalStateException(var5); } } - - @JsonModel - public record Response(Map headers, String payload, long validUntil) { - } } diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index d453f7fd..3692fb8c 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -70,7 +70,7 @@ void simplifiedOptions(@TempDir final Path work, final URI uri) throws IOExcepti @Test void list(@TempDir final Path work, final URI uri) { assertEquals(""" - - java: + - [zulu] java: -- 21.0.2""", captureOutput(work, uri, "list")); } diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java index de4c4c47..a3724077 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java @@ -19,6 +19,8 @@ import io.yupiik.dev.provider.model.Version; import io.yupiik.dev.shared.Archives; import io.yupiik.dev.shared.Os; +import io.yupiik.dev.shared.http.Cache; +import io.yupiik.dev.shared.http.HttpConfiguration; import io.yupiik.dev.shared.http.YemHttpClient; import io.yupiik.dev.test.Mock; import org.junit.jupiter.api.Test; @@ -174,6 +176,9 @@ void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) } private ZuluCdnClient newProvider(final URI uri, final YemHttpClient client, final Path local) { - return new ZuluCdnClient(client, new ZuluCdnConfiguration(true, true, uri.toASCIIString(), "linux_x64.zip", local.toString()), new Os(), new Archives()); + return new ZuluCdnClient( + client, + new ZuluCdnConfiguration(true, true, uri.toASCIIString(), "linux_x64.zip", local.toString()), + new Os(), new Archives(), new Cache(new HttpConfiguration(false, 30_000L, 30_000L, 0L, "none"), null)); } } diff --git a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java index a5696e26..ac069507 100644 --- a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java +++ b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java @@ -16,6 +16,7 @@ package io.yupiik.dev.test; import com.sun.net.httpserver.HttpServer; +import io.yupiik.dev.shared.http.Cache; import io.yupiik.dev.shared.http.HttpConfiguration; import io.yupiik.dev.shared.http.YemHttpClient; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; @@ -92,7 +93,8 @@ public Object resolveParameter(final ParameterContext parameterContext, final Ex return URI.create("http://localhost:" + context.getStore(NAMESPACE).get(HttpServer.class, HttpServer.class).getAddress().getPort() + "/2/"); } if (YemHttpClient.class == parameterContext.getParameter().getType()) { - return new YemHttpClient(new HttpConfiguration(false, 30_000L, 30_000L, 0, "none"), null); + final var configuration = new HttpConfiguration(false, 30_000L, 30_000L, 0, "none"); + return new YemHttpClient(configuration, new Cache(configuration, null)); } throw new ParameterResolutionException("Can't resolve " + parameterContext.getParameter().getType()); } From 0c17e4f638c15e772a6fcbf699a4b01e69b30f48 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Tue, 6 Feb 2024 11:17:45 +0100 Subject: [PATCH 09/26] [style] missing header --- .../java/io/yupiik/dev/shared/http/Cache.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java index 4cf0376f..110f3b3c 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared.http; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; From f6e4a680def74413d462fc81bd8a105681f03f8c Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Tue, 6 Feb 2024 15:48:20 +0100 Subject: [PATCH 10/26] [env manager] move to reactive programming to do remoting concurrently --- env-manager/pom.xml | 1 + .../java/io/yupiik/dev/command/Config.java | 39 +-- .../java/io/yupiik/dev/command/Delete.java | 18 +- .../main/java/io/yupiik/dev/command/Env.java | 69 +++--- .../java/io/yupiik/dev/command/Install.java | 35 ++- .../main/java/io/yupiik/dev/command/List.java | 66 +++-- .../java/io/yupiik/dev/command/ListLocal.java | 49 +++- .../java/io/yupiik/dev/command/Resolve.java | 20 +- .../main/java/io/yupiik/dev/command/Run.java | 116 ++++----- .../ImplicitKeysConfiguration.java | 24 +- .../java/io/yupiik/dev/provider/Provider.java | 11 +- .../yupiik/dev/provider/ProviderRegistry.java | 147 ++++++++--- .../central/ApacheMavenConfiguration.java | 23 -- .../provider/central/ApacheMavenProvider.java | 28 --- .../provider/central/CentralBaseProvider.java | 129 +++++----- .../central/CentralConfiguration.java | 3 +- .../provider/central/CentralProviderInit.java | 46 ++++ .../io/yupiik/dev/provider/central/Gav.java | 48 ++++ ...ralConfiguration.java => GavRegistry.java} | 21 +- .../provider/github/MinikubeGithubClient.java | 206 ++++++++-------- .../dev/provider/sdkman/SdkManClient.java | 128 +++++----- .../dev/provider/zulu/ZuluCdnClient.java | 69 +++--- .../java/io/yupiik/dev/shared/RcService.java | 100 ++++---- .../dev/shared/http/HttpConfiguration.java | 1 + .../yupiik/dev/shared/http/YemHttpClient.java | 229 ++++++++++-------- .../io/yupiik/dev/command/CommandsTest.java | 7 +- .../central/CentralBaseProviderTest.java | 32 ++- .../dev/provider/sdkman/SdkManClientTest.java | 31 +-- .../dev/provider/zulu/ZuluCdnClientTest.java | 20 +- .../io/yupiik/dev/test/HttpMockExtension.java | 2 +- 30 files changed, 1023 insertions(+), 695 deletions(-) delete mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java delete mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java create mode 100644 env-manager/src/main/java/io/yupiik/dev/provider/central/Gav.java rename env-manager/src/main/java/io/yupiik/dev/provider/central/{SingletonCentralConfiguration.java => GavRegistry.java} (57%) diff --git a/env-manager/pom.xml b/env-manager/pom.xml index 21dd19ed..2ec541a3 100644 --- a/env-manager/pom.xml +++ b/env-manager/pom.xml @@ -119,6 +119,7 @@ io.yupiik.logging.jul.YupiikLogManager + true diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Config.java b/env-manager/src/main/java/io/yupiik/dev/command/Config.java index d2368f66..2c280bd4 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Config.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Config.java @@ -15,58 +15,65 @@ */ package io.yupiik.dev.command; -import io.yupiik.dev.provider.central.ApacheMavenConfiguration; -import io.yupiik.dev.provider.central.SingletonCentralConfiguration; +import io.yupiik.dev.provider.central.CentralConfiguration; +import io.yupiik.dev.provider.central.GavRegistry; import io.yupiik.dev.provider.github.MinikubeConfiguration; import io.yupiik.dev.provider.github.SingletonGithubConfiguration; import io.yupiik.dev.provider.sdkman.SdkManConfiguration; import io.yupiik.dev.provider.zulu.ZuluCdnConfiguration; +import io.yupiik.fusion.framework.api.configuration.Configuration; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; import java.util.Map; import java.util.logging.Logger; +import java.util.stream.Stream; +import static java.util.Map.entry; import static java.util.stream.Collectors.joining; @Command(name = "config", description = "Show configuration.") public class Config implements Runnable { private final Logger logger = Logger.getLogger(getClass().getName()); - private final SingletonCentralConfiguration central; + private final CentralConfiguration central; private final SdkManConfiguration sdkman; private final SingletonGithubConfiguration github; private final ZuluCdnConfiguration zulu; private final MinikubeConfiguration minikube; - private final ApacheMavenConfiguration maven; + private final GavRegistry gavs; + private final Configuration configuration; public Config(final Conf conf, - final SingletonCentralConfiguration central, + final Configuration configuration, + final CentralConfiguration central, final SdkManConfiguration sdkman, final SingletonGithubConfiguration github, final ZuluCdnConfiguration zulu, final MinikubeConfiguration minikube, - final ApacheMavenConfiguration maven) { + final GavRegistry gavs) { + this.configuration = configuration; this.central = central; this.sdkman = sdkman; this.github = github; this.zulu = zulu; this.minikube = minikube; - this.maven = maven; + this.gavs = gavs; } @Override public void run() { - logger.info(() -> Map.of( - "central", central.configuration(), - "sdkman", sdkman, - "github", github.configuration(), - "zulu", zulu, - "minikube", minikube, - "maven", maven) - .entrySet().stream() + logger.info(() -> Stream.concat( + Map.of( + "central", central.toString(), + "sdkman", sdkman.toString(), + "github", github.configuration().toString(), + "zulu", zulu.toString(), + "minikube", minikube.toString()).entrySet().stream(), + gavs.gavs().stream() + .map(g -> entry(g.artifactId(), "[enabled=" + configuration.get(g.artifactId() + ".enabled").orElse("true") + "]"))) .sorted(Map.Entry.comparingByKey()) .map(e -> { - final var value = e.getValue().toString(); + final var value = e.getValue(); return "- " + e.getKey() + ": " + value.substring(value.indexOf('[') + 1, value.lastIndexOf(']')); }) .collect(joining("\n"))); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java index 3b0f5b20..332bce5f 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java @@ -20,6 +20,7 @@ import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import java.util.concurrent.ExecutionException; import java.util.logging.Logger; @Command(name = "delete", description = "Delete a distribution.") @@ -36,9 +37,20 @@ public Delete(final Conf conf, @Override public void run() { - final var providerAndVersion = registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false); - providerAndVersion.getKey().delete(conf.tool(), providerAndVersion.getValue().identifier()); - logger.info(() -> "Deleted " + conf.tool() + "@" + providerAndVersion.getValue().version()); + try { + registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false) + .thenAccept(providerAndVersion -> { + providerAndVersion.getKey().delete(conf.tool(), providerAndVersion.getValue().identifier()); + logger.info(() -> "Deleted " + conf.tool() + "@" + providerAndVersion.getValue().version()); + }) + .toCompletableFuture() + .get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } } diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index b18c388c..e07729ec 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -23,6 +23,7 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.concurrent.ExecutionException; import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; @@ -92,36 +93,44 @@ public void close() throws SecurityException { logger.addHandler(tempHandler); try { - final var resolved = rc.toToolProperties(tools); - final var toolVars = resolved.entrySet().stream() - .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\"") - .sorted() - .collect(joining("\n", "", "\n")); - - final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) - .or(() -> ofNullable(System.getenv(pathName))) - .orElse(""); - final var pathVars = resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath) ? - export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\"\n" + - export + pathName + "=\"" + resolved.entrySet().stream() - .filter(r -> r.getKey().addToPath()) - .map(r -> quoted(rc.toBin(r.getValue()))) - .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\"\n" : - ""; - final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? - resolved.entrySet().stream() - .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\"") - .collect(joining("\n", "", "\n")) : - ""; - - final var script = messages.stream().map(m -> "echo \"[yem] " + m + "\"").collect(joining("\n", "", "\n\n")) + - pathVars + toolVars + echos + "\n" + - comment + "To load a .yemrc configuration run:\n" + - comment + "[ -f .yemrc ] && eval $(yem env--env-file .yemrc)\n" + - comment + "\n" + - comment + "See https://www.yupiik.io/tools-maven-plugin/yem.html#autopath for details\n" + - "\n"; - System.out.println(script); + rc.toToolProperties(tools).thenAccept(resolved -> { + final var toolVars = resolved.entrySet().stream() + .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\"") + .sorted() + .collect(joining("\n", "", "\n")); + + final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) + .or(() -> ofNullable(System.getenv(pathName))) + .orElse(""); + final var pathVars = resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath) ? + export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\"\n" + + export + pathName + "=\"" + resolved.entrySet().stream() + .filter(r -> r.getKey().addToPath()) + .map(r -> quoted(rc.toBin(r.getValue()))) + .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\"\n" : + ""; + final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? + resolved.entrySet().stream() + .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\"") + .collect(joining("\n", "", "\n")) : + ""; + + final var script = messages.stream().map(m -> "echo \"[yem] " + m + "\"").collect(joining("\n", "", "\n\n")) + + pathVars + toolVars + echos + "\n" + + comment + "To load a .yemrc configuration run:\n" + + comment + "[ -f .yemrc ] && eval $(yem env--env-file .yemrc)\n" + + comment + "\n" + + comment + "See https://www.yupiik.io/tools-maven-plugin/yem.html#autopath for details\n" + + "\n"; + System.out.println(script); + }) + .toCompletableFuture() + .get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); } finally { logger.setUseParentHandlers(useParentHandlers); logger.removeHandler(tempHandler); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Install.java b/env-manager/src/main/java/io/yupiik/dev/command/Install.java index 91b2f2dc..ed5c9e67 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Install.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Install.java @@ -15,12 +15,12 @@ */ package io.yupiik.dev.command; -import io.yupiik.dev.provider.Provider; import io.yupiik.dev.provider.ProviderRegistry; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import java.util.concurrent.ExecutionException; import java.util.logging.Logger; import java.util.stream.IntStream; @@ -40,18 +40,27 @@ public Install(final Conf conf, @Override public void run() { - final var providerAndVersion = registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed()); - final var result = providerAndVersion.getKey().install(conf.tool(), providerAndVersion.getValue().identifier(), new Provider.ProgressListener() { - @Override - public void onProcess(final String name, final double percent) { - final int plain = (int) (10 * percent); - System.out.printf("%s [%s]\r", - name, - (plain == 0 ? "" : IntStream.range(0, plain).mapToObj(i -> "X").collect(joining(""))) + - (plain == 10 ? "" : IntStream.range(0, 10 - plain).mapToObj(i -> "_").collect(joining("")))); - } - }); - logger.info(() -> "Installed " + conf.tool() + "@" + providerAndVersion.getValue().version() + " at '" + result + "'"); + try { + registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed()) + .thenCompose(providerAndVersion -> providerAndVersion.getKey() + .install(conf.tool(), providerAndVersion.getValue().identifier(), this::onProgress) + .thenAccept(result -> logger.info(() -> "Installed " + conf.tool() + "@" + providerAndVersion.getValue().version() + " at '" + result + "'"))) + .toCompletableFuture() + .get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } + } + + private void onProgress(final String name, final double percent) { + final int plain = (int) (10 * percent); + System.out.printf("%s [%s]\r", + name, + (plain == 0 ? "" : IntStream.range(0, plain).mapToObj(i -> "X").collect(joining(""))) + + (plain == 10 ? "" : IntStream.range(0, 10 - plain).mapToObj(i -> "_").collect(joining("")))); } @RootConfiguration("install") diff --git a/env-manager/src/main/java/io/yupiik/dev/command/List.java b/env-manager/src/main/java/io/yupiik/dev/command/List.java index d1ca091d..31c00766 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/List.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/List.java @@ -16,19 +16,19 @@ package io.yupiik.dev.command; import io.yupiik.dev.provider.ProviderRegistry; -import io.yupiik.dev.provider.model.Candidate; -import io.yupiik.dev.provider.model.Version; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; -import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.function.Predicate; import java.util.logging.Logger; import java.util.stream.Stream; import static java.util.Locale.ROOT; -import static java.util.function.Function.identity; +import static java.util.concurrent.CompletableFuture.allOf; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.logging.Level.FINEST; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; @@ -50,27 +50,57 @@ public List(final Conf conf, public void run() { final var toolFilter = toFilter(conf.tools()); final var providerFilter = toFilter(conf.providers()); - final var collect = registry.providers().stream() + final var promises = registry.providers().stream() .filter(p -> providerFilter.test(p.name()) || providerFilter.test(p.getClass().getSimpleName().toLowerCase(ROOT))) .map(p -> { try { - return p.listTools().stream() - .filter(t -> toolFilter.test(t.tool()) || toolFilter.test(t.name())) - .collect(toMap(c -> "[" + p.name() + "] " + c.tool(), tool -> p.listVersions(tool.tool()))); + return p.listTools() + .thenCompose(tools -> { + final var filteredTools = tools.stream() + .filter(t -> toolFilter.test(t.tool()) || toolFilter.test(t.name())) + .toList(); + + final var versions = filteredTools.stream() + .map(v -> p.listVersions(v.tool()).toCompletableFuture()) + .toList(); + + final var versionsIt = versions.iterator(); + return allOf(versions.toArray(new CompletableFuture[0])) + .thenApply(ready -> filteredTools.stream() + .collect(toMap(c -> "[" + p.name() + "] " + c.tool(), tool -> versionsIt.next().getNow(java.util.List.of()))) + .entrySet().stream() + .filter(Predicate.not(m -> m.getValue().isEmpty())) + .map(e -> "- " + e.getKey() + ":" + e.getValue().stream() + .sorted((a, b) -> -a.compareTo(b)) + .map(v -> "-- " + v.version()) + .collect(joining("\n", "\n", "\n"))) + .toList()); + }) + .exceptionally(e -> { + logger.log(FINEST, e, e::getMessage); + return java.util.List.of(); + }) + .toCompletableFuture(); } catch (final RuntimeException re) { logger.log(FINEST, re, re::getMessage); - return Map.>of(); + return completedFuture(java.util.List.of()); } }) - .flatMap(m -> m.entrySet().stream()) - .filter(Predicate.not(m -> m.getValue().isEmpty())) - .map(e -> "- " + e.getKey() + ":" + e.getValue().stream() - .sorted((a, b) -> -a.compareTo(b)) - .map(v -> "-- " + v.version()) - .collect(joining("\n", "\n", "\n"))) - .sorted() - .collect(joining("\n")); - logger.info(() -> collect.isBlank() ? "No distribution available." : collect); + .toList(); + try { + allOf(promises.toArray(new CompletableFuture[0])) + .thenApply(ok -> promises.stream() + .flatMap(p -> p.getNow(java.util.List.of()).stream()) + .sorted() + .collect(joining("\n"))) + .thenAccept(collect -> logger.info(() -> collect.isBlank() ? "No distribution available." : collect)) + .get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } } private Predicate toFilter(final String values) { diff --git a/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java b/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java index f3d68e1b..f2e5b20b 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/ListLocal.java @@ -20,9 +20,13 @@ import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.logging.Logger; +import static java.util.concurrent.CompletableFuture.allOf; import static java.util.stream.Collectors.joining; @Command(name = "list-local", description = "List local available distributions.") @@ -40,19 +44,38 @@ public ListLocal(final Conf conf, @Override public void run() { - final var collect = registry.providers().stream() - .flatMap(p -> p.listLocal().entrySet().stream() - .filter(it -> (conf.tool() == null || Objects.equals(conf.tool(), it.getKey().tool())) && - !it.getValue().isEmpty()) - .map(e -> "- [" + p.name() + "] " + e.getKey().tool() + ":" + (e.getValue().isEmpty() ? - " no version" : - e.getValue().stream() - .sorted((a, b) -> -a.compareTo(b)) // more recent first - .map(v -> "-- " + v.version()) - .collect(joining("\n", "\n", "\n"))))) - .sorted() - .collect(joining("\n")); - logger.info(() -> collect.isBlank() ? "No distribution available." : collect); + final var promises = registry.providers().stream() + .map(p -> p.listLocal() + .thenApply(candidates -> candidates.entrySet().stream() + .filter(it -> (conf.tool() == null || Objects.equals(conf.tool(), it.getKey().tool())) && + !it.getValue().isEmpty()) + .map(e -> "- [" + p.name() + "] " + e.getKey().tool() + ":" + (e.getValue().isEmpty() ? + " no version" : + e.getValue().stream() + .sorted((a, b) -> -a.compareTo(b)) // more recent first + .map(v -> "-- " + v.version()) + .collect(joining("\n", "\n", "\n")))) + .toList()) + .toCompletableFuture()) + .toList(); + + try { + allOf(promises.toArray(new CompletableFuture[0])) + .thenAccept(ok -> { + final var collect = promises.stream() + .flatMap(p -> p.getNow(List.of()).stream()) + .sorted() + .collect(joining("\n")); + logger.info(() -> collect.isBlank() ? "No distribution available." : collect); + }) + .toCompletableFuture() + .get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } } @RootConfiguration("list-local") diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java index 1371f3d1..cd570c71 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java @@ -20,6 +20,7 @@ import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import java.util.concurrent.ExecutionException; import java.util.logging.Logger; @Command(name = "resolve", description = "Resolve a distribution.") @@ -36,10 +37,21 @@ public Resolve(final Conf conf, @Override public void run() { - final var providerAndVersion = registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false); - final var resolved = providerAndVersion.getKey().resolve(conf.tool(), providerAndVersion.getValue().identifier()) - .orElseThrow(() -> new IllegalArgumentException("No matching instance for " + conf.tool() + "@" + conf.version() + ", ensure to install it before resolving it.")); - logger.info(() -> "Resolved " + conf.tool() + "@" + providerAndVersion.getValue().version() + ": '" + resolved + "'"); + try { + registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false) + .thenAccept(providerAndVersion -> { + final var resolved = providerAndVersion.getKey().resolve(conf.tool(), providerAndVersion.getValue().identifier()) + .orElseThrow(() -> new IllegalArgumentException("No matching instance for " + conf.tool() + "@" + conf.version() + ", ensure to install it before resolving it.")); + logger.info(() -> "Resolved " + conf.tool() + "@" + providerAndVersion.getValue().version() + ": '" + resolved + "'"); + }) + .toCompletableFuture() + .get(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } } diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index c431c006..47f8aef3 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutionException; import java.util.function.Predicate; import java.util.logging.Logger; import java.util.stream.Stream; @@ -58,66 +59,73 @@ public Run(final Conf conf, final RcService rc, final Os os, final Args args) { @Override public void run() { final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); - final var resolved = rc.toToolProperties(tools); - - final int idx = args.args().indexOf("--"); - final var command = new ArrayList(8); - if (idx > 0) { - command.addAll(args.args().subList(idx + 1, args.args().size())); - } else { - command.addAll(args.args().subList(1, args.args().size())); - } - - if (!command.isEmpty()) { // handle aliasing - final var alias = tools.getProperty(command.get(0) + ".alias"); - if (alias != null) { - command.remove(0); - command.addAll(0, parseArgs(alias)); - } - } + try { + rc.toToolProperties(tools).thenAccept(resolved -> { + final int idx = args.args().indexOf("--"); + final var command = new ArrayList(8); + if (idx > 0) { + command.addAll(args.args().subList(idx + 1, args.args().size())); + } else { + command.addAll(args.args().subList(1, args.args().size())); + } - final var process = new ProcessBuilder(command); - process.inheritIO(); - - final var environment = process.environment(); - setEnv(resolved, environment); - - final var path = environment.getOrDefault("PATH", environment.getOrDefault("Path", "")); - if (!command.isEmpty() && !path.isBlank()) { - final var exec = command.get(0); - if (Files.notExists(Path.of(exec))) { - final var paths = path.split(pathSeparator); - final var exts = os.isWindows() ? - Stream.concat( - Stream.of(""), - Stream.ofNullable(System.getenv("PathExt")) - .map(i -> Stream.of(i.split(";")) - .map(String::strip) - .filter(Predicate.not(String::isBlank)))) - .toList() : - List.of(""); - Stream.of(paths) - .map(Path::of) - .filter(Files::exists) - .flatMap(d -> exts.stream() - .map(e -> d.resolve(exec + e))) - .filter(Files::exists) - .findFirst() - .ifPresent(bin -> command.set(0, bin.toString())); - } - } + if (!command.isEmpty()) { // handle aliasing + final var alias = tools.getProperty(command.get(0) + ".alias"); + if (alias != null) { + command.remove(0); + command.addAll(0, parseArgs(alias)); + } + } - logger.finest(() -> "Resolved command: " + command); + final var process = new ProcessBuilder(command); + process.inheritIO(); + + final var environment = process.environment(); + setEnv(resolved, environment); + + final var path = environment.getOrDefault("PATH", environment.getOrDefault("Path", "")); + if (!command.isEmpty() && !path.isBlank()) { + final var exec = command.get(0); + if (Files.notExists(Path.of(exec))) { + final var paths = path.split(pathSeparator); + final var exts = os.isWindows() ? + Stream.concat( + Stream.of(""), + Stream.ofNullable(System.getenv("PathExt")) + .map(i -> Stream.of(i.split(";")) + .map(String::strip) + .filter(Predicate.not(String::isBlank)))) + .toList() : + List.of(""); + Stream.of(paths) + .map(Path::of) + .filter(Files::exists) + .flatMap(d -> exts.stream() + .map(e -> d.resolve(exec + e))) + .filter(Files::exists) + .findFirst() + .ifPresent(bin -> command.set(0, bin.toString())); + } + } - try { - final var processExec = process.start(); - final int exitCode = processExec.waitFor(); - logger.finest(() -> "Process exited with status=" + exitCode); - } catch (final IOException e) { - throw new IllegalStateException(e); + logger.finest(() -> "Resolved command: " + command); + + try { + final var processExec = process.start(); + final int exitCode = processExec.waitFor(); + logger.finest(() -> "Process exited with status=" + exitCode); + } catch (final IOException e) { + throw new IllegalStateException(e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + }).toCompletableFuture().get(); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); } } diff --git a/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java index 40574ce4..dd87eeb8 100644 --- a/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/configuration/ImplicitKeysConfiguration.java @@ -19,13 +19,33 @@ import io.yupiik.fusion.framework.api.scope.DefaultScoped; import io.yupiik.fusion.framework.build.api.order.Order; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + @DefaultScoped @Order(Integer.MAX_VALUE) public class ImplicitKeysConfiguration implements ConfigurationSource { + private final Properties properties = new Properties(); + + public ImplicitKeysConfiguration() { + final var global = Path.of(System.getProperty("user.home", "")).resolve(".yupiik/yem/rc"); + if (Files.exists(global) && + !Boolean.getBoolean("yem.disableGlobalRcFileConfiguration") && + !Boolean.parseBoolean(System.getenv("YEM_DISABLE_GLOBAL_RC_FILE"))) { + try (final var reader = Files.newBufferedReader(global)) { + properties.load(reader); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + } + @Override public String get(final String key) { return "fusion.json.maxStringLength".equals(key) ? - System.getProperty(key, "8388608") /* zulu payload is huge and would be slow to keep allocating mem */ : - null; + System.getProperty(key, properties.getProperty(key, "8388608")) /* zulu payload is huge and would be slow to keep allocating mem */ : + properties.getProperty(key); } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java b/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java index 17aab7d9..04f5c181 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/Provider.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletionStage; /** * Represents a source of distribution/tool and integrates with a external+local (cache) storage. @@ -30,17 +31,17 @@ public interface Provider { // NOTE: normally we don't need a reactive impl since we resolve most of tools locally String name(); - List listTools(); + CompletionStage> listTools(); - List listVersions(String tool); + CompletionStage> listVersions(String tool); - Archive download(String tool, String version, Path target, ProgressListener progressListener); + CompletionStage download(String tool, String version, Path target, ProgressListener progressListener); void delete(String tool, String version); - Path install(String tool, String version, ProgressListener progressListener); + CompletionStage install(String tool, String version, ProgressListener progressListener); - Map> listLocal(); + CompletionStage>> listLocal(); Optional resolve(String tool, String version); diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java index 666bc840..2453f2cc 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java @@ -21,19 +21,27 @@ import io.yupiik.dev.provider.sdkman.SdkManClient; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; -import java.util.HashMap; -import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.logging.Logger; +import java.util.stream.Stream; import static java.util.Locale.ROOT; import static java.util.Map.entry; +import static java.util.Optional.empty; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.logging.Level.FINEST; import static java.util.stream.Collectors.joining; @ApplicationScoped public class ProviderRegistry { + private final Logger logger = Logger.getLogger(getClass().getName()); private final List providers; public ProviderRegistry(final List providers) { @@ -60,55 +68,116 @@ public List providers() { return providers; } - public Map.Entry findByToolVersionAndProvider(final String tool, final String version, final String provider, - final boolean relaxed) { - return tryFindByToolVersionAndProvider(tool, version, provider, relaxed, new Cache(new IdentityHashMap<>(), new IdentityHashMap<>())) - .orElseThrow(() -> new IllegalArgumentException("No provider for tool '" + tool + "' in version '" + version + "', available tools:\n" + - providers().stream() - .flatMap(it -> it.listTools().stream() - .map(Candidate::tool) - .map(t -> "- " + t + "\n" + it.listVersions(t).stream() - .map(v -> "-- " + v.identifier() + " (" + v.version() + ")") - .collect(joining("\n")))) - .sorted() - .collect(joining("\n")))); + public CompletionStage> findByToolVersionAndProvider(final String tool, final String version, final String provider, + final boolean relaxed) { + return tryFindByToolVersionAndProvider(tool, version, provider, relaxed, new Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())) + .thenApply(found -> found.orElseThrow(() -> new IllegalArgumentException( + "No provider for tool " + tool + "@" + version + "', available tools:\n" + + providers().stream() + .flatMap(it -> { // here we accept to block since normally we cached everything + try { + return it.listTools().toCompletableFuture().get().stream() + .map(Candidate::tool) + .map(t -> { + try { + return "- " + t + "\n" + it.listVersions(t) + .toCompletableFuture().get().stream() + .map(v -> "-- " + v.identifier() + " (" + v.version() + ")") + .collect(joining("\n")); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } + }); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } catch (final ExecutionException e) { + throw new IllegalStateException(e.getCause()); + } + }) + .sorted() + .collect(joining("\n"))))); } - public Optional> tryFindByToolVersionAndProvider( + public CompletionStage>> tryFindByToolVersionAndProvider( final String tool, final String version, final String provider, final boolean relaxed, final Cache cache) { - return providers().stream() + final var result = new CompletableFuture>>(); + final var promises = providers().stream() .filter(it -> provider == null || // enable "--install-provider zulu" for example Objects.equals(provider, it.name()) || it.getClass().getSimpleName().toLowerCase(ROOT).startsWith(provider.toLowerCase(ROOT))) - .map(it -> { - final var candidates = it.listTools(); + .map(it -> it.listTools().thenCompose(candidates -> { if (candidates.stream().anyMatch(t -> tool.equals(t.tool()))) { - return cache.local.computeIfAbsent(it, Provider::listLocal) - .entrySet().stream() - .filter(e -> Objects.equals(e.getKey().tool(), tool)) - .flatMap(e -> e.getValue().stream() - .filter(v -> matchVersion(v, version, relaxed)) - .findFirst() - .stream()) - .map(v -> entry(it, v)) + final var candidateListMap = cache.local.get(it); + return (candidateListMap != null ? + completedFuture(candidateListMap) : + it.listLocal().thenApply(res -> { + cache.local.putIfAbsent(it, res); + return res; + })).thenCompose(list -> findMatchingVersion(tool, version, relaxed, it, list) .findFirst() .map(Optional::of) - .orElseGet(() -> { - final var versions = cache.versions - .computeIfAbsent(it, p -> new HashMap<>()) - .computeIfAbsent(tool, it::listVersions); - return versions.stream() - .filter(v -> matchVersion(v, version, relaxed)) - .findFirst() - .map(v -> entry(it, v)); - }); + .map(CompletableFuture::completedFuture) + .orElseGet(() -> findRemoteVersions(tool, cache, it) + .thenApply(all -> all.stream() + .filter(v -> matchVersion(v, version, relaxed)) + .findFirst() + .map(v -> entry(it, v))) + .toCompletableFuture())); } - return Optional.>empty(); - }) - .flatMap(Optional::stream) - .findFirst(); + return completedFuture(Optional.empty()); + })) + .toList(); + + // don't leak a promise when exiting + var guard = completedFuture(null); + for (final var it : promises) { + guard = guard.thenCompose(ok -> it + .thenAccept(opt -> { + if (opt.isPresent() && !result.isDone()) { + result.complete(opt); + } + }) + .exceptionally(e -> { + logger.log(FINEST, e, e::getMessage); + return null; + }).thenApply(ignored -> null)); + } + + return guard.thenCompose(allDone -> { + if (!result.isDone()) { + result.complete(empty()); + } + return result; + }); + } + + private CompletionStage> findRemoteVersions(final String tool, final Cache cache, final Provider provider) { + final var providerCache = cache.versions + .computeIfAbsent(provider, p -> new ConcurrentHashMap<>()); + final var cached = providerCache.get(tool); + return (cached != null ? + completedFuture(cached) : + provider.listVersions(tool).thenApply(res -> { + providerCache.putIfAbsent(tool, res); + return res; + })); + } + + private Stream> findMatchingVersion(final String tool, final String version, + final boolean relaxed, final Provider provider, + final Map> versions) { + return versions.entrySet().stream() + .filter(e -> Objects.equals(e.getKey().tool(), tool)) + .flatMap(e -> e.getValue().stream().filter(v -> matchVersion(v, version, relaxed))) + .findFirst() + .stream() + .map(v -> entry(provider, v)); } private boolean matchVersion(final Version v, final String version, final boolean relaxed) { diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java deleted file mode 100644 index 5528817c..00000000 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenConfiguration.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com - * 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 io.yupiik.dev.provider.central; - -import io.yupiik.fusion.framework.build.api.configuration.Property; -import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; - -@RootConfiguration("maven") -public record ApacheMavenConfiguration(@Property(documentation = "Is Apache Maven provider enabled - from central.", defaultValue = "true") boolean enabled) { -} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java deleted file mode 100644 index d12d43e7..00000000 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/ApacheMavenProvider.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com - * 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 io.yupiik.dev.provider.central; - -import io.yupiik.dev.shared.Archives; -import io.yupiik.dev.shared.http.YemHttpClient; -import io.yupiik.fusion.framework.api.scope.DefaultScoped; - -@DefaultScoped -public class ApacheMavenProvider extends CentralBaseProvider { - public ApacheMavenProvider(final YemHttpClient client, final SingletonCentralConfiguration conf, - final Archives archives, final ApacheMavenConfiguration configuration) { - super(client, conf.configuration(), archives, "org.apache.maven:apache-maven:tar.gz:bin", configuration.enabled()); - } -} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java index d6aa568c..3b4bfa01 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java @@ -20,6 +20,7 @@ import io.yupiik.dev.provider.model.Candidate; import io.yupiik.dev.provider.model.Version; import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.http.Cache; import io.yupiik.dev.shared.http.YemHttpClient; import java.io.IOException; @@ -33,30 +34,35 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletionStage; import java.util.stream.Stream; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.function.Function.identity; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; -public abstract class CentralBaseProvider implements Provider { +public class CentralBaseProvider implements Provider { private final YemHttpClient client; private final Archives archives; private final URI base; + private final Cache cache; private final Gav gav; private final Path local; private final boolean enabled; - protected CentralBaseProvider(final YemHttpClient client, - final CentralConfiguration conf, // children must use SingletonCentralConfiguration to avoid multiple creations - final Archives archives, - final String gav, - final boolean enabled) { + public CentralBaseProvider(final YemHttpClient client, + final CentralConfiguration conf, // children must use SingletonCentralConfiguration to avoid multiple creations + final Archives archives, + final Cache cache, + final Gav gav, + final boolean enabled) { this.client = client; this.archives = archives; + this.cache = cache; this.base = URI.create(conf.base()); this.local = Path.of(conf.local()); - this.gav = Gav.of(gav); + this.gav = gav; this.enabled = enabled; } @@ -93,11 +99,11 @@ public void delete(final String tool, final String version) { } @Override - public Path install(final String tool, final String version, final ProgressListener progressListener) { + public CompletionStage install(final String tool, final String version, final ProgressListener progressListener) { final var archivePath = local.resolve(relativePath(version)); final var exploded = archivePath.getParent().resolve(archivePath.getFileName() + "_exploded"); if (Files.exists(exploded)) { - return exploded; + return completedFuture(exploded); } if (!enabled) { @@ -109,15 +115,13 @@ public Path install(final String tool, final String version, final ProgressListe } catch (final IOException e) { throw new IllegalStateException(e); } - final var archive = Files.notExists(archivePath) ? - download(tool, version, archivePath, progressListener) : - new Archive(gav.type(), archivePath); - return archives.unpack(archive, exploded); + return (Files.notExists(archivePath) ? download(tool, version, archivePath, progressListener) : completedFuture(new Archive(gav.type(), archivePath))) + .thenApply(archive -> archives.unpack(archive, exploded)); } @Override - public Map> listLocal() { - return listTools().stream() + public CompletionStage>> listLocal() { + return listTools().thenApply(candidates -> candidates.stream() .collect(toMap(identity(), it -> { final var artifactDir = local.resolve(relativePath("ignored")).getParent().getParent(); if (Files.notExists(artifactDir)) { @@ -146,7 +150,7 @@ public Map> listLocal() { } catch (final IOException e) { return List.of(); } - })); + }))); } @Override @@ -170,46 +174,62 @@ public Optional resolve(final String tool, final String version) { } @Override - public List listTools() { + public CompletionStage> listTools() { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } final var gavString = Stream.of(gav.groupId(), gav.artifactId(), gav.type(), gav.classifier()) .filter(Objects::nonNull) .collect(joining(":")); - return List.of(new Candidate(gavString, gav.artifactId(), gavString + " downloaded from central.", base.toASCIIString())); + return completedFuture(List.of(new Candidate(gavString, gav.artifactId(), gavString + " downloaded from central.", base.toASCIIString()))); } @Override - public Archive download(final String tool, final String version, final Path target, final ProgressListener progressListener) { + public CompletionStage download(final String tool, final String version, final Path target, final ProgressListener progressListener) { if (!enabled) { throw new IllegalStateException(gav + " support not enabled (by configuration)"); } - final var res = client.getFile(HttpRequest.newBuilder() - .uri(base.resolve(relativePath(version))) - .build(), - target, progressListener); - ensure200(res); - return new Archive(gav.type().endsWith(".zip") || gav.type().endsWith(".jar") ? "zip" : "tar.gz", target); + return client.getFile( + HttpRequest.newBuilder() + .uri(base.resolve(relativePath(version))) + .build(), + target, progressListener) + .thenApply(res -> { + ensure200(res); + return new Archive(gav.type().endsWith(".zip") || gav.type().endsWith(".jar") ? "zip" : "tar.gz", target); + }); } @Override - public List listVersions(final String tool) { + public CompletionStage> listVersions(final String tool) { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } - final var res = client.send(HttpRequest.newBuilder() - .GET() - .uri(base - .resolve(gav.groupId().replace('.', '/') + '/') - .resolve(gav.artifactId() + '/') - .resolve("maven-metadata.xml")) - .build()); - ensure200(res); - return parseVersions(res.body()); + final var entry = cache.lookup(base.toASCIIString()); + if (entry != null && entry.hit() != null) { + return completedFuture(parseVersions(entry.hit().payload())); + } + + return client.sendAsync(HttpRequest.newBuilder() + .GET() + .uri(base + .resolve(gav.groupId().replace('.', '/') + '/') + .resolve(gav.artifactId() + '/') + .resolve("maven-metadata.xml")) + .build()) + .thenApply(this::ensure200) + .thenApply(res -> { + final var filtered = parseVersions(res.body()); + if (entry != null) { + cache.save(entry.key(), Map.of(), filtered.stream() + .map(it -> "" + it.version() + "") + .collect(joining("\n", "", "\n"))); + } + return parseVersions(res.body()); + }); } private String relativePath(final String version) { @@ -236,41 +256,10 @@ private List parseVersions(final String body) { return out; } - private void ensure200(final HttpResponse res) { + private HttpResponse ensure200(final HttpResponse res) { if (res.statusCode() != 200) { throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); } - } - - public record Gav(String groupId, String artifactId, String type, String classifier) implements Comparable { - private static Gav of(final String gav) { - final var segments = gav.split(":"); - return switch (segments.length) { - case 2 -> new Gav(segments[0], segments[1], "jar", null); - case 3 -> new Gav(segments[0], segments[1], segments[2], null); - case 4 -> new Gav(segments[0], segments[1], segments[2], segments[3]); - default -> throw new IllegalArgumentException("Invalid gav: '" + gav + "'"); - }; - } - - @Override - public int compareTo(final Gav o) { - if (this == o) { - return 0; - } - final int g = groupId().compareTo(o.groupId()); - if (g != 0) { - return g; - } - final int a = artifactId().compareTo(o.artifactId()); - if (a != 0) { - return a; - } - final int t = type().compareTo(o.type()); - if (t != 0) { - return t; - } - return (classifier == null ? "" : classifier).compareTo(o.classifier() == null ? "" : o.classifier()); - } + return res; } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java index 4faf5ec5..eb43f082 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralConfiguration.java @@ -21,5 +21,6 @@ @RootConfiguration("central") public record CentralConfiguration( @Property(documentation = "Base repository URL.", defaultValue = "\"https://repo.maven.apache.org/maven2/\"") String base, - @Property(documentation = "Local repository path.", defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.m2/repository\"") String local) { + @Property(documentation = "Local repository path.", defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.m2/repository\"") String local, + @Property(documentation = "List of GAV to register (comma separated). Such a provider can be enabled/disabled using `artifactId.enabled` property.", defaultValue = "\"org.apache.maven:apache-maven:tar.gz:bin\"") String gavs) { } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java new file mode 100644 index 00000000..3a9ca6c5 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.http.Cache; +import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.fusion.framework.api.RuntimeContainer; +import io.yupiik.fusion.framework.api.configuration.Configuration; +import io.yupiik.fusion.framework.api.container.bean.ProvidedInstanceBean; +import io.yupiik.fusion.framework.api.lifecycle.Start; +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import io.yupiik.fusion.framework.api.scope.DefaultScoped; +import io.yupiik.fusion.framework.build.api.event.OnEvent; +import io.yupiik.fusion.framework.build.api.order.Order; + +@ApplicationScoped +public class CentralProviderInit { + public void onStart(@OnEvent @Order(Integer.MIN_VALUE + 100) final Start start, + final RuntimeContainer container, + final CentralConfiguration configuration, + final YemHttpClient client, + final Archives archives, + final Cache cache, + final Configuration conf, + final GavRegistry registry) { + final var beans = container.getBeans(); + registry.gavs().forEach(gav -> beans.doRegister(new ProvidedInstanceBean<>(DefaultScoped.class, CentralBaseProvider.class, () -> { + final var enabled = "true".equals(conf.get(gav.artifactId() + ".enabled").orElse("true")); + return new CentralBaseProvider(client, configuration, archives, cache, gav, enabled); + }))); + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/Gav.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/Gav.java new file mode 100644 index 00000000..cedc7445 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/Gav.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.provider.central; + +public record Gav(String groupId, String artifactId, String type, String classifier) implements Comparable { + public static Gav of(final String gav) { + final var segments = gav.split(":"); + return switch (segments.length) { + case 2 -> new Gav(segments[0], segments[1], "jar", null); + case 3 -> new Gav(segments[0], segments[1], segments[2], null); + case 4 -> new Gav(segments[0], segments[1], segments[2], segments[3]); + default -> throw new IllegalArgumentException("Invalid gav: '" + gav + "'"); + }; + } + + @Override + public int compareTo(final Gav o) { + if (this == o) { + return 0; + } + final int g = groupId().compareTo(o.groupId()); + if (g != 0) { + return g; + } + final int a = artifactId().compareTo(o.artifactId()); + if (a != 0) { + return a; + } + final int t = type().compareTo(o.type()); + if (t != 0) { + return t; + } + return (classifier == null ? "" : classifier).compareTo(o.classifier() == null ? "" : o.classifier()); + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/GavRegistry.java similarity index 57% rename from env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java rename to env-manager/src/main/java/io/yupiik/dev/provider/central/GavRegistry.java index 4baebb99..ba414c05 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/SingletonCentralConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/GavRegistry.java @@ -17,15 +17,24 @@ import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + @ApplicationScoped -public class SingletonCentralConfiguration { - private final CentralConfiguration configuration; +public class GavRegistry { + private final List gavs; - public SingletonCentralConfiguration(final CentralConfiguration configuration) { - this.configuration = configuration; + public GavRegistry(final CentralConfiguration configuration) { + this.gavs = configuration == null ? null : Stream.ofNullable(configuration.gavs()) + .flatMap(it -> Stream.of(it.split(","))) + .map(String::strip) + .filter(Predicate.not(String::isBlank)) + .map(Gav::of) + .toList(); } - public CentralConfiguration configuration() { - return configuration; + public List gavs() { + return gavs; } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java index 918dc03e..a78bc47e 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java @@ -34,6 +34,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletionStage; import java.util.stream.Stream; import static java.nio.file.attribute.PosixFilePermission.GROUP_EXECUTE; @@ -43,6 +44,7 @@ import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.function.Function.identity; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; @@ -79,72 +81,76 @@ public String name() { } @Override - public List listTools() { + public CompletionStage> listTools() { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } - return List.of(new Candidate( - "minikube", "Minikube", "Local development Kubernetes binary.", "https://minikube.sigs.k8s.io/docs/")); + return completedFuture(List.of(new Candidate( + "minikube", "Minikube", "Local development Kubernetes binary.", "https://minikube.sigs.k8s.io/docs/"))); } @Override - public List listVersions(final String tool) { + public CompletionStage> listVersions(final String tool) { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } - final var releases = findReleases(); - return releases.stream() - .filter(r -> r.name().startsWith("v") && r.assets() != null && r.assets().stream() - .anyMatch(a -> Objects.equals(a.name(), assetName))) - .map(r -> { - var version = r.name(); - if (version.startsWith("v")) { - version = version.substring(1); - } - return new Version("Kubernetes", version, "minikube", version); - }) - .toList(); + return findReleases() + .thenApply(releases -> releases.stream() + .filter(r -> r.name().startsWith("v") && r.assets() != null && r.assets().stream() + .anyMatch(a -> Objects.equals(a.name(), assetName))) + .map(r -> { + var version = r.name(); + if (version.startsWith("v")) { + version = version.substring(1); + } + return new Version("Kubernetes", version, "minikube", version); + }) + .toList()); } @Override - public Archive download(final String tool, final String version, final Path target, final ProgressListener progressListener) { + public CompletionStage download(final String tool, final String version, final Path target, final ProgressListener progressListener) { if (!enabled) { throw new IllegalStateException("Minikube support not enabled (by configuration)"); } // todo: we can simplify that normally - final var versions = findReleases(); - final var assets = versions.stream() - .filter(it -> Objects.equals(version, it.name()) || - (it.name().startsWith("v") && Objects.equals(version, it.name().substring(1)))) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No version '" + version + "' matched, availables:" + versions.stream() - .map(Release::name) - .map(v -> "- " + v) - .collect(joining("\n", "", "\n")))) - .assets(); - final var uri = URI.create(assets.stream() - .filter(a -> Objects.equals(a.name(), assetName)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No matching asset for this version:" + assets.stream() - .map(Release.Asset::name) - .map(a -> "- " + a) - .collect(joining("\n", "\n", "\n")))) - .browserDownloadUrl()); - final var res = client.getFile(HttpRequest.newBuilder().uri(uri).build(), target, progressListener); - if (res.statusCode() != 200) { - throw new IllegalArgumentException("Can't download " + uri + ": " + res + "\n" + res.body()); - } - return new Archive("tar.gz", target); + return findReleases() + .thenCompose(versions -> { + final var assets = versions.stream() + .filter(it -> Objects.equals(version, it.name()) || + (it.name().startsWith("v") && Objects.equals(version, it.name().substring(1)))) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No version '" + version + "' matched, availables:" + versions.stream() + .map(Release::name) + .map(v -> "- " + v) + .collect(joining("\n", "", "\n")))) + .assets(); + final var uri = URI.create(assets.stream() + .filter(a -> Objects.equals(a.name(), assetName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No matching asset for this version:" + assets.stream() + .map(Release.Asset::name) + .map(a -> "- " + a) + .collect(joining("\n", "\n", "\n")))) + .browserDownloadUrl()); + return client.getFile(HttpRequest.newBuilder().uri(uri).build(), target, progressListener) + .thenApply(res -> { + if (res.statusCode() != 200) { + throw new IllegalArgumentException("Can't download " + uri + ": " + res + "\n" + res.body()); + } + return new Archive("tar.gz", target); + }); + }); } @Override - public Path install(final String tool, final String version, final ProgressListener progressListener) { + public CompletionStage install(final String tool, final String version, final ProgressListener progressListener) { final var archivePath = local.resolve("minikube").resolve(version).resolve(assetName); final var exploded = archivePath.getParent().resolve("distribution_exploded"); if (Files.exists(exploded)) { - return exploded; + return completedFuture(exploded); } if (!enabled) { @@ -156,32 +162,32 @@ public Path install(final String tool, final String version, final ProgressListe } catch (final IOException e) { throw new IllegalStateException(e); } - final var archive = Files.notExists(archivePath) ? - download(tool, version, archivePath, progressListener) : - new Archive("tar.gz", archivePath); - final var unpacked = archives.unpack(archive, exploded); - try (final var list = Files.list(unpacked)) { - final var bin = assetName.substring(0, assetName.length() - ".tar.gz".length()); - list - .filter(Files::isRegularFile) - .filter(it -> Objects.equals(it.getFileName().toString(), bin)) - .forEach(f -> { - try { - Files.setPosixFilePermissions( - Files.move(f, f.getParent().resolve("minikube")), // rename to minikube - Stream.of( - OWNER_READ, OWNER_EXECUTE, OWNER_WRITE, - GROUP_READ, GROUP_EXECUTE, - OTHERS_READ, OTHERS_EXECUTE) - .collect(toSet())); - } catch (final IOException e) { - // no-op - } - }); - } catch (final IOException e) { - throw new IllegalStateException(e); - } - return unpacked; + return (Files.notExists(archivePath) ? download(tool, version, archivePath, progressListener) : completedFuture(new Archive("tar.gz", archivePath))) + .thenApply(archive -> { + final var unpacked = archives.unpack(archive, exploded); + try (final var list = Files.list(unpacked)) { + final var bin = assetName.substring(0, assetName.length() - ".tar.gz".length()); + list + .filter(Files::isRegularFile) + .filter(it -> Objects.equals(it.getFileName().toString(), bin)) + .forEach(f -> { + try { + Files.setPosixFilePermissions( + Files.move(f, f.getParent().resolve("minikube")), // rename to minikube + Stream.of( + OWNER_READ, OWNER_EXECUTE, OWNER_WRITE, + GROUP_READ, GROUP_EXECUTE, + OTHERS_READ, OTHERS_EXECUTE) + .collect(toSet())); + } catch (final IOException e) { + // no-op + } + }); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + return unpacked; + }); } @Override @@ -197,24 +203,25 @@ public void delete(final String tool, final String version) { } @Override - public Map> listLocal() { + public CompletionStage>> listLocal() { final var root = local.resolve("minikube"); if (Files.notExists(root)) { - return Map.of(); + return completedFuture(Map.of()); } - return listTools().stream().collect(toMap(identity(), t -> { - try (final var children = Files.list(root)) { - return children - .filter(r -> Files.exists(r.resolve("distribution_exploded"))) - .map(v -> { - final var version = v.getFileName().toString(); - return new Version("Kubernetes", version, "minikube", version); - }) - .toList(); - } catch (final IOException e) { - throw new IllegalStateException(e); - } - })); + return listTools().thenApply(candidates -> candidates.stream() + .collect(toMap(identity(), t -> { + try (final var children = Files.list(root)) { + return children + .filter(r -> Files.exists(r.resolve("distribution_exploded"))) + .map(v -> { + final var version = v.getFileName().toString(); + return new Version("Kubernetes", version, "minikube", version); + }) + .toList(); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + }))); } @Override @@ -226,20 +233,19 @@ public Optional resolve(final String tool, final String version) { return Optional.of(distribution); } - private List findReleases() { - final var res = client.send( - HttpRequest.newBuilder() - .GET() - .header("accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .uri(base.resolve("/repos/kubernetes/minikube/releases")) - .build()); - if (res.statusCode() != 200) { - throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); - } - - final var type = new Types.ParameterizedTypeImpl(List.class, Release.class); - final List releases = jsonMapper.fromString(type, res.body()); - return releases; + private CompletionStage> findReleases() { + return client.sendAsync( + HttpRequest.newBuilder() + .GET() + .header("accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .uri(base.resolve("/repos/kubernetes/minikube/releases")) + .build()) + .thenApply(res -> { + if (res.statusCode() != 200) { + throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); + } + return jsonMapper.fromString(new Types.ParameterizedTypeImpl(List.class, Release.class), res.body()); + }); } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java index 0480c5e9..da4a2365 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java @@ -40,17 +40,21 @@ import java.util.Optional; import java.util.Spliterator; import java.util.Spliterators; +import java.util.concurrent.CompletionStage; import java.util.function.Predicate; +import java.util.logging.Logger; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.stream.StreamSupport; import static java.util.Optional.of; import static java.util.Optional.ofNullable; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.stream.Collectors.toMap; @DefaultScoped public class SdkManClient implements Provider { + private final Logger logger = Logger.getLogger(getClass().getName()); private final String platform; private final Archives archives; private final YemHttpClient client; @@ -97,14 +101,14 @@ public void delete(final String tool, final String version) { } @Override - public Path install(final String tool, final String version, final ProgressListener progressListener) { + public CompletionStage install(final String tool, final String version, final ProgressListener progressListener) { final var target = local.resolve(tool).resolve(version); if (Files.exists(target)) { final var maybeMac = target.resolve("Contents/Home"); // todo: check it is the case if (Files.isDirectory(maybeMac)) { - return maybeMac; + return completedFuture(maybeMac); } - return target; + return completedFuture(target); } if (!enabled) { @@ -114,12 +118,13 @@ public Path install(final String tool, final String version, final ProgressListe try { final var toDelete = Files.createDirectories(local.resolve(tool).resolve(version + ".yem.tmp")); Files.createDirectories(local.resolve(tool).resolve(version)); - try { - final var archive = download(tool, version, toDelete.resolve("distro.archive"), progressListener); - return archives.unpack(archive, target); - } finally { - archives.delete(toDelete); - } + return download(tool, version, toDelete.resolve("distro.archive"), progressListener) + .thenApply(archive -> archives.unpack(archive, target)) + .exceptionally(e -> { + archives.delete(local.resolve(tool).resolve(version)); + throw new IllegalStateException(e); + }) + .whenComplete((ok, ko) -> archives.delete(toDelete)); } catch (final IOException e) { archives.delete(local.resolve(tool).resolve(version)); throw new IllegalStateException(e); @@ -127,13 +132,13 @@ public Path install(final String tool, final String version, final ProgressListe } @Override - public Map> listLocal() { + public CompletionStage>> listLocal() { if (Files.notExists(local)) { - return Map.of(); + return completedFuture(Map.of()); } try (final var tool = Files.list(local)) { // todo: if we cache in listTools we could reuse the meta there - return tool.collect(toMap( + return completedFuture(tool.collect(toMap( it -> { final var name = it.getFileName().toString(); return new Candidate(name, name, "", ""); @@ -153,7 +158,7 @@ public Map> listLocal() { } catch (final IOException e) { throw new IllegalStateException(e); } - })); + }))); } catch (final IOException e) { throw new IllegalStateException(e); } @@ -169,67 +174,76 @@ public Optional resolve(final String tool, final String version) { // don' } @Override // warn: zip for windows often and tar.gz for linux - public Archive download(final String tool, final String version, final Path target, final ProgressListener progressListener) { // todo: checksum (x-sdkman headers) etc + public CompletionStage download(final String tool, final String version, final Path target, final ProgressListener progressListener) { // todo: checksum (x-sdkman headers) etc if (!enabled) { throw new IllegalStateException("SDKMan support not enabled (by configuration)"); } - final var res = client.getFile(HttpRequest.newBuilder() - .uri(base.resolve("broker/download/" + tool + "/" + version + "/" + platform)) - .build(), - target, progressListener); - ensure200(res); - return new Archive( - StreamSupport.stream(Spliterators.spliteratorUnknownSize(new Iterator>() { - private HttpResponse current = res; - - @Override - public boolean hasNext() { - return current != null; - } - - @Override - public HttpResponse next() { - try { - return current; - } finally { - current = current.previousResponse().orElse(null); - } - } - }, Spliterator.IMMUTABLE), false) - .filter(Objects::nonNull) - .map(r -> r.headers().firstValue("x-sdkman-archivetype").orElse(null)) - .filter(Objects::nonNull) - .findFirst() - .orElse("tar.gz"), - target); + return client.getFile( + HttpRequest.newBuilder() + .uri(base.resolve("broker/download/" + tool + "/" + version + "/" + platform)) + .build(), + target, progressListener) + .thenApply(res -> { + ensure200(res); + return new Archive( + StreamSupport.stream(Spliterators.spliteratorUnknownSize(new Iterator>() { + private HttpResponse current = res; + + @Override + public boolean hasNext() { + return current != null; + } + + @Override + public HttpResponse next() { + try { + return current; + } finally { + current = current.previousResponse().orElse(null); + } + } + }, Spliterator.IMMUTABLE), false) + .filter(Objects::nonNull) + .map(r -> r.headers().firstValue("x-sdkman-archivetype").orElse(null)) + .filter(Objects::nonNull) + .findFirst() + .orElse("tar.gz"), + target); + }); } @Override - public List listTools() { // todo: cache in sdkman folder a sdkman.yem.properties? refresh once per day? + public CompletionStage> listTools() { // todo: cache in sdkman folder a sdkman.yem.properties? refresh once per day? if (!enabled) { - return List.of(); + return completedFuture(List.of()); } // note: we could use ~/.sdkman/var/candidates too but // this would assume sdkman keeps updating this list which is likely not true - final var res = client.send(HttpRequest.newBuilder() - .uri(base.resolve("candidates/list")) - .build()); - ensure200(res); - return parseList(res.body()); + return client.sendAsync( + HttpRequest.newBuilder() + .uri(base.resolve("candidates/list")) + .build()) + .thenApply(res -> { + ensure200(res); + return parseList(res.body()); + }); } @Override - public List listVersions(final String tool) { + public CompletionStage> listVersions(final String tool) { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } - final var res = client.send(HttpRequest.newBuilder() - .uri(base.resolve("candidates/" + tool + "/" + platform + "/versions/list?current=&installed=")) - .build()); - ensure200(res); - return parseVersions(tool, res.body()); + return client.sendAsync( + HttpRequest.newBuilder() + .uri(base.resolve("candidates/" + tool + "/" + platform + "/versions/list?current=&installed=")) + .build()) + .thenApply(res -> { + ensure200(res); + return parseVersions(tool, res.body()); + }); } private List parseVersions(final String tool, final String body) { diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java index d8d37b6b..4da5a311 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java @@ -36,8 +36,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletionStage; import static java.util.Optional.ofNullable; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.function.Function.identity; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; @@ -95,11 +97,11 @@ public void delete(final String tool, final String version) { } @Override - public Path install(final String tool, final String version, final ProgressListener progressListener) { + public CompletionStage install(final String tool, final String version, final ProgressListener progressListener) { final var archivePath = local.resolve(version).resolve(version + '-' + suffix); final var exploded = archivePath.getParent().resolve("distribution_exploded"); if (Files.exists(exploded)) { - return exploded; + return completedFuture(exploded); } if (!enabled) { @@ -111,18 +113,18 @@ public Path install(final String tool, final String version, final ProgressListe } catch (final IOException e) { throw new IllegalStateException(e); } - final var archive = Files.notExists(archivePath) ? + return (Files.notExists(archivePath) ? download(tool, version, archivePath, progressListener) : - new Archive(suffix.endsWith(".zip") ? "zip" : "tar.gz", archivePath); - return archives.unpack(archive, exploded); + completedFuture(new Archive(suffix.endsWith(".zip") ? "zip" : "tar.gz", archivePath))) + .thenApply(archive -> archives.unpack(archive, exploded)); } @Override - public Map> listLocal() { + public CompletionStage>> listLocal() { if (Files.notExists(local)) { - return Map.of(); + return completedFuture(Map.of()); } - return listTools().stream().collect(toMap(identity(), tool -> { + return listTools().thenApply(candidates -> candidates.stream().collect(toMap(identity(), tool -> { try (final var versions = Files.list(local)) { return versions .map(v -> { @@ -137,7 +139,7 @@ public Map> listLocal() { } catch (final IOException e) { throw new IllegalStateException(e); } - })); + }))); } @Override @@ -161,49 +163,54 @@ public Optional resolve(final String tool, final String version) { } @Override - public Archive download(final String tool, final String id, final Path target, final ProgressListener progressListener) { + public CompletionStage download(final String tool, final String id, final Path target, final ProgressListener progressListener) { if (!enabled) { throw new IllegalStateException("Zulu support not enabled (by configuration)"); } - final var res = client.getFile(HttpRequest.newBuilder() - .uri(base.resolve("zulu" + id + '-' + suffix)) - .build(), - target, progressListener); - ensure200(res); - return new Archive(suffix.endsWith(".zip") ? "zip" : "tar.gz", target); + return client.getFile( + HttpRequest.newBuilder() + .uri(base.resolve("zulu" + id + '-' + suffix)) + .build(), + target, progressListener) + .thenApply(res -> { + ensure200(res); + return new Archive(suffix.endsWith(".zip") ? "zip" : "tar.gz", target); + }); } @Override - public List listTools() { + public CompletionStage> listTools() { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } - return List.of(new Candidate("java", "java", "Java JRE or JDK downloaded from Azul CDN.", base.toASCIIString())); + return completedFuture(List.of(new Candidate("java", "java", "Java JRE or JDK downloaded from Azul CDN.", base.toASCIIString()))); } @Override - public List listVersions(final String tool) { + public CompletionStage> listVersions(final String tool) { if (!enabled) { - return List.of(); + return completedFuture(List.of()); } // here the payload is >5M so we can let the client cache it but saving the result will be way more efficient on the JSON side final var entry = cache.lookup(base.toASCIIString()); if (entry != null && entry.hit() != null) { - return parseVersions(entry.hit().payload()); + return completedFuture(parseVersions(entry.hit().payload())); } - final var res = client.send(HttpRequest.newBuilder().GET().uri(base).build()); - ensure200(res); + return client.sendAsync(HttpRequest.newBuilder().GET().uri(base).build()) + .thenApply(res -> { + ensure200(res); - final var filtered = parseVersions(res.body()); - if (entry != null) { - cache.save(entry.key(), Map.of(), filtered.stream() - .map(it -> "zulu" + it.identifier() + '-' + suffix + "") - .collect(joining("\n", "", "\n"))); - } - return filtered; + final var filtered = parseVersions(res.body()); + if (entry != null) { + cache.save(entry.key(), Map.of(), filtered.stream() + .map(it -> "zulu" + it.identifier() + '-' + suffix + "") + .collect(joining("\n", "", "\n"))); + } + return filtered; + }); } private void ensure200(final HttpResponse res) { diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index 4d4d8f0f..5438e934 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -22,15 +22,19 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.IdentityHashMap; import java.util.Map; import java.util.Optional; import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; import java.util.stream.Stream; import static java.util.Locale.ROOT; import static java.util.Map.entry; +import static java.util.concurrent.CompletableFuture.allOf; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.stream.Collectors.toMap; @ApplicationScoped @@ -44,48 +48,49 @@ public RcService(final Os os, final ProviderRegistry registry) { this.registry = registry; } - public Map toToolProperties(final Properties props) { - return props.stringPropertyNames().stream() + public CompletionStage> toToolProperties(final Properties props) { + final var promises = props.stringPropertyNames().stream() .filter(it -> it.endsWith(".version")) - .map(versionKey -> { - final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); - return new ToolProperties( - props.getProperty(name + ".toolName", name), - props.getProperty(versionKey), - props.getProperty(name + ".provider"), - Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), - props.getProperty(name + ".envVarName", name.toUpperCase(ROOT).replace('.', '_') + "_HOME"), - Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), - Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), - Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); - }) - .flatMap(tool -> registry.tryFindByToolVersionAndProvider( - tool.toolName(), tool.version(), - tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), - new ProviderRegistry.Cache(new IdentityHashMap<>(), new IdentityHashMap<>())) - .or(() -> { - if (tool.failOnMissing()) { - throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); - } - return Optional.empty(); - }) - .flatMap(providerAndVersion -> { - final var provider = providerAndVersion.getKey(); - final var version = providerAndVersion.getValue().identifier(); - return provider.resolve(tool.toolName(), tool.version()) - .or(() -> { - if (tool.installIfMissing()) { + .map(versionKey -> toToolProperties(props, versionKey)) + .map(tool -> { + final var providerAndVersionPromise = registry.tryFindByToolVersionAndProvider( + tool.toolName(), tool.version(), + tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), + new ProviderRegistry.Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())); + return providerAndVersionPromise.thenCompose(providerAndVersionOpt -> { + if (tool.failOnMissing() && !tool.installIfMissing() && providerAndVersionOpt.isEmpty()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); + } + + final var providerVersion = providerAndVersionOpt.orElseThrow(); + final var provider = providerVersion.getKey(); + final var version = providerVersion.getValue().identifier(); + + return provider.resolve(tool.toolName(), providerVersion.getValue().identifier()) + .map(path -> completedFuture(Optional.of(path))) + .or(() -> { + if (!tool.installIfMissing()) { + if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); + } + return Optional.empty(); + } + logger.info(() -> "Installing " + tool.toolName() + '@' + version); - provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP); - } else if (tool.failOnMissing()) { - throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); - } - return provider.resolve(tool.toolName(), version); - }); - }) - .stream() - .map(home -> entry(tool, home))) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + return Optional.of(provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP) + .thenApply(Optional::of) + .toCompletableFuture()); + }) + .orElseGet(() -> completedFuture(Optional.empty())) + .thenApply(path -> path.map(p -> entry(tool, p))); + }) + .toCompletableFuture(); + }) + .toList(); + return allOf(promises.toArray(new CompletableFuture[0])) + .thenApply(ok -> promises.stream() + .flatMap(p -> p.getNow(Optional.empty()).stream()) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); } public Properties loadPropertiesFrom(final String rcPath, final String defaultRcPath) { @@ -133,6 +138,19 @@ public Path toBin(final Path value) { .orElse(value); } + private ToolProperties toToolProperties(final Properties props, final String versionKey) { + final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); + return new ToolProperties( + props.getProperty(name + ".toolName", name), + props.getProperty(versionKey), + props.getProperty(name + ".provider"), + Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), + props.getProperty(name + ".envVarName", name.toUpperCase(ROOT).replace('.', '_') + "_HOME"), + Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), + Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), + Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); + } + private void readRc(final Path rcLocation, final Properties props) { try (final var reader = Files.newBufferedReader(rcLocation)) { props.load(reader); diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java index e88ae826..7a01e95d 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java @@ -20,6 +20,7 @@ @RootConfiguration("http") public record HttpConfiguration( + @Property(defaultValue = "4", documentation = "Number of NIO threads.") int threads, @Property(defaultValue = "false", documentation = "Should HTTP calls be logged.") boolean log, @Property(defaultValue = "60_000L", documentation = "Connection timeout in milliseconds.") long connectTimeout, @Property(defaultValue = "900_000L", documentation = "Request timeout in milliseconds.") long requestTimeout, diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java index 2cf4d235..6b488a4e 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java @@ -28,6 +28,7 @@ import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpHeaders; import java.net.http.HttpRequest; @@ -39,7 +40,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Flow; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import java.util.stream.Stream; import java.util.zip.GZIPInputStream; @@ -49,6 +56,7 @@ import static java.net.http.HttpResponse.BodyHandlers.ofByteArray; import static java.nio.charset.StandardCharsets.UTF_8; import static java.time.Clock.systemDefaultZone; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.stream.Collectors.toMap; @ApplicationScoped @@ -98,12 +106,36 @@ public RequestListener.State before(final long count, final HttpRequest re .setDelegate(HttpClient.newBuilder() .followRedirects(ALWAYS) .version(HTTP_1_1) + .executor(Executors.newFixedThreadPool(configuration.threads(), new ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(1); + + @Override + public Thread newThread(final Runnable r) { + final var thread = new Thread(r, YemHttpClient.class.getName() + "-" + counter.getAndIncrement()); + thread.setContextClassLoader(YemHttpClient.class.getClassLoader()); + return thread; + } + })) .connectTimeout(Duration.ofMillis(configuration.connectTimeout())) .build()) .setRequestListeners(listeners); this.cache = cache; - this.client = new ExtendedHttpClient(conf); + this.client = new ExtendedHttpClient(conf).onClose(c -> { + final var executorService = (ExecutorService) conf.getDelegate().executor().orElseThrow(); + executorService.shutdown(); + while (!executorService.isTerminated()) { + try { + if (executorService.awaitTermination(1L, TimeUnit.DAYS)) { + break; + } + } catch (final InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + break; + } + } + }); } @Override @@ -111,131 +143,124 @@ public void close() { this.client.close(); } - public HttpResponse getFile(final HttpRequest request, final Path target, final Provider.ProgressListener listener) { + public CompletionStage> getFile(final HttpRequest request, final Path target, final Provider.ProgressListener listener) { logger.finest(() -> "Calling " + request); - final var response = sendWithProgress(request, listener, HttpResponse.BodyHandlers.ofFile(target)); - if (isGzip(response) && Files.exists(response.body())) { - final var tmp = response.body().getParent().resolve(response.body().getFileName() + ".degzip.tmp"); - try (final var in = new GZIPInputStream(new BufferedInputStream(Files.newInputStream(response.body())))) { - Files.copy(in, tmp); - } catch (final IOException ioe) { - return response; - } finally { - if (Files.exists(tmp)) { - try { - Files.delete(tmp); - } catch (final IOException e) { - // no-op + return sendWithProgress(request, listener, HttpResponse.BodyHandlers.ofFile(target)) + .thenApply(response -> { + if (isGzip(response) && Files.exists(response.body())) { + final var tmp = response.body().getParent().resolve(response.body().getFileName() + ".degzip.tmp"); + try (final var in = new GZIPInputStream(new BufferedInputStream(Files.newInputStream(response.body())))) { + Files.copy(in, tmp); + } catch (final IOException ioe) { + return response; + } finally { + if (Files.exists(tmp)) { + try { + Files.delete(tmp); + } catch (final IOException e) { + // no-op + } + } + } + try { + Files.move(tmp, response.body()); + } catch (final IOException e) { + return response; + } + return new SimpleHttpResponse<>( + response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), + response.body()); } - } - } - try { - Files.move(tmp, response.body()); - } catch (final IOException e) { - return response; - } - return new StaticHttpResponse<>( - response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), - response.body()); - } - return response; + return response; + }); } - public HttpResponse send(final HttpRequest request) { + public CompletionStage> sendAsync(final HttpRequest request) { final var entry = cache.lookup(request); if (entry != null && entry.hit() != null) { - return new StaticHttpResponse<>( + return completedFuture(new SimpleHttpResponse<>( request, request.uri(), HTTP_1_1, 200, HttpHeaders.of( entry.hit().headers().entrySet().stream() .collect(toMap(Map.Entry::getKey, e -> List.of(e.getValue()))), (a, b) -> true), - entry.hit().payload()); + entry.hit().payload())); } logger.finest(() -> "Calling " + request); - try { - final var response = client.send(request, ofByteArray()); - HttpResponse result = null; - if (isGzip(response) && response.body() != null) { - try (final var in = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { - result = new StaticHttpResponse<>( + return client.sendAsync(request, ofByteArray()) + .thenApply(response -> { + HttpResponse result = null; + if (isGzip(response) && response.body() != null) { + try (final var in = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { + result = new SimpleHttpResponse<>( + response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), + new String(in.readAllBytes(), UTF_8)); + } catch (final IOException ioe) { + // no-op, use original response + } + } + result = result != null ? result : new SimpleHttpResponse<>( response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), - new String(in.readAllBytes(), UTF_8)); - } catch (final IOException ioe) { - // no-op, use original response - } - } - result = result != null ? result : new StaticHttpResponse<>( - response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), - new String(response.body(), UTF_8)); - if (entry != null && result.statusCode() == 200) { - cache.save(entry.key(), result); - } - return result; - } catch (final InterruptedException var4) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(var4); - } catch (final RuntimeException run) { - throw run; - } catch (final Exception others) { - throw new IllegalStateException(others); - } + new String(response.body(), UTF_8)); + if (entry != null && result.statusCode() == 200) { + cache.save(entry.key(), result); + } + return result; + }); } private boolean isGzip(final HttpResponse response) { return response.headers().allValues("content-encoding").stream().anyMatch(it -> it.contains("gzip")); } - /* not needed yet - public HttpResponse send(final HttpRequest request, final Provider.ProgressListener listener) { - return sendWithProgress(request, listener, ofString()); - } - */ - - private HttpResponse sendWithProgress(final HttpRequest request, final Provider.ProgressListener listener, - final HttpResponse.BodyHandler delegateHandler) { - try { - return client.send(request, listener == Provider.ProgressListener.NOOP ? delegateHandler : responseInfo -> { - final long contentLength = Long.parseLong(responseInfo.headers().firstValue("content-length").orElse("-1")); - final var delegate = delegateHandler.apply(responseInfo); - if (contentLength > 0) { - final var name = request.uri().getPath(); - return HttpResponse.BodySubscribers.fromSubscriber(new Flow.Subscriber>() { - private long current = 0; - - @Override - public void onSubscribe(final Flow.Subscription subscription) { - delegate.onSubscribe(subscription); - } + private CompletionStage> sendWithProgress(final HttpRequest request, final Provider.ProgressListener listener, + final HttpResponse.BodyHandler delegateHandler) { + return client.sendAsync(request, listener == Provider.ProgressListener.NOOP ? delegateHandler : responseInfo -> { + final long contentLength = Long.parseLong(responseInfo.headers().firstValue("content-length").orElse("-1")); + final var delegate = delegateHandler.apply(responseInfo); + if (contentLength > 0) { + final var name = request.uri().getPath(); + return HttpResponse.BodySubscribers.fromSubscriber(new Flow.Subscriber>() { + private long current = 0; - @Override - public void onNext(final List item) { - current += item.stream().mapToLong(ByteBuffer::remaining).sum(); - listener.onProcess(name, current * 1. / contentLength); - delegate.onNext(item); - } + @Override + public void onSubscribe(final Flow.Subscription subscription) { + delegate.onSubscribe(subscription); + } - @Override - public void onError(final Throwable throwable) { - delegate.onError(throwable); - } + @Override + public void onNext(final List item) { + current += item.stream().mapToLong(ByteBuffer::remaining).sum(); + listener.onProcess(name, current * 1. / contentLength); + delegate.onNext(item); + } - @Override - public void onComplete() { - delegate.onComplete(); - } - }, subscriber -> delegate.getBody().toCompletableFuture().getNow(null)); - } - return delegate; - }); - } catch (final InterruptedException var3) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(var3); - } catch (final RuntimeException var4) { - throw var4; - } catch (final Exception var5) { - throw new IllegalStateException(var5); + @Override + public void onError(final Throwable throwable) { + delegate.onError(throwable); + } + + @Override + public void onComplete() { + delegate.onComplete(); + } + }, subscriber -> delegate.getBody().toCompletableFuture().getNow(null)); + } + return delegate; + }); + } + + private static class SimpleHttpResponse extends StaticHttpResponse { + private SimpleHttpResponse(final HttpRequest request, final URI uri, final HttpClient.Version version, + final int status, final HttpHeaders headers, final T body) { + super(request, uri, version, status, headers, body); + } + + @Override + public String toString() { + final var uri = request().uri(); + return '(' + request().method() + ' ' + (uri == null ? "" : uri) + ") " + statusCode(); } } } diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 3692fb8c..ac7b3aba 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -46,11 +46,11 @@ class CommandsTest { @Test void config(@TempDir final Path work, final URI uri) { assertEquals(""" - - central: base=http://localhost:$port/2//m2/, local=$work/.m2/repository + - apache-maven: enabled=false + - central: base=http://localhost:$port/2//m2/, local=$work/.m2/repository, gavs=org.apache.maven:apache-maven:tar.gz:bin - github: base=http://localhost:$port/2//github/, local=/github - - maven: enabled=true - minikube: enabled=false - - sdkman: enabled=true, base=http://localhost:$port/2/, platform=linuxx64.tar.gz, local=$work/sdkman/candidates + - sdkman: enabled=false, base=http://localhost:$port/2/, platform=linuxx64.tar.gz, local=$work/sdkman/candidates - zulu: enabled=true, preferJre=false, base=http://localhost:$port/2/, platform=linux64.tar.gz, local=$work/zulu""" .replace("$work", work.toString()) .replace("$port", Integer.toString(uri.getPort())), @@ -206,6 +206,7 @@ private LocalSource(final Path work, final String mockHttp) { public String get(final String key) { return switch (key) { case "http.cache" -> "none"; + case "apache-maven.enabled", "sdkman.enabled", "minikube.enabled" -> "false"; case "github.base" -> baseHttp + "/github/"; case "github.local" -> work.resolve("/github").toString(); case "central.base" -> baseHttp + "/m2/"; diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java index b3af026c..b1a3937d 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java @@ -19,6 +19,8 @@ import io.yupiik.dev.provider.model.Archive; import io.yupiik.dev.provider.model.Version; import io.yupiik.dev.shared.Archives; +import io.yupiik.dev.shared.http.Cache; +import io.yupiik.dev.shared.http.HttpConfiguration; import io.yupiik.dev.shared.http.YemHttpClient; import io.yupiik.dev.test.Mock; import org.junit.jupiter.api.Test; @@ -29,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.ExecutionException; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -56,8 +59,8 @@ class CentralBaseProviderTest { """) - void lastVersions(final URI uri, @TempDir final Path work, final YemHttpClient client) { - final var actual = newProvider(uri, client, work).listVersions(""); + void lastVersions(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { + final var actual = newProvider(uri, client, work).listVersions("").toCompletableFuture().get(); assertEquals( List.of(new Version("org.foo", "1.0.0", "bar", "1.0.0"), new Version("org.foo", "1.0.2", "bar", "1.0.2"), @@ -70,43 +73,48 @@ void lastVersions(final URI uri, @TempDir final Path work, final YemHttpClient c @Test @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz") - void download(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + void download(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException, ExecutionException, InterruptedException { final var out = work.resolve("download.tar.gz"); - assertEquals(new Archive("tar.gz", out), newProvider(uri, client, work.resolve("local")).download("", "1.0.2", out, Provider.ProgressListener.NOOP)); + assertEquals(new Archive("tar.gz", out), newProvider(uri, client, work.resolve("local")) + .download("", "1.0.2", out, Provider.ProgressListener.NOOP) + .toCompletableFuture().get()); assertEquals("you got a tar.gz", Files.readString(out)); } @Test @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz", format = "tar.gz") - void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException, ExecutionException, InterruptedException { final var installationDir = work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz_exploded"); - assertEquals(installationDir, newProvider(uri, client, work.resolve("m2")).install("", "1.0.2", Provider.ProgressListener.NOOP)); + assertEquals(installationDir, newProvider(uri, client, work.resolve("m2")).install("", "1.0.2", Provider.ProgressListener.NOOP) + .toCompletableFuture().get()); assertTrue(Files.isDirectory(installationDir)); assertEquals("you got a tar.gz", Files.readString(installationDir.resolve("entry.txt"))); } @Test @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz", format = "tar.gz") - void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { final var installationDir = work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz_exploded"); final var provider = newProvider(uri, client, work.resolve("m2")); - provider.install("", "1.0.2", Provider.ProgressListener.NOOP); + provider.install("", "1.0.2", Provider.ProgressListener.NOOP).toCompletableFuture().get(); assertEquals(installationDir, provider.resolve("", "1.0.2").orElseThrow()); } @Test @Mock(uri = "/2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz", payload = "you got a tar.gz", format = "tar.gz") - void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { final var installationDir = work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz_exploded"); final var provider = newProvider(uri, client, work.resolve("m2")); - provider.install("", "1.0.2", Provider.ProgressListener.NOOP); + provider.install("", "1.0.2", Provider.ProgressListener.NOOP).toCompletableFuture().get(); provider.delete("", "1.0.2"); assertFalse(Files.exists(installationDir)); assertFalse(Files.exists(work.resolve("m2/org/foo/bar/1.0.2/bar-1.0.2-simple.tar.gz"))); } private CentralBaseProvider newProvider(final URI uri, final YemHttpClient client, final Path local) { - return new CentralBaseProvider(client, new CentralConfiguration(uri.toASCIIString(), local.toString()), new Archives(), "org.foo:bar:tar.gz:simple", true) { - }; + return new CentralBaseProvider( + client, new CentralConfiguration(uri.toASCIIString(), local.toString(), ""), new Archives(), + new Cache(new HttpConfiguration(1, false, 30_000L, 30_000L, 0, "none"), null), + Gav.of("org.foo:bar:tar.gz:simple"), true); } } diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java index 1167cdeb..c2e6cef4 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java @@ -31,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -81,8 +82,8 @@ the concept of a project object model (POM), Maven can manage a project's build, $ sdk install maven -------------------------------------------------------------------------------- """) - void listTools(final URI uri, @TempDir final Path work, final YemHttpClient client) { - final var actual = sdkMan(client, uri, work).listTools(); + void listTools(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { + final var actual = sdkMan(client, uri, work).listTools().toCompletableFuture().get(); final var expected = List.of( new Candidate( "activemq", "Apache ActiveMQ (Classic)", // "5.17.1", @@ -124,7 +125,7 @@ void listTools(final URI uri, @TempDir final Path work, final YemHttpClient clie $ sdk install java 221-zulu-tem Hit Q to exit this list view ================================================================================""") - void listToolVersions(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void listToolVersions(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { assertEquals( List.of( new Version("Gluon", "22.1.0.1.r17", "gln", "22.1.0.1.r17-gln"), @@ -136,7 +137,7 @@ void listToolVersions(final URI uri, @TempDir final Path work, final YemHttpClie new Version("Zulu", "21.0.1.crac", "zulu", "21.0.1.crac-zulu"), new Version("Zulu", "17.0.10", "zulu", "17.0.10-zulu"), new Version("Zulu", "17.0.10.fx", "zulu", "17.0.10.fx-zulu")), - sdkMan(client, uri, work).listVersions("java")); + sdkMan(client, uri, work).listVersions("java").toCompletableFuture().get()); } @Test @@ -152,48 +153,50 @@ void listToolVersions(final URI uri, @TempDir final Path work, final YemHttpClie * - installed > - currently in use ================================================================================""") - void listToolVersionsSimple(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void listToolVersionsSimple(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { assertEquals( Stream.of("5.19.1", "5.17.1", "5.15.9", "5.15.8", "5.14.0", "5.13.4", "5.10.0") .map(v -> new Version("activemq", v, "sdkman", v)) .toList(), - sdkMan(client, uri, work).listVersions("activemq").stream() + sdkMan(client, uri, work).listVersions("activemq").toCompletableFuture().get().stream() .sorted((a, b) -> -a.compareTo(b)) .toList()); } @Test @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz") - void download(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + void download(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException, ExecutionException, InterruptedException { final var out = work.resolve("download.tar.gz"); - assertEquals(new Archive("tar.gz", out), sdkMan(client, uri, work.resolve("local")).download("java", "21-zulu", out, Provider.ProgressListener.NOOP)); + assertEquals(new Archive("tar.gz", out), sdkMan(client, uri, work.resolve("local")).download("java", "21-zulu", out, Provider.ProgressListener.NOOP) + .toCompletableFuture().get()); assertEquals("you got a tar.gz", Files.readString(out)); } @Test @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz", format = "tar.gz") - void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException, ExecutionException, InterruptedException { final var installationDir = work.resolve("candidates/java/21-zulu"); - assertEquals(installationDir, sdkMan(client, uri, work.resolve("candidates")).install("java", "21-zulu", Provider.ProgressListener.NOOP)); + assertEquals(installationDir, sdkMan(client, uri, work.resolve("candidates")).install("java", "21-zulu", Provider.ProgressListener.NOOP) + .toCompletableFuture().get()); assertTrue(Files.isDirectory(installationDir)); assertEquals("you got a tar.gz", Files.readString(installationDir.resolve("entry.txt"))); } @Test @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz", format = "tar.gz") - void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { final var installationDir = work.resolve("candidates/java/21-zulu"); final var provider = sdkMan(client, uri, work.resolve("candidates")); - provider.install("java", "21-zulu", Provider.ProgressListener.NOOP); + provider.install("java", "21-zulu", Provider.ProgressListener.NOOP).toCompletableFuture().get(); assertEquals(installationDir, provider.resolve("java", "21-zulu").orElseThrow()); } @Test @Mock(uri = "/2/broker/download/java/21-zulu/linuxx64", payload = "you got a tar.gz", format = "tar.gz") - void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { final var installationDir = work.resolve("candidates/java/21-zulu"); final var provider = sdkMan(client, uri, work.resolve("candidates")); - provider.install("java", "21-zulu", Provider.ProgressListener.NOOP); + provider.install("java", "21-zulu", Provider.ProgressListener.NOOP).toCompletableFuture().get(); provider.delete("java", "21-zulu"); assertFalse(Files.exists(installationDir)); } diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java index a3724077..c66e6263 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java @@ -31,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.ExecutionException; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -139,8 +140,8 @@ class ZuluCdnClientTest {
""") - void listJavaVersions(final URI uri, @TempDir final Path local, final YemHttpClient client) { - final var actual = newProvider(uri, client, local).listVersions(""); + void listJavaVersions(final URI uri, @TempDir final Path local, final YemHttpClient client) throws ExecutionException, InterruptedException { + final var actual = newProvider(uri, client, local).listVersions("").toCompletableFuture().get(); assertEquals( List.of(new Version("Azul", "21.0.2", "zulu", "21.32.17-ca-jre21.0.2")), actual); @@ -148,28 +149,29 @@ void listJavaVersions(final URI uri, @TempDir final Path local, final YemHttpCli @Test @Mock(uri = "/2/zulu21.0.2-linux_x64.zip", payload = "you got a zip", format = "zip") - void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException { + void install(final URI uri, @TempDir final Path work, final YemHttpClient client) throws IOException, ExecutionException, InterruptedException { final var installationDir = work.resolve("yem/21.0.2/distribution_exploded"); - assertEquals(installationDir, newProvider(uri, client, work.resolve("yem")).install("java", "21.0.2", Provider.ProgressListener.NOOP)); + assertEquals(installationDir, newProvider(uri, client, work.resolve("yem")).install("java", "21.0.2", Provider.ProgressListener.NOOP) + .toCompletableFuture().get()); assertTrue(Files.isDirectory(installationDir)); assertEquals("you got a zip", Files.readString(installationDir.resolve("entry.txt"))); } @Test @Mock(uri = "/2/zulu21.0.2-linux_x64.zip", payload = "you got a zip", format = "zip") - void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void resolve(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { final var installationDir = work.resolve("yem/21.0.2/distribution_exploded"); final var provider = newProvider(uri, client, work.resolve("yem")); - provider.install("java", "21.0.2", Provider.ProgressListener.NOOP); + provider.install("java", "21.0.2", Provider.ProgressListener.NOOP).toCompletableFuture().get(); assertEquals(installationDir, provider.resolve("java", "21.0.2").orElseThrow()); } @Test @Mock(uri = "/2/zulu21.0.2-linux_x64.zip", payload = "you got a zip", format = "zip") - void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) { + void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) throws ExecutionException, InterruptedException { final var installationDir = work.resolve("yem/21.0.2/distribution_exploded"); final var provider = newProvider(uri, client, work.resolve("yem")); - provider.install("java", "21.0.2", Provider.ProgressListener.NOOP); + provider.install("java", "21.0.2", Provider.ProgressListener.NOOP).toCompletableFuture().get(); assertTrue(Files.exists(installationDir.getParent())); provider.delete("java", "21.0.2"); assertTrue(Files.notExists(installationDir.getParent())); @@ -179,6 +181,6 @@ private ZuluCdnClient newProvider(final URI uri, final YemHttpClient client, fin return new ZuluCdnClient( client, new ZuluCdnConfiguration(true, true, uri.toASCIIString(), "linux_x64.zip", local.toString()), - new Os(), new Archives(), new Cache(new HttpConfiguration(false, 30_000L, 30_000L, 0L, "none"), null)); + new Os(), new Archives(), new Cache(new HttpConfiguration(1, false, 30_000L, 30_000L, 0L, "none"), null)); } } diff --git a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java index ac069507..63b530cc 100644 --- a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java +++ b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java @@ -93,7 +93,7 @@ public Object resolveParameter(final ParameterContext parameterContext, final Ex return URI.create("http://localhost:" + context.getStore(NAMESPACE).get(HttpServer.class, HttpServer.class).getAddress().getPort() + "/2/"); } if (YemHttpClient.class == parameterContext.getParameter().getType()) { - final var configuration = new HttpConfiguration(false, 30_000L, 30_000L, 0, "none"); + final var configuration = new HttpConfiguration(1, false, 30_000L, 30_000L, 0, "none"); return new YemHttpClient(configuration, new Cache(configuration, null)); } throw new ParameterResolutionException("Can't resolve " + parameterContext.getParameter().getType()); From ee22a67ac08e443fa51a4cf194151b5e030f630c Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Tue, 6 Feb 2024 19:40:55 +0100 Subject: [PATCH 11/26] [env-manager] simple offline support --- .../src/main/minisite/content/yem.adoc | 15 ++++ .../main/java/io/yupiik/dev/command/Env.java | 20 +++--- .../java/io/yupiik/dev/shared/RcService.java | 22 ++++-- .../java/io/yupiik/dev/shared/http/Cache.java | 8 +-- .../dev/shared/http/HttpConfiguration.java | 2 + .../yupiik/dev/shared/http/YemHttpClient.java | 70 ++++++++++++++++--- .../io/yupiik/dev/command/CommandsTest.java | 18 ++--- .../central/CentralBaseProviderTest.java | 2 +- .../dev/provider/zulu/ZuluCdnClientTest.java | 2 +- .../io/yupiik/dev/test/HttpMockExtension.java | 2 +- 10 files changed, 121 insertions(+), 40 deletions(-) diff --git a/_documentation/src/main/minisite/content/yem.adoc b/_documentation/src/main/minisite/content/yem.adoc index f4737349..6d922e3d 100644 --- a/_documentation/src/main/minisite/content/yem.adoc +++ b/_documentation/src/main/minisite/content/yem.adoc @@ -72,3 +72,18 @@ The usage of `yem run -- xxxx` will be equivalent to run `yyyy` command (can hav == TIP If you need to see more logs from _yem_ you can add the following system properties: `-Djava.util.logging.manager=io.yupiik.logging.jul.YupiikLogManager -Dio.yupiik.logging.jul.handler.StandardHandler.level=FINEST -Dio.yupiik.level=FINEST`. + +You can also force some default configuration (typically central `gavs` or `offlineMode`) in `~/.yupiik/yem/rc`. +If you don't want this global file to be taken into account temporarly (or not) you can set the environment variable `YEM_DISABLE_GLOBAL_RC_FILE` to `true`. + +== Shell/Bash setup + +Yem is portable but we detail there only shell setup. + +The idea is to leverage `env` command. +Add to your global `~/.bashrc` (or `~/.profile`) configuration the following line: + +[source,bash] +---- +eval $(yem env) +---- diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index e07729ec..155efbbe 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -61,7 +61,7 @@ public void run() { return; } - final var logger = this.logger.getParent().getParent(); + final var logger = Logger.getLogger("io.yupiik.dev"); final var useParentHandlers = logger.getUseParentHandlers(); final var messages = new ArrayList(); final var tempHandler = new Handler() { // forward all standard messages to stderr and at debug level to avoid to break default behavior @@ -95,7 +95,7 @@ public void close() throws SecurityException { try { rc.toToolProperties(tools).thenAccept(resolved -> { final var toolVars = resolved.entrySet().stream() - .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\"") + .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\";") .sorted() .collect(joining("\n", "", "\n")); @@ -103,19 +103,19 @@ public void close() throws SecurityException { .or(() -> ofNullable(System.getenv(pathName))) .orElse(""); final var pathVars = resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath) ? - export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\"\n" + + export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\";\n" + export + pathName + "=\"" + resolved.entrySet().stream() .filter(r -> r.getKey().addToPath()) .map(r -> quoted(rc.toBin(r.getValue()))) - .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\"\n" : + .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\";\n" : ""; final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? resolved.entrySet().stream() - .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\"") + .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\";") .collect(joining("\n", "", "\n")) : ""; - final var script = messages.stream().map(m -> "echo \"[yem] " + m + "\"").collect(joining("\n", "", "\n\n")) + + final var script = messages.stream().map(m -> "echo \"[yem] " + m.replace("\"", "\"\\\"\"") + "\";").collect(joining("\n", "", "\n\n")) + pathVars + toolVars + echos + "\n" + comment + "To load a .yemrc configuration run:\n" + comment + "[ -f .yemrc ] && eval $(yem env--env-file .yemrc)\n" + @@ -150,17 +150,17 @@ private void resetOriginalPath(final String export, final String pathName) { ofNullable(System.getenv("YEM_ORIGINAL_PATH")) .ifPresent(value -> { if (os.isWindows()) { - System.out.println("set YEM_ORIGINAL_PATH="); + System.out.println("set YEM_ORIGINAL_PATH=;"); } else { - System.out.println("unset YEM_ORIGINAL_PATH"); + System.out.println("unset YEM_ORIGINAL_PATH;"); } - System.out.println(export + " " + pathName + "=\"" + value + '"'); + System.out.println(export + " " + pathName + "=\"" + value + "\";"); }); } @RootConfiguration("env") public record Conf( @Property(documentation = "Should `~/.yupiik/yem/rc` be ignored or not. If present it defines default versions and uses the same syntax than `yemrc`.", defaultValue = "System.getProperty(\"user.home\") + \"/.yupiik/yem/rc\"") String defaultRc, - @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\".yemrc\"") String rc) { + @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\"auto\"") String rc) { } } diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index 5438e934..8c5fb0be 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -17,6 +17,7 @@ import io.yupiik.dev.provider.Provider; import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.shared.http.YemHttpClient; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; import java.io.IOException; @@ -26,6 +27,7 @@ import java.util.Optional; import java.util.Properties; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; @@ -78,7 +80,17 @@ public CompletionStage> toToolProperties(final Propert logger.info(() -> "Installing " + tool.toolName() + '@' + version); return Optional.of(provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP) - .thenApply(Optional::of) + .exceptionally(e -> { + final var unwrapped = e instanceof CompletionException ce ? ce.getCause() : e; + if (unwrapped instanceof YemHttpClient.OfflineException ex) { + return null; + } + if (unwrapped instanceof RuntimeException ex) { + throw ex; + } + throw new IllegalStateException(unwrapped); + }) + .thenApply(Optional::ofNullable) .toCompletableFuture()); }) .orElseGet(() -> completedFuture(Optional.empty())) @@ -101,7 +113,7 @@ public Properties loadPropertiesFrom(final String rcPath, final String defaultRc } final var isAuto = "auto".equals(rcPath); - var rcLocation = isAuto ? auto() : Path.of(rcPath); + var rcLocation = isAuto ? auto(Path.of(".")) : Path.of(rcPath); final boolean isAbsolute = rcLocation.isAbsolute(); if (Files.notExists(rcLocation)) { // enable to navigate in the project without loosing the env while (!isAbsolute && Files.notExists(rcLocation)) { @@ -113,7 +125,7 @@ public Properties loadPropertiesFrom(final String rcPath, final String defaultRc if (parent == null || !Files.isReadable(parent)) { break; } - rcLocation = parent.resolve(isAuto ? rcLocation.getFileName().toString() : rcPath); + rcLocation = isAuto ? auto(parent) : parent.resolve(rcPath); } } @@ -169,9 +181,9 @@ private void rewritePropertiesFromSdkManRc(final Properties props) { .collect(toMap(p -> p + ".version", original::getProperty))); } - private Path auto() { + private Path auto(final Path from) { return Stream.of(".yemrc", ".sdkmanrc") - .map(Path::of) + .map(from::resolve) .filter(Files::exists) .findFirst() .orElseGet(() -> Path.of(".yemrc")); diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java index 110f3b3c..cb01e1dc 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/Cache.java @@ -92,17 +92,15 @@ public CachedEntry lookup(final String key) { if (Files.exists(cacheLocation)) { try { final var cached = jsonMapper.fromString(Response.class, Files.readString(cacheLocation)); - if (cached.validUntil() > clock.instant().toEpochMilli()) { - return new CachedEntry(cacheLocation, cached); - } + return new CachedEntry(cacheLocation, cached, cached.validUntil() < clock.instant().toEpochMilli()); } catch (final IOException e) { throw new IllegalStateException(e); } } - return new CachedEntry(cacheLocation, null); + return new CachedEntry(cacheLocation, null, true); } - public record CachedEntry(Path key, Response hit) { + public record CachedEntry(Path key, Response hit, boolean expired) { } @JsonModel diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java index 7a01e95d..10f9422f 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/HttpConfiguration.java @@ -20,6 +20,8 @@ @RootConfiguration("http") public record HttpConfiguration( + @Property(defaultValue = "false", documentation = "Force offline mode.") boolean offlineMode, + @Property(defaultValue = "10_000", documentation = "Check offline timeout. Per uri a test is done to verify the system is offline.") int offlineTimeout, @Property(defaultValue = "4", documentation = "Number of NIO threads.") int threads, @Property(defaultValue = "false", documentation = "Should HTTP calls be logged.") boolean log, @Property(defaultValue = "60_000L", documentation = "Connection timeout in milliseconds.") long connectTimeout, diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java index 6b488a4e..b8870527 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java @@ -28,6 +28,8 @@ import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpHeaders; @@ -40,7 +42,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Flow; @@ -65,10 +69,15 @@ public class YemHttpClient implements AutoCloseable { private final ExtendedHttpClient client; private final Cache cache; + private final Map state = new ConcurrentHashMap<>(); + private final int offlineTimeout; + private final boolean offline; protected YemHttpClient() { // for subclassing proxy this.client = null; this.cache = null; + this.offlineTimeout = 0; + this.offline = false; } public YemHttpClient(final HttpConfiguration configuration, final Cache cache) { @@ -120,6 +129,8 @@ public Thread newThread(final Runnable r) { .build()) .setRequestListeners(listeners); + this.offline = configuration.offlineMode(); + this.offlineTimeout = configuration.offlineTimeout(); this.cache = cache; this.client = new ExtendedHttpClient(conf).onClose(c -> { final var executorService = (ExecutorService) conf.getDelegate().executor().orElseThrow(); @@ -145,6 +156,7 @@ public void close() { public CompletionStage> getFile(final HttpRequest request, final Path target, final Provider.ProgressListener listener) { logger.finest(() -> "Calling " + request); + checkOffline(request.uri()); return sendWithProgress(request, listener, HttpResponse.BodyHandlers.ofFile(target)) .thenApply(response -> { if (isGzip(response) && Files.exists(response.body())) { @@ -177,17 +189,19 @@ public CompletionStage> getFile(final HttpRequest request, fi public CompletionStage> sendAsync(final HttpRequest request) { final var entry = cache.lookup(request); - if (entry != null && entry.hit() != null) { - return completedFuture(new SimpleHttpResponse<>( - request, request.uri(), HTTP_1_1, 200, - HttpHeaders.of( - entry.hit().headers().entrySet().stream() - .collect(toMap(Map.Entry::getKey, e -> List.of(e.getValue()))), - (a, b) -> true), - entry.hit().payload())); + if (entry != null && entry.hit() != null && !entry.expired()) { + return fromCache(request, entry); } logger.finest(() -> "Calling " + request); + try { + checkOffline(request.uri()); + } catch (final RuntimeException re) { + if (entry != null && entry.hit() != null) { // expired but use it instead of failling + return fromCache(request, entry); + } + throw re; + } return client.sendAsync(request, ofByteArray()) .thenApply(response -> { HttpResponse result = null; @@ -210,6 +224,34 @@ public CompletionStage> sendAsync(final HttpRequest request }); } + private CompletableFuture> fromCache(final HttpRequest request, final Cache.CachedEntry entry) { + return completedFuture(new SimpleHttpResponse<>( + request, request.uri(), HTTP_1_1, 200, + HttpHeaders.of( + entry.hit().headers().entrySet().stream() + .collect(toMap(Map.Entry::getKey, e -> List.of(e.getValue()))), + (a, b) -> true), + entry.hit().payload())); + } + + private void checkOffline(final URI uri) { + if (offline) { + throw OfflineException.INSTANCE; + } + if (state.computeIfAbsent(uri.getHost() + ":" + uri.getPort(), k -> { + try (final var socket = new Socket()) { + final var address = new InetSocketAddress(uri.getHost(), uri.getPort() >= 0 ? uri.getPort() : ("https".equals(uri.getScheme()) ? 443 : 80)); + socket.connect(address, offlineTimeout); + socket.getInputStream().close(); + return false; + } catch (final IOException e) { + return true; + } + })) { + throw OfflineException.INSTANCE; + } + } + private boolean isGzip(final HttpResponse response) { return response.headers().allValues("content-encoding").stream().anyMatch(it -> it.contains("gzip")); } @@ -263,4 +305,16 @@ public String toString() { return '(' + request().method() + ' ' + (uri == null ? "" : uri) + ") " + statusCode(); } } + + public static class OfflineException extends RuntimeException { + public static final OfflineException INSTANCE = new OfflineException(); + + static { + INSTANCE.setStackTrace(new StackTraceElement[0]); + } + + public OfflineException() { + super("Network is offline"); + } + } } diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index ac7b3aba..0db215e1 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -116,12 +116,12 @@ void env(@TempDir final Path work, final URI uri) throws IOException { final var rc = Files.writeString(work.resolve("rc"), "java.version = 21.\njava.relaxed = true\naddToPath = true\ninstallIfMissing = true"); final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); assertEquals((""" - echo "[yem] Installing java@21.32.17-ca-jdk21.0.2" + echo "[yem] Installing java@21.32.17-ca-jdk21.0.2"; - export YEM_ORIGINAL_PATH="$original_path" - export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH" - export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded" - echo "[yem] Resolved java@21. to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\"""") + export YEM_ORIGINAL_PATH="$original_path"; + export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; + export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; + echo "[yem] Resolved java@21. to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") .replace("$original_path", System.getenv("PATH")) .replace("$work", work.toString()), out @@ -136,10 +136,10 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { final var rc = Files.writeString(work.resolve(".sdkmanrc"), "java = 21.0.2"); final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); assertEquals((""" - export YEM_ORIGINAL_PATH="$original_path" - export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH" - export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded" - echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\"""") + export YEM_ORIGINAL_PATH="$original_path"; + export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; + export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; + echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") .replace("$original_path", System.getenv("PATH")) .replace("$work", work.toString()), out diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java index b1a3937d..2f761874 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java @@ -114,7 +114,7 @@ void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) private CentralBaseProvider newProvider(final URI uri, final YemHttpClient client, final Path local) { return new CentralBaseProvider( client, new CentralConfiguration(uri.toASCIIString(), local.toString(), ""), new Archives(), - new Cache(new HttpConfiguration(1, false, 30_000L, 30_000L, 0, "none"), null), + new Cache(new HttpConfiguration(false, 10_000, 1, false, 30_000L, 30_000L, 0, "none"), null), Gav.of("org.foo:bar:tar.gz:simple"), true); } } diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java index c66e6263..9dde5926 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java @@ -181,6 +181,6 @@ private ZuluCdnClient newProvider(final URI uri, final YemHttpClient client, fin return new ZuluCdnClient( client, new ZuluCdnConfiguration(true, true, uri.toASCIIString(), "linux_x64.zip", local.toString()), - new Os(), new Archives(), new Cache(new HttpConfiguration(1, false, 30_000L, 30_000L, 0L, "none"), null)); + new Os(), new Archives(), new Cache(new HttpConfiguration(false, 10_000, 1, false, 30_000L, 30_000L, 0L, "none"), null)); } } diff --git a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java index 63b530cc..f50d57ac 100644 --- a/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java +++ b/env-manager/src/test/java/io/yupiik/dev/test/HttpMockExtension.java @@ -93,7 +93,7 @@ public Object resolveParameter(final ParameterContext parameterContext, final Ex return URI.create("http://localhost:" + context.getStore(NAMESPACE).get(HttpServer.class, HttpServer.class).getAddress().getPort() + "/2/"); } if (YemHttpClient.class == parameterContext.getParameter().getType()) { - final var configuration = new HttpConfiguration(1, false, 30_000L, 30_000L, 0, "none"); + final var configuration = new HttpConfiguration(false, 10_000, 1, false, 30_000L, 30_000L, 0, "none"); return new YemHttpClient(configuration, new Cache(configuration, null)); } throw new ParameterResolutionException("Can't resolve " + parameterContext.getParameter().getType()); From 132acf0cd2c2666dba4b7ebbbb12c3a41acd9b73 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Tue, 6 Feb 2024 21:00:59 +0100 Subject: [PATCH 12/26] [env manager] tolerate to not mention apache- prefix for central providers --- .../src/main/minisite/content/yem.adoc | 27 +++- .../main/java/io/yupiik/dev/command/Env.java | 9 +- .../main/java/io/yupiik/dev/command/Run.java | 4 +- .../provider/central/CentralBaseProvider.java | 12 +- .../java/io/yupiik/dev/shared/RcService.java | 148 ++++++++++-------- .../yupiik/dev/shared/http/YemHttpClient.java | 1 + .../io/yupiik/dev/command/CommandsTest.java | 6 +- 7 files changed, 139 insertions(+), 68 deletions(-) diff --git a/_documentation/src/main/minisite/content/yem.adoc b/_documentation/src/main/minisite/content/yem.adoc index 6d922e3d..feb70531 100644 --- a/_documentation/src/main/minisite/content/yem.adoc +++ b/_documentation/src/main/minisite/content/yem.adoc @@ -85,5 +85,30 @@ Add to your global `~/.bashrc` (or `~/.profile`) configuration the following lin [source,bash] ---- -eval $(yem env) +yem_env() { + eval $(yem env) +} +if [[ -n "$ZSH_VERSION" ]]; then + chpwd_functions+=(yem_env) +else + trimmed_prompt_command="${PROMPT_COMMAND%"${PROMPT_COMMAND##*[![:space:]]}"}" + [[ -z "$trimmed_prompt_command" ]] && PROMPT_COMMAND="yem_env" || PROMPT_COMMAND="${trimmed_prompt_command%\;};yem_env" +fi +yem_env ---- + +-- +TIP: if you don't use zsh shell you can simplify it: + +[source,bash] +---- +yem_env() { + eval $(yem env) +} +trimmed_prompt_command="${PROMPT_COMMAND%"${PROMPT_COMMAND##*[![:space:]]}"}" +[[ -z "$trimmed_prompt_command" ]] && PROMPT_COMMAND="yem_env" || PROMPT_COMMAND="${trimmed_prompt_command%\;};yem_env" +yem_env +---- +-- + +TIP: it is also recommended to set some default versions in `~/.yupiik/yem/rc` diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 155efbbe..9b940e78 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -23,10 +23,13 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.Objects; import java.util.concurrent.ExecutionException; +import java.util.function.Predicate; import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; +import java.util.stream.Stream; import static java.io.File.pathSeparator; import static java.util.Optional.ofNullable; @@ -95,7 +98,9 @@ public void close() throws SecurityException { try { rc.toToolProperties(tools).thenAccept(resolved -> { final var toolVars = resolved.entrySet().stream() - .map(e -> export + e.getKey().envVarName() + "=\"" + quoted(e.getValue()) + "\";") + .flatMap(e -> Stream.of( + export + e.getKey().envPathVarName() + "=\"" + quoted(e.getValue()) + "\";", + export + e.getKey().envVersionVarName() + "=\"" + e.getKey().version() + "\";")) .sorted() .collect(joining("\n", "", "\n")); @@ -111,6 +116,8 @@ public void close() throws SecurityException { ""; final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? resolved.entrySet().stream() + // don't log too much, if it does not change, don't re-log it + .filter(Predicate.not(it -> Objects.equals(it.getValue().toString(), System.getenv(it.getKey().envPathVarName())))) .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\";") .collect(joining("\n", "", "\n")) : ""; diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index 47f8aef3..d1bfc89c 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -132,8 +132,8 @@ public void run() { private void setEnv(final Map resolved, final Map environment) { resolved.forEach((tool, home) -> { final var homeStr = home.toString(); - logger.finest(() -> "Setting '" + tool.envVarName() + "' to '" + homeStr + "'"); - environment.put(tool.envVarName(), homeStr); + logger.finest(() -> "Setting '" + tool.envPathVarName() + "' to '" + homeStr + "'"); + environment.put(tool.envPathVarName(), homeStr); }); if (resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath)) { final var pathName = os.isWindows() ? "Path" : "PATH"; diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java index 3b4bfa01..42208aa1 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java @@ -182,7 +182,17 @@ public CompletionStage> listTools() { final var gavString = Stream.of(gav.groupId(), gav.artifactId(), gav.type(), gav.classifier()) .filter(Objects::nonNull) .collect(joining(":")); - return completedFuture(List.of(new Candidate(gavString, gav.artifactId(), gavString + " downloaded from central.", base.toASCIIString()))); + + final var defaultCandidate = new Candidate(gavString, gav.artifactId(), gavString + " downloaded from central.", base.toASCIIString()); + final List candidates; + if (gav.artifactId().startsWith("apache-")) { + candidates = List.of( + defaultCandidate, + new Candidate(gavString, gav.artifactId().substring("apache-".length()), gavString + " downloaded from central.", base.toASCIIString())); + } else { + candidates = List.of(defaultCandidate); + } + return completedFuture(candidates); } @Override diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index 8c5fb0be..000eb0dc 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -17,6 +17,7 @@ import io.yupiik.dev.provider.Provider; import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.provider.model.Version; import io.yupiik.dev.shared.http.YemHttpClient; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; @@ -24,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Properties; import java.util.concurrent.CompletableFuture; @@ -42,69 +44,12 @@ @ApplicationScoped public class RcService { private final Logger logger = Logger.getLogger(getClass().getName()); - private final Os os; private final ProviderRegistry registry; - public RcService(final Os os, final ProviderRegistry registry) { - this.os = os; + public RcService(final ProviderRegistry registry) { this.registry = registry; } - public CompletionStage> toToolProperties(final Properties props) { - final var promises = props.stringPropertyNames().stream() - .filter(it -> it.endsWith(".version")) - .map(versionKey -> toToolProperties(props, versionKey)) - .map(tool -> { - final var providerAndVersionPromise = registry.tryFindByToolVersionAndProvider( - tool.toolName(), tool.version(), - tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), - new ProviderRegistry.Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())); - return providerAndVersionPromise.thenCompose(providerAndVersionOpt -> { - if (tool.failOnMissing() && !tool.installIfMissing() && providerAndVersionOpt.isEmpty()) { - throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); - } - - final var providerVersion = providerAndVersionOpt.orElseThrow(); - final var provider = providerVersion.getKey(); - final var version = providerVersion.getValue().identifier(); - - return provider.resolve(tool.toolName(), providerVersion.getValue().identifier()) - .map(path -> completedFuture(Optional.of(path))) - .or(() -> { - if (!tool.installIfMissing()) { - if (tool.failOnMissing()) { - throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); - } - return Optional.empty(); - } - - logger.info(() -> "Installing " + tool.toolName() + '@' + version); - return Optional.of(provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP) - .exceptionally(e -> { - final var unwrapped = e instanceof CompletionException ce ? ce.getCause() : e; - if (unwrapped instanceof YemHttpClient.OfflineException ex) { - return null; - } - if (unwrapped instanceof RuntimeException ex) { - throw ex; - } - throw new IllegalStateException(unwrapped); - }) - .thenApply(Optional::ofNullable) - .toCompletableFuture()); - }) - .orElseGet(() -> completedFuture(Optional.empty())) - .thenApply(path -> path.map(p -> entry(tool, p))); - }) - .toCompletableFuture(); - }) - .toList(); - return allOf(promises.toArray(new CompletableFuture[0])) - .thenApply(ok -> promises.stream() - .flatMap(p -> p.getNow(Optional.empty()).stream()) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); - } - public Properties loadPropertiesFrom(final String rcPath, final String defaultRcPath) { final var defaultRc = Path.of(defaultRcPath); final var defaultProps = new Properties(); @@ -130,6 +75,7 @@ public Properties loadPropertiesFrom(final String rcPath, final String defaultRc } final var props = new Properties(); + props.putAll(defaultProps); if (Files.exists(rcLocation)) { readRc(rcLocation, props); if (".sdkmanrc".equals(rcLocation.getFileName().toString())) { @@ -150,14 +96,93 @@ public Path toBin(final Path value) { .orElse(value); } + public CompletionStage> toToolProperties(final Properties props) { + final var promises = props.stringPropertyNames().stream() + .filter(it -> it.endsWith(".version")) + .map(versionKey -> toToolProperties(props, versionKey)) + .map(tool -> { + final var providerAndVersionPromise = registry.tryFindByToolVersionAndProvider( + tool.toolName(), tool.version(), + tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), + new ProviderRegistry.Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())); + return providerAndVersionPromise.thenCompose(providerAndVersionOpt -> providerAndVersionOpt + .map(providerVersion -> doResolveVersion(tool, providerVersion)) + .orElseGet(() -> { + if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + tool.version()); + } + logger.finest(() -> tool.toolName() + "@" + tool.version() + " not available"); + return completedFuture(Optional.empty()); + })) + .toCompletableFuture(); + }) + .toList(); + return allOf(promises.toArray(new CompletableFuture[0])) + .thenApply(ok -> promises.stream() + .flatMap(p -> p.getNow(Optional.empty()).stream()) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + + private CompletableFuture>> doResolveVersion(final ToolProperties tool, + final Map.Entry providerVersion) { + final var provider = providerVersion.getKey(); + final var version = providerVersion.getValue().identifier(); + return provider.resolve(tool.toolName(), providerVersion.getValue().identifier()) + .map(path -> completedFuture(Optional.of(path))) + .or(() -> { + if (!tool.installIfMissing()) { + if (tool.failOnMissing()) { + throw new IllegalStateException("Missing home for " + tool.toolName() + "@" + version); + } + return Optional.empty(); + } + + logger.info(() -> "Installing " + tool.toolName() + '@' + version); + return Optional.of(provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP) + .exceptionally(this::onInstallException) + .thenApply(Optional::ofNullable) + .toCompletableFuture()); + }) + .orElseGet(() -> completedFuture(Optional.empty())) + .thenApply(path -> path.map(p -> entry(adjustToolVersion(tool, providerVersion), p))); + } + + private Path onInstallException(final Throwable e) { + final var unwrapped = e instanceof CompletionException ce ? ce.getCause() : e; + if (unwrapped instanceof YemHttpClient.OfflineException) { + return null; + } + if (unwrapped instanceof RuntimeException ex) { + throw ex; + } + throw new IllegalStateException(unwrapped); + } + + private ToolProperties adjustToolVersion(final ToolProperties tool, final Map.Entry providerVersion) { + return Objects.equals(tool.version(), providerVersion.getValue().version()) ? + tool : + new ToolProperties( + tool.toolName(), + providerVersion.getValue().version(), + tool.provider(), + true, + tool.envPathVarName(), + tool.envVersionVarName(), + tool.addToPath(), + tool.failOnMissing(), + tool.installIfMissing()); + } + private ToolProperties toToolProperties(final Properties props, final String versionKey) { final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); + final var baseEnvVar = name.toUpperCase(ROOT).replace('.', '_'); return new ToolProperties( props.getProperty(name + ".toolName", name), props.getProperty(versionKey), props.getProperty(name + ".provider"), Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), - props.getProperty(name + ".envVarName", name.toUpperCase(ROOT).replace('.', '_') + "_HOME"), + props.getProperty(name + ".envVarName", baseEnvVar + "_HOME"), + props.getProperty(name + ".envVarVersionName", baseEnvVar + "_VERSION"), Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); @@ -186,7 +211,7 @@ private Path auto(final Path from) { .map(from::resolve) .filter(Files::exists) .findFirst() - .orElseGet(() -> Path.of(".yemrc")); + .orElseGet(() -> from.resolve(".yemrc")); } public record ToolProperties( @@ -194,7 +219,8 @@ public record ToolProperties( String version, String provider, boolean relaxed, - String envVarName, + String envPathVarName, + String envVersionVarName, boolean addToPath, boolean failOnMissing, boolean installIfMissing) { diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java index b8870527..d8d0da3a 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java @@ -218,6 +218,7 @@ public CompletionStage> sendAsync(final HttpRequest request response.request(), response.uri(), response.version(), response.statusCode(), response.headers(), new String(response.body(), UTF_8)); if (entry != null && result.statusCode() == 200) { + logger.info(() -> "Updated '" + request.uri() + "' metadata"); cache.save(entry.key(), result); } return result; diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 0db215e1..739f79b9 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -121,7 +121,8 @@ void env(@TempDir final Path work, final URI uri) throws IOException { export YEM_ORIGINAL_PATH="$original_path"; export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; - echo "[yem] Resolved java@21. to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + export JAVA_VERSION="21.0.2"; + echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") .replace("$original_path", System.getenv("PATH")) .replace("$work", work.toString()), out @@ -139,6 +140,7 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { export YEM_ORIGINAL_PATH="$original_path"; export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; + export JAVA_VERSION="21.0.2"; echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") .replace("$original_path", System.getenv("PATH")) .replace("$work", work.toString()), @@ -157,7 +159,7 @@ void run(@TempDir final Path work, final URI uri) throws IOException { "io.yupiik.dev.command.CommandsTest$SampleMain " + "\"" + output + "\"" + "hello YEM!"); - captureOutput(work, uri, "run", "--rc", yem.toString(), "--", "demo"); + captureOutput(work, uri, "run", "--rc", yem.toString(), "--defaultRc", "skip", "--", "demo"); assertEquals(">> [hello, YEM!]", Files.readString(output)); } From 5cc967dea1ce648ed6c0b2aeea1674237855660d Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Tue, 6 Feb 2024 22:00:16 +0100 Subject: [PATCH 13/26] [env-manager] using zulu api instead of the slow scraping --- env-manager/pom.xml | 3 + .../main/java/io/yupiik/dev/command/Env.java | 6 +- .../dev/provider/zulu/ZuluCdnClient.java | 107 ++++++++++++++++-- .../provider/zulu/ZuluCdnConfiguration.java | 2 + .../yupiik/dev/shared/http/YemHttpClient.java | 2 + .../io/yupiik/dev/command/CommandsTest.java | 16 +-- .../dev/provider/zulu/ZuluCdnClientTest.java | 4 +- 7 files changed, 120 insertions(+), 20 deletions(-) diff --git a/env-manager/pom.xml b/env-manager/pom.xml index 2ec541a3..b9aed8b7 100644 --- a/env-manager/pom.xml +++ b/env-manager/pom.xml @@ -121,6 +121,9 @@ io.yupiik.logging.jul.YupiikLogManager true + + skip + diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 9b940e78..a662f53b 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -57,7 +57,9 @@ public void run() { final var pathName = windows ? "Path" : "PATH"; final var pathVar = windows ? "%" + pathName + "%" : ("$" + pathName); - resetOriginalPath(export, pathName); + if (!conf.skipReset()) { + resetOriginalPath(export, pathName); + } final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); if (tools == null || tools.isEmpty()) { // nothing to do @@ -155,6 +157,7 @@ private String quoted(final Path path) { private void resetOriginalPath(final String export, final String pathName) { // just check we have YEM_ORIGINAL_PATH and reset PATH if needed ofNullable(System.getenv("YEM_ORIGINAL_PATH")) + .filter(it -> !"skip".equals(it)) .ifPresent(value -> { if (os.isWindows()) { System.out.println("set YEM_ORIGINAL_PATH=;"); @@ -167,6 +170,7 @@ private void resetOriginalPath(final String export, final String pathName) { @RootConfiguration("env") public record Conf( + @Property(documentation = "By default if `YEM_ORIGINAL_PATH` exists in the environment variables it is used as `PATH` base to not keep appending path to the `PATH` indefinively. This can be disabled setting this property to `false`", defaultValue = "false") boolean skipReset, @Property(documentation = "Should `~/.yupiik/yem/rc` be ignored or not. If present it defines default versions and uses the same syntax than `yemrc`.", defaultValue = "System.getProperty(\"user.home\") + \"/.yupiik/yem/rc\"") String defaultRc, @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\"auto\"") String rc) { } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java index 4da5a311..ec648f4a 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java @@ -23,11 +23,15 @@ import io.yupiik.dev.shared.Os; import io.yupiik.dev.shared.http.Cache; import io.yupiik.dev.shared.http.YemHttpClient; +import io.yupiik.fusion.framework.api.container.Types; import io.yupiik.fusion.framework.api.scope.DefaultScoped; +import io.yupiik.fusion.framework.build.api.json.JsonModel; +import io.yupiik.fusion.json.JsonMapper; import java.io.BufferedReader; import java.io.IOException; import java.io.StringReader; +import java.lang.reflect.Type; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -37,6 +41,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; import static java.util.Optional.ofNullable; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -51,18 +56,24 @@ public class ZuluCdnClient implements Provider { private final YemHttpClient client; private final Cache cache; private final URI base; + private final URI apiBase; private final Path local; private final boolean enabled; private final boolean preferJre; + private final boolean preferApi; + private final JsonMapper jsonMapper; public ZuluCdnClient(final YemHttpClient client, final ZuluCdnConfiguration configuration, final Os os, final Archives archives, - final Cache cache) { + final Cache cache, final JsonMapper jsonMapper) { this.client = client; + this.jsonMapper = jsonMapper; this.archives = archives; this.cache = cache; this.base = URI.create(configuration.base()); this.local = Path.of(configuration.local()); this.enabled = configuration.enabled(); + this.apiBase = URI.create(configuration.apiBase()); + this.preferApi = configuration.preferApi(); this.preferJre = configuration.preferJre(); this.suffix = ofNullable(configuration.platform()) .filter(i -> !"auto".equalsIgnoreCase(i)) @@ -199,6 +210,28 @@ public CompletionStage> listVersions(final String tool) { return completedFuture(parseVersions(entry.hit().payload())); } + if (preferApi) { + final var baseUrl = apiBase.resolve("/metadata/v1/zulu/packages/") + "?" + + "os=linux&" + + "arch=" + (suffix.contains("_aarch64") ? "aarch64" : "x64") + "&" + + "archive_type=" + (suffix.endsWith(".tar.gz") ? "tar.gz" : "zip") + "&" + + "java_package_type=" + (preferJre ? "jre" : "jdk") + "&" + + "release_status=&" + + "availability_types=&" + + "certifications=&" + + "page_size=900"; + final var listOfVersionsType = new Types.ParameterizedTypeImpl(List.class, APiVersion.class); + return fetchVersionPages(1, baseUrl, listOfVersionsType) + .thenApply(filtered -> { + if (entry != null) { + cache.save(entry.key(), Map.of(), filtered.stream() + .map(it -> "
zulu" + it.identifier() + '-' + suffix + "") + .collect(joining("\n", "", "\n"))); + } + return filtered; + }); + } + return client.sendAsync(HttpRequest.newBuilder().GET().uri(base).build()) .thenApply(res -> { ensure200(res); @@ -213,6 +246,29 @@ public CompletionStage> listVersions(final String tool) { }); } + private CompletionStage> fetchVersionPages(final int page, final String baseUrl, + final Type listOfVersionsType) { + return client.sendAsync(HttpRequest.newBuilder().GET() + .uri(URI.create(baseUrl + "&page=" + page)) + .header("accept", "application/json") + .build()) + .thenCompose(res -> { + ensure200(res); + + final List apiVersions = jsonMapper.fromString(listOfVersionsType, res.body()); + final var pageVersions = apiVersions.stream().map(APiVersion::name).map(this::toProviderVersion).toList(); + + return res.headers() + .firstValue("x-pagination") + .map(p -> jsonMapper.fromString(Pagination.class, p.strip())) + .filter(p -> p.last_page() <= page) + .map(p -> completedFuture(pageVersions)) + .orElseGet(() -> fetchVersionPages(page + 1, baseUrl, listOfVersionsType) + .thenApply(newVersions -> Stream.concat(newVersions.stream(), pageVersions.stream()).toList()) + .toCompletableFuture()); + }); + } + private void ensure200(final HttpResponse res) { if (res.statusCode() != 200) { throw new IllegalStateException("Invalid response: " + res + "\n" + res.body()); @@ -228,18 +284,51 @@ private List parseVersions(final String body) { return it.substring(from, it.indexOf('"', from)).strip(); }) .filter(it -> it.endsWith(suffix) && (preferJre ? it.contains("jre") : it.contains("jdk"))) - .map(v -> { // ex: "zulu21.32.17-ca-jre21.0.2-linux_x64.zip" - // path for the download directly without the prefix and suffix - final var identifier = v.substring("zulu".length(), v.length() - suffix.length() - 1); - final var distroType = preferJre ? "-jre" : "-jdk"; - final int versionStart = identifier.lastIndexOf(distroType); - final var version = identifier.substring(versionStart + distroType.length()).strip(); - return new Version("Azul", version, "zulu", identifier); - }) + .map(this::toProviderVersion) .distinct() .toList(); } catch (final IOException e) { throw new IllegalStateException(e); } } + + // ex: "zulu21.32.17-ca-jre21.0.2-linux_x64.zip" + // path for the download directly without the prefix and suffix + private Version toProviderVersion(final String name) { + final var identifier = name.substring("zulu".length(), name.length() - suffix.length() - 1); + final var distroType = preferJre ? "-jre" : "-jdk"; + final int versionStart = identifier.lastIndexOf(distroType); + final var version = identifier.substring(versionStart + distroType.length()).strip(); + return new Version("Azul", version, "zulu", identifier); + } + + @JsonModel + public record Pagination(long last_page) { + } + + @JsonModel + public record APiVersion(String name) { + /* + { + "availability_type": "CA", + "distro_version": [ + 21, + 32, + 17, + 0 + ], + "download_url": "https://cdn.azul.com/zulu/bin/zulu21.32.17-ca-fx-jdk21.0.2-linux_x64.tar.gz", + "java_version": [ + 21, + 0, + 2 + ], + "latest": true, + "name": "zulu21.32.17-ca-fx-jdk21.0.2-linux_x64.tar.gz", + "openjdk_build_number": 13, + "package_uuid": "f282c770-a435-4053-9d10-b3434305eb78", + "product": "zulu" + } + */ + } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java index 3444bb82..f52e2adf 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnConfiguration.java @@ -23,6 +23,8 @@ public record ZuluCdnConfiguration( @Property(documentation = "Is Zulu CDN support enabled.", defaultValue = "true") boolean enabled, @Property(documentation = "Should JRE be preferred over JDK.", defaultValue = "false") boolean preferJre, @Property(documentation = "Base URL for zulu CDN archives.", defaultValue = "\"https://cdn.azul.com/zulu/bin/\"") String base, + @Property(documentation = "YEM is able to scrape the CDN index page but it is slow when not cached so by default we prefer the Zulu API. This property enables to use the scrapping by being set to `false`.", defaultValue = "true") boolean preferApi, + @Property(documentation = "This property is the Zulu API base URI.", defaultValue = "\"https://api.azul.com\"") String apiBase, @Property(documentation = "Zulu platform value - if `auto` it will be computed.", defaultValue = "\"auto\"") String platform, @Property(documentation = "Local cache of distributions.", defaultValue = "System.getProperty(\"user.home\", \"\") + \"/.yupiik/yem/zulu\"") String local ) { diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java index d8d0da3a..64a09b33 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/http/YemHttpClient.java @@ -17,6 +17,7 @@ import io.yupiik.dev.provider.Provider; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import io.yupiik.fusion.framework.build.api.lifecycle.Destroy; import io.yupiik.fusion.httpclient.core.ExtendedHttpClient; import io.yupiik.fusion.httpclient.core.ExtendedHttpClientConfiguration; import io.yupiik.fusion.httpclient.core.listener.RequestListener; @@ -149,6 +150,7 @@ public Thread newThread(final Runnable r) { }); } + @Destroy @Override public void close() { this.client.close(); diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 739f79b9..d326f8b6 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -51,7 +51,7 @@ void config(@TempDir final Path work, final URI uri) { - github: base=http://localhost:$port/2//github/, local=/github - minikube: enabled=false - sdkman: enabled=false, base=http://localhost:$port/2/, platform=linuxx64.tar.gz, local=$work/sdkman/candidates - - zulu: enabled=true, preferJre=false, base=http://localhost:$port/2/, platform=linux64.tar.gz, local=$work/zulu""" + - zulu: enabled=true, preferJre=false, base=http://localhost:$port/2/, preferApi=false, apiBase=https://api.azul.com, platform=linux64.tar.gz, local=$work/zulu""" .replace("$work", work.toString()) .replace("$port", Integer.toString(uri.getPort())), captureOutput(work, uri, "config")); @@ -114,19 +114,19 @@ void delete(@TempDir final Path work, final URI uri) throws IOException { @Test void env(@TempDir final Path work, final URI uri) throws IOException { final var rc = Files.writeString(work.resolve("rc"), "java.version = 21.\njava.relaxed = true\naddToPath = true\ninstallIfMissing = true"); - final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); + final var out = captureOutput(work, uri, "env", "--skipReset", "true", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); assertEquals((""" echo "[yem] Installing java@21.32.17-ca-jdk21.0.2"; - export YEM_ORIGINAL_PATH="$original_path"; + export YEM_ORIGINAL_PATH="..."; export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") - .replace("$original_path", System.getenv("PATH")) .replace("$work", work.toString()), out .replaceAll("#.*", "") + .replaceFirst("export YEM_ORIGINAL_PATH=\"[^\"]+\"", "export YEM_ORIGINAL_PATH=\"...\"") .strip()); } @@ -135,17 +135,17 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { doInstall(work, uri); final var rc = Files.writeString(work.resolve(".sdkmanrc"), "java = 21.0.2"); - final var out = captureOutput(work, uri, "env", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); + final var out = captureOutput(work, uri, "env", "--skipReset", "true", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); assertEquals((""" - export YEM_ORIGINAL_PATH="$original_path"; + export YEM_ORIGINAL_PATH="..."; export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") - .replace("$original_path", System.getenv("PATH")) .replace("$work", work.toString()), out .replaceAll("#.*", "") + .replaceFirst("export YEM_ORIGINAL_PATH=\"[^\"]+\"", "export YEM_ORIGINAL_PATH=\"...\"") .strip()); } @@ -208,7 +208,7 @@ private LocalSource(final Path work, final String mockHttp) { public String get(final String key) { return switch (key) { case "http.cache" -> "none"; - case "apache-maven.enabled", "sdkman.enabled", "minikube.enabled" -> "false"; + case "apache-maven.enabled", "sdkman.enabled", "minikube.enabled", "zulu.preferApi" -> "false"; case "github.base" -> baseHttp + "/github/"; case "github.local" -> work.resolve("/github").toString(); case "central.base" -> baseHttp + "/m2/"; diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java index 9dde5926..5a134bc5 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/zulu/ZuluCdnClientTest.java @@ -180,7 +180,7 @@ void delete(final URI uri, @TempDir final Path work, final YemHttpClient client) private ZuluCdnClient newProvider(final URI uri, final YemHttpClient client, final Path local) { return new ZuluCdnClient( client, - new ZuluCdnConfiguration(true, true, uri.toASCIIString(), "linux_x64.zip", local.toString()), - new Os(), new Archives(), new Cache(new HttpConfiguration(false, 10_000, 1, false, 30_000L, 30_000L, 0L, "none"), null)); + new ZuluCdnConfiguration(true, true, uri.toASCIIString(), false, uri.toASCIIString(), "linux_x64.zip", local.toString()), + new Os(), new Archives(), new Cache(new HttpConfiguration(false, 10_000, 1, false, 30_000L, 30_000L, 0L, "none"), null), null); } } From 81213cb4cb2cfefa2da53aa293fbaca013640398 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 09:26:56 +0100 Subject: [PATCH 14/26] [env-manager] simplify central provider and only drop Apache from the tool name when present --- .../provider/central/CentralBaseProvider.java | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java index 42208aa1..e5174cf6 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java @@ -183,16 +183,9 @@ public CompletionStage> listTools() { .filter(Objects::nonNull) .collect(joining(":")); - final var defaultCandidate = new Candidate(gavString, gav.artifactId(), gavString + " downloaded from central.", base.toASCIIString()); - final List candidates; - if (gav.artifactId().startsWith("apache-")) { - candidates = List.of( - defaultCandidate, - new Candidate(gavString, gav.artifactId().substring("apache-".length()), gavString + " downloaded from central.", base.toASCIIString())); - } else { - candidates = List.of(defaultCandidate); - } - return completedFuture(candidates); + return completedFuture(List.of(new Candidate( + gav.artifactId().startsWith("apache-") ? gav.artifactId().substring("apache-".length()) : gavString, + toName(gav.artifactId()), gavString + " downloaded from central.", base.toASCIIString()))); } @Override @@ -242,6 +235,24 @@ public CompletionStage> listVersions(final String tool) { }); } + private String toName(final String artifactId) { + final var out = new StringBuilder(); + boolean up = true; + for (int i = 0; i < artifactId.length(); i++) { + final var c = artifactId.charAt(i); + if (up) { + out.append(Character.toUpperCase(c)); + up = false; + } else if (c == '-' || c == '_') { + out.append(' '); + up = true; + } else { + out.append(c); + } + } + return out.toString(); + } + private String relativePath(final String version) { return gav.groupId().replace('.', '/') + '/' + gav.artifactId() + '/' + From 2810d86fb6db9abe95556a1c7fd57647dcb56d4f Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 10:28:54 +0100 Subject: [PATCH 15/26] [env-manager] enable candidate to provide metadata --- env-manager/pom.xml | 1 + .../java/io/yupiik/dev/command/Delete.java | 12 +++-- .../main/java/io/yupiik/dev/command/Env.java | 26 +++++---- .../java/io/yupiik/dev/command/Install.java | 13 +++-- .../java/io/yupiik/dev/command/Resolve.java | 12 +++-- .../main/java/io/yupiik/dev/command/Run.java | 18 +++---- .../yupiik/dev/provider/ProviderRegistry.java | 35 +++++++----- .../provider/central/CentralBaseProvider.java | 7 ++- .../provider/central/CentralProviderInit.java | 7 ++- .../provider/github/MinikubeGithubClient.java | 3 +- .../yupiik/dev/provider/model/Candidate.java | 4 +- .../dev/provider/sdkman/SdkManClient.java | 16 +++++- .../dev/provider/zulu/ZuluCdnClient.java | 3 +- .../io/yupiik/dev/shared/MessageHelper.java | 54 +++++++++++++++++++ .../java/io/yupiik/dev/shared/RcService.java | 35 ++++++------ .../central/CentralBaseProviderTest.java | 3 +- .../dev/provider/sdkman/SdkManClientTest.java | 10 ++-- 17 files changed, 186 insertions(+), 73 deletions(-) create mode 100644 env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java diff --git a/env-manager/pom.xml b/env-manager/pom.xml index b9aed8b7..9cabac71 100644 --- a/env-manager/pom.xml +++ b/env-manager/pom.xml @@ -123,6 +123,7 @@ skip + true diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java index 332bce5f..51b08f07 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java @@ -16,6 +16,7 @@ package io.yupiik.dev.command; import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.shared.MessageHelper; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; @@ -28,20 +29,23 @@ public class Delete implements Runnable { private final Logger logger = Logger.getLogger(getClass().getName()); private final Conf conf; private final ProviderRegistry registry; + private final MessageHelper messageHelper; public Delete(final Conf conf, - final ProviderRegistry registry) { + final ProviderRegistry registry, + final MessageHelper messageHelper) { this.conf = conf; this.registry = registry; + this.messageHelper = messageHelper; } @Override public void run() { try { registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false) - .thenAccept(providerAndVersion -> { - providerAndVersion.getKey().delete(conf.tool(), providerAndVersion.getValue().identifier()); - logger.info(() -> "Deleted " + conf.tool() + "@" + providerAndVersion.getValue().version()); + .thenAccept(matched -> { + matched.provider().delete(conf.tool(), matched.version().identifier()); + logger.info(() -> "Deleted " + messageHelper.formatToolNameAndVersion(matched.candidate(), conf.tool(), matched.version().version())); }) .toCompletableFuture() .get(); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index a662f53b..7c644473 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -15,6 +15,7 @@ */ package io.yupiik.dev.command; +import io.yupiik.dev.shared.MessageHelper; import io.yupiik.dev.shared.Os; import io.yupiik.dev.shared.RcService; import io.yupiik.fusion.framework.build.api.cli.Command; @@ -42,11 +43,13 @@ public class Env implements Runnable { private final Conf conf; private final RcService rc; private final Os os; + private final MessageHelper messageHelper; - public Env(final Conf conf, final Os os, final RcService rc) { + public Env(final Conf conf, final Os os, final RcService rc, final MessageHelper messageHelper) { this.conf = conf; this.os = os; this.rc = rc; + this.messageHelper = messageHelper; } @Override @@ -99,28 +102,29 @@ public void close() throws SecurityException { try { rc.toToolProperties(tools).thenAccept(resolved -> { - final var toolVars = resolved.entrySet().stream() + final var toolVars = resolved.stream() .flatMap(e -> Stream.of( - export + e.getKey().envPathVarName() + "=\"" + quoted(e.getValue()) + "\";", - export + e.getKey().envVersionVarName() + "=\"" + e.getKey().version() + "\";")) + export + e.properties().envPathVarName() + "=\"" + quoted(e.path()) + "\";", + export + e.properties().envVersionVarName() + "=\"" + e.properties().version() + "\";")) .sorted() .collect(joining("\n", "", "\n")); final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) .or(() -> ofNullable(System.getenv(pathName))) .orElse(""); - final var pathVars = resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath) ? + final var pathVars = resolved.stream().map(RcService.MatchedPath::properties).anyMatch(RcService.ToolProperties::addToPath) ? export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\";\n" + - export + pathName + "=\"" + resolved.entrySet().stream() - .filter(r -> r.getKey().addToPath()) - .map(r -> quoted(rc.toBin(r.getValue()))) + export + pathName + "=\"" + resolved.stream() + .filter(r -> r.properties().addToPath()) + .map(r -> quoted(rc.toBin(r.path()))) .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\";\n" : ""; final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? - resolved.entrySet().stream() + resolved.stream() // don't log too much, if it does not change, don't re-log it - .filter(Predicate.not(it -> Objects.equals(it.getValue().toString(), System.getenv(it.getKey().envPathVarName())))) - .map(e -> "echo \"[yem] Resolved " + e.getKey().toolName() + "@" + e.getKey().version() + " to '" + e.getValue() + "'\";") + .filter(Predicate.not(it -> Objects.equals(it.path().toString(), System.getenv(it.properties().envPathVarName())))) + .map(e -> "echo \"[yem] Resolved " + messageHelper.formatToolNameAndVersion( + e.candidate(), e.properties().toolName(), e.properties().version()) + " to '" + e.path() + "'\";") .collect(joining("\n", "", "\n")) : ""; diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Install.java b/env-manager/src/main/java/io/yupiik/dev/command/Install.java index ed5c9e67..7e4b553a 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Install.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Install.java @@ -16,6 +16,7 @@ package io.yupiik.dev.command; import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.shared.MessageHelper; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; @@ -31,20 +32,24 @@ public class Install implements Runnable { private final Logger logger = Logger.getLogger(getClass().getName()); private final Conf conf; private final ProviderRegistry registry; + private final MessageHelper messageHelper; public Install(final Conf conf, - final ProviderRegistry registry) { + final ProviderRegistry registry, + final MessageHelper messageHelper) { this.conf = conf; this.registry = registry; + this.messageHelper = messageHelper; } @Override public void run() { try { registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed()) - .thenCompose(providerAndVersion -> providerAndVersion.getKey() - .install(conf.tool(), providerAndVersion.getValue().identifier(), this::onProgress) - .thenAccept(result -> logger.info(() -> "Installed " + conf.tool() + "@" + providerAndVersion.getValue().version() + " at '" + result + "'"))) + .thenCompose(matched -> matched.provider() + .install(conf.tool(), matched.version().identifier(), this::onProgress) + .thenAccept(result -> logger.info(() -> "Installed " + messageHelper.formatToolNameAndVersion( + matched.candidate(), conf.tool(), matched.version().version()) + " at '" + result + "'"))) .toCompletableFuture() .get(); } catch (final InterruptedException e) { diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java index cd570c71..0e8094bc 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java @@ -16,6 +16,7 @@ package io.yupiik.dev.command; import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.shared.MessageHelper; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; @@ -28,21 +29,24 @@ public class Resolve implements Runnable { private final Logger logger = Logger.getLogger(getClass().getName()); private final Conf conf; private final ProviderRegistry registry; + private final MessageHelper messageHelper; public Resolve(final Conf conf, - final ProviderRegistry registry) { + final ProviderRegistry registry, + final MessageHelper messageHelper) { this.conf = conf; this.registry = registry; + this.messageHelper = messageHelper; } @Override public void run() { try { registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false) - .thenAccept(providerAndVersion -> { - final var resolved = providerAndVersion.getKey().resolve(conf.tool(), providerAndVersion.getValue().identifier()) + .thenAccept(matched -> { + final var resolved = matched.provider().resolve(conf.tool(), matched.version().identifier()) .orElseThrow(() -> new IllegalArgumentException("No matching instance for " + conf.tool() + "@" + conf.version() + ", ensure to install it before resolving it.")); - logger.info(() -> "Resolved " + conf.tool() + "@" + providerAndVersion.getValue().version() + ": '" + resolved + "'"); + logger.info(() -> "Resolved " + messageHelper.formatToolNameAndVersion(matched.candidate(), conf.tool(), matched.version().version()) + ": '" + resolved + "'"); }) .toCompletableFuture() .get(); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index d1bfc89c..93478664 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -129,17 +129,17 @@ public void run() { } } - private void setEnv(final Map resolved, final Map environment) { - resolved.forEach((tool, home) -> { - final var homeStr = home.toString(); - logger.finest(() -> "Setting '" + tool.envPathVarName() + "' to '" + homeStr + "'"); - environment.put(tool.envPathVarName(), homeStr); + private void setEnv(final List resolved, final Map environment) { + resolved.forEach(it -> { + final var homeStr = it.path().toString(); + logger.finest(() -> "Setting '" + it.properties().envPathVarName() + "' to '" + homeStr + "'"); + environment.put(it.properties().envPathVarName(), homeStr); }); - if (resolved.keySet().stream().anyMatch(RcService.ToolProperties::addToPath)) { + if (resolved.stream().map(RcService.MatchedPath::properties).anyMatch(RcService.ToolProperties::addToPath)) { final var pathName = os.isWindows() ? "Path" : "PATH"; - final var path = resolved.entrySet().stream() - .filter(r -> r.getKey().addToPath()) - .map(r -> rc.toBin(r.getValue()).toString()) + final var path = resolved.stream() + .filter(r -> r.properties().addToPath()) + .map(r -> rc.toBin(r.path()).toString()) .collect(joining(pathSeparator, "", pathSeparator)) + ofNullable(System.getenv(pathName)).orElse(""); logger.finest(() -> "Setting 'PATH' to '" + path + "'"); diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java index 2453f2cc..f1f197f5 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java @@ -33,7 +33,6 @@ import java.util.stream.Stream; import static java.util.Locale.ROOT; -import static java.util.Map.entry; import static java.util.Optional.empty; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.logging.Level.FINEST; @@ -68,8 +67,8 @@ public List providers() { return providers; } - public CompletionStage> findByToolVersionAndProvider(final String tool, final String version, final String provider, - final boolean relaxed) { + public CompletionStage findByToolVersionAndProvider(final String tool, final String version, final String provider, + final boolean relaxed) { return tryFindByToolVersionAndProvider(tool, version, provider, relaxed, new Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())) .thenApply(found -> found.orElseThrow(() -> new IllegalArgumentException( "No provider for tool " + tool + "@" + version + "', available tools:\n" + @@ -102,10 +101,10 @@ public CompletionStage> findByToolVersionAndProvide .collect(joining("\n"))))); } - public CompletionStage>> tryFindByToolVersionAndProvider( + public CompletionStage> tryFindByToolVersionAndProvider( final String tool, final String version, final String provider, final boolean relaxed, final Cache cache) { - final var result = new CompletableFuture>>(); + final var result = new CompletableFuture>(); final var promises = providers().stream() .filter(it -> provider == null || // enable "--install-provider zulu" for example @@ -127,7 +126,13 @@ public CompletionStage>> tryFindByToolVers .thenApply(all -> all.stream() .filter(v -> matchVersion(v, version, relaxed)) .findFirst() - .map(v -> entry(it, v))) + .map(v -> new MatchedVersion( + it, + candidates.stream() + .filter(c -> Objects.equals(c.tool(), tool)) + .findFirst() + .orElse(null), + v))) .toCompletableFuture())); } return completedFuture(Optional.empty()); @@ -169,15 +174,16 @@ private CompletionStage> findRemoteVersions(final String tool, fin })); } - private Stream> findMatchingVersion(final String tool, final String version, - final boolean relaxed, final Provider provider, - final Map> versions) { + private Stream findMatchingVersion(final String tool, final String version, + final boolean relaxed, final Provider provider, + final Map> versions) { return versions.entrySet().stream() .filter(e -> Objects.equals(e.getKey().tool(), tool)) - .flatMap(e -> e.getValue().stream().filter(v -> matchVersion(v, version, relaxed))) - .findFirst() - .stream() - .map(v -> entry(provider, v)); + .flatMap(e -> e.getValue().stream() + .filter(v -> matchVersion(v, version, relaxed)) + .findFirst() + .stream() + .map(v -> new MatchedVersion(provider, e.getKey(), v))); } private boolean matchVersion(final Version v, final String version, final boolean relaxed) { @@ -188,4 +194,7 @@ private boolean matchVersion(final Version v, final String version, final boolea public record Cache(Map>> local, Map>> versions) { } + + public record MatchedVersion(Provider provider, Candidate candidate, Version version) { + } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java index e5174cf6..32143ca4 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralBaseProvider.java @@ -50,13 +50,15 @@ public class CentralBaseProvider implements Provider { private final Gav gav; private final Path local; private final boolean enabled; + private final Map meta; public CentralBaseProvider(final YemHttpClient client, final CentralConfiguration conf, // children must use SingletonCentralConfiguration to avoid multiple creations final Archives archives, final Cache cache, final Gav gav, - final boolean enabled) { + final boolean enabled, + final Map meta) { this.client = client; this.archives = archives; this.cache = cache; @@ -64,6 +66,7 @@ public CentralBaseProvider(final YemHttpClient client, this.local = Path.of(conf.local()); this.gav = gav; this.enabled = enabled; + this.meta = meta; } public Gav gav() { @@ -185,7 +188,7 @@ public CompletionStage> listTools() { return completedFuture(List.of(new Candidate( gav.artifactId().startsWith("apache-") ? gav.artifactId().substring("apache-".length()) : gavString, - toName(gav.artifactId()), gavString + " downloaded from central.", base.toASCIIString()))); + toName(gav.artifactId()), gavString + " downloaded from central.", base.toASCIIString(), meta))); } @Override diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java index 3a9ca6c5..353f4d2a 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/central/CentralProviderInit.java @@ -27,6 +27,8 @@ import io.yupiik.fusion.framework.build.api.event.OnEvent; import io.yupiik.fusion.framework.build.api.order.Order; +import java.util.Map; + @ApplicationScoped public class CentralProviderInit { public void onStart(@OnEvent @Order(Integer.MIN_VALUE + 100) final Start start, @@ -40,7 +42,10 @@ public void onStart(@OnEvent @Order(Integer.MIN_VALUE + 100) final Start start, final var beans = container.getBeans(); registry.gavs().forEach(gav -> beans.doRegister(new ProvidedInstanceBean<>(DefaultScoped.class, CentralBaseProvider.class, () -> { final var enabled = "true".equals(conf.get(gav.artifactId() + ".enabled").orElse("true")); - return new CentralBaseProvider(client, configuration, archives, cache, gav, enabled); + return new CentralBaseProvider(client, configuration, archives, cache, gav, enabled, switch (gav.artifactId()) { + case "apache-maven" -> Map.of("emoji", "\uD83E\uDD89"); + default -> Map.of(); + }); }))); } } diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java index a78bc47e..4d0cfc79 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java @@ -86,7 +86,8 @@ public CompletionStage> listTools() { return completedFuture(List.of()); } return completedFuture(List.of(new Candidate( - "minikube", "Minikube", "Local development Kubernetes binary.", "https://minikube.sigs.k8s.io/docs/"))); + "minikube", "Minikube", "Local development Kubernetes binary.", "https://minikube.sigs.k8s.io/docs/", + Map.of("emoji", "☸️")))); } @Override diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java b/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java index 535c9505..ce3d7baf 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/model/Candidate.java @@ -15,5 +15,7 @@ */ package io.yupiik.dev.provider.model; -public record Candidate(String tool, String name, String description, String url) { +import java.util.Map; + +public record Candidate(String tool, String name, String description, String url, Map metadata) { } \ No newline at end of file diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java index da4a2365..aa589280 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/sdkman/SdkManClient.java @@ -141,7 +141,7 @@ public CompletionStage>> listLocal() { return completedFuture(tool.collect(toMap( it -> { final var name = it.getFileName().toString(); - return new Candidate(name, name, "", ""); + return new Candidate(name, name, "", "", toMetadata(name)); }, it -> { if (Files.notExists(it)) { @@ -346,11 +346,23 @@ private List parseList(final String body) { } candidates.add(new Candidate( tool, line1.substring(0, sep1), // version=line1.substring(sep1 + 2, sep2), - description.toString(), link > 0 ? line1.substring(link) : "")); + description.toString(), link > 0 ? line1.substring(link) : "", + toMetadata(tool))); } return candidates; } + private Map toMetadata(final String tool) { + if (tool == null) { + return Map.of(); + } + return switch (tool) { + case "java" -> Map.of("emoji", "☕"); + case "maven" -> Map.of("emoji", "\uD83E\uDD89"); + default -> Map.of(); + }; + } + private List lines(final String body) { final List allLines; try (final var reader = new BufferedReader(new StringReader(body))) { diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java index ec648f4a..7365fc42 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java @@ -195,7 +195,8 @@ public CompletionStage> listTools() { if (!enabled) { return completedFuture(List.of()); } - return completedFuture(List.of(new Candidate("java", "java", "Java JRE or JDK downloaded from Azul CDN.", base.toASCIIString()))); + return completedFuture(List.of(new Candidate( + "java", "java", "Java JRE or JDK downloaded from Azul CDN.", base.toASCIIString(), Map.of("emoji", "☕")))); } @Override diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java new file mode 100644 index 00000000..1b85bd95 --- /dev/null +++ b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java @@ -0,0 +1,54 @@ +package io.yupiik.dev.shared; + +import io.yupiik.dev.provider.model.Candidate; +import io.yupiik.fusion.framework.api.scope.ApplicationScoped; +import io.yupiik.fusion.framework.build.api.configuration.Property; +import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import io.yupiik.fusion.framework.build.api.lifecycle.Init; + +import java.nio.file.Files; +import java.nio.file.Path; + +@ApplicationScoped +public class MessageHelper { + private final MessagesConfiguration configuration; + private boolean supportsEmoji; + + public MessageHelper(final MessagesConfiguration configuration) { + this.configuration = configuration; + } + + @Init + protected void init() { + supportsEmoji = switch (configuration.disableEmoji()) { + case "auto" -> !Boolean.parseBoolean(System.getenv("CI")) && + Files.exists(Path.of("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf")); + default -> !Boolean.parseBoolean(configuration.disableEmoji()); + }; + } + + public boolean supportsEmoji() { + return supportsEmoji; + } + + public String formatToolNameAndVersion(final Candidate candidate, final String tool, final String version) { + final var base = tool + '@' + version; + if (!supportsEmoji) { + return base; + } + + final var metadata = candidate.metadata(); + if (metadata.containsKey("emoji")) { + return metadata.get("emoji") + ' ' + base; + } + + return base; + } + + @RootConfiguration("messages") + public record MessagesConfiguration( + @Property(documentation = "If `false` emoji are totally disabled. " + + "`auto` will test `/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf` presence to enable emojis. " + + "`true`/`false` disable/enable emoji whatever the available fonts.", defaultValue = "\"auto\"") String disableEmoji) { + } +} diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index 000eb0dc..fc4d6606 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -17,6 +17,7 @@ import io.yupiik.dev.provider.Provider; import io.yupiik.dev.provider.ProviderRegistry; +import io.yupiik.dev.provider.model.Candidate; import io.yupiik.dev.provider.model.Version; import io.yupiik.dev.shared.http.YemHttpClient; import io.yupiik.fusion.framework.api.scope.ApplicationScoped; @@ -24,7 +25,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Map; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Properties; @@ -36,7 +37,6 @@ import java.util.stream.Stream; import static java.util.Locale.ROOT; -import static java.util.Map.entry; import static java.util.concurrent.CompletableFuture.allOf; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.stream.Collectors.toMap; @@ -96,16 +96,16 @@ public Path toBin(final Path value) { .orElse(value); } - public CompletionStage> toToolProperties(final Properties props) { + public CompletionStage> toToolProperties(final Properties props) { final var promises = props.stringPropertyNames().stream() .filter(it -> it.endsWith(".version")) .map(versionKey -> toToolProperties(props, versionKey)) .map(tool -> { - final var providerAndVersionPromise = registry.tryFindByToolVersionAndProvider( + final var promise = registry.tryFindByToolVersionAndProvider( tool.toolName(), tool.version(), tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), new ProviderRegistry.Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())); - return providerAndVersionPromise.thenCompose(providerAndVersionOpt -> providerAndVersionOpt + return promise.thenCompose(providerAndVersionOpt -> providerAndVersionOpt .map(providerVersion -> doResolveVersion(tool, providerVersion)) .orElseGet(() -> { if (tool.failOnMissing()) { @@ -120,14 +120,13 @@ public CompletionStage> toToolProperties(final Propert return allOf(promises.toArray(new CompletableFuture[0])) .thenApply(ok -> promises.stream() .flatMap(p -> p.getNow(Optional.empty()).stream()) - .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); + .toList()); } - private CompletableFuture>> doResolveVersion(final ToolProperties tool, - final Map.Entry providerVersion) { - final var provider = providerVersion.getKey(); - final var version = providerVersion.getValue().identifier(); - return provider.resolve(tool.toolName(), providerVersion.getValue().identifier()) + private CompletableFuture> doResolveVersion(final ToolProperties tool, + final ProviderRegistry.MatchedVersion matchedVersion) { + final var version = matchedVersion.version().identifier(); + return matchedVersion.provider().resolve(tool.toolName(), version) .map(path -> completedFuture(Optional.of(path))) .or(() -> { if (!tool.installIfMissing()) { @@ -138,13 +137,14 @@ private CompletableFuture>> doResolveVe } logger.info(() -> "Installing " + tool.toolName() + '@' + version); - return Optional.of(provider.install(tool.toolName(), version, Provider.ProgressListener.NOOP) + return Optional.of(matchedVersion.provider().install(tool.toolName(), version, Provider.ProgressListener.NOOP) .exceptionally(this::onInstallException) .thenApply(Optional::ofNullable) .toCompletableFuture()); }) .orElseGet(() -> completedFuture(Optional.empty())) - .thenApply(path -> path.map(p -> entry(adjustToolVersion(tool, providerVersion), p))); + .thenApply(path -> path.map(p -> new MatchedPath( + p, adjustToolVersion(tool, matchedVersion.version()), matchedVersion.provider(), matchedVersion.candidate()))); } private Path onInstallException(final Throwable e) { @@ -158,12 +158,12 @@ private Path onInstallException(final Throwable e) { throw new IllegalStateException(unwrapped); } - private ToolProperties adjustToolVersion(final ToolProperties tool, final Map.Entry providerVersion) { - return Objects.equals(tool.version(), providerVersion.getValue().version()) ? + private ToolProperties adjustToolVersion(final ToolProperties tool, final Version version) { + return Objects.equals(tool.version(), version.version()) ? tool : new ToolProperties( tool.toolName(), - providerVersion.getValue().version(), + version.version(), tool.provider(), true, tool.envPathVarName(), @@ -225,4 +225,7 @@ public record ToolProperties( boolean failOnMissing, boolean installIfMissing) { } + + public record MatchedPath(Path path, ToolProperties properties, Provider provider, Candidate candidate) { + } } diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java index 2f761874..83eeeae1 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/central/CentralBaseProviderTest.java @@ -31,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutionException; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -115,6 +116,6 @@ private CentralBaseProvider newProvider(final URI uri, final YemHttpClient clien return new CentralBaseProvider( client, new CentralConfiguration(uri.toASCIIString(), local.toString(), ""), new Archives(), new Cache(new HttpConfiguration(false, 10_000, 1, false, 30_000L, 30_000L, 0, "none"), null), - Gav.of("org.foo:bar:tar.gz:simple"), true); + Gav.of("org.foo:bar:tar.gz:simple"), true, Map.of()); } } diff --git a/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java b/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java index c2e6cef4..0512a712 100644 --- a/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/provider/sdkman/SdkManClientTest.java @@ -31,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.stream.Stream; @@ -88,15 +89,18 @@ void listTools(final URI uri, @TempDir final Path work, final YemHttpClient clie new Candidate( "activemq", "Apache ActiveMQ (Classic)", // "5.17.1", "Apache ActiveMQ® is a popular open source, multi-protocol, Java-based message broker. It supports industry standard protocols so users get the benefits of client choices across a broad range of languages and platforms. Connect from clients written in JavaScript, C, C++, Python, .Net, and more. Integrate your multi-platform applications using the ubiquitous AMQP protocol. Exchange messages between your web applications using STOMP over websockets. Manage your IoT devices using MQTT. Support your existing JMS infrastructure and beyond. ActiveMQ offers the power and flexibility to support any messaging use-case.", - "https://activemq.apache.org/"), + "https://activemq.apache.org/", + Map.of()), new Candidate( "java", "Java", // "221-zulu-tem", "Java Platform, Standard Edition (or Java SE) is a widely used platform for development and deployment of portable code for desktop and server environments. Java SE uses the object-oriented Java programming language. It is part of the Java software-platform family. Java SE defines a wide range of general-purpose APIs – such as Java APIs for the Java Class Library – and also includes the Java Language Specification and the Java Virtual Machine Specification.", - "https://projects.eclipse.org/projects/adoptium.temurin/"), + "https://projects.eclipse.org/projects/adoptium.temurin/", + Map.of("emoji", "☕")), new Candidate( "maven", "Maven", // "3.9.6", "Apache Maven is a software project management and comprehension tool. Based on the concept of a project object model (POM), Maven can manage a project's build, reporting and documentation from a central piece of information.", - "https://maven.apache.org/")); + "https://maven.apache.org/", + Map.of("emoji", "\uD83E\uDD89"))); assertEquals(expected, actual); } From 89117a6f32a5f8ceff9e18c7e96071180a91bea7 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 10:29:16 +0100 Subject: [PATCH 16/26] [style] missing header --- .../java/io/yupiik/dev/shared/MessageHelper.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java index 1b85bd95..aed0aa15 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2020 - 2023 - Yupiik SAS - https://www.yupiik.com + * 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 io.yupiik.dev.shared; import io.yupiik.dev.provider.model.Candidate; From 40f0c152c77bc0ba21072f96dd8070183f2f3288 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 11:55:05 +0100 Subject: [PATCH 17/26] [env-manager] basic color support --- .../main/java/io/yupiik/dev/command/Env.java | 4 +++- .../io/yupiik/dev/shared/MessageHelper.java | 24 +++++++++++++------ .../io/yupiik/dev/command/CommandsTest.java | 6 ++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 7c644473..4d341371 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -119,12 +119,14 @@ public void close() throws SecurityException { .map(r -> quoted(rc.toBin(r.path()))) .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\";\n" : ""; + final var home = System.getProperty("user.home", ""); final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? resolved.stream() // don't log too much, if it does not change, don't re-log it .filter(Predicate.not(it -> Objects.equals(it.path().toString(), System.getenv(it.properties().envPathVarName())))) .map(e -> "echo \"[yem] Resolved " + messageHelper.formatToolNameAndVersion( - e.candidate(), e.properties().toolName(), e.properties().version()) + " to '" + e.path() + "'\";") + e.candidate(), e.properties().toolName(), e.properties().version()) + " to '" + + e.path().toString().replace(home, "~") + "'\";") .collect(joining("\n", "", "\n")) : ""; diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java index aed0aa15..b8a29980 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java @@ -26,8 +26,10 @@ @ApplicationScoped public class MessageHelper { + private final String colorPrefix = new String(new char[]{27, '['}); private final MessagesConfiguration configuration; private boolean supportsEmoji; + private boolean enableColors; public MessageHelper(final MessagesConfiguration configuration) { this.configuration = configuration; @@ -40,28 +42,36 @@ protected void init() { Files.exists(Path.of("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf")); default -> !Boolean.parseBoolean(configuration.disableEmoji()); }; - } - - public boolean supportsEmoji() { - return supportsEmoji; + enableColors = switch (configuration.disableColors()) { + case "auto" -> !Boolean.parseBoolean(System.getenv("CI")); + default -> !Boolean.parseBoolean(configuration.disableColors()); + }; } public String formatToolNameAndVersion(final Candidate candidate, final String tool, final String version) { - final var base = tool + '@' + version; + final var base = format(configuration.toolColor(), tool) + " @ " + format(configuration.versionColor(), version); if (!supportsEmoji) { return base; } final var metadata = candidate.metadata(); - if (metadata.containsKey("emoji")) { - return metadata.get("emoji") + ' ' + base; + final var emoji = metadata.get("emoji"); + if (emoji != null) { + return emoji + ' ' + base; } return base; } + private String format(final String color, final String value) { + return (enableColors ? colorPrefix + color + 'm' : "") + value + (enableColors ? colorPrefix + "0m" : ""); + } + @RootConfiguration("messages") public record MessagesConfiguration( + @Property(documentation = "Are colors disabled for the terminal.", defaultValue = "\"auto\"") String disableColors, + @Property(documentation = "When color are enabled the tool name color.", defaultValue = "\"0;49;34\"") String toolColor, + @Property(documentation = "When color are enabled the version color.", defaultValue = "\"0;49;96\"") String versionColor, @Property(documentation = "If `false` emoji are totally disabled. " + "`auto` will test `/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf` presence to enable emojis. " + "`true`/`false` disable/enable emoji whatever the available fonts.", defaultValue = "\"auto\"") String disableEmoji) { diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index d326f8b6..8a6595a2 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -87,7 +87,7 @@ void listLocal(@TempDir final Path work, final URI uri) { void resolve(@TempDir final Path work, final URI uri) { doInstall(work, uri); assertEquals( - "Resolved java@21.0.2: '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'" + "Resolved java @ 21.0.2: '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'" .replace("$work", work.toString()), captureOutput(work, uri, "resolve", "--tool", "java", "--version", "21.0.2")); } @@ -122,7 +122,7 @@ void env(@TempDir final Path work, final URI uri) throws IOException { export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; - echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") .replace("$work", work.toString()), out .replaceAll("#.*", "") @@ -141,7 +141,7 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; - echo "[yem] Resolved java@21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") .replace("$work", work.toString()), out .replaceAll("#.*", "") From ec6c98d92b598d611cfb9b3037f58f5f30f2ab27 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 12:54:30 +0100 Subject: [PATCH 18/26] [env-manager] no progress bar on CI + no quote on windows in env command --- .../src/main/java/io/yupiik/dev/command/Env.java | 11 ++++++----- .../src/main/java/io/yupiik/dev/command/Install.java | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 4d341371..14eddc60 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -59,6 +59,7 @@ public void run() { final var comment = windows ? "%% " : "# "; final var pathName = windows ? "Path" : "PATH"; final var pathVar = windows ? "%" + pathName + "%" : ("$" + pathName); + final var quote = windows ? "" : "\""; if (!conf.skipReset()) { resetOriginalPath(export, pathName); @@ -104,8 +105,8 @@ public void close() throws SecurityException { rc.toToolProperties(tools).thenAccept(resolved -> { final var toolVars = resolved.stream() .flatMap(e -> Stream.of( - export + e.properties().envPathVarName() + "=\"" + quoted(e.path()) + "\";", - export + e.properties().envVersionVarName() + "=\"" + e.properties().version() + "\";")) + export + e.properties().envPathVarName() + "=" + quote + quoted(e.path()) + quote + ";", + export + e.properties().envVersionVarName() + "=" + quote + e.properties().version() + quote + ";")) .sorted() .collect(joining("\n", "", "\n")); @@ -113,11 +114,11 @@ public void close() throws SecurityException { .or(() -> ofNullable(System.getenv(pathName))) .orElse(""); final var pathVars = resolved.stream().map(RcService.MatchedPath::properties).anyMatch(RcService.ToolProperties::addToPath) ? - export + "YEM_ORIGINAL_PATH=\"" + pathBase + "\";\n" + - export + pathName + "=\"" + resolved.stream() + export + "YEM_ORIGINAL_PATH=" + quote + pathBase + quote + ";\n" + + export + pathName + "=" + quote + resolved.stream() .filter(r -> r.properties().addToPath()) .map(r -> quoted(rc.toBin(r.path()))) - .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + "\";\n" : + .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + quote + ";\n" : ""; final var home = System.getProperty("user.home", ""); final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Install.java b/env-manager/src/main/java/io/yupiik/dev/command/Install.java index 7e4b553a..87ac1c83 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Install.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Install.java @@ -25,6 +25,7 @@ import java.util.logging.Logger; import java.util.stream.IntStream; +import static io.yupiik.dev.provider.Provider.ProgressListener.NOOP; import static java.util.stream.Collectors.joining; @Command(name = "install", description = "Install a distribution.") @@ -47,7 +48,7 @@ public void run() { try { registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed()) .thenCompose(matched -> matched.provider() - .install(conf.tool(), matched.version().identifier(), this::onProgress) + .install(conf.tool(), matched.version().identifier(), Boolean.parseBoolean(System.getenv("CI")) ? NOOP : this::onProgress) .thenAccept(result -> logger.info(() -> "Installed " + messageHelper.formatToolNameAndVersion( matched.candidate(), conf.tool(), matched.version().version()) + " at '" + result + "'"))) .toCompletableFuture() From 7a7f7fe8269d811aabbca9a7ac28638845269159 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 19:39:57 +0100 Subject: [PATCH 19/26] [minisite] ensure there is no link issue on windows --- .../tools/injector/versioning/VersioningInjectorTest.java | 2 +- .../src/main/java/io/yupiik/tools/minisite/IndexService.java | 3 ++- .../src/main/java/io/yupiik/tools/minisite/MiniSite.java | 2 +- .../minisite/test/MiniSiteConfigurationBuilderProvider.java | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/html-versioning-injector/src/test/java/io/yupiik/tools/injector/versioning/VersioningInjectorTest.java b/html-versioning-injector/src/test/java/io/yupiik/tools/injector/versioning/VersioningInjectorTest.java index 10c95aa8..ce03f222 100644 --- a/html-versioning-injector/src/test/java/io/yupiik/tools/injector/versioning/VersioningInjectorTest.java +++ b/html-versioning-injector/src/test/java/io/yupiik/tools/injector/versioning/VersioningInjectorTest.java @@ -137,7 +137,7 @@ void inject(@TempDir final Path workDir) throws IOException { " \n" + "
\n" + " ", - extractContent(Files.readString(output.resolve("page.html"))).strip()); + extractContent(Files.readString(output.resolve("page.html"))).strip().replace("\r", "")); } } diff --git a/minisite-core/src/main/java/io/yupiik/tools/minisite/IndexService.java b/minisite-core/src/main/java/io/yupiik/tools/minisite/IndexService.java index 13b8d22e..8fd0a29f 100644 --- a/minisite-core/src/main/java/io/yupiik/tools/minisite/IndexService.java +++ b/minisite-core/src/main/java/io/yupiik/tools/minisite/IndexService.java @@ -28,6 +28,7 @@ import org.jsoup.select.Elements; import java.io.BufferedWriter; +import java.io.File; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -50,7 +51,7 @@ public Index index(final Path base, final String siteBase, final Predicate @Override public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { if (file.getFileName().toString().endsWith(".html") && filter.test(file)) { - doIndex(file, siteBase + '/' + base.relativize(file)).ifPresent(result.getEntries()::add); + doIndex(file, siteBase + '/' + base.relativize(file).toString().replace(File.separatorChar, '/')).ifPresent(result.getEntries()::add); } return super.visitFile(file, attrs); } diff --git a/minisite-core/src/main/java/io/yupiik/tools/minisite/MiniSite.java b/minisite-core/src/main/java/io/yupiik/tools/minisite/MiniSite.java index 471bd8b5..38227300 100644 --- a/minisite-core/src/main/java/io/yupiik/tools/minisite/MiniSite.java +++ b/minisite-core/src/main/java/io/yupiik/tools/minisite/MiniSite.java @@ -647,7 +647,7 @@ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attr .reduce(s -> false, Predicate::or); final IndexService indexer = new IndexService(); indexer.write(indexer.index(output, configuration.getSiteBase(), path -> { - final String location = configuration.getTarget().relativize(path).toString().replace(File.pathSeparatorChar, '/'); + final String location = configuration.getTarget().relativize(path).toString().replace(File.separatorChar, '/'); final String name = path.getFileName().toString(); if ((location.startsWith("blog/") && (name.startsWith("page-") || name.equals("index.html"))) || ignoredPages.test(name)) { return false; diff --git a/minisite-core/src/test/java/io/yupiik/tools/minisite/test/MiniSiteConfigurationBuilderProvider.java b/minisite-core/src/test/java/io/yupiik/tools/minisite/test/MiniSiteConfigurationBuilderProvider.java index 899c9385..c8ff33f3 100644 --- a/minisite-core/src/test/java/io/yupiik/tools/minisite/test/MiniSiteConfigurationBuilderProvider.java +++ b/minisite-core/src/test/java/io/yupiik/tools/minisite/test/MiniSiteConfigurationBuilderProvider.java @@ -182,7 +182,7 @@ public void afterEach(final ExtensionContext context) { Files.walkFileTree(base, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { - collector.generated.put(base.relativize(file).toString().replace(File.pathSeparatorChar, '/'), Files.readString(file)); + collector.generated.put(base.relativize(file).toString().replace(File.separatorChar, '/'), Files.readString(file)); return super.visitFile(file, attrs); } }); From 3fa30f36e114890e6b602373158d9347638a9bbf Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 19:59:27 +0100 Subject: [PATCH 20/26] [tests] make env-manager tests running on windows --- .../src/main/java/io/yupiik/dev/command/Run.java | 11 +++++++---- .../java/io/yupiik/dev/command/CommandsTest.java | 15 ++++++++++++--- .../java/io/yupiik/dev/shared/ArchivesTest.java | 5 +++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index 93478664..9dd16ffb 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -153,7 +153,13 @@ private List parseArgs(final String alias) { char await = 0; boolean escaped = false; for (final char c : alias.toCharArray()) { - if (await == 0 && c == ' ') { + if (escaped) { + if (!(c == '"' || c == '\'')) { + builder.append('\\'); + } + builder.append(c); + escaped = false; + } else if (await == 0 && c == ' ') { if (!builder.isEmpty()) { out.add(builder.toString().strip()); builder.setLength(0); @@ -162,9 +168,6 @@ private List parseArgs(final String alias) { out.add(builder.toString().strip()); builder.setLength(0); await = 0; - } else if (escaped) { - builder.append(c); - escaped = false; } else if (c == '\\') { escaped = true; } else if (c == '"' && builder.isEmpty()) { diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 8a6595a2..87b5b376 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -25,6 +25,7 @@ import io.yupiik.fusion.framework.api.main.Awaiter; import io.yupiik.fusion.framework.api.scope.DefaultScoped; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayOutputStream; @@ -37,13 +38,16 @@ import java.util.stream.Stream; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Locale.ROOT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.condition.OS.WINDOWS; @Mock(uri = "/2/", payload = "zulu21.32.17-ca-jdk21.0.2-linux64.tar.gz") @Mock(uri = "/2/zulu21.32.17-ca-jdk21.0.2-linux64.tar.gz", payload = "this is Java", format = "tar.gz") class CommandsTest { @Test + @DisabledOnOs(WINDOWS) void config(@TempDir final Path work, final URI uri) { assertEquals(""" - apache-maven: enabled=false @@ -84,6 +88,7 @@ void listLocal(@TempDir final Path work, final URI uri) { } @Test + @DisabledOnOs(WINDOWS) void resolve(@TempDir final Path work, final URI uri) { doInstall(work, uri); assertEquals( @@ -112,6 +117,7 @@ void delete(@TempDir final Path work, final URI uri) throws IOException { } @Test + @DisabledOnOs(WINDOWS) void env(@TempDir final Path work, final URI uri) throws IOException { final var rc = Files.writeString(work.resolve("rc"), "java.version = 21.\njava.relaxed = true\naddToPath = true\ninstallIfMissing = true"); final var out = captureOutput(work, uri, "env", "--skipReset", "true", "--env-rc", rc.toString(), "--env-defaultRc", work.resolve("missing").toString()); @@ -131,6 +137,7 @@ void env(@TempDir final Path work, final URI uri) throws IOException { } @Test + @DisabledOnOs(WINDOWS) void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { doInstall(work, uri); @@ -154,10 +161,12 @@ void run(@TempDir final Path work, final URI uri) throws IOException { final var output = work.resolve("output"); final var yem = Files.writeString( work.resolve(".yemrc"), - "demo.alias = " + System.getProperty("java.home") + "/bin/java " + + "demo.alias = " + (System.getProperty("java.home") + "/bin/java" + + (System.getProperty("os.name", "").toLowerCase(ROOT).contains("win") ? ".exe" : "")) + .replace("\\", "\\\\") + ' ' + "-cp target/test-classes " + "io.yupiik.dev.command.CommandsTest$SampleMain " + - "\"" + output + "\"" + + "\"" + output.toString().replace("\\", "\\\\") + "\"" + "hello YEM!"); captureOutput(work, uri, "run", "--rc", yem.toString(), "--defaultRc", "skip", "--", "demo"); assertEquals(">> [hello, YEM!]", Files.readString(output)); @@ -208,7 +217,7 @@ private LocalSource(final Path work, final String mockHttp) { public String get(final String key) { return switch (key) { case "http.cache" -> "none"; - case "apache-maven.enabled", "sdkman.enabled", "minikube.enabled", "zulu.preferApi" -> "false"; + case "apache-maven.enabled", "sdkman.enabled", "minikube.enabled", "zulu.preferApi" -> "false"; case "github.base" -> baseHttp + "/github/"; case "github.local" -> work.resolve("/github").toString(); case "central.base" -> baseHttp + "/m2/"; diff --git a/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java b/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java index 1aafac31..be3987d3 100644 --- a/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/shared/ArchivesTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; @@ -98,14 +99,14 @@ private void assertFiles(final Map files, final Path exploded) t @Override public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException { if (!Objects.equals(dir, exploded)) { - actual.put(exploded.relativize(dir).toString() + '/', ""); + actual.put(exploded.relativize(dir).toString().replace(File.separatorChar, '/') + '/', ""); } return super.preVisitDirectory(dir, attrs); } @Override public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException { - actual.put(exploded.relativize(file).toString(), Files.readString(file)); + actual.put(exploded.relativize(file).toString().replace(File.separatorChar, '/'), Files.readString(file)); return super.visitFile(file, attrs); } }); From a38393913b5f689905a20c0c6f128bc3774057fd Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 21:39:35 +0100 Subject: [PATCH 21/26] [env-manager] some adjustments for windows + fix zulu OS filtering --- env-manager/pom.xml | 62 +++++++++++++++++++ .../main/java/io/yupiik/dev/command/Env.java | 10 +-- .../main/java/io/yupiik/dev/command/List.java | 4 +- .../main/java/io/yupiik/dev/command/Run.java | 3 +- .../provider/github/MinikubeGithubClient.java | 2 +- .../dev/provider/zulu/ZuluCdnClient.java | 6 +- .../java/io/yupiik/dev/shared/Archives.java | 18 +++--- .../io/yupiik/dev/command/CommandsTest.java | 2 +- 8 files changed, 90 insertions(+), 17 deletions(-) diff --git a/env-manager/pom.xml b/env-manager/pom.xml index 9cabac71..b922fb1a 100644 --- a/env-manager/pom.xml +++ b/env-manager/pom.xml @@ -152,6 +152,68 @@ -H:+StaticExecutableWithDynamicLibC -Djava.util.logging.manager=io.yupiik.logging.jul.YupiikLogManager + + + org.apache.commons.compress.archivers.zip.AsiExtraField + true + + + org.apache.commons.compress.archivers.zip.X5455_ExtendedTimestamp + true + + + org.apache.commons.compress.archivers.zip.X7875_NewUnix + true + + + org.apache.commons.compress.archivers.zip.JarMarker + true + + + org.apache.commons.compress.archivers.zip.UnicodePathExtraField + true + + + org.apache.commons.compress.archivers.zip.UnicodeCommentExtraField + true + + + org.apache.commons.compress.archivers.zip.Zip64ExtendedInformationExtraField + true + + + org.apache.commons.compress.archivers.zip.X000A_NTFS + true + + + org.apache.commons.compress.archivers.zip.X0014_X509Certificates + true + + + org.apache.commons.compress.archivers.zip.X0015_CertificateIdForFile + true + + + org.apache.commons.compress.archivers.zip.X0016_CertificateIdForCentralDirectory + true + + + org.apache.commons.compress.archivers.zip.X0017_StrongEncryptionHeader + true + + + org.apache.commons.compress.archivers.zip.X0019_EncryptionRecipientCertificateList + true + + + org.apache.commons.compress.archivers.zip.ResourceAlignmentExtraField + true + + + diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 14eddc60..53d79572 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -54,7 +54,7 @@ public Env(final Conf conf, final Os os, final RcService rc, final MessageHelper @Override public void run() { - final var windows = os.isWindows(); + final var windows = os.isWindows() && System.getenv("TERM") == null /* if not null behave as bash */; final var export = windows ? "set " : "export "; final var comment = windows ? "%% " : "# "; final var pathName = windows ? "Path" : "PATH"; @@ -62,7 +62,7 @@ public void run() { final var quote = windows ? "" : "\""; if (!conf.skipReset()) { - resetOriginalPath(export, pathName); + resetOriginalPath(export, pathName, windows); } final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); @@ -134,7 +134,7 @@ public void close() throws SecurityException { final var script = messages.stream().map(m -> "echo \"[yem] " + m.replace("\"", "\"\\\"\"") + "\";").collect(joining("\n", "", "\n\n")) + pathVars + toolVars + echos + "\n" + comment + "To load a .yemrc configuration run:\n" + - comment + "[ -f .yemrc ] && eval $(yem env--env-file .yemrc)\n" + + comment + "[ -f .yemrc ] && eval $(yem env --env-file .yemrc)\n" + comment + "\n" + comment + "See https://www.yupiik.io/tools-maven-plugin/yem.html#autopath for details\n" + "\n"; @@ -161,12 +161,12 @@ private String quoted(final Path path) { .replace("\"", "\\\""); } - private void resetOriginalPath(final String export, final String pathName) { + private void resetOriginalPath(final String export, final String pathName, final boolean windows) { // just check we have YEM_ORIGINAL_PATH and reset PATH if needed ofNullable(System.getenv("YEM_ORIGINAL_PATH")) .filter(it -> !"skip".equals(it)) .ifPresent(value -> { - if (os.isWindows()) { + if (windows) { System.out.println("set YEM_ORIGINAL_PATH=;"); } else { System.out.println("unset YEM_ORIGINAL_PATH;"); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/List.java b/env-manager/src/main/java/io/yupiik/dev/command/List.java index 31c00766..af80dc6c 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/List.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/List.java @@ -20,6 +20,7 @@ import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Predicate; @@ -72,7 +73,8 @@ public void run() { .filter(Predicate.not(m -> m.getValue().isEmpty())) .map(e -> "- " + e.getKey() + ":" + e.getValue().stream() .sorted((a, b) -> -a.compareTo(b)) - .map(v -> "-- " + v.version()) + .map(v -> "-- " + v.version() + (!Objects.equals(v.version(), v.identifier()) ? " (" + v.identifier() + ")" : "")) + .distinct() .collect(joining("\n", "\n", "\n"))) .toList()); }) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index 9dd16ffb..92aeda19 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -91,7 +91,8 @@ public void run() { final var exts = os.isWindows() ? Stream.concat( Stream.of(""), - Stream.ofNullable(System.getenv("PathExt")) + Stream.ofNullable(ofNullable(System.getenv("PathExt")) + .orElseGet(() -> System.getenv("PATHEXT"))) .map(i -> Stream.of(i.split(";")) .map(String::strip) .filter(Predicate.not(String::isBlank)))) diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java index 4d0cfc79..4de74391 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/github/MinikubeGithubClient.java @@ -180,7 +180,7 @@ public CompletionStage install(final String tool, final String version, fi GROUP_READ, GROUP_EXECUTE, OTHERS_READ, OTHERS_EXECUTE) .collect(toSet())); - } catch (final IOException e) { + } catch (final UnsupportedOperationException | IOException e) { // no-op } }); diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java index 7365fc42..00f37560 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/zulu/ZuluCdnClient.java @@ -213,7 +213,11 @@ public CompletionStage> listVersions(final String tool) { if (preferApi) { final var baseUrl = apiBase.resolve("/metadata/v1/zulu/packages/") + "?" + - "os=linux&" + + "os=" + (suffix.startsWith("win") ? + "windows" : + (suffix.startsWith("mac") ? + "macosx" : + (Files.exists(Path.of("/lib/ld-musl-x86_64.so.1")) ? "linux-musl" : "linux-glibc"))) + "&" + "arch=" + (suffix.contains("_aarch64") ? "aarch64" : "x64") + "&" + "archive_type=" + (suffix.endsWith(".tar.gz") ? "tar.gz" : "zip") + "&" + "java_package_type=" + (preferJre ? "jre" : "jdk") + "&" + diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java b/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java index d31455e1..36cdee34 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/Archives.java @@ -196,13 +196,17 @@ private void setExecutableIfNeeded(final Path target) throws IOException { (parentFilename.equals("lib") && ( filename.contains("exec") || filename.startsWith("j") || (filename.startsWith("lib") && filename.contains(".so"))))) { - Files.setPosixFilePermissions( - target, - Stream.of( - OWNER_READ, OWNER_EXECUTE, OWNER_WRITE, - GROUP_READ, GROUP_EXECUTE, - OTHERS_READ, OTHERS_EXECUTE) - .collect(toSet())); + try { + Files.setPosixFilePermissions( + target, + Stream.of( + OWNER_READ, OWNER_EXECUTE, OWNER_WRITE, + GROUP_READ, GROUP_EXECUTE, + OTHERS_READ, OTHERS_EXECUTE) + .collect(toSet())); + } catch (final UnsupportedOperationException ue) { + // no-op, likely windows + } } } } diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 87b5b376..8efd41f5 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -75,7 +75,7 @@ void simplifiedOptions(@TempDir final Path work, final URI uri) throws IOExcepti void list(@TempDir final Path work, final URI uri) { assertEquals(""" - [zulu] java: - -- 21.0.2""", captureOutput(work, uri, "list")); + -- 21.0.2 (21.32.17-ca-jdk21.0.2)""", captureOutput(work, uri, "list")); } @Test From 538d6e8a2319f6fefb5ceda19f61712972eedc47 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Wed, 7 Feb 2024 22:24:37 +0100 Subject: [PATCH 22/26] [env-manager] ensure run works on windows --- env-manager/src/main/java/io/yupiik/dev/command/Env.java | 6 ++++-- env-manager/src/main/java/io/yupiik/dev/command/Run.java | 2 +- env-manager/src/main/java/io/yupiik/dev/shared/Os.java | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 53d79572..36e39b47 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -54,7 +54,8 @@ public Env(final Conf conf, final Os os, final RcService rc, final MessageHelper @Override public void run() { - final var windows = os.isWindows() && System.getenv("TERM") == null /* if not null behave as bash */; + final var hasTerm = os.isUnixLikeTerm(); + final var windows = os.isWindows() && hasTerm /* if not null behave as bash */; final var export = windows ? "set " : "export "; final var comment = windows ? "%% " : "# "; final var pathName = windows ? "Path" : "PATH"; @@ -113,12 +114,13 @@ public void close() throws SecurityException { final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) .or(() -> ofNullable(System.getenv(pathName))) .orElse(""); + final var pathsSeparator = hasTerm ? ":" : pathSeparator; final var pathVars = resolved.stream().map(RcService.MatchedPath::properties).anyMatch(RcService.ToolProperties::addToPath) ? export + "YEM_ORIGINAL_PATH=" + quote + pathBase + quote + ";\n" + export + pathName + "=" + quote + resolved.stream() .filter(r -> r.properties().addToPath()) .map(r -> quoted(rc.toBin(r.path()))) - .collect(joining(pathSeparator, "", pathSeparator)) + pathVar + quote + ";\n" : + .collect(joining(pathsSeparator, "", pathsSeparator)) + pathVar + quote + ";\n" : ""; final var home = System.getProperty("user.home", ""); final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index 92aeda19..e0b39a5e 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -93,7 +93,7 @@ public void run() { Stream.of(""), Stream.ofNullable(ofNullable(System.getenv("PathExt")) .orElseGet(() -> System.getenv("PATHEXT"))) - .map(i -> Stream.of(i.split(";")) + .flatMap(i -> Stream.of(i.split(";")) .map(String::strip) .filter(Predicate.not(String::isBlank)))) .toList() : diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/Os.java b/env-manager/src/main/java/io/yupiik/dev/shared/Os.java index 2d2f4f53..b1aad0d7 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/Os.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/Os.java @@ -58,4 +58,8 @@ public boolean isAarch64() { public boolean isWindows() { return "windows".equals(findOs()); } + + public boolean isUnixLikeTerm() { + return System.getenv("TERM") != null; + } } From 3541027ab8bba87a131635e40391aa4eb076700d Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Fri, 9 Feb 2024 14:33:13 +0100 Subject: [PATCH 23/26] [env-manager] enable to use env with an inlinerc file content --- .../java/io/yupiik/dev/command/Delete.java | 2 +- .../main/java/io/yupiik/dev/command/Env.java | 114 ++++++++++++------ .../java/io/yupiik/dev/command/Install.java | 2 +- .../java/io/yupiik/dev/command/Resolve.java | 2 +- .../main/java/io/yupiik/dev/command/Run.java | 5 +- .../yupiik/dev/provider/ProviderRegistry.java | 32 ++--- .../java/io/yupiik/dev/shared/RcService.java | 95 +++++++++++---- 7 files changed, 169 insertions(+), 83 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java index 51b08f07..82abd2c8 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Delete.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Delete.java @@ -42,7 +42,7 @@ public Delete(final Conf conf, @Override public void run() { try { - registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false) + registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false, false) .thenAccept(matched -> { matched.provider().delete(conf.tool(), matched.version().identifier()); logger.info(() -> "Deleted " + messageHelper.formatToolNameAndVersion(matched.candidate(), conf.tool(), matched.version().version())); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 36e39b47..bb098d7e 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -22,9 +22,13 @@ import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; +import java.io.IOException; +import java.io.StringReader; import java.nio.file.Path; import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.Properties; import java.util.concurrent.ExecutionException; import java.util.function.Predicate; import java.util.logging.Handler; @@ -67,7 +71,15 @@ public void run() { } final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); - if (tools == null || tools.isEmpty()) { // nothing to do + final var inlineProps = new Properties(); + if (conf.inlineRc() != null && !conf.inlineRc().isBlank()) { + try (final var reader = new StringReader(conf.inlineRc().replace("\\n", "\n"))) { + inlineProps.load(reader); + } catch (final IOException e) { + throw new IllegalStateException(e); + } + } + if (tools == null || (inlineProps.isEmpty() && tools.global().isEmpty() && tools.local().isEmpty())) { // nothing to do return; } @@ -103,45 +115,8 @@ public void close() throws SecurityException { logger.addHandler(tempHandler); try { - rc.toToolProperties(tools).thenAccept(resolved -> { - final var toolVars = resolved.stream() - .flatMap(e -> Stream.of( - export + e.properties().envPathVarName() + "=" + quote + quoted(e.path()) + quote + ";", - export + e.properties().envVersionVarName() + "=" + quote + e.properties().version() + quote + ";")) - .sorted() - .collect(joining("\n", "", "\n")); - - final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) - .or(() -> ofNullable(System.getenv(pathName))) - .orElse(""); - final var pathsSeparator = hasTerm ? ":" : pathSeparator; - final var pathVars = resolved.stream().map(RcService.MatchedPath::properties).anyMatch(RcService.ToolProperties::addToPath) ? - export + "YEM_ORIGINAL_PATH=" + quote + pathBase + quote + ";\n" + - export + pathName + "=" + quote + resolved.stream() - .filter(r -> r.properties().addToPath()) - .map(r -> quoted(rc.toBin(r.path()))) - .collect(joining(pathsSeparator, "", pathsSeparator)) + pathVar + quote + ";\n" : - ""; - final var home = System.getProperty("user.home", ""); - final var echos = Boolean.parseBoolean(tools.getProperty("echo", "true")) ? - resolved.stream() - // don't log too much, if it does not change, don't re-log it - .filter(Predicate.not(it -> Objects.equals(it.path().toString(), System.getenv(it.properties().envPathVarName())))) - .map(e -> "echo \"[yem] Resolved " + messageHelper.formatToolNameAndVersion( - e.candidate(), e.properties().toolName(), e.properties().version()) + " to '" + - e.path().toString().replace(home, "~") + "'\";") - .collect(joining("\n", "", "\n")) : - ""; - - final var script = messages.stream().map(m -> "echo \"[yem] " + m.replace("\"", "\"\\\"\"") + "\";").collect(joining("\n", "", "\n\n")) + - pathVars + toolVars + echos + "\n" + - comment + "To load a .yemrc configuration run:\n" + - comment + "[ -f .yemrc ] && eval $(yem env --env-file .yemrc)\n" + - comment + "\n" + - comment + "See https://www.yupiik.io/tools-maven-plugin/yem.html#autopath for details\n" + - "\n"; - System.out.println(script); - }) + rc.match(inlineProps, tools.local(), tools.global()) + .thenAccept(resolved -> createScript(resolved, export, quote, pathName, hasTerm, pathVar, tools, messages, comment)) .toCompletableFuture() .get(); } catch (final InterruptedException e) { @@ -155,6 +130,64 @@ public void close() throws SecurityException { } } + private void createScript(final List rawResolved, + final String export, + final String quote, final String pathName, final boolean hasTerm, final String pathVar, + final RcService.Props tools, final List messages, final String comment) { + final var resolved = rawResolved.stream() + // ignore manually overriden vars + .filter(it -> { + final var overridenEnvVar = rc.toOverridenEnvVar(it.properties()); + if (System.getenv(overridenEnvVar) != null) { + logger.finest(() -> "Ignoring '" + it.properties().envPathVarName() + "' because '" + overridenEnvVar + "' is defined"); + return false; + } + return true; + }) + .toList(); + final var toolVars = resolved.stream() + .flatMap(e -> Stream.concat( + Stream.of( + export + e.properties().envPathVarName() + "=" + quote + quoted(e.path()) + quote + ";", + export + e.properties().envVersionVarName() + "=" + quote + e.properties().version() + quote + ";"), + e.properties().index() == 0 /* custom override */ ? + Stream.of(export + rc.toOverridenEnvVar(e.properties()) + "=" + quote + e.properties().version() + quote + ";") : + Stream.empty())) + .sorted() + .collect(joining("\n", "", "\n")); + + final var pathBase = ofNullable(System.getenv("YEM_ORIGINAL_PATH")) + .or(() -> ofNullable(System.getenv(pathName))) + .orElse(""); + final var pathsSeparator = hasTerm ? ":" : pathSeparator; + final var pathVars = resolved.stream().map(RcService.MatchedPath::properties).anyMatch(RcService.ToolProperties::addToPath) ? + export + "YEM_ORIGINAL_PATH=" + quote + pathBase + quote + ";\n" + + export + pathName + "=" + quote + resolved.stream() + .filter(r -> r.properties().addToPath()) + .map(r -> quoted(rc.toBin(r.path()))) + .collect(joining(pathsSeparator, "", pathsSeparator)) + pathVar + quote + ";\n" : + ""; + final var home = System.getProperty("user.home", ""); + final var echos = Boolean.parseBoolean(tools.global().getProperty("echo", tools.local().getProperty("echo", "true"))) ? + resolved.stream() + // don't log too much, if it does not change, don't re-log it + .filter(Predicate.not(it -> Objects.equals(it.path().toString(), System.getenv(it.properties().envPathVarName())))) + .map(e -> "echo \"[yem] Resolved " + messageHelper.formatToolNameAndVersion( + e.candidate(), e.properties().toolName(), e.properties().version()) + " to '" + + e.path().toString().replace(home, "~") + "'\";") + .collect(joining("\n", "", "\n")) : + ""; + + final var script = messages.stream().map(m -> "echo \"[yem] " + m.replace("\"", "\"\\\"\"") + "\";").collect(joining("\n", "", "\n\n")) + + pathVars + toolVars + echos + "\n" + + comment + "To load a .yemrc configuration run:\n" + + comment + "[ -f .yemrc ] && eval $(yem env --env-file .yemrc)\n" + + comment + "\n" + + comment + "See https://www.yupiik.io/tools-maven-plugin/yem.html#autopath for details\n" + + "\n"; + System.out.println(script); + } + private String quoted(final Path path) { return path .toAbsolutePath() @@ -181,6 +214,7 @@ private void resetOriginalPath(final String export, final String pathName, final public record Conf( @Property(documentation = "By default if `YEM_ORIGINAL_PATH` exists in the environment variables it is used as `PATH` base to not keep appending path to the `PATH` indefinively. This can be disabled setting this property to `false`", defaultValue = "false") boolean skipReset, @Property(documentation = "Should `~/.yupiik/yem/rc` be ignored or not. If present it defines default versions and uses the same syntax than `yemrc`.", defaultValue = "System.getProperty(\"user.home\") + \"/.yupiik/yem/rc\"") String defaultRc, + @Property(documentation = "Enables to set inline a rc file, ex: `eval $(yem env --inlineRc 'java.version=17.0.9')`, you can use EOL too: `eval $(yem env --inlineRc 'java.version=17.\\njava.relaxed = true')`. Note that to persist the change even if you automatically switch from the global `yemrc` file the context, we set `YEM_$TOOLPATHVARNAME_OVERRIDEN` environment variable. To reset the value to the global configuration just `unset` this variable (ex: `unset YEM_JAVA_PATH_OVERRIDEN`)") String inlineRc, @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\"auto\"") String rc) { } } diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Install.java b/env-manager/src/main/java/io/yupiik/dev/command/Install.java index 87ac1c83..c6a9dafc 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Install.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Install.java @@ -46,7 +46,7 @@ public Install(final Conf conf, @Override public void run() { try { - registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed()) + registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), conf.relaxed(), true) .thenCompose(matched -> matched.provider() .install(conf.tool(), matched.version().identifier(), Boolean.parseBoolean(System.getenv("CI")) ? NOOP : this::onProgress) .thenAccept(result -> logger.info(() -> "Installed " + messageHelper.formatToolNameAndVersion( diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java index 0e8094bc..0ea02414 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Resolve.java @@ -42,7 +42,7 @@ public Resolve(final Conf conf, @Override public void run() { try { - registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false) + registry.findByToolVersionAndProvider(conf.tool(), conf.version(), conf.provider(), false, false) .thenAccept(matched -> { final var resolved = matched.provider().resolve(conf.tool(), matched.version().identifier()) .orElseThrow(() -> new IllegalArgumentException("No matching instance for " + conf.tool() + "@" + conf.version() + ", ensure to install it before resolving it.")); diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Run.java b/env-manager/src/main/java/io/yupiik/dev/command/Run.java index e0b39a5e..70ffc185 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Run.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Run.java @@ -60,7 +60,7 @@ public Run(final Conf conf, final RcService rc, final Os os, final Args args) { public void run() { final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); try { - rc.toToolProperties(tools).thenAccept(resolved -> { + rc.match(tools.local(), tools.global()).thenAccept(resolved -> { final int idx = args.args().indexOf("--"); final var command = new ArrayList(8); if (idx > 0) { @@ -70,7 +70,8 @@ public void run() { } if (!command.isEmpty()) { // handle aliasing - final var alias = tools.getProperty(command.get(0) + ".alias"); + final var aliasKey = command.get(0) + ".alias"; + final var alias = tools.local().getProperty(aliasKey, tools.global().getProperty(aliasKey)); if (alias != null) { command.remove(0); command.addAll(0, parseArgs(alias)); diff --git a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java index f1f197f5..5fbfafa3 100644 --- a/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java +++ b/env-manager/src/main/java/io/yupiik/dev/provider/ProviderRegistry.java @@ -68,8 +68,8 @@ public List providers() { } public CompletionStage findByToolVersionAndProvider(final String tool, final String version, final String provider, - final boolean relaxed) { - return tryFindByToolVersionAndProvider(tool, version, provider, relaxed, new Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())) + final boolean relaxed, final boolean canBeRemote) { + return tryFindByToolVersionAndProvider(tool, version, provider, relaxed, canBeRemote, new Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())) .thenApply(found -> found.orElseThrow(() -> new IllegalArgumentException( "No provider for tool " + tool + "@" + version + "', available tools:\n" + providers().stream() @@ -103,7 +103,7 @@ public CompletionStage findByToolVersionAndProvider(final String public CompletionStage> tryFindByToolVersionAndProvider( final String tool, final String version, final String provider, final boolean relaxed, - final Cache cache) { + final boolean testRemote, final Cache cache) { final var result = new CompletableFuture>(); final var promises = providers().stream() .filter(it -> provider == null || @@ -122,18 +122,20 @@ public CompletionStage> tryFindByToolVersionAndProvider .findFirst() .map(Optional::of) .map(CompletableFuture::completedFuture) - .orElseGet(() -> findRemoteVersions(tool, cache, it) - .thenApply(all -> all.stream() - .filter(v -> matchVersion(v, version, relaxed)) - .findFirst() - .map(v -> new MatchedVersion( - it, - candidates.stream() - .filter(c -> Objects.equals(c.tool(), tool)) - .findFirst() - .orElse(null), - v))) - .toCompletableFuture())); + .orElseGet(() -> testRemote ? + findRemoteVersions(tool, cache, it) + .thenApply(all -> all.stream() + .filter(v -> matchVersion(v, version, relaxed)) + .findFirst() + .map(v -> new MatchedVersion( + it, + candidates.stream() + .filter(c -> Objects.equals(c.tool(), tool)) + .findFirst() + .orElse(null), + v))) + .toCompletableFuture() : + completedFuture(empty()))); } return completedFuture(Optional.empty()); })) diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index fc4d6606..c0c103b9 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -50,11 +51,14 @@ public RcService(final ProviderRegistry registry) { this.registry = registry; } - public Properties loadPropertiesFrom(final String rcPath, final String defaultRcPath) { + public Props loadPropertiesFrom(final String rcPath, final String defaultRcPath) { final var defaultRc = Path.of(defaultRcPath); final var defaultProps = new Properties(); if (Files.exists(defaultRc)) { readRc(defaultRc, defaultProps); + if (defaultRcPath.endsWith(".sdkmanrc")) { + rewritePropertiesFromSdkManRc(defaultProps); + } } final var isAuto = "auto".equals(rcPath); @@ -75,7 +79,6 @@ public Properties loadPropertiesFrom(final String rcPath, final String defaultRc } final var props = new Properties(); - props.putAll(defaultProps); if (Files.exists(rcLocation)) { readRc(rcLocation, props); if (".sdkmanrc".equals(rcLocation.getFileName().toString())) { @@ -85,7 +88,7 @@ public Properties loadPropertiesFrom(final String rcPath, final String defaultRc return null; // no config at all } - return props; + return new Props(defaultProps, props); } public Path toBin(final Path value) { @@ -96,15 +99,64 @@ public Path toBin(final Path value) { .orElse(value); } - public CompletionStage> toToolProperties(final Properties props) { - final var promises = props.stringPropertyNames().stream() - .filter(it -> it.endsWith(".version")) - .map(versionKey -> toToolProperties(props, versionKey)) + /** + * @param props input properties sorted in overriding order (first overriding second, second overriding the third etc) + * @return matched paths for the aggregated set of incoming properties - env path var name being the identifier. + */ + public CompletionStage> match(final Properties... props) { + if (props.length == 0 || Stream.of(props).allMatch(Properties::isEmpty)) { + return completedFuture(List.of()); + } + + final var toolProps = new ArrayList(); + int index = 0; + for (final var it : props) { + if (it.isEmpty()) { + index++; + continue; + } + toolProps.addAll(toToolProperties(it, index++).stream() + .filter(p -> toolProps.stream().noneMatch(existing -> Objects.equals(existing.envPathVarName(), p.envPathVarName()))) + .toList()); + } + + if (toolProps.isEmpty()) { + return completedFuture(List.of()); + } + return doToolProperties(toolProps); + } + + public String toOverridenEnvVar(final ToolProperties toolProperties) { + return "YEM_" + toolProperties.envPathVarName() + "_OVERRIDEN"; + } + + private ToolProperties toSingleToolProperties(final Properties props, final String versionKey, final int index) { + final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); + final var baseEnvVar = name.toUpperCase(ROOT).replace('.', '_'); + return new ToolProperties( + index, + props.getProperty(name + ".toolName", name), + props.getProperty(versionKey), + props.getProperty(name + ".provider"), + Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), + props.getProperty(name + ".envVarName", baseEnvVar + "_HOME"), + props.getProperty(name + ".envVarVersionName", baseEnvVar + "_VERSION"), + Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), + Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), + Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); + } + + private CompletableFuture> doToolProperties(final List props) { + if (props.isEmpty()) { + return completedFuture(List.of()); + } + + final var promises = props.stream() .map(tool -> { final var promise = registry.tryFindByToolVersionAndProvider( tool.toolName(), tool.version(), tool.provider() == null || tool.provider().isBlank() ? null : tool.provider(), tool.relaxed(), - new ProviderRegistry.Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())); + tool.installIfMissing(), new ProviderRegistry.Cache(new ConcurrentHashMap<>(), new ConcurrentHashMap<>())); return promise.thenCompose(providerAndVersionOpt -> providerAndVersionOpt .map(providerVersion -> doResolveVersion(tool, providerVersion)) .orElseGet(() -> { @@ -123,6 +175,13 @@ public CompletionStage> toToolProperties(final Properties prop .toList()); } + public List toToolProperties(final Properties props, final int index) { + return props.stringPropertyNames().stream() + .filter(it -> it.endsWith(".version")) + .map(versionKey -> toSingleToolProperties(props, versionKey, index)) + .toList(); + } + private CompletableFuture> doResolveVersion(final ToolProperties tool, final ProviderRegistry.MatchedVersion matchedVersion) { final var version = matchedVersion.version().identifier(); @@ -162,6 +221,7 @@ private ToolProperties adjustToolVersion(final ToolProperties tool, final Versio return Objects.equals(tool.version(), version.version()) ? tool : new ToolProperties( + tool.index(), tool.toolName(), version.version(), tool.provider(), @@ -173,21 +233,6 @@ private ToolProperties adjustToolVersion(final ToolProperties tool, final Versio tool.installIfMissing()); } - private ToolProperties toToolProperties(final Properties props, final String versionKey) { - final var name = versionKey.substring(0, versionKey.lastIndexOf('.')); - final var baseEnvVar = name.toUpperCase(ROOT).replace('.', '_'); - return new ToolProperties( - props.getProperty(name + ".toolName", name), - props.getProperty(versionKey), - props.getProperty(name + ".provider"), - Boolean.parseBoolean(props.getProperty(name + ".relaxed", props.getProperty("relaxed"))), - props.getProperty(name + ".envVarName", baseEnvVar + "_HOME"), - props.getProperty(name + ".envVarVersionName", baseEnvVar + "_VERSION"), - Boolean.parseBoolean(props.getProperty(name + ".addToPath", props.getProperty("addToPath", "true"))), - Boolean.parseBoolean(props.getProperty(name + ".failOnMissing", props.getProperty("failOnMissing"))), - Boolean.parseBoolean(props.getProperty(name + ".installIfMissing", props.getProperty("installIfMissing")))); - } - private void readRc(final Path rcLocation, final Properties props) { try (final var reader = Files.newBufferedReader(rcLocation)) { props.load(reader); @@ -215,6 +260,7 @@ private Path auto(final Path from) { } public record ToolProperties( + int index, String toolName, String version, String provider, @@ -228,4 +274,7 @@ public record ToolProperties( public record MatchedPath(Path path, ToolProperties properties, Provider provider, Candidate candidate) { } + + public record Props(Properties global, Properties local) { + } } From 5e1ce466ae108597bbfdd86b285560107c9f073b Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Sat, 10 Feb 2024 18:52:13 +0100 Subject: [PATCH 24/26] [env-manager] support inline args for env command --- .../main/java/io/yupiik/dev/command/Env.java | 34 ++++++++++++++++--- .../EnableSimpleOptionsArgs.java | 5 ++- .../io/yupiik/dev/command/CommandsTest.java | 27 +++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index bb098d7e..8f7f34e3 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -18,6 +18,7 @@ import io.yupiik.dev.shared.MessageHelper; import io.yupiik.dev.shared.Os; import io.yupiik.dev.shared.RcService; +import io.yupiik.fusion.framework.api.main.Args; import io.yupiik.fusion.framework.build.api.cli.Command; import io.yupiik.fusion.framework.build.api.configuration.Property; import io.yupiik.fusion.framework.build.api.configuration.RootConfiguration; @@ -34,12 +35,14 @@ import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; +import java.util.stream.IntStream; import java.util.stream.Stream; import static java.io.File.pathSeparator; import static java.util.Optional.ofNullable; import static java.util.logging.Level.FINE; import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; @Command(name = "env", description = "Creates a script you can eval in a shell to prepare the environment from a file. Often used as `eval $(yem env--env-rc .yemrc)`") public class Env implements Runnable { @@ -48,12 +51,14 @@ public class Env implements Runnable { private final RcService rc; private final Os os; private final MessageHelper messageHelper; + private final Args args; - public Env(final Conf conf, final Os os, final RcService rc, final MessageHelper messageHelper) { + public Env(final Conf conf, final Os os, final RcService rc, final MessageHelper messageHelper, final Args args) { this.conf = conf; this.os = os; this.rc = rc; this.messageHelper = messageHelper; + this.args = args; } @Override @@ -72,6 +77,24 @@ public void run() { final var tools = rc.loadPropertiesFrom(conf.rc(), conf.defaultRc()); final var inlineProps = new Properties(); + // check if we have any --xxxx-version arg and if so we inject all args in props + // there is no real chance of conflict with default props so we can do it + if (args.args().size() > 1 /* "env" */ && + args.args().stream().anyMatch(a -> a.startsWith("--") && !Objects.equals("--version", a) && a.endsWith("-version"))) { + final var params = args.args().subList(1, args.args().size()); + if (!params.isEmpty()) { + inlineProps.putAll(IntStream.range(0, params.size() / 2) + .boxed() + .filter(f -> { + final var key = params.get(2 * f); + return key.startsWith("--env-") && key.lastIndexOf('-') > "--env-".length() /* default options */; + }) + .collect(toMap(d -> { + final var key = params.get(2 * d); + return key.substring("--env-".length()).replace('-', '.'); + }, i -> params.get(2 * i + 1)))); + } + } if (conf.inlineRc() != null && !conf.inlineRc().isBlank()) { try (final var reader = new StringReader(conf.inlineRc().replace("\\n", "\n"))) { inlineProps.load(reader); @@ -79,7 +102,8 @@ public void run() { throw new IllegalStateException(e); } } - if (tools == null || (inlineProps.isEmpty() && tools.global().isEmpty() && tools.local().isEmpty())) { // nothing to do + + if ((tools == null || (tools.global().isEmpty() && tools.local().isEmpty())) && inlineProps.isEmpty()) { // nothing to do return; } @@ -115,7 +139,7 @@ public void close() throws SecurityException { logger.addHandler(tempHandler); try { - rc.match(inlineProps, tools.local(), tools.global()) + (tools == null ? rc.match(inlineProps) : rc.match(inlineProps, tools.local(), tools.global())) .thenAccept(resolved -> createScript(resolved, export, quote, pathName, hasTerm, pathVar, tools, messages, comment)) .toCompletableFuture() .get(); @@ -168,7 +192,7 @@ private void createScript(final List rawResolved, .collect(joining(pathsSeparator, "", pathsSeparator)) + pathVar + quote + ";\n" : ""; final var home = System.getProperty("user.home", ""); - final var echos = Boolean.parseBoolean(tools.global().getProperty("echo", tools.local().getProperty("echo", "true"))) ? + final var echos = tools == null || Boolean.parseBoolean(tools.global().getProperty("echo", tools.local().getProperty("echo", "true"))) ? resolved.stream() // don't log too much, if it does not change, don't re-log it .filter(Predicate.not(it -> Objects.equals(it.path().toString(), System.getenv(it.properties().envPathVarName())))) @@ -214,7 +238,7 @@ private void resetOriginalPath(final String export, final String pathName, final public record Conf( @Property(documentation = "By default if `YEM_ORIGINAL_PATH` exists in the environment variables it is used as `PATH` base to not keep appending path to the `PATH` indefinively. This can be disabled setting this property to `false`", defaultValue = "false") boolean skipReset, @Property(documentation = "Should `~/.yupiik/yem/rc` be ignored or not. If present it defines default versions and uses the same syntax than `yemrc`.", defaultValue = "System.getProperty(\"user.home\") + \"/.yupiik/yem/rc\"") String defaultRc, - @Property(documentation = "Enables to set inline a rc file, ex: `eval $(yem env --inlineRc 'java.version=17.0.9')`, you can use EOL too: `eval $(yem env --inlineRc 'java.version=17.\\njava.relaxed = true')`. Note that to persist the change even if you automatically switch from the global `yemrc` file the context, we set `YEM_$TOOLPATHVARNAME_OVERRIDEN` environment variable. To reset the value to the global configuration just `unset` this variable (ex: `unset YEM_JAVA_PATH_OVERRIDEN`)") String inlineRc, + @Property(documentation = "Enables to set inline a rc file, ex: `eval $(yem env --inlineRc 'java.version=17.0.9')`, you can use EOL too: `eval $(yem env --inlineRc 'java.version=17.\\njava.relaxed = true')`. Note that to persist the change even if you automatically switch from the global `yemrc` file the context, we set `YEM_$TOOLPATHVARNAME_OVERRIDEN` environment variable. To reset the value to the global configuration just `unset` this variable (ex: `unset YEM_JAVA_PATH_OVERRIDEN`). Note that you can also just set the values inline as args without that option: `eval $(yem env --java-version 17. --java-relaxed true ...)`.") String inlineRc, @Property(documentation = "Env file location to read to generate the script. Note that `auto` will try to pick `.yemrc` and if not there will use `.sdkmanrc` if present.", defaultValue = "\"auto\"") String rc) { } } diff --git a/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java b/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java index 47fb407f..8c375ee7 100644 --- a/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java +++ b/env-manager/src/main/java/io/yupiik/dev/configuration/EnableSimpleOptionsArgs.java @@ -24,6 +24,7 @@ import io.yupiik.fusion.framework.build.api.order.Order; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import static java.util.Optional.ofNullable; @@ -44,10 +45,12 @@ private Args enrich(final List args) { return new Args(args); } final var prefix = "--" + args.get(0) + '-'; + final var counter = new AtomicInteger(); return new Args(args.stream() + .peek(it -> counter.getAndIncrement()) .flatMap(i -> !"--".equals(i) && i.startsWith("--") && !i.startsWith(prefix) ? (i.substring("--".length()).contains("-") ? - Stream.of(prefix + i.substring("--".length()), i) : + Stream.of(prefix + i.substring("--".length()), args.size() > counter.get() ? args.get(counter.get()) : "", i) : Stream.of(prefix + i.substring("--".length()))) : Stream.of(i)) .toList()); diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 8efd41f5..0578363a 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -136,6 +136,33 @@ void env(@TempDir final Path work, final URI uri) throws IOException { .strip()); } + @Test + @DisabledOnOs(WINDOWS) + void envInline(@TempDir final Path work, final URI uri) { + final var out = captureOutput(work, uri, + "env", + "--skipReset", "true", + "--env-rc", work.resolve("missing_rc").toString(), + "--env-defaultRc", work.resolve("missing_defaultRc").toString(), + "--java-version", "21", + "--java-relaxed", "true", + "--java-installIfMissing", "true"); + assertEquals((""" + echo "[yem] Installing java@21.32.17-ca-jdk21.0.2"; + + export YEM_ORIGINAL_PATH="..."; + export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; + export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; + export JAVA_VERSION="21.0.2"; + export YEM_JAVA_HOME_OVERRIDEN="21.0.2"; + echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + .replace("$work", work.toString()), + out + .replaceAll("#.*", "") + .replaceFirst("export YEM_ORIGINAL_PATH=\"[^\"]+\"", "export YEM_ORIGINAL_PATH=\"...\"") + .strip()); + } + @Test @DisabledOnOs(WINDOWS) void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { From 824937d78bb0118c457ca1c428e53b55d11af7e4 Mon Sep 17 00:00:00 2001 From: Romain Manni-Bucau Date: Sun, 11 Feb 2024 11:14:43 +0100 Subject: [PATCH 25/26] [env-manager] better log message formatting for env command --- .../main/java/io/yupiik/dev/command/Env.java | 7 ++-- .../io/yupiik/dev/shared/MessageHelper.java | 37 +++++++++++++++++-- .../java/io/yupiik/dev/shared/RcService.java | 2 +- .../io/yupiik/dev/command/CommandsTest.java | 6 +-- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/env-manager/src/main/java/io/yupiik/dev/command/Env.java b/env-manager/src/main/java/io/yupiik/dev/command/Env.java index 8f7f34e3..5280973f 100644 --- a/env-manager/src/main/java/io/yupiik/dev/command/Env.java +++ b/env-manager/src/main/java/io/yupiik/dev/command/Env.java @@ -41,6 +41,7 @@ import static java.io.File.pathSeparator; import static java.util.Optional.ofNullable; import static java.util.logging.Level.FINE; +import static java.util.logging.Level.INFO; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; @@ -115,7 +116,7 @@ public void run() { public void publish(final LogRecord record) { // capture to forward messages in the shell when init is done (thanks eval call) if (logger.isLoggable(record.getLevel())) { - messages.add(record.getMessage()); + messages.add(messageHelper.formatLog(record.getLevel(), record.getMessage())); } // enable to log at fine level for debug purposes @@ -196,9 +197,9 @@ private void createScript(final List rawResolved, resolved.stream() // don't log too much, if it does not change, don't re-log it .filter(Predicate.not(it -> Objects.equals(it.path().toString(), System.getenv(it.properties().envPathVarName())))) - .map(e -> "echo \"[yem] Resolved " + messageHelper.formatToolNameAndVersion( + .map(e -> "echo \"[yem] " + messageHelper.formatLog(INFO, "Resolved " + messageHelper.formatToolNameAndVersion( e.candidate(), e.properties().toolName(), e.properties().version()) + " to '" + - e.path().toString().replace(home, "~") + "'\";") + e.path().toString().replace(home, "~") + "'") + "\";") .collect(joining("\n", "", "\n")) : ""; diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java index b8a29980..f8439cec 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/MessageHelper.java @@ -23,6 +23,8 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.logging.Level; +import java.util.stream.Stream; @ApplicationScoped public class MessageHelper { @@ -38,8 +40,11 @@ public MessageHelper(final MessagesConfiguration configuration) { @Init protected void init() { supportsEmoji = switch (configuration.disableEmoji()) { - case "auto" -> !Boolean.parseBoolean(System.getenv("CI")) && - Files.exists(Path.of("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf")); + case "auto" -> !Boolean.parseBoolean(System.getenv("CI")) && Stream.of( + "/usr/share/fonts/AppleColorEmoji/", + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", + "/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf") + .anyMatch(p -> Files.exists(Path.of(p))); default -> !Boolean.parseBoolean(configuration.disableEmoji()); }; enableColors = switch (configuration.disableColors()) { @@ -67,13 +72,39 @@ private String format(final String color, final String value) { return (enableColors ? colorPrefix + color + 'm' : "") + value + (enableColors ? colorPrefix + "0m" : ""); } + public String error(final String value) { + return format(configuration.errorColor(), value); + } + + public String warning(final String value) { + return format(configuration.warningColor(), value); + } + + public String formatLog(final Level level, final String message) { + return (supportsEmoji ? + switch (level.intValue()) { + case /*FINE, FINER, FINEST*/ 300, 400, 500 -> "🐞 "; + case /*INFO*/ 800 -> "ℹ "; + case /*WARNING*/ 900 -> "⚠️ "; + case /*SEVERE*/ 1000 -> "🚩 "; + default -> ""; + } : "") + + (enableColors ? switch (level.intValue()) { + case 900 -> format(configuration.warningColor(), message); + case 1000 -> format(configuration.errorColor(), message); + default -> message; + } : message); + } + @RootConfiguration("messages") public record MessagesConfiguration( @Property(documentation = "Are colors disabled for the terminal.", defaultValue = "\"auto\"") String disableColors, @Property(documentation = "When color are enabled the tool name color.", defaultValue = "\"0;49;34\"") String toolColor, + @Property(documentation = "Error message color.", defaultValue = "\"31\"") String errorColor, + @Property(documentation = "Warning message color.", defaultValue = "\"33\"") String warningColor, @Property(documentation = "When color are enabled the version color.", defaultValue = "\"0;49;96\"") String versionColor, @Property(documentation = "If `false` emoji are totally disabled. " + - "`auto` will test `/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf` presence to enable emojis. " + + "`auto` will test `/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf` and `/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf` presence to enable emojis. " + "`true`/`false` disable/enable emoji whatever the available fonts.", defaultValue = "\"auto\"") String disableEmoji) { } } diff --git a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java index c0c103b9..1af1895a 100644 --- a/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java +++ b/env-manager/src/main/java/io/yupiik/dev/shared/RcService.java @@ -163,7 +163,7 @@ private CompletableFuture> doToolProperties(final List tool.toolName() + "@" + tool.version() + " not available"); + logger.warning(() -> tool.toolName() + "@" + tool.version() + " not available"); return completedFuture(Optional.empty()); })) .toCompletableFuture(); diff --git a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java index 0578363a..e8089b59 100644 --- a/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java +++ b/env-manager/src/test/java/io/yupiik/dev/command/CommandsTest.java @@ -128,7 +128,7 @@ void env(@TempDir final Path work, final URI uri) throws IOException { export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; - echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'";""") .replace("$work", work.toString()), out .replaceAll("#.*", "") @@ -155,7 +155,7 @@ void envInline(@TempDir final Path work, final URI uri) { export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; export YEM_JAVA_HOME_OVERRIDEN="21.0.2"; - echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'";""") .replace("$work", work.toString()), out .replaceAll("#.*", "") @@ -175,7 +175,7 @@ void envSdkManRc(@TempDir final Path work, final URI uri) throws IOException { export PATH="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded:$PATH"; export JAVA_HOME="$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded"; export JAVA_VERSION="21.0.2"; - echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'\";""") + echo "[yem] Resolved java @ 21.0.2 to '$work/zulu/21.32.17-ca-jdk21.0.2/distribution_exploded'";""") .replace("$work", work.toString()), out .replaceAll("#.*", "") From 2131ff13976b945a27ffdd780e61ffa89898ab6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20Boutemy?= Date: Sun, 11 Feb 2024 11:40:57 +0100 Subject: [PATCH 26/26] activate Reproducible Builds --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 7aefd835..b40b5967 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ UTF-8 + 2024-02-11T10:49:01Z 1.0.14 3.6.3