diff --git a/README.md b/README.md index 499424d..a498259 100644 --- a/README.md +++ b/README.md @@ -55,25 +55,28 @@ The application can be configured using environment variables or by modifying th ### Environment Variables -| Setting | Default | Required | Description | -|---------------------------------|--------------------------------------------------|------------|--------------------------------------------------------------| -| `APP_DOCKER_REGISTRY` | | Yes | The Docker registry where images are stored. | -| `APP_DIAL_BASE_URL` | | Yes | The base URL for the DIAL service. | -| `APP_DEPLOY_NAMESPACE` | `default` | No | The Kubernetes namespace used for deploying services. | -| `APP_BUILD_NAMESPACE` | `default` | No | The Kubernetes namespace used for building images. | -| `APP_HEARTBEAT_PERIOD_SEC` | `30` | No | The interval in seconds for sending heartbeat events. | -| `APP_IMAGE_NAME_FORMAT` | `app-%s` | No | Format for naming Docker images. | -| `APP_IMAGE_LABEL` | `latest` | No | The label used for Docker images. | -| `APP_IMAGE_BUILD_TIMEOUT_SEC` | `300` | No | Timeout in seconds for building Docker images. | -| `APP_SERVICE_SETUP_TIMEOUT_SEC` | `300` | No | Timeout in seconds for setting up Knative services. | -| `APP_MAX_ERROR_LOG_LINES` | `20` | No | Maximum number of error log lines to return in message. | -| `APP_MAX_ERROR_LOG_CHARS` | `1000` | No | Maximum number of error log characters to return in message. | -| `APP_TEMPLATE_IMAGE` | `${app.docker-registry}/builder-template:latest` | No | The Docker image used as the template for building. | -| `APP_BUILDER_IMAGE` | `gcr.io/kaniko-project/executor:latest` | No | The Docker image used for building applications. | -| `APP_TEMPLATE_CONTAINER` | `template` | No | Name of the template container in Kubernetes job. | -| `APP_BUILDER_CONTAINER` | `builder` | No | Name of the builder container in Kubernetes job. | -| `APP_SERVICE_CONTAINER` | `app-container` | No | Name of the service container. | -| `APP_DEFAULT_RUNTIME` | `python3.11` | No | Default runtime for Python applications. | +| Setting | Default | Required | Description | +|---------------------------------|--------------------------------------------------|--------------|--------------------------------------------------------------| +| `APP_DOCKER_REGISTRY` | | Yes | The Docker registry where images are stored. | +| `APP_DIAL_BASE_URL` | | Yes | The base URL for the DIAL service. | +| `APP_DEPLOY_NAMESPACE` | `default` | No | The Kubernetes namespace used for deploying services. | +| `APP_BUILD_NAMESPACE` | `default` | No | The Kubernetes namespace used for building images. | +| `APP_HEARTBEAT_PERIOD_SEC` | `30` | No | The interval in seconds for sending heartbeat events. | +| `APP_IMAGE_NAME_FORMAT` | `app-%s` | No | Format for naming Docker images. | +| `APP_IMAGE_LABEL` | `latest` | No | The label used for Docker images. | +| `APP_IMAGE_BUILD_TIMEOUT_SEC` | `300` | No | Timeout in seconds for building Docker images. | +| `APP_SERVICE_SETUP_TIMEOUT_SEC` | `300` | No | Timeout in seconds for setting up Knative services. | +| `APP_MAX_ERROR_LOG_LINES` | `20` | No | Maximum number of error log lines to return in message. | +| `APP_MAX_ERROR_LOG_CHARS` | `1000` | No | Maximum number of error log characters to return in message. | +| `APP_TEMPLATE_IMAGE` | `${app.docker-registry}/builder-template:latest` | No | The Docker image used as the template for building. | +| `APP_BUILDER_IMAGE` | `gcr.io/kaniko-project/executor:latest` | No | The Docker image used for building applications. | +| `APP_TEMPLATE_CONTAINER` | `template` | No | Name of the template container in Kubernetes job. | +| `APP_BUILDER_CONTAINER` | `builder` | No | Name of the builder container in Kubernetes job. | +| `APP_SERVICE_CONTAINER` | `app-container` | No | Name of the service container. | +| `APP_DEFAULT_RUNTIME` | `python3.11` | No | Default runtime for Python applications. | +| `APP_DOCKER_REGISTRY_AUTH` | `NONE` | No | Authentication method for Docker registry (NONE, BASIC). | +| `APP_DOCKER_REGISTRY_USER` | | Conditional | Username for Docker registry when auth is BASIC. | +| `APP_DOCKER_REGISTRY_PASS` | | Conditional | Password for Docker registry when auth is BASIC. | ## Usage diff --git a/build.gradle b/build.gradle index 2b0b875..d2611ff 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web:3.4.0' implementation 'org.springframework.boot:spring-boot-starter-webflux:3.4.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2' testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/src/main/java/com/epam/aidial/config/AppConfiguration.java b/src/main/java/com/epam/aidial/config/AppConfiguration.java index 5fb466b..37960f2 100644 --- a/src/main/java/com/epam/aidial/config/AppConfiguration.java +++ b/src/main/java/com/epam/aidial/config/AppConfiguration.java @@ -1,66 +1,66 @@ -package com.epam.aidial.config; - -import com.epam.aidial.kubernetes.knative.V1Service; -import io.kubernetes.client.openapi.models.V1Job; -import io.kubernetes.client.openapi.models.V1Secret; -import io.kubernetes.client.util.Yaml; -import lombok.Data; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.Map; - -@Component -@ConfigurationProperties(prefix = "app") -public class AppConfiguration { - @Getter - private V1Secret secretConfig; - private String secretConfigString; - - @Getter - private V1Job jobConfig; - private String jobConfigString; - - @Getter - private V1Service serviceConfig; - private String serviceConfigString; - - @Getter - @Setter - private Map runtimes; - - public void setSecretConfig(V1Secret secretConfig) { - this.secretConfig = secretConfig; - this.secretConfigString = Yaml.dump(secretConfig); - } - - public void setJobConfig(V1Job jobConfig) { - this.jobConfig = jobConfig; - this.jobConfigString = Yaml.dump(jobConfig); - } - - public void setServiceConfig(V1Service serviceConfig) { - this.serviceConfig = serviceConfig; - this.serviceConfigString = Yaml.dump(serviceConfig); - } - - public V1Secret cloneSecretConfig() { - return Yaml.loadAs(secretConfigString, V1Secret.class); - } - - public V1Job cloneJobConfig() { - return Yaml.loadAs(jobConfigString, V1Job.class); - } - - public V1Service cloneServiceConfig() { - return Yaml.loadAs(serviceConfigString, V1Service.class); - } - - @Data - public static class RuntimeConfiguration { - private String image; - private String profile; - } -} +package com.epam.aidial.config; + +import com.epam.aidial.kubernetes.knative.V1Service; +import io.kubernetes.client.openapi.models.V1Job; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.util.Yaml; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@ConfigurationProperties(prefix = "app") +public class AppConfiguration { + @Getter + private V1Secret secretConfig; + private String secretConfigString; + + @Getter + private V1Job jobConfig; + private String jobConfigString; + + @Getter + private V1Service serviceConfig; + private String serviceConfigString; + + @Getter + @Setter + private Map runtimes; + + public void setSecretConfig(V1Secret secretConfig) { + this.secretConfig = secretConfig; + this.secretConfigString = Yaml.dump(secretConfig); + } + + public void setJobConfig(V1Job jobConfig) { + this.jobConfig = jobConfig; + this.jobConfigString = Yaml.dump(jobConfig); + } + + public void setServiceConfig(V1Service serviceConfig) { + this.serviceConfig = serviceConfig; + this.serviceConfigString = Yaml.dump(serviceConfig); + } + + public V1Secret cloneSecretConfig() { + return Yaml.loadAs(secretConfigString, V1Secret.class); + } + + public V1Job cloneJobConfig() { + return Yaml.loadAs(jobConfigString, V1Job.class); + } + + public V1Service cloneServiceConfig() { + return Yaml.loadAs(serviceConfigString, V1Service.class); + } + + @Data + public static class RuntimeConfiguration { + private String image; + private String profile; + } +} diff --git a/src/main/java/com/epam/aidial/config/DockerAuthScheme.java b/src/main/java/com/epam/aidial/config/DockerAuthScheme.java new file mode 100644 index 0000000..a329d50 --- /dev/null +++ b/src/main/java/com/epam/aidial/config/DockerAuthScheme.java @@ -0,0 +1,6 @@ +package com.epam.aidial.config; + +public enum DockerAuthScheme { + NONE, + BASIC +} diff --git a/src/main/java/com/epam/aidial/config/WebFluxConfig.java b/src/main/java/com/epam/aidial/config/WebFluxConfig.java index b110d57..1cc4e6f 100644 --- a/src/main/java/com/epam/aidial/config/WebFluxConfig.java +++ b/src/main/java/com/epam/aidial/config/WebFluxConfig.java @@ -1,33 +1,33 @@ -package com.epam.aidial.config; - -import com.epam.aidial.util.KubernetesUtils; -import io.kubernetes.client.openapi.ApiClient; -import okhttp3.OkHttpClient; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.io.IOException; - -@Configuration -public class WebFluxConfig { - @Bean - public ApiClient buildKubeClient( - @Value("${app.kube-config}") String configPath, - @Value("${app.build-context:#{null}}") String context) throws IOException { - return KubernetesUtils.createClient(configPath, context); - } - - @Bean - public ApiClient deployKubeClient( - @Value("${app.kube-config}") String configPath, - @Value("${app.deploy-context:#{null}}") String context) throws IOException { - return KubernetesUtils.createClient(configPath, context); - } - - @Bean - public OkHttpClient okHttpClient() { - return new OkHttpClient.Builder() - .build(); - } -} +package com.epam.aidial.config; + +import com.epam.aidial.util.KubernetesUtils; +import io.kubernetes.client.openapi.ApiClient; +import okhttp3.OkHttpClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +@Configuration +public class WebFluxConfig { + @Bean + public ApiClient buildKubeClient( + @Value("${app.kube-config}") String configPath, + @Value("${app.build-context:#{null}}") String context) throws IOException { + return KubernetesUtils.createClient(configPath, context); + } + + @Bean + public ApiClient deployKubeClient( + @Value("${app.kube-config}") String configPath, + @Value("${app.deploy-context:#{null}}") String context) throws IOException { + return KubernetesUtils.createClient(configPath, context); + } + + @Bean + public OkHttpClient okHttpClient() { + return new OkHttpClient.Builder() + .build(); + } +} diff --git a/src/main/java/com/epam/aidial/service/ConfigService.java b/src/main/java/com/epam/aidial/service/ConfigService.java index 833ec86..99eaa1a 100644 --- a/src/main/java/com/epam/aidial/service/ConfigService.java +++ b/src/main/java/com/epam/aidial/service/ConfigService.java @@ -2,6 +2,7 @@ import com.epam.aidial.config.AppConfiguration; +import com.epam.aidial.config.DockerAuthScheme; import com.epam.aidial.kubernetes.knative.V1Service; import com.epam.aidial.util.mapping.ListMapper; import com.epam.aidial.util.mapping.MappingChain; @@ -12,6 +13,8 @@ import io.kubernetes.client.openapi.models.V1PodSpec; import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.openapi.models.V1SecretEnvSource; +import io.kubernetes.client.openapi.models.V1SecretVolumeSource; +import io.kubernetes.client.openapi.models.V1VolumeMount; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -29,6 +32,7 @@ import static com.epam.aidial.util.mapping.Mappers.CONTAINER_ENV_FIELD; import static com.epam.aidial.util.mapping.Mappers.CONTAINER_ENV_FROM_FIELD; import static com.epam.aidial.util.mapping.Mappers.CONTAINER_NAME; +import static com.epam.aidial.util.mapping.Mappers.CONTAINER_VOLUME_MOUNTS_FIELD; import static com.epam.aidial.util.mapping.Mappers.ENV_VAR_NAME; import static com.epam.aidial.util.mapping.Mappers.JOB_METADATA_FIELD; import static com.epam.aidial.util.mapping.Mappers.JOB_SPEC_FIELD; @@ -36,19 +40,24 @@ import static com.epam.aidial.util.mapping.Mappers.JOB_TEMPLATE_SPEC_FIELD; import static com.epam.aidial.util.mapping.Mappers.POD_CONTAINERS_FIELD; import static com.epam.aidial.util.mapping.Mappers.POD_INIT_CONTAINERS_FIELD; +import static com.epam.aidial.util.mapping.Mappers.POD_VOLUMES_FIELD; import static com.epam.aidial.util.mapping.Mappers.SECRET_METADATA_FIELD; import static com.epam.aidial.util.mapping.Mappers.SERVICE_METADATA_FIELD; import static com.epam.aidial.util.mapping.Mappers.SERVICE_SPEC_FIELD; import static com.epam.aidial.util.mapping.Mappers.SERVICE_TEMPLATE_FIELD; import static com.epam.aidial.util.mapping.Mappers.SERVICE_TEMPLATE_SPEC_FIELD; import static com.epam.aidial.util.mapping.Mappers.TEMPLATE_CONTAINERS_FIELD; +import static com.epam.aidial.util.mapping.Mappers.VOLUME_MOUNT_PATH; +import static com.epam.aidial.util.mapping.Mappers.VOLUME_NAME; @Slf4j @Service @RequiredArgsConstructor public class ConfigService { + private static final String DOCKER_CONFIG_KEY = "docker.config"; + private final RegistryService registryService; - private final AppConfiguration appConfiguration; + private final AppConfiguration appconfig; @Value("${app.template-container}") private final String pullerContainer; @@ -59,6 +68,9 @@ public class ConfigService { @Value("${app.service-container}") private final String serviceContainer; + @Value("${app.docker-config-path}") + private final String dockerConfigPath; + public V1Secret dialAuthSecretConfig(String name, String apiKey, String jwt) { Map creds = new HashMap<>(); if (StringUtils.isNotBlank(apiKey)) { @@ -67,8 +79,11 @@ public V1Secret dialAuthSecretConfig(String name, String apiKey, String jwt) { if (StringUtils.isNotBlank(jwt)) { creds.put("JWT", jwt); } + if (registryService.getAuthScheme() == DockerAuthScheme.BASIC) { + creds.put(DOCKER_CONFIG_KEY, registryService.dockerConfig()); + } - MappingChain config = new MappingChain<>(this.appConfiguration.cloneSecretConfig()); + MappingChain config = new MappingChain<>(this.appconfig.cloneSecretConfig()); config.get(SECRET_METADATA_FIELD) .data() .setName(dialAuthSecretName(name)); @@ -80,43 +95,54 @@ public V1Job buildJobConfig(String name, String sources, String runtime) { String targetImage = registryService.fullImageName(name); log.info("Target image: {}", targetImage); - MappingChain config = new MappingChain<>(this.appConfiguration.cloneJobConfig()); + MappingChain config = new MappingChain<>(this.appconfig.cloneJobConfig()); config.get(JOB_METADATA_FIELD) .data() .setName(buildJobName(name)); MappingChain podSpec = config.get(JOB_SPEC_FIELD) .get(JOB_TEMPLATE_FIELD) .get(JOB_TEMPLATE_SPEC_FIELD); - MappingChain puller = podSpec - .getList(POD_INIT_CONTAINERS_FIELD, CONTAINER_NAME) + MappingChain puller = podSpec.getList(POD_INIT_CONTAINERS_FIELD, CONTAINER_NAME) .get(pullerContainer); puller.getList(CONTAINER_ENV_FIELD, ENV_VAR_NAME) .get("SOURCES") .data() .setValue(sources); - AppConfiguration.RuntimeConfiguration runtimeConfig = appConfiguration.getRuntimes().get(runtime); + AppConfiguration.RuntimeConfiguration runtimeConfig = this.appconfig.getRuntimes().get(runtime); if (runtimeConfig == null) { throw new IllegalArgumentException( - "Unsupported runtime: %s. Supported: %s".formatted(runtime, appConfiguration.getRuntimes().keySet())); + "Unsupported runtime: %s. Supported: %s".formatted(runtime, this.appconfig.getRuntimes().keySet())); } + String secretName = dialAuthSecretName(name); puller.get(CONTAINER_ENV_FROM_FIELD) .data() - .add(new V1EnvFromSource().secretRef( - new V1SecretEnvSource().name(dialAuthSecretName(name)))); - podSpec.getList(POD_CONTAINERS_FIELD, CONTAINER_NAME) - .get(builderContainer) - .get(CONTAINER_ARGS_FIELD) + .add(new V1EnvFromSource().secretRef(new V1SecretEnvSource().name(secretName))); + MappingChain builder = podSpec.getList(POD_CONTAINERS_FIELD, CONTAINER_NAME) + .get(builderContainer); + builder.get(CONTAINER_ARGS_FIELD) .data() .addAll(List.of( "--dockerfile=/templates/%s/Dockerfile".formatted(runtimeConfig.getProfile()), "--destination=%s".formatted(targetImage), "--build-arg=PYTHON_IMAGE=%s".formatted(runtimeConfig.getImage()))); + if (registryService.getAuthScheme() == DockerAuthScheme.BASIC) { + String volumeName = "secret-volume"; + podSpec.getList(POD_VOLUMES_FIELD, VOLUME_NAME) + .get(volumeName) + .data() + .setSecret(new V1SecretVolumeSource().secretName(secretName)); + V1VolumeMount volumeMount = builder.getList(CONTAINER_VOLUME_MOUNTS_FIELD, VOLUME_MOUNT_PATH) + .get(dockerConfigPath) + .data(); + volumeMount.setName(volumeName); + volumeMount.setSubPath(DOCKER_CONFIG_KEY); + } return config.data(); } public V1Service appServiceConfig(String name, Map env) { - MappingChain config = new MappingChain<>(this.appConfiguration.cloneServiceConfig()); + MappingChain config = new MappingChain<>(this.appconfig.cloneServiceConfig()); config.get(SERVICE_METADATA_FIELD) .data() .setName(appName(name)); diff --git a/src/main/java/com/epam/aidial/service/RegistryService.java b/src/main/java/com/epam/aidial/service/RegistryService.java index bcb90b5..156c674 100644 --- a/src/main/java/com/epam/aidial/service/RegistryService.java +++ b/src/main/java/com/epam/aidial/service/RegistryService.java @@ -1,121 +1,177 @@ -package com.epam.aidial.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; -import reactor.core.publisher.Mono; - -import java.io.IOException; -import java.net.URI; - -@Slf4j -@Service -@RequiredArgsConstructor -public class RegistryService { - private static final String MANIFEST_URL_TEMPLATE = "%s://%s/v2/%s/manifests/%s"; - private final OkHttpClient okHttpClient; - - @Value("${app.docker-registry}") - private final String dockerRegistry; - - @Value("${app.docker-registry-protocol}") - private final URI dockerRegistryProtocol; - - @Value("${app.image-name-format}") - private final String imageFormat; - - @Value("${app.image-label}") - private final String imageLabel; - - public Mono getDigest(String image) { - return getDigest("application/vnd.oci.image.manifest.v1+json", image) - .switchIfEmpty(getDigest("application/vnd.docker.distribution.manifest.v2+json", image)); - } - - private Mono getDigest(String manifestVersion, String name) { - return Mono.create(sink -> { - String imageName = imageName(name); - log.info("Retrieving digest for {} from manifest {}", imageName, manifestVersion); - String url = MANIFEST_URL_TEMPLATE.formatted( - dockerRegistryProtocol, dockerRegistry, imageName, imageLabel); - Request request = new Request.Builder() - .head() - .url(url) - .header("Accept", manifestVersion) - .build(); - okHttpClient.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - sink.error(e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - if (response.code() == 404) { - sink.success(); - } else if (response.isSuccessful()) { - String digest = response.header("Docker-Content-Digest"); - if (StringUtils.isBlank(digest)) { - sink.error(new IllegalStateException( - "Missing digest in manifest %s response".formatted(manifestVersion))); - } else { - log.info("Retrieved {} digest for image {} and label {}: {}", - manifestVersion, imageName, imageLabel, digest); - sink.success(digest); - } - } else { - sink.error(new ResponseStatusException(response.code(), response.message(), null)); - } - } - }); - }); - } - - public Mono deleteManifest(String name, String digest) { - return Mono.create(sink -> { - String imageName = imageName(name); - log.info("Deleting {} manifest", imageName); - String url = MANIFEST_URL_TEMPLATE.formatted( - dockerRegistryProtocol, dockerRegistry, imageName, digest); - Request request = new Request.Builder() - .delete() - .url(url) - .build(); - okHttpClient.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - sink.error(e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - if (response.code() == 404) { - sink.success(false); - } else if (response.isSuccessful()) { - log.info("Deleted image {} with digest {}", imageName, digest); - sink.success(true); - } else { - sink.error(new ResponseStatusException(response.code(), response.message(), null)); - } - } - }); - }); - } - - public String fullImageName(String name) { - return "%s/%s:%s".formatted(dockerRegistry, imageName(name), imageLabel); - } - - private String imageName(String name) { - return imageFormat.formatted(name); - } -} +package com.epam.aidial.service; + +import com.epam.aidial.config.DockerAuthScheme; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Credentials; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import javax.annotation.PostConstruct; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegistryService { + private static final String API_URL_TEMPLATE = "%s://%s/v2"; + private static final String MANIFEST_URL_TEMPLATE = API_URL_TEMPLATE + "/%s/manifests/%s"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final OkHttpClient okHttpClient; + + @Value("${app.docker-registry}") + private final String registry; + + @Value("${app.docker-registry-protocol}") + private final URI registryProtocol; + + @Value("${app.image-name-format}") + private final String imageFormat; + + @Value("${app.image-label}") + private final String imageLabel; + + @Getter + @Value("${app.docker-registry-auth}") + private final DockerAuthScheme authScheme; + + @Nullable + @Value("${app.docker-registry-user:#{null}}") + private final String user; + + @Nullable + @Value("${app.docker-registry-pass:#{null}}") + private final String password; + + @PostConstruct + public void validate() { + if (authScheme == DockerAuthScheme.BASIC + && (StringUtils.isBlank(user) || password == null)) { + throw new IllegalStateException("User and password are required for BASIC docker registry authentication."); + } + } + + public Mono getDigest(String image) { + return getDigest("application/vnd.oci.image.manifest.v1+json", image) + .switchIfEmpty(getDigest("application/vnd.docker.distribution.manifest.v2+json", image)); + } + + private Mono getDigest(String manifestVersion, String name) { + return Mono.create(sink -> { + String imageName = imageName(name); + log.info("Retrieving digest for {} from manifest {}", imageName, manifestVersion); + String url = MANIFEST_URL_TEMPLATE.formatted( + registryProtocol, registry, imageName, imageLabel); + Request request = requestBuilder() + .head() + .url(url) + .header("Accept", manifestVersion) + .build(); + okHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + sink.error(e); + } + + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) { + if (response.code() == 404) { + sink.success(); + } else if (response.isSuccessful()) { + String digest = response.header("Docker-Content-Digest"); + if (StringUtils.isBlank(digest)) { + sink.error(new IllegalStateException( + "Missing digest in manifest %s response".formatted(manifestVersion))); + } else { + log.info("Retrieved {} digest for image {} and label {}: {}", + manifestVersion, imageName, imageLabel, digest); + sink.success(digest); + } + } else { + sink.error(new ResponseStatusException(response.code(), response.message(), null)); + } + } + }); + }); + } + + public Mono deleteManifest(String name, String digest) { + return Mono.create(sink -> { + String imageName = imageName(name); + log.info("Deleting {} manifest", imageName); + String url = MANIFEST_URL_TEMPLATE.formatted( + registryProtocol, registry, imageName, digest); + Request request = requestBuilder() + .delete() + .url(url) + .build(); + okHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@NotNull Call call, @NotNull IOException e) { + sink.error(e); + } + + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) { + if (response.code() == 404) { + sink.success(false); + } else if (response.isSuccessful()) { + log.info("Deleted image {} with digest {}", imageName, digest); + sink.success(true); + } else { + sink.error(new ResponseStatusException(response.code(), response.message(), null)); + } + } + }); + }); + } + + public String fullImageName(String name) { + return "%s/%s:%s".formatted(registry, imageName(name), imageLabel); + } + + @SneakyThrows + public String dockerConfig() { + if (authScheme == DockerAuthScheme.BASIC) { + byte[] bytes = "%s:%s".formatted(user, password).getBytes(StandardCharsets.UTF_8); + String auth = Base64.getEncoder().encodeToString(bytes); + return MAPPER.writeValueAsString(Map.of( + "auths", + Map.of( + API_URL_TEMPLATE.formatted(registryProtocol, registry), + Map.of("auth", auth)))); + } + + return "{}"; + } + + private String imageName(String name) { + return imageFormat.formatted(name); + } + + private Request.Builder requestBuilder() { + Request.Builder builder = new Request.Builder(); + if (authScheme == DockerAuthScheme.BASIC) { + builder.header("Authorization", Credentials.basic(user, password)); + } + + return builder; + } +} diff --git a/src/main/java/com/epam/aidial/util/mapping/Mappers.java b/src/main/java/com/epam/aidial/util/mapping/Mappers.java index d6a6253..5f96cd4 100644 --- a/src/main/java/com/epam/aidial/util/mapping/Mappers.java +++ b/src/main/java/com/epam/aidial/util/mapping/Mappers.java @@ -1,107 +1,129 @@ -package com.epam.aidial.util.mapping; - -import com.epam.aidial.kubernetes.knative.V1RevisionSpec; -import com.epam.aidial.kubernetes.knative.V1RevisionTemplateSpec; -import com.epam.aidial.kubernetes.knative.V1Service; -import com.epam.aidial.kubernetes.knative.V1ServiceSpec; -import io.kubernetes.client.openapi.models.V1Container; -import io.kubernetes.client.openapi.models.V1EnvFromSource; -import io.kubernetes.client.openapi.models.V1EnvVar; -import io.kubernetes.client.openapi.models.V1Job; -import io.kubernetes.client.openapi.models.V1JobSpec; -import io.kubernetes.client.openapi.models.V1ObjectMeta; -import io.kubernetes.client.openapi.models.V1PodSpec; -import io.kubernetes.client.openapi.models.V1PodTemplateSpec; -import io.kubernetes.client.openapi.models.V1Secret; -import lombok.experimental.UtilityClass; - -import java.util.ArrayList; -import java.util.List; - -@UtilityClass -public class Mappers { - public static final NamedItemMapper CONTAINER_NAME = new NamedItemMapper<>( - V1Container::new, - V1Container::getName, - V1Container::setName); - - public static final NamedItemMapper ENV_VAR_NAME = new NamedItemMapper<>( - V1EnvVar::new, - V1EnvVar::getName, - V1EnvVar::setName); - - public static final FieldMapper SECRET_METADATA_FIELD = new FieldMapper<>( - V1ObjectMeta::new, - V1Secret::getMetadata, - V1Secret::setMetadata); - - public static final FieldMapper JOB_METADATA_FIELD = new FieldMapper<>( - V1ObjectMeta::new, - V1Job::getMetadata, - V1Job::setMetadata); - - public static final FieldMapper JOB_SPEC_FIELD = new FieldMapper<>( - V1JobSpec::new, - V1Job::getSpec, - V1Job::setSpec); - - public static final FieldMapper JOB_TEMPLATE_FIELD = new FieldMapper<>( - V1PodTemplateSpec::new, - V1JobSpec::getTemplate, - V1JobSpec::setTemplate); - - public static final FieldMapper JOB_TEMPLATE_SPEC_FIELD = new FieldMapper<>( - V1PodSpec::new, - V1PodTemplateSpec::getSpec, - V1PodTemplateSpec::setSpec); - - public static final FieldMapper> POD_INIT_CONTAINERS_FIELD = new FieldMapper<>( - ArrayList::new, - V1PodSpec::getInitContainers, - V1PodSpec::setInitContainers); - - public static final FieldMapper> POD_CONTAINERS_FIELD = new FieldMapper<>( - ArrayList::new, - V1PodSpec::getContainers, - V1PodSpec::setContainers); - - public static final FieldMapper SERVICE_METADATA_FIELD = new FieldMapper<>( - V1ObjectMeta::new, - V1Service::getMetadata, - V1Service::setMetadata); - - public static final FieldMapper SERVICE_SPEC_FIELD = new FieldMapper<>( - V1ServiceSpec::new, - V1Service::getSpec, - V1Service::setSpec); - - public static final FieldMapper SERVICE_TEMPLATE_FIELD = new FieldMapper<>( - V1RevisionTemplateSpec::new, - V1ServiceSpec::getTemplate, - V1ServiceSpec::setTemplate); - - public static final FieldMapper SERVICE_TEMPLATE_SPEC_FIELD = new FieldMapper<>( - V1RevisionSpec::new, - V1RevisionTemplateSpec::getSpec, - V1RevisionTemplateSpec::setSpec); - - public static final FieldMapper> TEMPLATE_CONTAINERS_FIELD = new FieldMapper<>( - ArrayList::new, - V1RevisionSpec::getContainers, - V1RevisionSpec::setContainers); - - public static final FieldMapper> CONTAINER_ENV_FIELD = new FieldMapper<>( - ArrayList::new, - V1Container::getEnv, - V1Container::setEnv); - - public static final FieldMapper> CONTAINER_ENV_FROM_FIELD = new FieldMapper<>( - ArrayList::new, - V1Container::getEnvFrom, - V1Container::setEnvFrom); - - public static final FieldMapper> CONTAINER_ARGS_FIELD = new FieldMapper<>( - ArrayList::new, - V1Container::getArgs, - V1Container::setArgs); +package com.epam.aidial.util.mapping; + +import com.epam.aidial.kubernetes.knative.V1RevisionSpec; +import com.epam.aidial.kubernetes.knative.V1RevisionTemplateSpec; +import com.epam.aidial.kubernetes.knative.V1Service; +import com.epam.aidial.kubernetes.knative.V1ServiceSpec; +import io.kubernetes.client.openapi.models.V1Container; +import io.kubernetes.client.openapi.models.V1EnvFromSource; +import io.kubernetes.client.openapi.models.V1EnvVar; +import io.kubernetes.client.openapi.models.V1Job; +import io.kubernetes.client.openapi.models.V1JobSpec; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1PodSpec; +import io.kubernetes.client.openapi.models.V1PodTemplateSpec; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1Volume; +import io.kubernetes.client.openapi.models.V1VolumeMount; +import lombok.experimental.UtilityClass; + +import java.util.ArrayList; +import java.util.List; + +@UtilityClass +public class Mappers { + public static final NamedItemMapper CONTAINER_NAME = new NamedItemMapper<>( + V1Container::new, + V1Container::getName, + V1Container::setName); + + public static final NamedItemMapper ENV_VAR_NAME = new NamedItemMapper<>( + V1EnvVar::new, + V1EnvVar::getName, + V1EnvVar::setName); + + public static final NamedItemMapper VOLUME_NAME = new NamedItemMapper<>( + V1Volume::new, + V1Volume::getName, + V1Volume::setName); + + public static final NamedItemMapper VOLUME_MOUNT_PATH = new NamedItemMapper<>( + V1VolumeMount::new, + V1VolumeMount::getMountPath, + V1VolumeMount::setMountPath); + + public static final FieldMapper SECRET_METADATA_FIELD = new FieldMapper<>( + V1ObjectMeta::new, + V1Secret::getMetadata, + V1Secret::setMetadata); + + public static final FieldMapper JOB_METADATA_FIELD = new FieldMapper<>( + V1ObjectMeta::new, + V1Job::getMetadata, + V1Job::setMetadata); + + public static final FieldMapper JOB_SPEC_FIELD = new FieldMapper<>( + V1JobSpec::new, + V1Job::getSpec, + V1Job::setSpec); + + public static final FieldMapper JOB_TEMPLATE_FIELD = new FieldMapper<>( + V1PodTemplateSpec::new, + V1JobSpec::getTemplate, + V1JobSpec::setTemplate); + + public static final FieldMapper JOB_TEMPLATE_SPEC_FIELD = new FieldMapper<>( + V1PodSpec::new, + V1PodTemplateSpec::getSpec, + V1PodTemplateSpec::setSpec); + + public static final FieldMapper> POD_INIT_CONTAINERS_FIELD = new FieldMapper<>( + ArrayList::new, + V1PodSpec::getInitContainers, + V1PodSpec::setInitContainers); + + public static final FieldMapper> POD_CONTAINERS_FIELD = new FieldMapper<>( + ArrayList::new, + V1PodSpec::getContainers, + V1PodSpec::setContainers); + + public static final FieldMapper> POD_VOLUMES_FIELD = new FieldMapper<>( + ArrayList::new, + V1PodSpec::getVolumes, + V1PodSpec::setVolumes); + + public static final FieldMapper SERVICE_METADATA_FIELD = new FieldMapper<>( + V1ObjectMeta::new, + V1Service::getMetadata, + V1Service::setMetadata); + + public static final FieldMapper SERVICE_SPEC_FIELD = new FieldMapper<>( + V1ServiceSpec::new, + V1Service::getSpec, + V1Service::setSpec); + + public static final FieldMapper SERVICE_TEMPLATE_FIELD = new FieldMapper<>( + V1RevisionTemplateSpec::new, + V1ServiceSpec::getTemplate, + V1ServiceSpec::setTemplate); + + public static final FieldMapper SERVICE_TEMPLATE_SPEC_FIELD = new FieldMapper<>( + V1RevisionSpec::new, + V1RevisionTemplateSpec::getSpec, + V1RevisionTemplateSpec::setSpec); + + public static final FieldMapper> TEMPLATE_CONTAINERS_FIELD = new FieldMapper<>( + ArrayList::new, + V1RevisionSpec::getContainers, + V1RevisionSpec::setContainers); + + public static final FieldMapper> CONTAINER_ENV_FIELD = new FieldMapper<>( + ArrayList::new, + V1Container::getEnv, + V1Container::setEnv); + + public static final FieldMapper> CONTAINER_ENV_FROM_FIELD = new FieldMapper<>( + ArrayList::new, + V1Container::getEnvFrom, + V1Container::setEnvFrom); + + public static final FieldMapper> CONTAINER_ARGS_FIELD = new FieldMapper<>( + ArrayList::new, + V1Container::getArgs, + V1Container::setArgs); + + public static final FieldMapper> CONTAINER_VOLUME_MOUNTS_FIELD = new FieldMapper<>( + ArrayList::new, + V1Container::getVolumeMounts, + V1Container::setVolumeMounts); } \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1b44462..9619917 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,6 +9,8 @@ app: build-namespace: default deploy-namespace: default docker-registry-protocol: https + docker-registry-auth: NONE + docker-config-path: /kaniko/.docker/config.json template-image: ${app.docker-registry}/builder-template:latest builder-image: gcr.io/kaniko-project/executor:latest image-name-format: app-%s @@ -98,7 +100,6 @@ app: backoffLimit: 0 template: spec: - serviceAccountName: builder automountServiceAccountToken: false initContainers: - name: ${app.template-container} diff --git a/src/test/java/com/epam/aidial/service/ConfigServiceTest.java b/src/test/java/com/epam/aidial/service/ConfigServiceTest.java index 03a6ef3..d14bb7f 100644 --- a/src/test/java/com/epam/aidial/service/ConfigServiceTest.java +++ b/src/test/java/com/epam/aidial/service/ConfigServiceTest.java @@ -1,89 +1,76 @@ -package com.epam.aidial.service; - -import com.epam.aidial.kubernetes.knative.V1Service; -import io.kubernetes.client.openapi.ApiClient; -import io.kubernetes.client.openapi.models.V1Job; -import io.kubernetes.client.openapi.models.V1Secret; -import io.kubernetes.client.util.Yaml; -import org.apache.commons.io.IOUtils; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -@SpringBootTest -@ActiveProfiles("configtest") -class ConfigServiceTest { - private static final String TEST_NAME = "test-name"; - private static final String TEST_IMAGE_NAME = "test-image-name"; - - @Autowired - private ConfigService configService; - - @MockitoBean - private RegistryService registryService; - - @MockitoBean - private ApiClient buildKubeClient; - - @MockitoBean - private ApiClient deployKubeClient; - - @Captor - private ArgumentCaptor fullImageNameCaptor; - - @Test - void testDialAuthSecretConfig() throws IOException { - V1Secret expected = readExpected("dial-auth-secret", V1Secret.class); - - V1Secret actual = configService.dialAuthSecretConfig(TEST_NAME, "test-api-key", "test-jwt"); - - assertThat(Yaml.dump(actual)).isEqualTo(Yaml.dump(expected)); - } - - @Test - void testBuildJobConfig() throws IOException { - // Arrange - when(registryService.fullImageName(fullImageNameCaptor.capture())) - .thenReturn(TEST_IMAGE_NAME); - V1Job expected = readExpected("build-job", V1Job.class); - - // Act - V1Job actual = configService.buildJobConfig(TEST_NAME, "test-sources", "python3.11"); - - // Assert - assertThat(Yaml.dump(actual)).isEqualTo(Yaml.dump(expected)); - assertThat(fullImageNameCaptor.getValue()).isEqualTo(TEST_NAME); - } - - @Test - void testAppServiceConfig() throws IOException { - // Arrange - when(registryService.fullImageName(fullImageNameCaptor.capture())) - .thenReturn(TEST_IMAGE_NAME); - V1Service expected = readExpected("app-service", V1Service.class); - - // Act - V1Service actual = configService.appServiceConfig(TEST_NAME, Map.of("test-env-name", "test-env-value")); - - // Assert - assertThat(Yaml.dump(actual)).isEqualTo(Yaml.dump(expected)); - assertThat(fullImageNameCaptor.getValue()).isEqualTo(TEST_NAME); - } - - private static T readExpected(String name, Class clazz) throws IOException { - return Yaml.loadAs( - IOUtils.resourceToString("/expected-configs/" + name + ".yaml", StandardCharsets.UTF_8), - clazz); - } +package com.epam.aidial.service; + +import com.epam.aidial.kubernetes.knative.V1Service; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.models.V1Job; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.util.Yaml; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("configtest") +class ConfigServiceTest { + private static final String TEST_NAME = "test-name"; + + @Autowired + private ConfigService configService; + + @MockitoBean + private ApiClient buildKubeClient; + + @MockitoBean + private ApiClient deployKubeClient; + + @Test + void testDialAuthSecretConfig() throws IOException { + // Arrange + V1Secret expected = readExpected("dial-auth-secret", V1Secret.class); + + // Act + V1Secret actual = configService.dialAuthSecretConfig(TEST_NAME, "test-api-key", "test-jwt"); + + // Assert + assertThat(Yaml.dump(actual)).isEqualTo(Yaml.dump(expected)); + } + + @Test + void testBuildJobConfig() throws IOException { + // Arrange + V1Job expected = readExpected("build-job", V1Job.class); + + // Act + V1Job actual = configService.buildJobConfig(TEST_NAME, "test-sources", "python3.11"); + + // Assert + assertThat(Yaml.dump(actual)).isEqualTo(Yaml.dump(expected)); + } + + @Test + void testAppServiceConfig() throws IOException { + // Arrange + V1Service expected = readExpected("app-service", V1Service.class); + + // Act + V1Service actual = configService.appServiceConfig(TEST_NAME, Map.of("test-env-name", "test-env-value")); + + // Assert + assertThat(Yaml.dump(actual)).isEqualTo(Yaml.dump(expected)); + } + + private static T readExpected(String name, Class clazz) throws IOException { + return Yaml.loadAs( + IOUtils.resourceToString("/expected-configs/" + name + ".yaml", StandardCharsets.UTF_8), + clazz); + } } \ No newline at end of file diff --git a/src/test/resources/application-configtest.yaml b/src/test/resources/application-configtest.yaml index 3b4782e..33fd4bf 100644 --- a/src/test/resources/application-configtest.yaml +++ b/src/test/resources/application-configtest.yaml @@ -1,5 +1,9 @@ app: + docker-registry: test-docker-registry template-image: test-template-image builder-image: test-builder-image dial-base-url: test-dial-base-url service-container: test-container + docker-registry-auth: BASIC + docker-registry-user: test + docker-registry-pass: password diff --git a/src/test/resources/expected-configs/app-service.yaml b/src/test/resources/expected-configs/app-service.yaml index a6f6de7..e1fce07 100644 --- a/src/test/resources/expected-configs/app-service.yaml +++ b/src/test/resources/expected-configs/app-service.yaml @@ -17,7 +17,7 @@ spec: - env: - name: test-env-name value: test-env-value - image: test-image-name + image: test-docker-registry/app-test-name:latest imagePullPolicy: Always name: test-container resources: diff --git a/src/test/resources/expected-configs/build-job.yaml b/src/test/resources/expected-configs/build-job.yaml index fc9d9a4..48a52bd 100644 --- a/src/test/resources/expected-configs/build-job.yaml +++ b/src/test/resources/expected-configs/build-job.yaml @@ -11,7 +11,7 @@ spec: - args: - --context=/sources - --dockerfile=/templates/python-pip/Dockerfile - - --destination=test-image-name + - --destination=test-docker-registry/app-test-name:latest - --build-arg=PYTHON_IMAGE=python:3.11-slim image: test-builder-image name: builder @@ -24,6 +24,9 @@ spec: name: volume readOnly: true subPath: templates + - mountPath: /kaniko/.docker/config.json + name: secret-volume + subPath: docker.config initContainers: - env: - name: DIAL_BASE_URL @@ -54,8 +57,10 @@ spec: name: volume subPath: templates restartPolicy: Never - serviceAccountName: builder volumes: - emptyDir: sizeLimit: 10Mi name: volume + - name: secret-volume + secret: + secretName: app-ctrl-dial-auth-test-name diff --git a/src/test/resources/expected-configs/dial-auth-secret.yaml b/src/test/resources/expected-configs/dial-auth-secret.yaml index 3626180..efd4859 100644 --- a/src/test/resources/expected-configs/dial-auth-secret.yaml +++ b/src/test/resources/expected-configs/dial-auth-secret.yaml @@ -1,7 +1,8 @@ -apiVersion: v1 -kind: Secret -metadata: - name: app-ctrl-dial-auth-test-name -stringData: - API_KEY: test-api-key - JWT: test-jwt +apiVersion: v1 +kind: Secret +metadata: + name: app-ctrl-dial-auth-test-name +stringData: + API_KEY: test-api-key + JWT: test-jwt + docker.config: '{"auths":{"https://test-docker-registry/v2":{"auth":"dGVzdDpwYXNzd29yZA=="}}}' \ No newline at end of file