diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 05c42c1..ecbd34b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 with: cache-read-only: false @@ -56,15 +56,16 @@ jobs: # The next step will generate these files. # This order is necessary since this step will generate the openapi-spec. - name: Quarkus App Parts Build - run: ./gradlew :server:quarkusAppPartsBuild + run: ./gradlew :server:quarkusAppPartsBuild "-Pversion=${{ github.event.inputs.version }}" continue-on-error: true - name: Generate OpenAPI - run: ./gradlew :server:openApiGenerate + run: ./gradlew :server:openApiGenerate "-Pversion=${{ github.event.inputs.version }}" - name: Build and Push Arch-Specific Image run: | ./gradlew :server:build \ + "-Pversion=${{ github.event.inputs.version }}" \ "-Dquarkus.native.container-build=true" \ "-Dquarkus.container-image.builder=docker" \ "-Dquarkus.native.enabled=true" \ @@ -87,13 +88,11 @@ jobs: - name: Create and Push Multi-Arch Manifest run: | - docker manifest create bethibande/repository:${{ github.event.inputs.version }} \ - --amend bethibande/repository:${{ github.event.inputs.version }}-amd64 \ - --amend bethibande/repository:${{ github.event.inputs.version }}-arm64 - - docker manifest create bethibande/repository:latest \ - --amend bethibande/repository:${{ github.event.inputs.version }}-amd64 \ - --amend bethibande/repository:${{ github.event.inputs.version }}-arm64 - - docker manifest push bethibande/repository:${{ github.event.inputs.version }} - docker manifest push bethibande/repository:latest + docker buildx imagetools create -t bethibande/repository:${{ github.event.inputs.version }} \ + bethibande/repository:${{ github.event.inputs.version }}-amd64 \ + bethibande/repository:${{ github.event.inputs.version }}-arm64 + + # Create the latest tag + docker buildx imagetools create -t bethibande/repository:latest \ + bethibande/repository:${{ github.event.inputs.version }}-amd64 \ + bethibande/repository:${{ github.event.inputs.version }}-arm64 diff --git a/docker-compose.yaml b/docker-compose.yaml index 039d368..8c4c35e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,7 +33,7 @@ services: volumes: - minio:/data repository: - image: bethibande/repository:1.0 + image: bethibande/repository:latest restart: unless-stopped ports: - "8080:8080" diff --git a/server/build.gradle.kts b/server/build.gradle.kts index fa3d9ed..c3c31cd 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -39,10 +39,12 @@ dependencies { implementation("com.cronutils:cron-utils:9.2.1") implementation("software.amazon.awssdk:s3:2.41.24") + implementation("software.amazon.awssdk:apache-client:2.41.26") implementation("io.hypersistence:hypersistence-utils-hibernate-71:3.14.1") implementation("com.bethibande.process:annotations:1.5") + implementation("io.quarkus:quarkus-kubernetes-client") annotationProcessor("com.bethibande.process:processor:1.5") // Jackson & Hibernate Search ORM diff --git a/server/src/main/java/com/bethibande/repository/jpa/repository/PackageManager.java b/server/src/main/java/com/bethibande/repository/jpa/repository/PackageManager.java index 916d764..35277e6 100644 --- a/server/src/main/java/com/bethibande/repository/jpa/repository/PackageManager.java +++ b/server/src/main/java/com/bethibande/repository/jpa/repository/PackageManager.java @@ -1,6 +1,7 @@ package com.bethibande.repository.jpa.repository; import com.bethibande.repository.repository.ManagedRepository; +import com.bethibande.repository.repository.RepositoryApplicationContext; import com.bethibande.repository.repository.maven.MavenRepository; import com.bethibande.repository.repository.oci.OCIRepository; import com.fasterxml.jackson.core.JsonProcessingException; @@ -17,9 +18,9 @@ public enum PackageManager { this.factory = factory; } - public ManagedRepository createRepository(final Repository info, final ObjectMapper mapper) { + public ManagedRepository createRepository(final Repository info, final RepositoryApplicationContext ctx) { try { - return factory.createRepository(info, mapper); + return factory.createRepository(info, ctx); } catch (final JsonProcessingException ex) { throw new RuntimeException(ex); } diff --git a/server/src/main/java/com/bethibande/repository/jpa/repository/Repository.java b/server/src/main/java/com/bethibande/repository/jpa/repository/Repository.java index d8c3913..4c4b613 100644 --- a/server/src/main/java/com/bethibande/repository/jpa/repository/Repository.java +++ b/server/src/main/java/com/bethibande/repository/jpa/repository/Repository.java @@ -53,8 +53,17 @@ public T getMetadata(final RepositoryMetadataKey key) { return (T) metadata.get(key); } + public T getMetadataOrDefault(final RepositoryMetadataKey key, final T defaultValue) { + final T value = getMetadata(key); + return value != null ? value : defaultValue; + } + public void setMetadata(final RepositoryMetadataKey key, final Object value) { - metadata.put(key, value); + if (value == null) { + metadata.remove(key); + } else { + metadata.put(key, value); + } } public boolean canView(final User user) { diff --git a/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryFactory.java b/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryFactory.java index b325589..fa49cb6 100644 --- a/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryFactory.java +++ b/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryFactory.java @@ -1,12 +1,14 @@ package com.bethibande.repository.jpa.repository; import com.bethibande.repository.repository.ManagedRepository; +import com.bethibande.repository.repository.RepositoryApplicationContext; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @FunctionalInterface public interface RepositoryFactory { - ManagedRepository createRepository(final Repository info, final ObjectMapper mapper) throws JsonProcessingException; + ManagedRepository createRepository(final Repository info, + final RepositoryApplicationContext ctx) throws JsonProcessingException; } diff --git a/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryManager.java b/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryManager.java index 226df41..4a9e21a 100644 --- a/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryManager.java +++ b/server/src/main/java/com/bethibande/repository/jpa/repository/RepositoryManager.java @@ -1,7 +1,10 @@ package com.bethibande.repository.jpa.repository; +import com.bethibande.repository.k8s.KubernetesSupport; import com.bethibande.repository.repository.ManagedRepository; +import com.bethibande.repository.repository.RepositoryApplicationContext; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -11,6 +14,16 @@ public class RepositoryManager { @Inject protected ObjectMapper mapper; + @Inject + protected KubernetesSupport kubernetesSupport; + + private RepositoryApplicationContext ctx; + + @PostConstruct + protected void init() { + ctx = new RepositoryApplicationContext(mapper, kubernetesSupport); + } + public T findRepository(final String name, final PackageManager packageManager) { final Repository repo = Repository.find("name = ?1 and packageManager = ?2", name, packageManager).firstResult(); if (repo == null) return null; @@ -20,7 +33,7 @@ public T findRepository(final String name, final P @SuppressWarnings("unchecked") public T manage(final Repository repo) { - return (T) repo.packageManager.createRepository(repo, mapper); + return (T) repo.packageManager.createRepository(repo, this.ctx); } } diff --git a/server/src/main/java/com/bethibande/repository/jpa/repository/permissions/PermissionScope.java b/server/src/main/java/com/bethibande/repository/jpa/repository/permissions/PermissionScope.java index b75a98b..7c8cd30 100644 --- a/server/src/main/java/com/bethibande/repository/jpa/repository/permissions/PermissionScope.java +++ b/server/src/main/java/com/bethibande/repository/jpa/repository/permissions/PermissionScope.java @@ -37,7 +37,7 @@ public boolean canView(final User user) { return switch (type) { case ANONYMOUS -> true; case AUTHENTICATED -> user != null; - case USER -> Objects.equals(this.user.id, user.id); + case USER -> user != null && Objects.equals(this.user.id, user.id); }; } @@ -47,7 +47,7 @@ public boolean canWrite(final User user) { return switch (type) { case ANONYMOUS -> true; case AUTHENTICATED -> user != null; - case USER -> Objects.equals(this.user.id, user.id); + case USER -> user != null && Objects.equals(this.user.id, user.id); }; } diff --git a/server/src/main/java/com/bethibande/repository/jpa/user/User.java b/server/src/main/java/com/bethibande/repository/jpa/user/User.java index d0a4c0c..6654228 100644 --- a/server/src/main/java/com/bethibande/repository/jpa/user/User.java +++ b/server/src/main/java/com/bethibande/repository/jpa/user/User.java @@ -2,6 +2,7 @@ import com.bethibande.process.annotation.EntityDTO; import io.quarkus.hibernate.orm.panache.PanacheEntity; +import io.quarkus.runtime.annotations.RegisterForReflection; import jakarta.persistence.*; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed; @@ -14,6 +15,7 @@ @Entity @Indexed +@RegisterForReflection @Table(name = "Users") @EntityDTO(excludeProperties = "id") @EntityDTO(excludeProperties = "password") diff --git a/server/src/main/java/com/bethibande/repository/k8s/KubernetesSupport.java b/server/src/main/java/com/bethibande/repository/k8s/KubernetesSupport.java new file mode 100644 index 0000000..b50c981 --- /dev/null +++ b/server/src/main/java/com/bethibande/repository/k8s/KubernetesSupport.java @@ -0,0 +1,154 @@ +package com.bethibande.repository.k8s; + +import com.bethibande.repository.jpa.repository.PackageManager; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectAccessReview; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectAccessReviewBuilder; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.HTTPRoute; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.HTTPRouteBuilder; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.HTTPRouteRuleBuilder; +import io.fabric8.kubernetes.api.model.gatewayapi.v1.ParentReferenceBuilder; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.quarkus.runtime.Startup; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Startup +@ApplicationScoped +public class KubernetesSupport { + + private static final Logger LOGGER = LoggerFactory.getLogger(KubernetesSupport.class); + + public static final String GATEWAY_API_GROUP = "gateway.networking.k8s.io"; + public static final String HTTP_ROUTE_NAME = "httproutes"; + + @Inject + protected KubernetesClient client; + + private String namespace; + + protected boolean kubernetesSupport = true; + protected boolean canManageHttpRoutes = false; + + protected boolean canAccessApi() { + try (final KubernetesClient timeoutClient = new KubernetesClientBuilder() + .withConfig(new ConfigBuilder() + .withConnectionTimeout(200) + .withRequestTimeout(200) + .withRequestRetryBackoffLimit(0) + .build()) + .build()) { + + timeoutClient.getApiGroups(); + return true; + } catch (final Throwable _) { + return false; + } + } + + @PostConstruct + protected void init() { + if (this.client == null) return; + if (!canAccessApi()) { + this.kubernetesSupport = false; + LOGGER.info("Disabled kubernetes support"); + return; + } + + this.namespace = client.getNamespace(); + + this.canManageHttpRoutes = hasPermission("create", HTTP_ROUTE_NAME, GATEWAY_API_GROUP) + && hasPermission("delete", HTTP_ROUTE_NAME, GATEWAY_API_GROUP) + && hasPermission("get", HTTP_ROUTE_NAME, GATEWAY_API_GROUP) + && hasPermission("patch", HTTP_ROUTE_NAME, GATEWAY_API_GROUP) + && hasPermission("list", HTTP_ROUTE_NAME, GATEWAY_API_GROUP); + } + + public boolean isEnabled() { + return this.kubernetesSupport; + } + + public boolean hasHttpRouteSupport() { + return this.canManageHttpRoutes; + } + + protected String toHttpRouteName(final String repository, final PackageManager packageManager) { + return "%s-%s".formatted(packageManager.name().toLowerCase(), repository); + } + + public void deleteRepositoryHttpRouteIfExists(final String repository, final PackageManager packageManager) { + this.client.resources(HTTPRoute.class) + .inNamespace(this.namespace) + .withName(toHttpRouteName(repository, packageManager)) + .delete(); + } + + public void createOrUpdateRepositoryHttpRoute(final String repository, + final PackageManager packageManager, + final String host, + final String targetService, + final int targetPort, + final String gateway, + final String gatewayNamespace) { + final HTTPRoute route = new HTTPRouteBuilder() + .withNewMetadata() + .withName(toHttpRouteName(repository, packageManager)) + .withNamespace(this.namespace) + .endMetadata() + .withNewSpec() + .withHostnames(host) + .withParentRefs(new ParentReferenceBuilder() + .withName(gateway) + .withNamespace(gatewayNamespace) + .withGroup(GATEWAY_API_GROUP) + .withKind("Gateway") + .build()) + .withRules(new HTTPRouteRuleBuilder() + .addNewMatch() + .withNewPath() + .withType("PathPrefix") + .withValue("/v2") + .endPath() + .endMatch() + .addNewFilter() + .withType("URLRewrite") + .withNewUrlRewrite() + .withNewPath() + .withType("ReplacePrefixMatch") + .withReplacePrefixMatch("/repositories/%s/%s/v2".formatted(packageManager.name().toLowerCase(), repository)) + .endPath() + .endUrlRewrite() + .endFilter() + .addNewBackendRef() + .withName(targetService) + .withPort(targetPort) + .endBackendRef() + .build()) + .endSpec() + .build(); + + client.resource(route).serverSideApply(); + } + + protected boolean hasPermission(final String verb, final String resource, final String group) { + final SelfSubjectAccessReview review = new SelfSubjectAccessReviewBuilder() + .withNewSpec() + .withNewResourceAttributes() + .withNamespace(this.namespace) + .withVerb(verb) + .withGroup(group) + .withResource(resource) + .endResourceAttributes() + .endSpec() + .build(); + + final SelfSubjectAccessReview result = client.resource(review).create(); + + return result.getStatus().getAllowed(); + } + +} diff --git a/server/src/main/java/com/bethibande/repository/repository/ManagedRepository.java b/server/src/main/java/com/bethibande/repository/repository/ManagedRepository.java index f086209..252384a 100644 --- a/server/src/main/java/com/bethibande/repository/repository/ManagedRepository.java +++ b/server/src/main/java/com/bethibande/repository/repository/ManagedRepository.java @@ -4,6 +4,7 @@ import com.bethibande.repository.jpa.artifact.ArtifactVersion; import com.bethibande.repository.jpa.repository.Repository; import com.bethibande.repository.jpa.user.User; +import jakarta.persistence.EntityManager; import org.hibernate.search.mapper.orm.Search; import org.hibernate.search.mapper.orm.session.SearchSession; diff --git a/server/src/main/java/com/bethibande/repository/repository/RepositoryApplicationContext.java b/server/src/main/java/com/bethibande/repository/repository/RepositoryApplicationContext.java new file mode 100644 index 0000000..9152fb6 --- /dev/null +++ b/server/src/main/java/com/bethibande/repository/repository/RepositoryApplicationContext.java @@ -0,0 +1,10 @@ +package com.bethibande.repository.repository; + +import com.bethibande.repository.k8s.KubernetesSupport; +import com.fasterxml.jackson.databind.ObjectMapper; + +public record RepositoryApplicationContext( + ObjectMapper objectMapper, + KubernetesSupport kubernetesSupport +) { +} diff --git a/server/src/main/java/com/bethibande/repository/repository/RepositoryUpdatedNotifier.java b/server/src/main/java/com/bethibande/repository/repository/RepositoryUpdatedNotifier.java new file mode 100644 index 0000000..005efb2 --- /dev/null +++ b/server/src/main/java/com/bethibande/repository/repository/RepositoryUpdatedNotifier.java @@ -0,0 +1,11 @@ +package com.bethibande.repository.repository; + +/** + * An interface that extends {@link ManagedRepository} to provide additional functionality + * for handling repository update notifications. + */ +public interface RepositoryUpdatedNotifier extends ManagedRepository { + + void processUpdate(final UpdateType type); + +} diff --git a/server/src/main/java/com/bethibande/repository/repository/UpdateType.java b/server/src/main/java/com/bethibande/repository/repository/UpdateType.java new file mode 100644 index 0000000..28e7a0f --- /dev/null +++ b/server/src/main/java/com/bethibande/repository/repository/UpdateType.java @@ -0,0 +1,9 @@ +package com.bethibande.repository.repository; + +public enum UpdateType { + + CREATE, + UPDATE, + DELETE + +} diff --git a/server/src/main/java/com/bethibande/repository/repository/backend/S3Backend.java b/server/src/main/java/com/bethibande/repository/repository/backend/S3Backend.java index a7487ed..6c3558b 100644 --- a/server/src/main/java/com/bethibande/repository/repository/backend/S3Backend.java +++ b/server/src/main/java/com/bethibande/repository/repository/backend/S3Backend.java @@ -7,6 +7,7 @@ import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; @@ -25,6 +26,7 @@ public class S3Backend implements RepositoryBackend { public S3Backend(final S3Config config) { this.config = config; this.client = S3Client.builder() + .httpClientBuilder(ApacheHttpClient.builder()) .endpointOverride(URI.create(config.url())) .region(Region.of(config.region())) .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(config.accessKey(), config.secretKey()))) diff --git a/server/src/main/java/com/bethibande/repository/repository/maven/MavenRepository.java b/server/src/main/java/com/bethibande/repository/repository/maven/MavenRepository.java index b83fcbe..4e01b3a 100644 --- a/server/src/main/java/com/bethibande/repository/repository/maven/MavenRepository.java +++ b/server/src/main/java/com/bethibande/repository/repository/maven/MavenRepository.java @@ -1,16 +1,16 @@ package com.bethibande.repository.repository.maven; -import com.bethibande.repository.jpa.files.StoredFile; import com.bethibande.repository.jpa.artifact.Artifact; import com.bethibande.repository.jpa.artifact.ArtifactVersion; +import com.bethibande.repository.jpa.files.StoredFile; import com.bethibande.repository.jpa.repository.Repository; import com.bethibande.repository.jpa.user.User; import com.bethibande.repository.repository.ManagedRepository; +import com.bethibande.repository.repository.RepositoryApplicationContext; import com.bethibande.repository.repository.StreamHandle; import com.bethibande.repository.repository.backend.RepositoryBackend; import com.bethibande.repository.repository.backend.S3Backend; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.security.UnauthorizedException; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.NotFoundException; @@ -43,8 +43,8 @@ public class MavenRepository implements ManagedRepository { private final RepositoryBackend backend; - public MavenRepository(final Repository info, final ObjectMapper mapper) throws JsonProcessingException { - this(info, mapper.readValue(info.settings, MavenRepositoryConfig.class)); + public MavenRepository(final Repository info, final RepositoryApplicationContext ctx) throws JsonProcessingException { + this(info, ctx.objectMapper().readValue(info.settings, MavenRepositoryConfig.class)); } public MavenRepository(final Repository info, final MavenRepositoryConfig config) { diff --git a/server/src/main/java/com/bethibande/repository/repository/oci/OCIRepository.java b/server/src/main/java/com/bethibande/repository/repository/oci/OCIRepository.java index 137006e..26e8e54 100644 --- a/server/src/main/java/com/bethibande/repository/repository/oci/OCIRepository.java +++ b/server/src/main/java/com/bethibande/repository/repository/oci/OCIRepository.java @@ -6,13 +6,15 @@ import com.bethibande.repository.jpa.files.OCISubject; import com.bethibande.repository.jpa.files.StoredFile; import com.bethibande.repository.jpa.repository.Repository; +import com.bethibande.repository.jpa.repository.RepositoryMetadataKey; import com.bethibande.repository.jpa.user.User; -import com.bethibande.repository.repository.ArtifactAndGroupId; -import com.bethibande.repository.repository.ManagedRepository; -import com.bethibande.repository.repository.StreamHandle; +import com.bethibande.repository.k8s.KubernetesSupport; +import com.bethibande.repository.repository.*; import com.bethibande.repository.repository.backend.MultipartUploadStatus; import com.bethibande.repository.repository.backend.ObjectInfo; import com.bethibande.repository.repository.backend.S3Backend; +import com.bethibande.repository.repository.oci.config.OCIRepositoryConfig; +import com.bethibande.repository.repository.oci.config.OCIRoutingConfig; import com.bethibande.repository.repository.oci.details.OCILayerReference; import com.bethibande.repository.repository.oci.details.OCIManifestDetails; import com.bethibande.repository.repository.oci.details.OCIManifestReference; @@ -22,6 +24,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.narayana.jta.TransactionSemantics; import io.quarkus.security.UnauthorizedException; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ForbiddenException; @@ -41,7 +45,7 @@ import java.time.Instant; import java.util.*; -public class OCIRepository implements ManagedRepository { +public class OCIRepository implements ManagedRepository, RepositoryUpdatedNotifier { public static final long MAX_MANIFEST_SIZE = 10_000_000L; @@ -49,20 +53,50 @@ public class OCIRepository implements ManagedRepository { private final Repository info; private final OCIRepositoryConfig config; + private final KubernetesSupport kubernetesSupport; private final S3Backend backend; - public OCIRepository(final Repository info, final ObjectMapper mapper) throws JsonProcessingException { - this(info, mapper.readValue(info.settings, OCIRepositoryConfig.class)); + public OCIRepository(final Repository info, final RepositoryApplicationContext ctx) throws JsonProcessingException { + this(info, ctx.objectMapper().readValue(info.settings, OCIRepositoryConfig.class), ctx.kubernetesSupport()); } - public OCIRepository(final Repository info, final OCIRepositoryConfig config) { + public OCIRepository(final Repository info, + final OCIRepositoryConfig config, + final KubernetesSupport kubernetesSupport) { this.info = info; this.config = config; + this.kubernetesSupport = kubernetesSupport; this.backend = new S3Backend(config.s3Config()); } + @Override + public void processUpdate(final UpdateType type) { + final OCIRoutingConfig routing = this.config.routingConfig(); + + if (!this.kubernetesSupport.hasHttpRouteSupport()) return; + + if (routing.enabled() && type != UpdateType.DELETE) { + this.kubernetesSupport.createOrUpdateRepositoryHttpRoute( + info.name, + info.packageManager, + info.getMetadata(RepositoryMetadataKey.HOST_NAME), + routing.targetService(), + routing.targetPort(), + routing.gatewayName(), + routing.gatewayNamespace() + ); + } + if (!routing.enabled() || type == UpdateType.DELETE) { + this.kubernetesSupport.deleteRepositoryHttpRouteIfExists( + info.name, + info.packageManager + ); + } + info.persist(); + } + @Override public Repository getInfo() { return info; @@ -165,13 +199,15 @@ protected StoredFile findManifestFileByTag(final String namespace, final String final String groupId = artifactAndGroupId.groupId(); final String artifactId = artifactAndGroupId.artifactId(); - final Artifact artifact = Artifact.find("groupId = ?1 and artifactId = ?2 and repository.id = ?3", groupId, artifactId, info.id).firstResult(); - if (artifact == null) return null; + return QuarkusTransaction.runner(TransactionSemantics.JOIN_EXISTING).call(() -> { + final Artifact artifact = Artifact.find("groupId = ?1 and artifactId = ?2 and repository.id = ?3", groupId, artifactId, info.id).firstResult(); + if (artifact == null) return null; - final ArtifactVersion version = ArtifactVersion.find("artifact = ?1 and version = ?2", artifact, tag).firstResult(); - if (version == null) return null; + final ArtifactVersion version = ArtifactVersion.find("artifact = ?1 and version = ?2", artifact, tag).firstResult(); + if (version == null) return null; - return version.manifest; + return version.manifest; + }); } protected boolean isDigest(final String reference) { @@ -205,7 +241,7 @@ public OCIStreamHandle getManifest(final User user, final String namespace, fina public OCIContentInfo getManifestInfo(final User user, final String namespace, final String reference) { checkViewAccess(user); - if (reference.matches("^sha256:[0-9a-fA-F]{64}$|^sha512:[0-9a-fA-F]{128}$")) { + if (isDigest(reference)) { final ObjectInfo info = this.backend.headObject(toManifestKey(namespace, reference)); if (info == null) return null; return new OCIContentInfo( @@ -219,6 +255,7 @@ public OCIContentInfo getManifestInfo(final User user, final String namespace, f final String digest = file.key.substring(file.key.lastIndexOf('/') + 1); final ObjectInfo info = this.backend.headObject(file.key); + if (info == null) return null; return new OCIContentInfo( digest, @@ -253,7 +290,23 @@ public StreamHandle getBlob(final User user, public void uploadBlob(final User user, final String namespace, final String digest, final StreamHandle stream) { checkWriteAccess(user); - this.backend.put(toBlobKey(namespace, digest), stream); + final String key = toBlobKey(namespace, digest); + this.backend.put(key, stream); + + QuarkusTransaction.runner(TransactionSemantics.JOIN_EXISTING).run(() -> { + final String[] digestParts = digest.split(":"); + + final Instant now = Instant.now(); + final StoredFile file = new StoredFile(); + file.key = key; + file.repository = info; + file.created = now; + file.updated = now; + file.contentType = stream.contentType(); + file.contentLength = stream.contentLength(); + file.hashes = Map.of(digestParts[0], digestParts[1]); + file.persist(); + }); } public UploadSessionHandle startUploadSession(final User user, final String namespace) { @@ -335,15 +388,18 @@ public void completeUploadSession(final User user, final ObjectInfo blobInfo = this.backend.headObject(blobKey); - final StoredFile file = new StoredFile(); - file.key = toBlobKey(namespace, digest); - file.repository = info; - file.created = Instant.now(); - file.updated = Instant.now(); - file.contentType = "application/octet-stream"; - file.contentLength = blobInfo.contentLength(); - file.hashes = Map.of(algorithm, hash); - file.persist(); + QuarkusTransaction.runner(TransactionSemantics.JOIN_EXISTING).run(() -> { + final StoredFile file = new StoredFile(); + file.key = toBlobKey(namespace, digest); + file.repository = info; + file.created = Instant.now(); + file.updated = Instant.now(); + file.contentType = "application/octet-stream"; + file.contentLength = blobInfo.contentLength(); + file.hashes = Map.of(algorithm, hash); + file.persist(); + }); + } public void abortUpload(final User user, final String uploadId, final String namespace, final UUID sessionId) { @@ -635,38 +691,40 @@ public PutOCIManifestResult putManifest(final User user, final String fileKey = toManifestKey(namespace, "sha256:" + hash); putFile(fileKey, contents, stream.contentType()); - final Instant now = Instant.now(); - final StoredFile file = storeOrUpdateFileReference( - fileKey, - now, - hash, - stream.contentType(), - stream.contentLength() - ); - final OCISubject subject = createSubjectInfo(namespace, file, contents); + return QuarkusTransaction.runner(TransactionSemantics.JOIN_EXISTING).call(() -> { + final Instant now = Instant.now(); + final StoredFile file = storeOrUpdateFileReference( + fileKey, + now, + hash, + stream.contentType(), + stream.contentLength() + ); + final OCISubject subject = createSubjectInfo(namespace, file, contents); - if (!isDigest(reference)) { - final ArtifactVersion version = updateOrCreateArtifactAndVersion(now, namespace, reference); - version.files.add(file); - version.manifest = file; + if (!isDigest(reference)) { + final ArtifactVersion version = updateOrCreateArtifactAndVersion(now, namespace, reference); + version.files.add(file); + version.manifest = file; - try { - version.details = parseDetails(contents); + try { + version.details = parseDetails(contents); - if (version.details.additionalData() instanceof OCIManifestDetails manifestDetails) { - version.files.addAll(collectAdditionalFileReferences(namespace, manifestDetails)); + if (version.details.additionalData() instanceof OCIManifestDetails manifestDetails) { + version.files.addAll(collectAdditionalFileReferences(namespace, manifestDetails)); + } + } catch (final IOException ex) { + throw new RuntimeException(ex); } - } catch (final IOException ex) { - throw new RuntimeException(ex); - } - return new PutOCIManifestResult( - file, - version, - subject - ); - } + return new PutOCIManifestResult( + file, + version, + subject + ); + } - return new PutOCIManifestResult(file, null, subject); + return new PutOCIManifestResult(file, null, subject); + }); } } diff --git a/server/src/main/java/com/bethibande/repository/repository/oci/OCIRepositoryConfig.java b/server/src/main/java/com/bethibande/repository/repository/oci/config/OCIRepositoryConfig.java similarity index 63% rename from server/src/main/java/com/bethibande/repository/repository/oci/OCIRepositoryConfig.java rename to server/src/main/java/com/bethibande/repository/repository/oci/config/OCIRepositoryConfig.java index 1b77bbc..0d1cf80 100644 --- a/server/src/main/java/com/bethibande/repository/repository/oci/OCIRepositoryConfig.java +++ b/server/src/main/java/com/bethibande/repository/repository/oci/config/OCIRepositoryConfig.java @@ -1,4 +1,4 @@ -package com.bethibande.repository.repository.oci; +package com.bethibande.repository.repository.oci.config; import com.bethibande.repository.repository.S3Config; import io.quarkus.runtime.annotations.RegisterForReflection; @@ -7,6 +7,8 @@ @RegisterForReflection public record OCIRepositoryConfig( @NotNull - S3Config s3Config + S3Config s3Config, + @NotNull + OCIRoutingConfig routingConfig ) { } diff --git a/server/src/main/java/com/bethibande/repository/repository/oci/config/OCIRoutingConfig.java b/server/src/main/java/com/bethibande/repository/repository/oci/config/OCIRoutingConfig.java new file mode 100644 index 0000000..6ec2084 --- /dev/null +++ b/server/src/main/java/com/bethibande/repository/repository/oci/config/OCIRoutingConfig.java @@ -0,0 +1,13 @@ +package com.bethibande.repository.repository.oci.config; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record OCIRoutingConfig( + boolean enabled, + String targetService, + int targetPort, + String gatewayName, + String gatewayNamespace +) { +} diff --git a/server/src/main/java/com/bethibande/repository/repository/oci/details/OCILayerReference.java b/server/src/main/java/com/bethibande/repository/repository/oci/details/OCILayerReference.java index bccf646..563c25f 100644 --- a/server/src/main/java/com/bethibande/repository/repository/oci/details/OCILayerReference.java +++ b/server/src/main/java/com/bethibande/repository/repository/oci/details/OCILayerReference.java @@ -1,5 +1,8 @@ package com.bethibande.repository.repository.oci.details; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public record OCILayerReference( String digest ) { diff --git a/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestDetails.java b/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestDetails.java index 3f08402..a16db52 100644 --- a/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestDetails.java +++ b/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestDetails.java @@ -1,7 +1,10 @@ package com.bethibande.repository.repository.oci.details; +import io.quarkus.runtime.annotations.RegisterForReflection; + import java.util.List; +@RegisterForReflection public record OCIManifestDetails( String configDigest, List manifests, diff --git a/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestReference.java b/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestReference.java index 4862ba5..76b8ce9 100644 --- a/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestReference.java +++ b/server/src/main/java/com/bethibande/repository/repository/oci/details/OCIManifestReference.java @@ -1,5 +1,8 @@ package com.bethibande.repository.repository.oci.details; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public record OCIManifestReference( String digest, String architecture, diff --git a/server/src/main/java/com/bethibande/repository/security/BearerTokenAuthenticationRequest.java b/server/src/main/java/com/bethibande/repository/security/BearerTokenAuthenticationRequest.java index 9da2daf..4ef51bc 100644 --- a/server/src/main/java/com/bethibande/repository/security/BearerTokenAuthenticationRequest.java +++ b/server/src/main/java/com/bethibande/repository/security/BearerTokenAuthenticationRequest.java @@ -1,7 +1,9 @@ package com.bethibande.repository.security; +import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.security.identity.request.BaseAuthenticationRequest; +@RegisterForReflection public class BearerTokenAuthenticationRequest extends BaseAuthenticationRequest { private final String accessToken; diff --git a/server/src/main/java/com/bethibande/repository/web/api/RepositoryEndpoint.java b/server/src/main/java/com/bethibande/repository/web/api/RepositoryEndpoint.java index dccf3e1..f543792 100644 --- a/server/src/main/java/com/bethibande/repository/web/api/RepositoryEndpoint.java +++ b/server/src/main/java/com/bethibande/repository/web/api/RepositoryEndpoint.java @@ -2,14 +2,14 @@ import com.bethibande.repository.jpa.artifact.Artifact; import com.bethibande.repository.jpa.artifact.ArtifactVersion; -import com.bethibande.repository.jpa.repository.PublicRepositoryDTO; -import com.bethibande.repository.jpa.repository.Repository; -import com.bethibande.repository.jpa.repository.RepositoryDTO; -import com.bethibande.repository.jpa.repository.RepositoryDTOWithoutId; +import com.bethibande.repository.jpa.repository.*; import com.bethibande.repository.jpa.repository.permissions.PermissionScope; import com.bethibande.repository.jpa.repository.permissions.UserSelectionType; import com.bethibande.repository.jpa.user.User; import com.bethibande.repository.jpa.user.UserRole; +import com.bethibande.repository.repository.ManagedRepository; +import com.bethibande.repository.repository.RepositoryUpdatedNotifier; +import com.bethibande.repository.repository.UpdateType; import com.bethibande.repository.web.AuthenticatedUser; import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; @@ -31,10 +31,19 @@ public class RepositoryEndpoint { private final AuthenticatedUser authenticatedUser; + private final RepositoryManager repositoryManager; @Inject - public RepositoryEndpoint(final AuthenticatedUser authenticatedUser) { + public RepositoryEndpoint(final AuthenticatedUser authenticatedUser, final RepositoryManager repositoryManager) { this.authenticatedUser = authenticatedUser; + this.repositoryManager = repositoryManager; + } + + protected void processUpdate(final Repository entity, final UpdateType type) { + final ManagedRepository managed = repositoryManager.manage(entity); + if (managed instanceof RepositoryUpdatedNotifier notifier) { + notifier.processUpdate(type); + } } @POST @@ -49,6 +58,8 @@ public RepositoryDTO create(final RepositoryDTOWithoutId dto) { repository.persist(); + processUpdate(repository, UpdateType.CREATE); + return RepositoryDTO.from(repository); } @@ -64,6 +75,8 @@ public RepositoryDTO update(final RepositoryDTO dto) { repository.cleanupPolicies = dto.cleanupPolicies(); repository.persist(); + processUpdate(repository, UpdateType.UPDATE); + return RepositoryDTO.from(repository); } @@ -179,6 +192,11 @@ public void delete(@PathParam("id") final Long id) { if (Artifact.count("repository.id = ?1", id) > 0) throw new ClientErrorException("Cannot delete repository with artifacts", HttpStatus.SC_CONFLICT); + final Repository repository = Repository.findById(id); + if (repository != null) { + processUpdate(repository, UpdateType.DELETE); + } + Repository.deleteById(id); } diff --git a/server/src/main/java/com/bethibande/repository/web/api/SystemEndpoint.java b/server/src/main/java/com/bethibande/repository/web/api/SystemEndpoint.java new file mode 100644 index 0000000..ce9ed97 --- /dev/null +++ b/server/src/main/java/com/bethibande/repository/web/api/SystemEndpoint.java @@ -0,0 +1,32 @@ +package com.bethibande.repository.web.api; + +import com.bethibande.repository.k8s.KubernetesSupport; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@RolesAllowed("ADMIN") +@Path("/api/v1/system") +public class SystemEndpoint { + + @Inject + protected KubernetesSupport kubernetesSupport; + + public record KubernetesCapabilities( + @NotNull boolean enabled, + @NotNull boolean routing + ) { + } + + @GET + @Path("/k8s/capabilities") + public @NotNull KubernetesCapabilities hasKubernetesRoutingSupport() { + return new KubernetesCapabilities( + kubernetesSupport.isEnabled(), + kubernetesSupport.hasHttpRouteSupport() + ); + } + +} diff --git a/server/src/main/java/com/bethibande/repository/web/repositories/OCIRepositoryEndpoint.java b/server/src/main/java/com/bethibande/repository/web/repositories/OCIRepositoryEndpoint.java index 8d60731..d074266 100644 --- a/server/src/main/java/com/bethibande/repository/web/repositories/OCIRepositoryEndpoint.java +++ b/server/src/main/java/com/bethibande/repository/web/repositories/OCIRepositoryEndpoint.java @@ -21,7 +21,10 @@ import com.fasterxml.jackson.databind.node.IntNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.narayana.jta.TransactionSemantics; import io.quarkus.security.UnauthorizedException; +import io.vertx.ext.web.RoutingContext; import jakarta.inject.Inject; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; @@ -35,6 +38,7 @@ import jakarta.ws.rs.core.UriInfo; import org.apache.http.HttpHeaders; import org.apache.http.entity.ContentType; +import org.hibernate.Hibernate; import org.jboss.resteasy.reactive.server.ServerResponseFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,9 +71,16 @@ public class OCIRepositoryEndpoint { @Context protected UriInfo uriInfo; + @Inject + public OCIRepositoryEndpoint(final RepositoryManager repositoryManager, final AuthenticatedUser authenticatedUser) { + this.repositoryManager = repositoryManager; + this.authenticatedUser = authenticatedUser; + } + @ServerResponseFilter - public void authResponseInterceptor(final ContainerResponseContext context) { + public void authResponseInterceptor(final ContainerResponseContext context, final RoutingContext routing) { if (!uriInfo.getPath().startsWith("/repositories/oci")) return; + if (context.getStatus() == 401) { String baseUri = uriInfo.getBaseUri().toString(); if (baseUri.endsWith("/")) { @@ -87,20 +98,18 @@ public void authResponseInterceptor(final ContainerResponseContext context) { } } - @Inject - public OCIRepositoryEndpoint(final RepositoryManager repositoryManager, final AuthenticatedUser authenticatedUser) { - this.repositoryManager = repositoryManager; - this.authenticatedUser = authenticatedUser; - } - protected OCIRepository repositoryOrThrow(final String repositoryId) { - final OCIRepository repository = repositoryManager.findRepository(repositoryId, PackageManager.OCI); - if (repository == null) throw new NotFoundException( - Response.status(Response.Status.NOT_FOUND) - .entity(OCIError.of(OCIErrorCodes.NAME_UNKNOWN, "Unknown repository", "The specified repository does not exist")) - .build() - ); - return repository; + return QuarkusTransaction.runner(TransactionSemantics.JOIN_EXISTING).call(() -> { + final OCIRepository repository = repositoryManager.findRepository(repositoryId, PackageManager.OCI); + if (repository == null) throw new NotFoundException( + Response.status(Response.Status.NOT_FOUND) + .entity(OCIError.of(OCIErrorCodes.NAME_UNKNOWN, "Unknown repository", "The specified repository does not exist")) + .build() + ); + + Hibernate.initialize(repository.getInfo().permissions); + return repository; + }); } @GET @@ -155,7 +164,6 @@ public Response get(final @PathParam("repositoryId") String repositoryId) { } @HEAD - @Transactional @Path("/{namespace: .*}/blobs/{digest}") public Response headBlob(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -174,7 +182,6 @@ public Response headBlob(final @PathParam("repositoryId") String repositoryId, } @HEAD - @Transactional @Path("/{namespace: .*}/manifests/{reference}") public Response headManifest(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -209,7 +216,6 @@ protected StreamHandle getBlob(final OCIRepository repository, } @GET - @Transactional @Path("/{namespace: .*}/blobs/{digest}") public Response getBlob(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -229,7 +235,6 @@ public Response getBlob(final @PathParam("repositoryId") String repositoryId, } @GET - @Transactional @Path("/{namespace: .*}/manifests/{reference}") public Response getManifest(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -250,7 +255,6 @@ public Response getManifest(final @PathParam("repositoryId") String repositoryId } @POST - @Transactional @Path("/{namespace: .*}/blobs/uploads") public Response createUpload(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -293,7 +297,6 @@ public Response createUpload(final @PathParam("repositoryId") String repositoryI } @PATCH - @Transactional @Path("/{namespace: .*}/blobs/uploads/{sessionId}") public Response uploadChunk(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -336,7 +339,6 @@ public Response uploadChunk(final @PathParam("repositoryId") String repositoryId } @PUT - @Transactional @Path("/{namespace: .*}/blobs/uploads/{sessionId}") public Response completeUpload(final @PathParam("repositoryId") String repositoryId, final @PathParam("namespace") String namespace, @@ -346,34 +348,28 @@ public Response completeUpload(final @PathParam("repositoryId") String repositor final @QueryParam("part") int partNumber, final @HeaderParam(HttpHeaders.CONTENT_LENGTH) Long contentLength, final InputStream content) { - try { - final OCIRepository repository = repositoryOrThrow(repositoryId); - final User user = authenticatedUser.getSelf(); - final UploadSessionHandle handle = new UploadSessionHandle(sessionId, uploadId); + final OCIRepository repository = repositoryOrThrow(repositoryId); + final User user = authenticatedUser.getSelf(); + final UploadSessionHandle handle = new UploadSessionHandle(sessionId, uploadId); - if (contentLength != null && contentLength > 0) { - final StreamHandle streamHandle = new StreamHandle(content, ContentType.APPLICATION_OCTET_STREAM.getMimeType(), contentLength); - repository.uploadPart(user, namespace, handle, partNumber, streamHandle); - } + if (contentLength != null && contentLength > 0) { + final StreamHandle streamHandle = new StreamHandle(content, ContentType.APPLICATION_OCTET_STREAM.getMimeType(), contentLength); + repository.uploadPart(user, namespace, handle, partNumber, streamHandle); + } - repository.completeUploadSession(user, namespace, digest, handle); + repository.completeUploadSession(user, namespace, digest, handle); - final String url = "/v2/%s/blobs/%s".formatted(namespace, digest); - return Response.created(URI.create(url)) - .build(); - } catch (final Throwable th) { - System.err.println(th.getMessage()); - return Response.serverError().build(); - } + final String url = "/v2/%s/blobs/%s".formatted(namespace, digest); + return Response.created(URI.create(url)) + .build(); } @GET - @Transactional @Path("/{namespace: .*}/blobs/uploads/{sessionId}") - public Response completeUpload(final @PathParam("repositoryId") String repositoryId, - final @PathParam("namespace") String namespace, - final @PathParam("sessionId") UUID sessionId, - final @QueryParam("uploadId") String uploadId) { + public Response getUploadStatus(final @PathParam("repositoryId") String repositoryId, + final @PathParam("namespace") String namespace, + final @PathParam("sessionId") UUID sessionId, + final @QueryParam("uploadId") String uploadId) { final OCIRepository repository = repositoryOrThrow(repositoryId); final User user = authenticatedUser.getSelf(); diff --git a/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIError.java b/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIError.java index 9764365..2a4e60e 100644 --- a/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIError.java +++ b/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIError.java @@ -1,7 +1,10 @@ package com.bethibande.repository.web.repositories.oci; +import io.quarkus.runtime.annotations.RegisterForReflection; + import java.util.List; +@RegisterForReflection public record OCIError( List errors ) { diff --git a/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIErrorEntry.java b/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIErrorEntry.java index cebdb58..0354ad3 100644 --- a/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIErrorEntry.java +++ b/server/src/main/java/com/bethibande/repository/web/repositories/oci/OCIErrorEntry.java @@ -1,5 +1,8 @@ package com.bethibande.repository.web.repositories.oci; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public record OCIErrorEntry( String code, String message, diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index 0814e44..0b21b9e 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -2,6 +2,7 @@ quarkus.liquibase.change-log=classpath:liquibase/master.xml quarkus.liquibase.migrate-at-start=true quarkus.http.limits.max-body-size=10G +quarkus.http.proxy.proxy-address-forwarding=true quarkus.http.proxy.allow-xForwarded=true %dev.search.hosts=localhost:9200 diff --git a/server/src/main/webui/src/components/repository/OCIConfigForm.tsx b/server/src/main/webui/src/components/repository/OCIConfigForm.tsx index cb78a8f..8fa3c59 100644 --- a/server/src/main/webui/src/components/repository/OCIConfigForm.tsx +++ b/server/src/main/webui/src/components/repository/OCIConfigForm.tsx @@ -1,11 +1,24 @@ -import { z } from "zod"; -import { type Control, type FieldPath, type FieldValues } from "react-hook-form"; -import { Card, CardContent } from "@/components/ui/card.tsx"; -import { s3Schema, S3ConfigForm } from "@/components/repository/S3ConfigForm.tsx"; -import { FormField } from "@/components/form-field.tsx"; +import {z} from "zod"; +import {type Control, Controller, type FieldPath, type FieldValues, useWatch} from "react-hook-form"; +import {Card, CardContent} from "@/components/ui/card.tsx"; +import {S3ConfigForm, s3Schema} from "@/components/repository/S3ConfigForm.tsx"; +import {FormField} from "@/components/form-field.tsx"; +import {useEffect, useState} from "react"; +import {SystemEndpointApi} from "@/generated"; +import {Switch} from "@/components/ui/switch.tsx"; export const ociSchema = z.object({ s3Config: s3Schema, + routingConfig: z.object({ + enabled: z.boolean(), + targetService: z.string().optional(), + targetPort: z.coerce.number().min(1).max(65535).optional(), + gatewayName: z.string().optional(), + gatewayNamespace: z.string().optional(), + }).refine(data => !data.enabled || (data.targetService && data.targetPort && data.gatewayName && data.gatewayNamespace), { + message: "All routing fields are required when routing is enabled", + path: ["enabled"] + }), }); export type OCIConfig = z.infer; @@ -17,6 +30,13 @@ export const defaultOCIConfig: OCIConfig = { bucket: "", accessKey: "", secretKey: "" + }, + routingConfig: { + enabled: false, + targetService: "", + targetPort: 80, + gatewayName: "", + gatewayNamespace: "" } }; @@ -26,6 +46,24 @@ interface OCIConfigFormProps { } export function OCIConfigForm({ control, prefix }: OCIConfigFormProps) { + const [k8sRoutingSupported, setK8sRoutingSupported] = useState(false); + const routingToggleId = "oci-routing-toggle"; + + useEffect(() => { + new SystemEndpointApi().apiV1SystemK8sCapabilitiesGet() + .then(capabilities => { + setK8sRoutingSupported(capabilities.routing); + }) + .catch(err => { + console.error("Failed to fetch k8s capabilities", err); + }); + }, []); + + const routingEnabled = useWatch({ + control, + name: `${prefix}.routingConfig.enabled` as any + }); + return (
@@ -33,16 +71,74 @@ export function OCIConfigForm({ control, prefi

External Access

- - } - control={control} - placeholder="oci.test.org:8080" - /> -

- The expected host name for connecting to the OCI repository (e.g., host or host:port). -

+ +
+ } + control={control} + placeholder="oci.test.org:8080" + /> +

+ The expected host name for connecting to the OCI repository (e.g., host or host:port). +

+
+ + {k8sRoutingSupported && ( +
+
+
+ +

+ Automatically create a Kubernetes HTTPRoute for this repository. +

+
+
+ ( + + )} + /> +
+
+ + {routingEnabled && ( +
+ + + + +
+ )} +
+ )}
diff --git a/server/src/main/webui/src/view/dashboard/RepositoryEditView.tsx b/server/src/main/webui/src/view/dashboard/RepositoryEditView.tsx index 9aaa997..28d9ae5 100644 --- a/server/src/main/webui/src/view/dashboard/RepositoryEditView.tsx +++ b/server/src/main/webui/src/view/dashboard/RepositoryEditView.tsx @@ -54,6 +54,7 @@ export default function RepositoryEditView() { const pmConfig = CONFIG_MAPPING[selectedPackageManager]; const [initialPermissions, setInitialPermissions] = useState([]); + const [initialMetadata, setInitialMetadata] = useState>({}); useEffect(() => { if (isEdit) { @@ -92,7 +93,12 @@ export default function RepositoryEditView() { setInitialPermissions([...mappedPermissions]); if (repo.metadata) { - form.setValue("externalHost", repo.metadata["HOST_NAME"]); + setInitialMetadata(repo.metadata as any); + if ((repo.metadata as any)["HOST_NAME"]) { + form.setValue("externalHost", (repo.metadata as any)["HOST_NAME"]); + } + } else { + setInitialMetadata({}); } if (repo.settings) { @@ -117,6 +123,12 @@ export default function RepositoryEditView() { ...(settings.mirrorConfig || {}) }; } + if (repo.packageManager === PackageManager.Oci) { + mergedSettings.routingConfig = { + ...(currentPmConfig.defaultValues.routingConfig || {}), + ...(settings.routingConfig || {}) + }; + } } form.setValue(currentPmConfig.configKey as keyof DynamicFormValues, mergedSettings); @@ -143,9 +155,16 @@ export default function RepositoryEditView() { const config = currentPmConfig ? data[currentPmConfig.configKey as keyof DynamicFormValues] : undefined; const settings = config ? JSON.stringify(config) : undefined; - const metadata = data.packageManager === PackageManager.Oci ? { - "HOST_NAME": data.externalHost - } : undefined; + + // Preserve existing metadata and override only the keys managed by this form + const mergedMetadata: Record = isEdit ? { ...initialMetadata } : {}; + if (data.packageManager === PackageManager.Oci) { + if (data.externalHost && data.externalHost.trim().length > 0) { + mergedMetadata["HOST_NAME"] = data.externalHost.trim(); + } + } + // Avoid sending undefined which would wipe metadata server-side + const metadata = mergedMetadata; try { let repoId: number;