diff --git a/README.md b/README.md index 3909ae29..abe06939 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ Configurable properties : | `logging.structured.format.console` | `` | Format for structured logging. Valid values : `ecs`, `gelf`, `logstash`. Leave empty for no structured logging (default). See https://docs.spring.io/spring-boot/reference/features/logging.html#features.logging.structured | | `springdoc.swagger-ui.path` | `/` | Open API (swagger) UI path | | `springdoc.swagger-ui.oauth.clientId` | `` | clientid used by swagger to authenticate the user, in general the same which is used by onyxia-ui is ok. | +| `DEBUG_JMX` | `` | Enable JMX monitoring. This is useful for profiling the app to improve performance but is not intended for production / daily use. Once enabled (`true`), use `kubectl port-forward` + a profiler (e.g VisualVM) to profile the app. | +| `JMX_PORT` | `10000` | Port used by JMX if enabled. | ## Onyxia API dependency to Helm diff --git a/helm-wrapper/pom.xml b/helm-wrapper/pom.xml index 1f09338d..2e37d73b 100644 --- a/helm-wrapper/pom.xml +++ b/helm-wrapper/pom.xml @@ -61,8 +61,22 @@ io.fabric8 kubernetes-client + + + io.fabric8 + kubernetes-httpclient-vertx + + + + io.fabric8 + kubernetes-httpclient-okhttp + 7.0.0 + runtime + + + org.apache.commons commons-lang3 diff --git a/onyxia-api/entrypoint.sh b/onyxia-api/entrypoint.sh index c4437bfe..56512b24 100755 --- a/onyxia-api/entrypoint.sh +++ b/onyxia-api/entrypoint.sh @@ -13,4 +13,9 @@ if [[ -n "$CACERTS_DIR" ]]; then fi # Run application -java org.springframework.boot.loader.launch.JarLauncher +if [ -n "$DEBUG_JMX" ]; then + JMX_PORT="${JMX_PORT:-10000}" + java -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=$JMX_PORT -Dcom.sun.management.jmxremote.rmi.port=$JMX_PORT -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.local.only=false -Djava.rmi.server.hostname=127.0.0.1 org.springframework.boot.loader.launch.JarLauncher +else + java org.springframework.boot.loader.launch.JarLauncher +fi \ No newline at end of file diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/CatalogWrapper.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/CatalogWrapper.java index 06bbecc0..589eada6 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/CatalogWrapper.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/CatalogWrapper.java @@ -8,7 +8,9 @@ import fr.insee.onyxia.model.helm.Repository; import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.regex.Pattern; @JsonIgnoreProperties(ignoreUnknown = true) @@ -54,6 +56,15 @@ public class CatalogWrapper { @Schema(description = "only include charts with one or more of the given keywords") private List includeKeywords = new ArrayList<>(); + @Schema(description = "exclude any charts which have one or more of the given keywords") + private List excludeKeywords = new ArrayList<>(); + + @Schema(description = "only include charts with one or more of the given annotations") + private Map includeAnnotations = new HashMap<>(); + + @Schema(description = "exclude any charts which have one or more of the given annotations") + private Map excludeAnnotations = new HashMap<>(); + @Schema(description = "Skip tls certificate checks for the repository") private boolean skipTlsVerify; @@ -205,6 +216,30 @@ public void setIncludeKeywords(List includeKeywords) { this.includeKeywords = includeKeywords; } + public void setExcludeKeywords(List excludeKeywords) { + this.excludeKeywords = excludeKeywords; + } + + public List getExcludeKeywords() { + return excludeKeywords; + } + + public Map getIncludeAnnotations() { + return includeAnnotations; + } + + public void setIncludeAnnotations(Map includeAnnotations) { + this.includeAnnotations = includeAnnotations; + } + + public Map getExcludeAnnotations() { + return excludeAnnotations; + } + + public void setExcludeAnnotations(Map excludeAnnotations) { + this.excludeAnnotations = excludeAnnotations; + } + public boolean getSkipTlsVerify() { return skipTlsVerify; } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java index 42cbaabd..085cc8b7 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java @@ -6,6 +6,8 @@ import io.fabric8.kubernetes.client.ConfigBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import java.util.HashMap; +import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +18,10 @@ public class KubernetesClientProvider { private static final Logger LOGGER = LoggerFactory.getLogger(KubernetesClientProvider.class); + private static Map rootKubernetesClientCache = new HashMap<>(); + + private static KubernetesClient userKubernetesClientCache = null; + /** * This returns the root client which has extended permissions. Currently cluster-admin. User * calls should be done using the userClient which only has user permissions. @@ -23,12 +29,28 @@ public class KubernetesClientProvider { * @param region * @return */ - public KubernetesClient getRootClient(Region region) { - final Config config = getDefaultConfiguration(region).build(); - return new KubernetesClientBuilder().withConfig(config).build(); + public synchronized KubernetesClient getRootClient(Region region) { + if (!rootKubernetesClientCache.containsKey(region.getId())) { + final Config config = getDefaultConfiguration(region).build(); + rootKubernetesClientCache.put( + region.getId(), new KubernetesClientBuilder().withConfig(config).build()); + } + return rootKubernetesClientCache.get(region.getId()); } public KubernetesClient getUserClient(Region region, User user) { + // In case of SERVICEACCOUNT authentication, we can safely mutualize and use a single + // KubernetesClient + if (region.getServices().getAuthenticationMode() + == Region.Services.AuthenticationMode.SERVICEACCOUNT) { + if (userKubernetesClientCache != null) { + return userKubernetesClientCache; + } + Config config = getDefaultConfiguration(region).build(); + KubernetesClient client = new KubernetesClientBuilder().withConfig(config).build(); + userKubernetesClientCache = client; + return client; + } final Config config = getDefaultConfiguration(region).build(); String username = user.getIdep(); if (region.getServices().getUsernamePrefix() != null) { diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/dao/universe/CatalogLoader.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/dao/universe/CatalogLoader.java index 2feab6b2..64e9a78a 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/dao/universe/CatalogLoader.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/dao/universe/CatalogLoader.java @@ -29,6 +29,7 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @Service @@ -76,19 +77,33 @@ private void updateHelmRepository(CatalogWrapper cw) { excludedChart.equalsIgnoreCase( entry.getKey()))) .filter( - // If includeKeywords is defined, include only services where - // the latest version has the desired keyword. entry -> - cw.getIncludeKeywords() == null - || cw.getIncludeKeywords().isEmpty() - || cw.getIncludeKeywords().stream() - .anyMatch( - include -> - entry.getValue() - .getFirst() - .getKeywords() - .contains( - include))) + (CollectionUtils.isEmpty(cw.getIncludeKeywords()) + || entry.getValue() + .getFirst() + .hasKeywords( + cw + .getIncludeKeywords())) + && (CollectionUtils.isEmpty( + cw.getIncludeAnnotations()) + || entry.getValue() + .getFirst() + .hasAnnotations( + cw + .getIncludeAnnotations()))) + .filter( + entry -> + CollectionUtils.isEmpty(cw.getExcludeKeywords()) + || !entry.getValue() + .getFirst() + .hasKeywords(cw.getExcludeKeywords())) + .filter( + entry -> + CollectionUtils.isEmpty(cw.getExcludeAnnotations()) + || !entry.getValue() + .getFirst() + .hasAnnotations( + cw.getExcludeAnnotations())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); // For each service, filter the multiple versions if needed then refresh remaining // versions diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/security/LogUserInfoFilter.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/security/LogUserInfoFilter.java new file mode 100644 index 00000000..767a4f8d --- /dev/null +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/security/LogUserInfoFilter.java @@ -0,0 +1,42 @@ +package fr.insee.onyxia.api.security; + +import fr.insee.onyxia.api.configuration.properties.RegionsConfiguration; +import fr.insee.onyxia.api.services.UserProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class LogUserInfoFilter extends OncePerRequestFilter { + + private UserProvider userProvider; + + private RegionsConfiguration regionsConfiguration; + + @Autowired + public LogUserInfoFilter(UserProvider userProvider, RegionsConfiguration regionsConfiguration) { + this.userProvider = userProvider; + this.regionsConfiguration = regionsConfiguration; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + MDC.put( + "username", + userProvider.getUser(regionsConfiguration.getDefaultRegion()).getIdep()); + } catch (Exception ignored) { + + } + filterChain.doFilter(request, response); + MDC.clear(); + } +} diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/UserControllerTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/UserControllerTest.java index d5f90ff6..35452393 100644 --- a/onyxia-api/src/test/java/fr/insee/onyxia/api/UserControllerTest.java +++ b/onyxia-api/src/test/java/fr/insee/onyxia/api/UserControllerTest.java @@ -12,6 +12,7 @@ import fr.insee.onyxia.api.configuration.SecurityConfig; import fr.insee.onyxia.api.configuration.properties.RegionsConfiguration; import fr.insee.onyxia.api.controller.api.user.UserController; +import fr.insee.onyxia.api.services.UserProvider; import fr.insee.onyxia.api.services.utils.HttpRequestUtils; import fr.insee.onyxia.api.user.OnyxiaUserProvider; import fr.insee.onyxia.model.OnyxiaUser; @@ -41,6 +42,8 @@ public class UserControllerTest extends BaseTest { @MockBean private OnyxiaUserProvider onyxiaUserProvider; + @MockBean private UserProvider userProvider; + @BeforeEach public void setUp() { User user = new User(); diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/dao/universe/CatalogLoaderTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/dao/universe/CatalogLoaderTest.java index 4f852b3f..304b97dc 100644 --- a/onyxia-api/src/test/java/fr/insee/onyxia/api/dao/universe/CatalogLoaderTest.java +++ b/onyxia-api/src/test/java/fr/insee/onyxia/api/dao/universe/CatalogLoaderTest.java @@ -4,7 +4,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; import fr.insee.onyxia.api.configuration.CatalogWrapper; import fr.insee.onyxia.api.configuration.CustomObjectMapper; @@ -13,6 +13,7 @@ import fr.insee.onyxia.api.util.TestUtils; import fr.insee.onyxia.model.helm.Chart; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -161,21 +162,144 @@ void packageOnClassPathNotFound() { @ParameterizedTest @MethodSource("includeKeywords") - void filterIncludeKeywordsTest(List includeKeywords, Set expectedServices) { + @MethodSource("excludeKeywords") + @MethodSource("includeAnnotations") + @MethodSource("excludeAnnotations") + void filterServicesTest( + List includeKeywords, + List excludeKeywords, + Map includeAnnotations, + Map excludeAnnotations, + Set expectedServices) { CatalogWrapper cw = new CatalogWrapper(); cw.setType("helm"); - cw.setLocation("classpath:/catalog-loader-test-with-keywords"); + cw.setLocation("classpath:/catalog-loader-test-with-keywords-and-annotations"); cw.setIncludeKeywords(includeKeywords); + cw.setExcludeKeywords(excludeKeywords); + cw.setIncludeAnnotations(includeAnnotations); + cw.setExcludeAnnotations(excludeAnnotations); catalogLoader.updateCatalog(cw); assertEquals(expectedServices, cw.getCatalog().getEntries().keySet()); } private static Stream includeKeywords() { return Stream.of( - arguments(List.of("CD"), Set.of("keepme")), - arguments(List.of("CD", "Experimental"), Set.of("keepme", "excludeme")), - arguments(List.of(), Set.of("keepme", "excludeme")), - arguments(null, Set.of("keepme", "excludeme")), - arguments(List.of("no one knows"), Set.of())); + argumentSet( + "One keyword to include", + List.of("CD"), + null, + null, + null, + Set.of("keepme")), + argumentSet( + "Two keywords to include", + List.of("CD", "Experimental"), + null, + null, + null, + Set.of("keepme", "excludeme")), + argumentSet( + "Empty list of keywords to include", + List.of(), + null, + null, + null, + Set.of("keepme", "excludeme")), + argumentSet( + "null for all filters", + null, + null, + null, + null, + Set.of("keepme", "excludeme")), + argumentSet( + "Unknown keyword to include", + List.of("no one knows"), + null, + null, + null, + Set.of())); + } + + private static Stream excludeKeywords() { + return Stream.of( + argumentSet( + "One keyword to exclude", + null, + List.of("Experimental"), + null, + null, + Set.of("keepme")), + argumentSet( + "Exclusive keywords to include and exclude", + List.of("CD"), + List.of("Experimental"), + null, + null, + Set.of("keepme")), + argumentSet( + "Keyword to exclude takes precedence", + List.of("Experimental"), + List.of("Experimental"), + null, + null, + Set.of()), + argumentSet( + "Two keywords to exclude", + List.of("CD"), + List.of("CD", "Experimental"), + null, + null, + Set.of()), + argumentSet( + "Empty lists of keywords to include and exclude", + List.of(), + List.of(), + null, + null, + Set.of("keepme", "excludeme")), + argumentSet( + "Unknown keyword to exclude", + null, + List.of("no one knows"), + null, + null, + Set.of("keepme", "excludeme"))); + } + + private static Stream includeAnnotations() { + return Stream.of( + argumentSet( + "One annotation to include", + null, + null, + Map.of("lifecycle", "production"), + null, + Set.of("keepme")), + argumentSet( + "Exclude keyword takes precedence", + null, + List.of("CD"), + Map.of("lifecycle", "production"), + null, + Set.of())); + } + + private static Stream excludeAnnotations() { + return Stream.of( + argumentSet( + "One annotation to exclude", + null, + null, + null, + Map.of("lifecycle", "production"), + Set.of("excludeme")), + argumentSet( + "Exclude annotation takes precedence", + null, + null, + Map.of("lifecycle", "production"), + Map.of("lifecycle", "production"), + Set.of())); } } diff --git a/onyxia-api/src/test/resources/catalog-loader-test-with-keywords/catalogs.json5 b/onyxia-api/src/test/resources/catalog-loader-test-with-keywords-and-annotations/catalogs.json5 similarity index 100% rename from onyxia-api/src/test/resources/catalog-loader-test-with-keywords/catalogs.json5 rename to onyxia-api/src/test/resources/catalog-loader-test-with-keywords-and-annotations/catalogs.json5 diff --git a/onyxia-api/src/test/resources/catalog-loader-test-with-keywords/index.yaml b/onyxia-api/src/test/resources/catalog-loader-test-with-keywords-and-annotations/index.yaml similarity index 96% rename from onyxia-api/src/test/resources/catalog-loader-test-with-keywords/index.yaml rename to onyxia-api/src/test/resources/catalog-loader-test-with-keywords-and-annotations/index.yaml index 0ec72541..57f698d1 100644 --- a/onyxia-api/src/test/resources/catalog-loader-test-with-keywords/index.yaml +++ b/onyxia-api/src/test/resources/catalog-loader-test-with-keywords-and-annotations/index.yaml @@ -28,6 +28,8 @@ entries: maintainers: - email: test@example.com name: test + annotations: + lifecycle: production - apiVersion: v2 appVersion: "1" created: "2022-10-03T11:53:20.589754116Z" @@ -55,6 +57,8 @@ entries: maintainers: - email: test@example.com name: test + annotations: + lifecycle: production - apiVersion: v2 appVersion: "2" created: "2022-10-03T11:53:20.589754116Z" @@ -79,6 +83,8 @@ entries: urls: - keepeme2.gz version: 2.4.0 + annotations: + lifecycle: experimental excludeme: - apiVersion: v2 appVersion: "1" @@ -129,3 +135,5 @@ entries: urls: - excludeme2.gz version: 2.4.0 + annotations: + lifecycle: experimental diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/helm/Chart.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/helm/Chart.java index 266c7811..a066352b 100644 --- a/onyxia-model/src/main/java/fr/insee/onyxia/model/helm/Chart.java +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/helm/Chart.java @@ -22,7 +22,8 @@ "name", "sources", "urls", - "version" + "version", + "annotations", }) @Schema(description = "") public class Chart extends Pkg { @@ -277,6 +278,29 @@ public void setAdditionalProperty(String name, Object value) { this.additionalProperties.put(name, value); } + /** + * Does the chart have any of the given keywords? + * + * @param keywordsToCheck The list of keywords we're interested in. + * @return true if any of the given keywords appear in the keywords on the chart. + */ + public Boolean hasKeywords(List keywordsToCheck) { + return getKeywords() != null + && keywordsToCheck.stream().anyMatch(keyword -> getKeywords().contains(keyword)); + } + + /** + * Does the chart have any of the given annotations? + * + * @param annotationsToCheck The map of annotations we're interested in. + * @return true if any of the given annotations appear in the annotations on the chart. + */ + public Boolean hasAnnotations(Map annotationsToCheck) { + return getAnnotations() != null + && annotationsToCheck.entrySet().stream() + .anyMatch(annotation -> getAnnotations().entrySet().contains(annotation)); + } + public static class Maintainer { @JsonProperty("email") private String email;