From 5e7146b9349cd3cce57bcf7b4cf5f0812eeade5c Mon Sep 17 00:00:00 2001 From: wellCh4n Date: Tue, 8 Sep 2026 14:01:15 +0800 Subject: [PATCH 01/15] feat(deploy): publish prebuilt images as a third application source An application can now be sourced from an image instead of a Git repository or a ZIP. The build config's existing `repository` field carries the image name (stored as `ImageSourceConfig`, validated to carry no tag or digest) and each publish names the tag, mirroring how a Git publish names the branch. Nothing is built: the pipeline is born with `artifact = name:tag`, the same shape the build path produces, so the StatefulSet processor, rollback, notifications and namespace migration need no special case. The deploy phase that a manual deploy, a rollback and now an image publish all run is extracted into `ArtifactDeployRunner`; `IMMEDIATE` goes INITIALIZED -> DEPLOYING -> ROLLING_OUT through it and `MANUAL` parks in BUILD_SUCCEEDED, the one new transition the state machine allows, for the ordinary deploy call. Build-only settings are dropped when an application switches to IMAGE rather than kept invisibly, and the IDE is refused for it as for ZIP since there is nothing to clone. The editor shows an Image source tab with only the image field; the publish page renders the tag as a prefixed input with the image name fixed in front of it and offers the last published tag back; the pipeline page and list mark an image publish and skip the step bar and log streams it does not have. The OpenAPI docs describe the new strategy in all four locales. First version accepts only images pullable without credentials, does not check the registry (a wrong tag fails the rollout as ImagePullBackOff), and treats re-publishing the same tag as a no-op rollout, as documented in AGENTS.md. Co-authored-by: Claude Fable 5.1 --- AGENTS.md | 6 +- .../application/dto/ApplicationConfigDto.java | 10 +- .../application/dto/DeployStrategyParam.java | 5 +- .../dto/ImageDeployStrategyParam.java | 15 ++ .../service/ArtifactDeployRunner.java | 123 ++++++++++++++ .../service/DeploymentService.java | 54 ++++++- .../oops/application/service/IdeService.java | 5 +- .../application/service/PipelineService.java | 106 +------------ .../oops/domain/application/Application.java | 19 ++- .../application/ApplicationBuildConfig.java | 13 +- .../ApplicationBuildConfigPolicy.java | 39 ++++- .../domain/application/ImageSourceConfig.java | 9 ++ .../oops/domain/application/SourceConfig.java | 5 +- .../domain/delivery/DeployStrategyPolicy.java | 23 +++ .../domain/delivery/ImagePublishConfig.java | 13 ++ .../oops/domain/delivery/Pipeline.java | 29 ++++ .../domain/delivery/PipelineStateMachine.java | 3 + .../oops/domain/delivery/PublishConfig.java | 5 +- .../domain/shared/ApplicationSourceType.java | 4 +- .../DeploymentServiceImagePublishTests.java | 150 ++++++++++++++++++ .../PipelineHealthVerificationTests.java | 3 +- .../service/PipelineRollbackTests.java | 3 +- .../service/PipelineStopTests.java | 3 +- .../ApplicationBuildConfigPolicyTests.java | 29 ++++ .../domain/application/ApplicationTests.java | 30 ++++ .../delivery/DeployStrategyPolicyTests.java | 27 ++++ .../delivery/PipelineStateMachineTests.java | 8 +- tests/integration/oops_client.py | 6 +- tests/integration/test_application_config.py | 47 ++++++ tests/integration/test_deploy.py | 87 ++++++++++ .../[name]/pipelines/[pipelineId]/page.tsx | 24 ++- .../apps/[namespace]/[name]/publish/page.tsx | 86 +++++++++- .../components/application-build-info.tsx | 51 ++++-- web/app/apps/schema.ts | 15 +- web/app/help/docs/deployments/page.tsx | 14 ++ web/app/ides/page.tsx | 8 +- web/app/pipelines/columns.tsx | 13 +- web/components/command-palette.tsx | 2 +- web/components/ui/input-group.tsx | 50 ++++++ web/lib/api/types.ts | 18 ++- web/locales/en-US/apps.ts | 14 ++ web/locales/en-US/doc.ts | 12 +- web/locales/en-US/ide.ts | 4 +- web/locales/en-US/pipelines.ts | 1 + web/locales/ja-JP/apps.ts | 14 ++ web/locales/ja-JP/doc.ts | 12 +- web/locales/ja-JP/ide.ts | 4 +- web/locales/ja-JP/pipelines.ts | 1 + web/locales/zh-CN/apps.ts | 14 ++ web/locales/zh-CN/doc.ts | 12 +- web/locales/zh-CN/ide.ts | 4 +- web/locales/zh-CN/pipelines.ts | 1 + web/locales/zh-TW/apps.ts | 14 ++ web/locales/zh-TW/doc.ts | 12 +- web/locales/zh-TW/ide.ts | 4 +- web/locales/zh-TW/pipelines.ts | 1 + 56 files changed, 1100 insertions(+), 184 deletions(-) create mode 100644 src/main/java/com/github/wellch4n/oops/application/dto/ImageDeployStrategyParam.java create mode 100644 src/main/java/com/github/wellch4n/oops/application/service/ArtifactDeployRunner.java create mode 100644 src/main/java/com/github/wellch4n/oops/domain/application/ImageSourceConfig.java create mode 100644 src/main/java/com/github/wellch4n/oops/domain/delivery/ImagePublishConfig.java create mode 100644 src/test/java/com/github/wellch4n/oops/application/service/DeploymentServiceImagePublishTests.java create mode 100644 web/components/ui/input-group.tsx diff --git a/AGENTS.md b/AGENTS.md index f9df320d..166a33a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,9 @@ Build pipelines run as Kubernetes Jobs with init containers. The container names 3. **compile** (optional, `CompileContainer`): Runs custom build commands 4. **publish** (`PublishContainer`): Builds and pushes Docker image using Buildah, with the `overlay` storage driver over the `container-storage` emptyDir that `ContainerStorageVolume` mounts at `/var/lib/containers`. Both halves matter: the store defaults into the container's writable layer, where every write pays an overlayfs copy-up, and `vfs` (used until the volume existed) copies the entire tree per layer — together they made a build of any size stall for minutes with no log output after its last line. Buildah's own image declares a `VOLUME` for this path, but Kubernetes ignores image `VOLUME` declarations, so the mount has to be explicit. -Two source types exist: `GIT` (default) and `ZIP`. ZIP uploads use presigned S3 URLs via `BuildSourceObjectStorageService` — the frontend gets a presigned PUT URL from `POST .../deployments/source-upload`, uploads the file, then triggers the pipeline. ZIP builds use `oops.pipeline.image.zip` (defaults to `alpine/curl:8.17.0`) to download the archive. +Three source types exist: `GIT` (default), `ZIP` and `IMAGE`. ZIP uploads use presigned S3 URLs via `BuildSourceObjectStorageService` — the frontend gets a presigned PUT URL from `POST .../deployments/source-upload`, uploads the file, then triggers the pipeline. ZIP builds use `oops.pipeline.image.zip` (defaults to `alpine/curl:8.17.0`) to download the archive. + +**`IMAGE` runs no build at all.** The build config stores the image name without a tag (`ImageSourceConfig.repository`), the publish names the tag (`ImageDeployStrategyParam.tag`, validated against the OCI tag grammar so it cannot smuggle another image or a digest), and `Pipeline.initializeWithArtifact` is born with `artifact = repository:tag` — the same shape the build path produces (`registry/app:pipelineId`), so `StatefulSetProcessor`, rollback, notifications and namespace migration need no special case. The pipeline never enters `RUNNING`: `IMMEDIATE` goes `INITIALIZED → DEPLOYING → ROLLING_OUT` through `ArtifactDeployRunner` (the deploy phase shared with rollback and manual deploy), `MANUAL` parks in `BUILD_SUCCEEDED` (the one `INITIALIZED → BUILD_SUCCEEDED` transition the state machine allows) for the ordinary `deployPipeline()` call. Consequences worth knowing: `Application.updateBuildConfig` drops the Dockerfile, build image and build commands for an IMAGE application rather than keep dead configuration; the pipeline page shows an image badge and no step bar (`Pipeline.hasBuild()` is false, as for a rollback); the IDE is refused, like ZIP, because there is no repository to clone; a wrong tag fails the rollout as `ImagePullBackOff` rather than at publish time, since nothing checks the registry; re-publishing the same tag leaves the pod template unchanged and so restarts nothing; and rollback is by tag, not digest, so a tag overwritten in the registry rolls back to whatever it now holds. First version supports only images pullable without credentials — `ImagePullSecretProcessor` still copies just the environment's own registry secret. Pipeline build logs are served over SSE, one container at a time (see **Pipeline log streaming** below). A `@Scheduled(fixedRate=5000)` job (`PipelineInstanceScanJob`) polls K8s for build completion and rollout convergence. Pipeline state transitions use optimistic locking: `PipelineRepository.updateStatusIfMatch()` does a conditional UPDATE and returns row count (0 = lost the race). **Every** status write goes through it, user-triggered ones included — `stopPipeline` transitions from the status the caller read and fails with "state changed concurrently" when that read is stale, and a lost `DEPLOYING → ROLLING_OUT` claim (the pipeline was stopped while its artifact was applied) is logged and not announced as rolling out. Two ways a pipeline could otherwise stick forever and block its application are recovered by the scan job: a RUNNING pipeline whose build Job the cluster no longer has (deleted, namespace cleaned, TTL-reaped) is failed at once, and a pipeline that has stayed DEPLOYING for 10 minutes (its driver died mid-deploy) is failed by whichever server can claim its lock — measured from when that server first saw it deploying, since no column records status-change time and the conditional updates bypass entity timestamps. @@ -203,7 +205,7 @@ Deployment triggering logic lives in `DeploymentService` (not `PipelineService`) The K8s client is created per-task and closed via try-with-resources in `ArtifactDeployTask.call()`. **Per-application config entities**: -- `ApplicationBuildConfig`: Stores source type (`GIT`/`ZIP`), repository/source key, build image/commands, and Dockerfile config (`BUILTIN` path or inline `USER` content). Frontend: `application-build-info.tsx`. +- `ApplicationBuildConfig`: Stores source type (`GIT`/`ZIP`/`IMAGE`), repository or image name (`SourceConfig` JSON blob: `GitSourceConfig`, `ZipSourceConfig`, `ImageSourceConfig`), build image/commands, and Dockerfile config (`BUILTIN` path or inline `USER` content). Frontend: `application-build-info.tsx`; the publish page renders the IMAGE tag as a prefixed input (`components/ui/input-group.tsx`) with the image name fixed in front of it. - `ApplicationServiceConfig`: Stores container `port` and per-environment hostname/HTTPS overrides (`List` as JSON blob). Frontend: `application-service-info.tsx`. Each host can also carry **HTTP basic auth** (`basicAuthEnabled` / `basicAuthUsername` / `basicAuthPasswordHash`, set in the host editor dialog `apps/components/host-editor-dialog.tsx`, which is the single add/edit form for a host's name, HTTPS and basic auth). Only the BCrypt hash is stored — the plaintext is hashed in `ApplicationService.updateApplicationServiceConfig` and never returned, so the DTO exposes a write-only `basicAuthPassword` plus a read-only `basicAuthPasswordSet` marker, and a blank password on update carries the stored hash forward. At deploy time `IngressRouteProcessor` writes an htpasswd Secret and a Traefik `basicAuth` Middleware named `{app}-basic-auth-{host}` (labelled `oops.resource=basic-auth` so hosts that turn auth off get their pair deleted), attached to the route that actually serves traffic — for an HTTPS host that is the `websecure` route, since the `web` one only redirects. Note the endpoint rewrites the whole service config: a client that PUTs `environmentConfigs` without these fields (e.g. `oops app service set`) clears basic auth, exactly as it already clears unlisted hosts. diff --git a/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationConfigDto.java b/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationConfigDto.java index 9ab250f8..70d6855a 100644 --- a/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationConfigDto.java +++ b/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationConfigDto.java @@ -7,6 +7,7 @@ import com.github.wellch4n.oops.domain.application.ApplicationRuntimeSpec; import com.github.wellch4n.oops.domain.application.ApplicationServiceConfig; import com.github.wellch4n.oops.domain.application.GitSourceConfig; +import com.github.wellch4n.oops.domain.application.ImageSourceConfig; import com.github.wellch4n.oops.domain.application.ZipSourceConfig; import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; import com.github.wellch4n.oops.domain.shared.DockerFileType; @@ -48,6 +49,7 @@ public record BuildConfig( String namespace, String applicationName, ApplicationSourceType sourceType, + /** Git URL for GIT, image name without a tag for IMAGE, unused for ZIP. */ String repository, DockerFileConfig dockerFileConfig, String buildImage, @@ -77,9 +79,11 @@ public ApplicationBuildConfig toDomain() { config.setNamespace(namespace); config.setApplicationName(applicationName); config.setSourceType(sourceType); - config.setSourceConfig(sourceType == ApplicationSourceType.ZIP - ? new ZipSourceConfig() - : new GitSourceConfig(repository)); + config.setSourceConfig(switch (sourceType != null ? sourceType : ApplicationSourceType.GIT) { + case GIT -> new GitSourceConfig(repository); + case ZIP -> new ZipSourceConfig(); + case IMAGE -> new ImageSourceConfig(repository); + }); config.setDockerFileConfig(dockerFileConfig != null ? dockerFileConfig.toDomain() : null); config.setBuildImage(buildImage); config.setEnvironmentConfigs(map(environmentConfigs, BuildEnvironmentConfig::toDomain)); diff --git a/src/main/java/com/github/wellch4n/oops/application/dto/DeployStrategyParam.java b/src/main/java/com/github/wellch4n/oops/application/dto/DeployStrategyParam.java index 11b7d3b9..be173eea 100644 --- a/src/main/java/com/github/wellch4n/oops/application/dto/DeployStrategyParam.java +++ b/src/main/java/com/github/wellch4n/oops/application/dto/DeployStrategyParam.java @@ -7,9 +7,10 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = GitDeployStrategyParam.class, name = "GIT"), - @JsonSubTypes.Type(value = ZipDeployStrategyParam.class, name = "ZIP") + @JsonSubTypes.Type(value = ZipDeployStrategyParam.class, name = "ZIP"), + @JsonSubTypes.Type(value = ImageDeployStrategyParam.class, name = "IMAGE") }) -public sealed interface DeployStrategyParam permits GitDeployStrategyParam, ZipDeployStrategyParam { +public sealed interface DeployStrategyParam permits GitDeployStrategyParam, ZipDeployStrategyParam, ImageDeployStrategyParam { ApplicationSourceType getType(); } diff --git a/src/main/java/com/github/wellch4n/oops/application/dto/ImageDeployStrategyParam.java b/src/main/java/com/github/wellch4n/oops/application/dto/ImageDeployStrategyParam.java new file mode 100644 index 00000000..ad30bc56 --- /dev/null +++ b/src/main/java/com/github/wellch4n/oops/application/dto/ImageDeployStrategyParam.java @@ -0,0 +1,15 @@ +package com.github.wellch4n.oops.application.dto; + +import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; + +/** + * Publishes a prebuilt image. Only the tag is taken from the request; the image name is the + * application's build config, so a publish can never point the application at a different image. + */ +public record ImageDeployStrategyParam(String tag) implements DeployStrategyParam { + + @Override + public ApplicationSourceType getType() { + return ApplicationSourceType.IMAGE; + } +} diff --git a/src/main/java/com/github/wellch4n/oops/application/service/ArtifactDeployRunner.java b/src/main/java/com/github/wellch4n/oops/application/service/ArtifactDeployRunner.java new file mode 100644 index 00000000..9fddc0be --- /dev/null +++ b/src/main/java/com/github/wellch4n/oops/application/service/ArtifactDeployRunner.java @@ -0,0 +1,123 @@ +package com.github.wellch4n.oops.application.service; + +import com.github.wellch4n.oops.application.event.PipelineNotificationEvent; +import com.github.wellch4n.oops.application.event.PipelineNotificationType; +import com.github.wellch4n.oops.application.port.ArtifactDeploymentExecutor; +import com.github.wellch4n.oops.application.port.repository.PipelineRepository; +import com.github.wellch4n.oops.domain.application.Application; +import com.github.wellch4n.oops.domain.application.ApplicationExpertConfig; +import com.github.wellch4n.oops.domain.application.ApplicationRuntimeSpec; +import com.github.wellch4n.oops.domain.application.ApplicationServiceConfig; +import com.github.wellch4n.oops.domain.delivery.Pipeline; +import com.github.wellch4n.oops.domain.delivery.PipelineStateMachine; +import com.github.wellch4n.oops.domain.environment.Environment; +import com.github.wellch4n.oops.domain.shared.PipelineStatus; +import com.github.wellch4n.oops.shared.exception.BizException; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * Drives a pipeline whose artifact already exists through the deploy phase: claims it into DEPLOYING with + * a conditional update, applies the artifact to the cluster and hands it to the rollout scan as ROLLING_OUT. + * Three callers share it — a manual deploy of a built pipeline, a rollback and an image publish — and they + * differ only in the status they start from and the words in their notifications. + */ +@Slf4j +@Component +public class ArtifactDeployRunner { + + /** The notification texts one caller uses; {@code failurePrefix} heads the exception thrown on failure. */ + public record Messages(String deploying, String rollingOut, String failedFallback, String failurePrefix) { + } + + private final PipelineRepository pipelineRepository; + private final EnvironmentService environmentService; + private final ApplicationEventPublisher eventPublisher; + private final ArtifactDeploymentExecutor artifactDeploymentExecutor; + private final PipelineStateMachine pipelineStateMachine; + + public ArtifactDeployRunner(PipelineRepository pipelineRepository, + EnvironmentService environmentService, + ApplicationEventPublisher eventPublisher, + ArtifactDeploymentExecutor artifactDeploymentExecutor, + PipelineStateMachine pipelineStateMachine) { + this.pipelineRepository = pipelineRepository; + this.environmentService = environmentService; + this.eventPublisher = eventPublisher; + this.artifactDeploymentExecutor = artifactDeploymentExecutor; + this.pipelineStateMachine = pipelineStateMachine; + } + + /** + * Moves {@code pipeline} from {@code from} through DEPLOYING into ROLLING_OUT. Throws {@link BizException} + * when the claim is lost or the deploy fails; in the latter case the pipeline has already been marked ERROR. + */ + public void run(Pipeline pipeline, Application application, PipelineStatus from, Messages messages) { + pipelineStateMachine.ensureCanTransition(from, PipelineStatus.DEPLOYING); + int claimed = pipelineRepository.updateStatusIfMatch(pipeline.getId(), from, PipelineStatus.DEPLOYING); + if (claimed == 0) { + throw new BizException("Pipeline state changed concurrently, please retry"); + } + pipeline.markDeploying(); + eventPublisher.publishEvent(PipelineNotificationEvent.of( + pipeline, PipelineNotificationType.DEPLOYING, messages.deploying() + )); + + try { + Environment environment = requireEnvironment(pipeline.getEnvironment()); + ApplicationRuntimeSpec.EnvironmentConfig runtimeSpec = + application.runtimeEnvironmentConfigOrDefault(pipeline.getEnvironment()); + ApplicationRuntimeSpec.HealthCheck healthCheck = application.healthCheckOrDefault(); + ApplicationServiceConfig serviceConfig = application.serviceConfigOrDefault(); + ApplicationExpertConfig.EnvironmentConfig expertConfig = + application.expertEnvironmentConfigOrDefault(pipeline.getEnvironment()); + + artifactDeploymentExecutor.deploy(pipeline, application, environment, runtimeSpec, healthCheck, serviceConfig, expertConfig); + + completeDeployPhase(pipeline, messages.rollingOut()); + } catch (Exception exception) { + pipelineStateMachine.ensureCanTransition(PipelineStatus.DEPLOYING, PipelineStatus.ERROR); + String message = StringUtils.defaultIfBlank(exception.getMessage(), messages.failedFallback()); + int failed = pipelineRepository.updateStatusAndMessageIfMatch( + pipeline.getId(), PipelineStatus.DEPLOYING, PipelineStatus.ERROR, message); + if (failed > 0) { + pipeline.markFailed(message); + eventPublisher.publishEvent(PipelineNotificationEvent.of( + pipeline, PipelineNotificationType.FAILED, message + )); + } + throw new BizException(messages.failurePrefix() + exception.getMessage(), exception); + } + } + + /** + * Completes the deploy phase after the artifact has been applied. The pipeline moves to ROLLING_OUT; the + * scan job later reads Kubernetes rollout status and decides SUCCEEDED/ERROR. + */ + private void completeDeployPhase(Pipeline pipeline, String rollingOutDetail) { + pipelineStateMachine.ensureCanTransition(PipelineStatus.DEPLOYING, PipelineStatus.ROLLING_OUT); + int updated = pipelineRepository.updateStatusIfMatch( + pipeline.getId(), PipelineStatus.DEPLOYING, PipelineStatus.ROLLING_OUT); + if (updated == 0) { + // The pipeline was moved while its artifact was being applied — a stop is the only legal way — so the + // rollout is no longer this pipeline's to report on. The workload is updated regardless: a stop cannot + // take back an artifact that has already been applied. + log.info("Pipeline {} left DEPLOYING while its artifact was applied; not entering rollout", pipeline.getId()); + return; + } + pipeline.markRollingOut(); + eventPublisher.publishEvent(PipelineNotificationEvent.of( + pipeline, PipelineNotificationType.ROLLING_OUT, rollingOutDetail + )); + } + + private Environment requireEnvironment(String environmentName) { + Environment environment = environmentService.getEnvironment(environmentName); + if (environment == null) { + throw new BizException("Environment not found: " + environmentName); + } + return environment; + } +} diff --git a/src/main/java/com/github/wellch4n/oops/application/service/DeploymentService.java b/src/main/java/com/github/wellch4n/oops/application/service/DeploymentService.java index 9760a01f..58b58061 100644 --- a/src/main/java/com/github/wellch4n/oops/application/service/DeploymentService.java +++ b/src/main/java/com/github/wellch4n/oops/application/service/DeploymentService.java @@ -10,15 +10,19 @@ import com.github.wellch4n.oops.domain.delivery.DeployStrategyPolicy; import com.github.wellch4n.oops.domain.delivery.DeploymentConcurrencyPolicy; import com.github.wellch4n.oops.domain.delivery.GitPublishConfig; +import com.github.wellch4n.oops.domain.delivery.ImagePublishConfig; import com.github.wellch4n.oops.domain.delivery.Pipeline; import com.github.wellch4n.oops.domain.environment.Environment; import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; +import com.github.wellch4n.oops.domain.shared.DeployMode; +import com.github.wellch4n.oops.domain.shared.PipelineStatus; import com.github.wellch4n.oops.application.event.PipelineNotificationEvent; import com.github.wellch4n.oops.application.event.PipelineNotificationType; import com.github.wellch4n.oops.shared.exception.BizException; import com.github.wellch4n.oops.application.dto.DeployCommand; import com.github.wellch4n.oops.application.dto.DeployStrategyParam; import com.github.wellch4n.oops.application.dto.GitDeployStrategyParam; +import com.github.wellch4n.oops.application.dto.ImageDeployStrategyParam; import com.github.wellch4n.oops.application.dto.ZipDeployStrategyParam; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -40,6 +44,7 @@ public class DeploymentService { private final DeploymentConcurrencyPolicy deploymentConcurrencyPolicy; private final ApplicationAccessPolicy applicationAccessPolicy; private final UserService userService; + private final ArtifactDeployRunner artifactDeployRunner; public DeploymentService(ApplicationRepository applicationRepository, PipelineRepository pipelineRepository, @@ -49,7 +54,8 @@ public DeploymentService(ApplicationRepository applicationRepository, DeployStrategyPolicy deployStrategyPolicy, DeploymentConcurrencyPolicy deploymentConcurrencyPolicy, ApplicationAccessPolicy applicationAccessPolicy, - UserService userService) { + UserService userService, + ArtifactDeployRunner artifactDeployRunner) { this.applicationRepository = applicationRepository; this.pipelineRepository = pipelineRepository; this.environmentService = environmentService; @@ -59,6 +65,7 @@ public DeploymentService(ApplicationRepository applicationRepository, this.deploymentConcurrencyPolicy = deploymentConcurrencyPolicy; this.applicationAccessPolicy = applicationAccessPolicy; this.userService = userService; + this.artifactDeployRunner = artifactDeployRunner; } public String deployApplication(String namespace, @@ -88,6 +95,10 @@ public String deployApplication(String namespace, ApplicationSourceType publishType = request.strategy().getType(); deployStrategyPolicy.ensureStrategyMatches(sourceType, publishType); + if (request.strategy() instanceof ImageDeployStrategyParam imageStrategy) { + return publishImage(application, buildConfig, environment, imageStrategy, request.deployMode(), operatorUserId); + } + Pipeline pipeline = Pipeline.initialize( namespace, application.getName(), @@ -107,6 +118,45 @@ public String deployApplication(String namespace, return submission.pipelineId(); } + /** + * An image publish has its artifact before it starts and runs no build job, so it never enters RUNNING: + * IMMEDIATE deploys it right away along the rollback path, MANUAL parks it in BUILD_SUCCEEDED for the same + * deploy call a built pipeline waits for. + */ + private String publishImage(Application application, + ApplicationBuildConfig buildConfig, + Environment environment, + ImageDeployStrategyParam strategy, + DeployMode deployMode, + String operatorUserId) { + ImagePublishConfig publishConfig = deployStrategyPolicy.resolveImagePublishConfig( + buildConfig != null ? buildConfig.repository() : null, strategy.tag()); + Pipeline pipeline = pipelineRepository.save(Pipeline.initializeWithArtifact( + application.getNamespace(), + application.getName(), + environment.getName(), + publishConfig, + deployMode, + operatorUserId)); + eventPublisher.publishEvent(PipelineNotificationEvent.of( + pipeline, PipelineNotificationType.CREATED, "发布流程已经启动,镜像 " + pipeline.getArtifact() + "。" + )); + + if (pipeline.getDeployMode() == DeployMode.MANUAL) { + int parked = pipelineRepository.updateStatusIfMatch( + pipeline.getId(), PipelineStatus.INITIALIZED, PipelineStatus.BUILD_SUCCEEDED); + if (parked == 0) { + throw new BizException("Pipeline state changed concurrently, please retry"); + } + pipeline.markReadyToDeploy(); + return pipeline.getId(); + } + + artifactDeployRunner.run(pipeline, application, PipelineStatus.INITIALIZED, new ArtifactDeployRunner.Messages( + "发布任务已进入部署阶段。", "正在等待新版本发布生效…", "发布任务执行失败,请查看日志。", "Deploy failed: ")); + return pipeline.getId(); + } + private Environment requireEnvironment(String environmentName) { Environment environment = environmentService.getEnvironment(environmentName); if (environment == null) { @@ -126,6 +176,8 @@ private void applyDeployStrategy(Pipeline pipeline, DeployStrategyParam strategy case ZipDeployStrategyParam zipStrategy -> pipeline.setPublishConfig( deployStrategyPolicy.resolveZipPublishConfig( zipStrategy.objectKey(), zipStrategy.url(), zipStrategy.repository())); + case ImageDeployStrategyParam ignored -> + throw new IllegalStateException("Image publishes do not run a build"); } } } diff --git a/src/main/java/com/github/wellch4n/oops/application/service/IdeService.java b/src/main/java/com/github/wellch4n/oops/application/service/IdeService.java index 53f6bf32..736138ef 100644 --- a/src/main/java/com/github/wellch4n/oops/application/service/IdeService.java +++ b/src/main/java/com/github/wellch4n/oops/application/service/IdeService.java @@ -45,8 +45,9 @@ public String create(String namespace, String applicationName, String env, Creat ApplicationSourceType sourceType = applicationBuildConfig != null && applicationBuildConfig.getSourceType() != null ? applicationBuildConfig.getSourceType() : ApplicationSourceType.GIT; - if (sourceType == ApplicationSourceType.ZIP) { - throw new BizException("IDE is not supported for ZIP source applications"); + if (sourceType != ApplicationSourceType.GIT) { + // The IDE clones the application's repository into its workspace; ZIP and IMAGE have none. + throw new BizException("IDE is only supported for GIT source applications"); } return ideGateway.create(namespace, applicationName, environment, application, applicationBuildConfig, request); } diff --git a/src/main/java/com/github/wellch4n/oops/application/service/PipelineService.java b/src/main/java/com/github/wellch4n/oops/application/service/PipelineService.java index 6481887e..446c0588 100644 --- a/src/main/java/com/github/wellch4n/oops/application/service/PipelineService.java +++ b/src/main/java/com/github/wellch4n/oops/application/service/PipelineService.java @@ -1,6 +1,5 @@ package com.github.wellch4n.oops.application.service; -import com.github.wellch4n.oops.application.port.ArtifactDeploymentExecutor; import com.github.wellch4n.oops.application.port.PipelineJobGateway; import com.github.wellch4n.oops.application.port.EventStreamSink; import com.github.wellch4n.oops.application.port.PipelineLogStreamGateway; @@ -8,9 +7,6 @@ import com.github.wellch4n.oops.application.port.repository.PipelineRepository; import com.github.wellch4n.oops.domain.application.Application; import com.github.wellch4n.oops.domain.application.ApplicationAccessPolicy; -import com.github.wellch4n.oops.domain.application.ApplicationExpertConfig; -import com.github.wellch4n.oops.domain.application.ApplicationRuntimeSpec; -import com.github.wellch4n.oops.domain.application.ApplicationServiceConfig; import com.github.wellch4n.oops.domain.delivery.Pipeline; import com.github.wellch4n.oops.domain.delivery.DeploymentConcurrencyPolicy; import com.github.wellch4n.oops.domain.delivery.PipelineStateMachine; @@ -45,7 +41,7 @@ public class PipelineService { private final ApplicationRepository applicationRepository; private final UserService userService; private final ApplicationEventPublisher eventPublisher; - private final ArtifactDeploymentExecutor artifactDeploymentExecutor; + private final ArtifactDeployRunner artifactDeployRunner; private final PipelineJobGateway pipelineJobGateway; private final PipelineLogStreamGateway pipelineLogStreamGateway; private final PipelineStateMachine pipelineStateMachine; @@ -56,7 +52,7 @@ public PipelineService(PipelineRepository pipelineRepository, EnvironmentService ApplicationRepository applicationRepository, UserService userService, ApplicationEventPublisher eventPublisher, - ArtifactDeploymentExecutor artifactDeploymentExecutor, + ArtifactDeployRunner artifactDeployRunner, PipelineJobGateway pipelineJobGateway, PipelineLogStreamGateway pipelineLogStreamGateway, PipelineStateMachine pipelineStateMachine, @@ -67,7 +63,7 @@ public PipelineService(PipelineRepository pipelineRepository, EnvironmentService this.applicationRepository = applicationRepository; this.userService = userService; this.eventPublisher = eventPublisher; - this.artifactDeploymentExecutor = artifactDeploymentExecutor; + this.artifactDeployRunner = artifactDeployRunner; this.pipelineJobGateway = pipelineJobGateway; this.pipelineLogStreamGateway = pipelineLogStreamGateway; this.pipelineStateMachine = pipelineStateMachine; @@ -213,42 +209,8 @@ public Boolean deployPipeline(String namespace, String applicationName, String i deploymentConcurrencyPolicy.ensureNoActivePipeline(pipelineRepository.existsByNamespaceAndApplicationNameAndStatusIn( namespace, applicationName, deploymentConcurrencyPolicy.activePipelineStatuses() )); - pipelineStateMachine.ensureCanTransition(PipelineStatus.BUILD_SUCCEEDED, PipelineStatus.DEPLOYING); - - int claimed = pipelineRepository.updateStatusIfMatch(pipeline.getId(), PipelineStatus.BUILD_SUCCEEDED, PipelineStatus.DEPLOYING); - if (claimed == 0) { - throw new BizException("Pipeline state changed concurrently, please retry"); - } - pipeline.markDeploying(); - eventPublisher.publishEvent(PipelineNotificationEvent.of( - pipeline, PipelineNotificationType.DEPLOYING, "发布任务已进入部署阶段。" - )); - - try { - Environment environment = requireEnvironment(pipeline.getEnvironment()); - ApplicationRuntimeSpec.EnvironmentConfig runtimeSpec = - application.runtimeEnvironmentConfigOrDefault(pipeline.getEnvironment()); - ApplicationRuntimeSpec.HealthCheck healthCheck = application.healthCheckOrDefault(); - ApplicationServiceConfig serviceConfig = application.serviceConfigOrDefault(); - ApplicationExpertConfig.EnvironmentConfig expertConfig = - application.expertEnvironmentConfigOrDefault(pipeline.getEnvironment()); - - artifactDeploymentExecutor.deploy(pipeline, application, environment, runtimeSpec, healthCheck, serviceConfig, expertConfig); - - completeDeployPhase(pipeline, "正在等待新版本发布生效…"); - } catch (Exception exception) { - pipelineStateMachine.ensureCanTransition(PipelineStatus.DEPLOYING, PipelineStatus.ERROR); - String message = StringUtils.defaultIfBlank(exception.getMessage(), "发布任务执行失败,请查看日志。"); - int failed = pipelineRepository.updateStatusAndMessageIfMatch( - pipeline.getId(), PipelineStatus.DEPLOYING, PipelineStatus.ERROR, message); - if (failed > 0) { - pipeline.markFailed(message); - eventPublisher.publishEvent(PipelineNotificationEvent.of( - pipeline, PipelineNotificationType.FAILED, message - )); - } - throw new BizException("Deploy failed: " + exception.getMessage(), exception); - } + artifactDeployRunner.run(pipeline, application, PipelineStatus.BUILD_SUCCEEDED, new ArtifactDeployRunner.Messages( + "发布任务已进入部署阶段。", "正在等待新版本发布生效…", "发布任务执行失败,请查看日志。", "Deploy failed: ")); return true; } @@ -274,41 +236,8 @@ public String rollback(String namespace, String applicationName, String targetPi rollbackPipeline, PipelineNotificationType.CREATED, "回滚任务已创建。" )); - pipelineStateMachine.ensureCanTransition(PipelineStatus.INITIALIZED, PipelineStatus.DEPLOYING); - int claimed = pipelineRepository.updateStatusIfMatch(rollbackPipeline.getId(), PipelineStatus.INITIALIZED, PipelineStatus.DEPLOYING); - if (claimed == 0) { - throw new BizException("Pipeline state changed concurrently, please retry"); - } - rollbackPipeline.markDeploying(); - eventPublisher.publishEvent(PipelineNotificationEvent.of( - rollbackPipeline, PipelineNotificationType.DEPLOYING, "回滚任务已进入部署阶段。" - )); - - try { - Environment environment = requireEnvironment(rollbackPipeline.getEnvironment()); - ApplicationRuntimeSpec.EnvironmentConfig runtimeSpec = - application.runtimeEnvironmentConfigOrDefault(rollbackPipeline.getEnvironment()); - ApplicationRuntimeSpec.HealthCheck healthCheck = application.healthCheckOrDefault(); - ApplicationServiceConfig serviceConfig = application.serviceConfigOrDefault(); - ApplicationExpertConfig.EnvironmentConfig expertConfig = - application.expertEnvironmentConfigOrDefault(rollbackPipeline.getEnvironment()); - - artifactDeploymentExecutor.deploy(rollbackPipeline, application, environment, runtimeSpec, healthCheck, serviceConfig, expertConfig); - - completeDeployPhase(rollbackPipeline, "正在等待回滚版本发布生效…"); - } catch (Exception exception) { - pipelineStateMachine.ensureCanTransition(PipelineStatus.DEPLOYING, PipelineStatus.ERROR); - String message = StringUtils.defaultIfBlank(exception.getMessage(), "回滚任务执行失败,请查看日志。"); - int failed = pipelineRepository.updateStatusAndMessageIfMatch( - rollbackPipeline.getId(), PipelineStatus.DEPLOYING, PipelineStatus.ERROR, message); - if (failed > 0) { - rollbackPipeline.markFailed(message); - eventPublisher.publishEvent(PipelineNotificationEvent.of( - rollbackPipeline, PipelineNotificationType.FAILED, message - )); - } - throw new BizException("Rollback failed: " + exception.getMessage(), exception); - } + artifactDeployRunner.run(rollbackPipeline, application, PipelineStatus.INITIALIZED, new ArtifactDeployRunner.Messages( + "回滚任务已进入部署阶段。", "正在等待回滚版本发布生效…", "回滚任务执行失败,请查看日志。", "Rollback failed: ")); return rollbackPipeline.getId(); } @@ -341,27 +270,6 @@ public Boolean stopPipeline(String namespace, String applicationName, String id, return true; } - /** - * Completes the deploy phase after the artifact has been applied. The pipeline moves to ROLLING_OUT; the - * scan job later reads Kubernetes rollout status and decides SUCCEEDED/ERROR. - */ - private void completeDeployPhase(Pipeline pipeline, String rollingOutDetail) { - pipelineStateMachine.ensureCanTransition(PipelineStatus.DEPLOYING, PipelineStatus.ROLLING_OUT); - int updated = pipelineRepository.updateStatusIfMatch( - pipeline.getId(), PipelineStatus.DEPLOYING, PipelineStatus.ROLLING_OUT); - if (updated == 0) { - // The pipeline was moved while its artifact was being applied — a stop is the only legal way — so the - // rollout is no longer this pipeline's to report on. The workload is updated regardless: a stop cannot - // take back an artifact that has already been applied. - log.info("Pipeline {} left DEPLOYING while its artifact was applied; not entering rollout", pipeline.getId()); - return; - } - pipeline.markRollingOut(); - eventPublisher.publishEvent(PipelineNotificationEvent.of( - pipeline, PipelineNotificationType.ROLLING_OUT, rollingOutDetail - )); - } - private Environment requireEnvironment(String environmentName) { Environment environment = environmentService.getEnvironment(environmentName); if (environment == null) { diff --git a/src/main/java/com/github/wellch4n/oops/domain/application/Application.java b/src/main/java/com/github/wellch4n/oops/domain/application/Application.java index e5fc2bf6..7e821004 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/application/Application.java +++ b/src/main/java/com/github/wellch4n/oops/domain/application/Application.java @@ -74,16 +74,25 @@ public void updateBuildConfig( ApplicationBuildConfig target = ensureBuildConfig(); var dockerFileConfig = request.getDockerFileConfig(); ApplicationSourceType sourceType = buildConfigPolicy.normalizeSourceType(request.getSourceType()); + String repository = request.repository(); buildConfigPolicy.validate( sourceType, - request.repository(), + repository, dockerFileConfig != null ? dockerFileConfig.getType() : null, dockerFileConfig != null ? dockerFileConfig.getContent() : null); target.setSourceType(sourceType); - target.setSourceConfig(buildConfigPolicy.buildSourceConfig(sourceType, request.repository())); - target.setDockerFileConfig(dockerFileConfig); - target.setBuildImage(request.getBuildImage()); - target.setEnvironmentConfigs(request.getEnvironmentConfigs()); + target.setSourceConfig(buildConfigPolicy.buildSourceConfig(sourceType, repository)); + if (sourceType == ApplicationSourceType.IMAGE) { + // Nothing is built, so a Dockerfile, build image or build command would be dead configuration + // that the editor hides — drop it rather than carry it around invisibly. + target.setDockerFileConfig(null); + target.setBuildImage(null); + target.setEnvironmentConfigs(null); + } else { + target.setDockerFileConfig(dockerFileConfig); + target.setBuildImage(request.getBuildImage()); + target.setEnvironmentConfigs(request.getEnvironmentConfigs()); + } } public void updateBuildEnvironmentConfigs(List configs) { diff --git a/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfig.java b/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfig.java index a682ef0f..6b9071b9 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfig.java +++ b/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfig.java @@ -19,12 +19,17 @@ public class ApplicationBuildConfig extends BaseDomainObject { private List environmentConfigs; /** - * Git repository URL when the source is GIT, otherwise {@code null}. Convenience accessor over - * {@link #sourceConfig}; named without a {@code get} prefix so Jackson does not treat it as a bean - * property during entity/domain mapping. + * Where the source lives: the Git repository URL for GIT, the image name (without tag) for IMAGE, + * {@code null} for ZIP. Convenience accessor over {@link #sourceConfig}; named without a {@code get} + * prefix so Jackson does not treat it as a bean property during entity/domain mapping. */ public String repository() { - return sourceConfig instanceof GitSourceConfig gitSourceConfig ? gitSourceConfig.repository() : null; + return switch (sourceConfig) { + case GitSourceConfig gitSourceConfig -> gitSourceConfig.repository(); + case ImageSourceConfig imageSourceConfig -> imageSourceConfig.repository(); + case ZipSourceConfig ignored -> null; + case null -> null; + }; } @Data diff --git a/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicy.java b/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicy.java index cd95f13e..5a6a5ba9 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicy.java +++ b/src/main/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicy.java @@ -10,6 +10,9 @@ public ApplicationSourceType normalizeSourceType(ApplicationSourceType sourceTyp return sourceType != null ? sourceType : ApplicationSourceType.GIT; } + /** + * {@code repository} is the Git URL for GIT and the image name for IMAGE; ZIP carries none. + */ public void validate(ApplicationSourceType sourceType, String repository, DockerFileType dockerFileType, @@ -18,15 +21,43 @@ public void validate(ApplicationSourceType sourceType, if (normalized == ApplicationSourceType.GIT && isBlank(repository)) { throw new BizException("Repository is required when source type is GIT"); } - if (dockerFileType == DockerFileType.USER && isBlank(dockerFileContent)) { + if (normalized == ApplicationSourceType.IMAGE) { + ensureImageRepositoryWithoutTag(repository); + } + if (normalized != ApplicationSourceType.IMAGE + && dockerFileType == DockerFileType.USER && isBlank(dockerFileContent)) { throw new BizException("Dockerfile content is required when type is USER"); } } public SourceConfig buildSourceConfig(ApplicationSourceType sourceType, String repository) { - return normalizeSourceType(sourceType) == ApplicationSourceType.GIT - ? new GitSourceConfig(repository) - : new ZipSourceConfig(); + return switch (normalizeSourceType(sourceType)) { + case GIT -> new GitSourceConfig(repository); + case ZIP -> new ZipSourceConfig(); + case IMAGE -> new ImageSourceConfig(repository.trim()); + }; + } + + /** + * The tag is a publish-time choice, so an image name that already carries one (or a digest) would + * either be silently doubled or override what the operator picks. The check looks only past the + * last {@code /} because a registry host may legitimately carry a port ({@code host:5000/app}). + */ + private void ensureImageRepositoryWithoutTag(String repository) { + if (isBlank(repository)) { + throw new BizException("Image repository is required when source type is IMAGE"); + } + String trimmed = repository.trim(); + if (trimmed.chars().anyMatch(Character::isWhitespace)) { + throw new BizException("Image repository must not contain whitespace"); + } + String lastSegment = trimmed.substring(trimmed.lastIndexOf('/') + 1); + if (lastSegment.isEmpty()) { + throw new BizException("Image repository must not end with '/'"); + } + if (lastSegment.indexOf(':') >= 0 || lastSegment.indexOf('@') >= 0) { + throw new BizException("Image repository must not include a tag or digest; the tag is chosen when publishing"); + } } private boolean isBlank(String value) { diff --git a/src/main/java/com/github/wellch4n/oops/domain/application/ImageSourceConfig.java b/src/main/java/com/github/wellch4n/oops/domain/application/ImageSourceConfig.java new file mode 100644 index 00000000..4a806c7b --- /dev/null +++ b/src/main/java/com/github/wellch4n/oops/domain/application/ImageSourceConfig.java @@ -0,0 +1,9 @@ +package com.github.wellch4n.oops.domain.application; + +/** + * Prebuilt-image build source. {@code repository} is the image name without a tag or digest + * (e.g. {@code ghcr.io/org/app}); the tag is chosen per publish and lives on the pipeline's + * {@code ImagePublishConfig}, mirroring how a Git branch is chosen per publish. + */ +public record ImageSourceConfig(String repository) implements SourceConfig { +} diff --git a/src/main/java/com/github/wellch4n/oops/domain/application/SourceConfig.java b/src/main/java/com/github/wellch4n/oops/domain/application/SourceConfig.java index dc25bfd0..b3a0ada2 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/application/SourceConfig.java +++ b/src/main/java/com/github/wellch4n/oops/domain/application/SourceConfig.java @@ -11,7 +11,8 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = GitSourceConfig.class, name = "GIT"), - @JsonSubTypes.Type(value = ZipSourceConfig.class, name = "ZIP") + @JsonSubTypes.Type(value = ZipSourceConfig.class, name = "ZIP"), + @JsonSubTypes.Type(value = ImageSourceConfig.class, name = "IMAGE") }) -public sealed interface SourceConfig permits GitSourceConfig, ZipSourceConfig { +public sealed interface SourceConfig permits GitSourceConfig, ZipSourceConfig, ImageSourceConfig { } diff --git a/src/main/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicy.java b/src/main/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicy.java index 4e02308c..146cb59b 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicy.java +++ b/src/main/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicy.java @@ -2,9 +2,13 @@ import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; import com.github.wellch4n.oops.shared.exception.BizException; +import java.util.regex.Pattern; public class DeployStrategyPolicy { + /** OCI distribution tag grammar: {@code [A-Za-z0-9_][A-Za-z0-9_.-]{0,127}}. */ + private static final Pattern IMAGE_TAG = Pattern.compile("[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}"); + public void ensureStrategyMatches(ApplicationSourceType configuredSourceType, ApplicationSourceType requestedPublishType) { ApplicationSourceType sourceType = configuredSourceType != null @@ -50,6 +54,25 @@ public ZipPublishConfig resolveZipPublishConfig(String objectKey, String url, St return new ZipPublishConfig(objectKey, url); } + /** + * Resolves the IMAGE publish config. The image name comes from the build config, never from the + * request — the publish only names the tag, and the tag must be an OCI tag on its own, so a caller + * cannot smuggle a different image (or a digest) in through it. + */ + public ImagePublishConfig resolveImagePublishConfig(String repository, String tag) { + if (repository == null || repository.isBlank()) { + throw new BizException("Image repository is required for IMAGE publish"); + } + String normalizedTag = blankToNull(tag == null ? null : tag.trim()); + if (normalizedTag == null) { + throw new BizException("Image tag is required for IMAGE publish"); + } + if (!IMAGE_TAG.matcher(normalizedTag).matches()) { + throw new BizException("Invalid image tag: " + normalizedTag); + } + return new ImagePublishConfig(repository.trim(), normalizedTag); + } + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } diff --git a/src/main/java/com/github/wellch4n/oops/domain/delivery/ImagePublishConfig.java b/src/main/java/com/github/wellch4n/oops/domain/delivery/ImagePublishConfig.java new file mode 100644 index 00000000..7f33306f --- /dev/null +++ b/src/main/java/com/github/wellch4n/oops/domain/delivery/ImagePublishConfig.java @@ -0,0 +1,13 @@ +package com.github.wellch4n.oops.domain.delivery; + +/** + * Image publish parameters: the image name from the build config and the tag the operator chose. + * Kept split (rather than only the joined artifact) so the publish page can offer the last tag back, + * the way it offers the last Git branch. + */ +public record ImagePublishConfig(String repository, String tag) implements PublishConfig { + + public String artifact() { + return repository + ":" + tag; + } +} diff --git a/src/main/java/com/github/wellch4n/oops/domain/delivery/Pipeline.java b/src/main/java/com/github/wellch4n/oops/domain/delivery/Pipeline.java index 139d1c44..9f1ee5e5 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/delivery/Pipeline.java +++ b/src/main/java/com/github/wellch4n/oops/domain/delivery/Pipeline.java @@ -67,6 +67,30 @@ public static Pipeline rollback(Pipeline source, String operatorId) { return pipeline; } + /** + * An image publish: the artifact is known up front and no build job runs, so the pipeline is born with + * it and, like a rollback, goes from {@link PipelineStatus#INITIALIZED} straight to deploying (or parks in + * {@link PipelineStatus#BUILD_SUCCEEDED} under {@link DeployMode#MANUAL}). + */ + public static Pipeline initializeWithArtifact( + String namespace, + String applicationName, + String environment, + ImagePublishConfig publishConfig, + DeployMode deployMode, + String operatorId + ) { + Pipeline pipeline = initialize(namespace, applicationName, environment, + ApplicationSourceType.IMAGE, deployMode, operatorId); + pipeline.setPublishConfig(publishConfig); + pipeline.setArtifact(publishConfig.artifact()); + return pipeline; + } + + public boolean hasBuild() { + return triggerType != PipelineTriggerType.ROLLBACK && publishType != ApplicationSourceType.IMAGE; + } + public String getName() { return String.format("%s-pipeline-%s", applicationName, getId()); } @@ -76,6 +100,11 @@ public void startBuild(String artifact) { transitionTo(PipelineStatus.RUNNING); } + /** MANUAL-mode image publish: nothing to build, park where a built pipeline waits for its deploy. */ + public void markReadyToDeploy() { + transitionTo(PipelineStatus.BUILD_SUCCEEDED); + } + public void markBuildSucceeded() { transitionTo(PipelineStatus.BUILD_SUCCEEDED); } diff --git a/src/main/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachine.java b/src/main/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachine.java index 1701e26f..0853a83b 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachine.java +++ b/src/main/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachine.java @@ -15,8 +15,11 @@ public class PipelineStateMachine { new EnumMap<>(PipelineStatus.class); static { + // BUILD_SUCCEEDED straight from INITIALIZED is the MANUAL-mode image publish: it has no build, so + // it parks where a built pipeline parks and waits for the same deploy call. ALLOWED_TRANSITIONS.put(PipelineStatus.INITIALIZED, EnumSet.of( PipelineStatus.RUNNING, + PipelineStatus.BUILD_SUCCEEDED, PipelineStatus.DEPLOYING, PipelineStatus.ERROR, PipelineStatus.STOPPED diff --git a/src/main/java/com/github/wellch4n/oops/domain/delivery/PublishConfig.java b/src/main/java/com/github/wellch4n/oops/domain/delivery/PublishConfig.java index 74bea3a6..b89fa40a 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/delivery/PublishConfig.java +++ b/src/main/java/com/github/wellch4n/oops/domain/delivery/PublishConfig.java @@ -10,7 +10,8 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = GitPublishConfig.class, name = "GIT"), - @JsonSubTypes.Type(value = ZipPublishConfig.class, name = "ZIP") + @JsonSubTypes.Type(value = ZipPublishConfig.class, name = "ZIP"), + @JsonSubTypes.Type(value = ImagePublishConfig.class, name = "IMAGE") }) -public sealed interface PublishConfig permits GitPublishConfig, ZipPublishConfig { +public sealed interface PublishConfig permits GitPublishConfig, ZipPublishConfig, ImagePublishConfig { } diff --git a/src/main/java/com/github/wellch4n/oops/domain/shared/ApplicationSourceType.java b/src/main/java/com/github/wellch4n/oops/domain/shared/ApplicationSourceType.java index a3c2c495..9d04a5cb 100644 --- a/src/main/java/com/github/wellch4n/oops/domain/shared/ApplicationSourceType.java +++ b/src/main/java/com/github/wellch4n/oops/domain/shared/ApplicationSourceType.java @@ -2,5 +2,7 @@ public enum ApplicationSourceType { GIT, - ZIP + ZIP, + /** A prebuilt image: no build job runs, the publish names the tag and the pipeline deploys it as is. */ + IMAGE } diff --git a/src/test/java/com/github/wellch4n/oops/application/service/DeploymentServiceImagePublishTests.java b/src/test/java/com/github/wellch4n/oops/application/service/DeploymentServiceImagePublishTests.java new file mode 100644 index 00000000..8e7e0e0d --- /dev/null +++ b/src/test/java/com/github/wellch4n/oops/application/service/DeploymentServiceImagePublishTests.java @@ -0,0 +1,150 @@ +package com.github.wellch4n.oops.application.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.wellch4n.oops.application.dto.DeployCommand; +import com.github.wellch4n.oops.application.dto.GitDeployStrategyParam; +import com.github.wellch4n.oops.application.dto.ImageDeployStrategyParam; +import com.github.wellch4n.oops.application.port.ArtifactDeploymentExecutor; +import com.github.wellch4n.oops.application.port.PipelineBuildExecutor; +import com.github.wellch4n.oops.application.port.repository.ApplicationRepository; +import com.github.wellch4n.oops.application.port.repository.PipelineRepository; +import com.github.wellch4n.oops.domain.application.Application; +import com.github.wellch4n.oops.domain.application.ApplicationAccessPolicy; +import com.github.wellch4n.oops.domain.application.ApplicationBuildConfig; +import com.github.wellch4n.oops.domain.application.ApplicationBuildConfigPolicy; +import com.github.wellch4n.oops.domain.application.ImageSourceConfig; +import com.github.wellch4n.oops.domain.delivery.DeployStrategyPolicy; +import com.github.wellch4n.oops.domain.delivery.DeploymentConcurrencyPolicy; +import com.github.wellch4n.oops.domain.delivery.ImagePublishConfig; +import com.github.wellch4n.oops.domain.delivery.Pipeline; +import com.github.wellch4n.oops.domain.delivery.PipelineStateMachine; +import com.github.wellch4n.oops.domain.environment.Environment; +import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; +import com.github.wellch4n.oops.domain.shared.DeployMode; +import com.github.wellch4n.oops.domain.shared.Operator; +import com.github.wellch4n.oops.domain.shared.PipelineStatus; +import com.github.wellch4n.oops.domain.shared.PipelineTriggerType; +import com.github.wellch4n.oops.domain.shared.UserRole; +import com.github.wellch4n.oops.shared.exception.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; + +class DeploymentServiceImagePublishTests { + + private static final String NAMESPACE = "default"; + private static final String APP_NAME = "demo"; + private static final String ENV = "prod"; + private static final String NEW_ID = "new-pipeline-id"; + private static final String OPERATOR = "operator-1"; + + private PipelineRepository pipelineRepository; + private PipelineBuildExecutor pipelineBuildExecutor; + private ArtifactDeploymentExecutor artifactDeploymentExecutor; + private DeploymentService deploymentService; + + @BeforeEach + void setUp() { + pipelineRepository = mock(PipelineRepository.class); + pipelineBuildExecutor = mock(PipelineBuildExecutor.class); + artifactDeploymentExecutor = mock(ArtifactDeploymentExecutor.class); + ApplicationRepository applicationRepository = mock(ApplicationRepository.class); + EnvironmentService environmentService = mock(EnvironmentService.class); + UserService userService = mock(UserService.class); + ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + + deploymentService = new DeploymentService( + applicationRepository, + pipelineRepository, + environmentService, + pipelineBuildExecutor, + eventPublisher, + new DeployStrategyPolicy(), + new DeploymentConcurrencyPolicy(), + new ApplicationAccessPolicy(), + userService, + new ArtifactDeployRunner(pipelineRepository, environmentService, eventPublisher, + artifactDeploymentExecutor, PipelineStateMachine.getInstance())); + + Application application = new Application(); + application.setName(APP_NAME); + application.setNamespace(NAMESPACE); + application.setOwner(OPERATOR); + ApplicationBuildConfig buildConfig = new ApplicationBuildConfig(); + buildConfig.setSourceType(ApplicationSourceType.IMAGE); + buildConfig.setSourceConfig(new ImageSourceConfig("ghcr.io/org/demo")); + application.updateBuildConfig(buildConfig, new ApplicationBuildConfigPolicy()); + when(applicationRepository.findAggregate(NAMESPACE, APP_NAME)).thenReturn(application); + when(userService.findOperatorById(OPERATOR)).thenReturn(new Operator(OPERATOR, UserRole.USER, true)); + + Environment environment = new Environment(); + environment.setName(ENV); + when(environmentService.getEnvironment(ENV)).thenReturn(environment); + + when(pipelineRepository.save(any(Pipeline.class))).thenAnswer(invocation -> { + Pipeline saved = invocation.getArgument(0); + saved.setId(NEW_ID); + return saved; + }); + when(pipelineRepository.existsByNamespaceAndApplicationNameAndStatusIn(eq(NAMESPACE), eq(APP_NAME), anyList())) + .thenReturn(false); + when(pipelineRepository.updateStatusIfMatch(eq(NEW_ID), any(PipelineStatus.class), any(PipelineStatus.class))) + .thenReturn(1); + } + + @Test + void immediateImagePublishDeploysWithoutABuild() { + String id = deploymentService.deployApplication(NAMESPACE, APP_NAME, + new DeployCommand(ENV, DeployMode.IMMEDIATE, new ImageDeployStrategyParam("1.2.3")), OPERATOR); + + assertEquals(NEW_ID, id); + verify(pipelineBuildExecutor, never()).submit(any(), any(), any(), any()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Pipeline.class); + verify(pipelineRepository).save(saved.capture()); + Pipeline pipeline = saved.getValue(); + assertEquals("ghcr.io/org/demo:1.2.3", pipeline.getArtifact()); + assertEquals(ApplicationSourceType.IMAGE, pipeline.getPublishType()); + assertEquals(PipelineTriggerType.RELEASE, pipeline.getTriggerType()); + assertEquals(new ImagePublishConfig("ghcr.io/org/demo", "1.2.3"), pipeline.getPublishConfig()); + + verify(artifactDeploymentExecutor).deploy(eq(pipeline), any(), any(), any(), any(), any(), any()); + verify(pipelineRepository).updateStatusIfMatch(NEW_ID, PipelineStatus.INITIALIZED, PipelineStatus.DEPLOYING); + verify(pipelineRepository).updateStatusIfMatch(NEW_ID, PipelineStatus.DEPLOYING, PipelineStatus.ROLLING_OUT); + assertEquals(PipelineStatus.ROLLING_OUT, pipeline.getStatus()); + } + + @Test + void manualImagePublishParksInBuildSucceeded() { + deploymentService.deployApplication(NAMESPACE, APP_NAME, + new DeployCommand(ENV, DeployMode.MANUAL, new ImageDeployStrategyParam("1.2.3")), OPERATOR); + + verify(pipelineBuildExecutor, never()).submit(any(), any(), any(), any()); + verify(artifactDeploymentExecutor, never()).deploy(any(), any(), any(), any(), any(), any(), any()); + verify(pipelineRepository).updateStatusIfMatch(NEW_ID, PipelineStatus.INITIALIZED, PipelineStatus.BUILD_SUCCEEDED); + } + + @Test + void imagePublishRequiresATag() { + assertThrows(BizException.class, () -> deploymentService.deployApplication(NAMESPACE, APP_NAME, + new DeployCommand(ENV, DeployMode.IMMEDIATE, new ImageDeployStrategyParam(" ")), OPERATOR)); + verify(pipelineRepository, never()).save(any()); + } + + @Test + void gitStrategyIsRejectedForImageApplication() { + assertThrows(BizException.class, () -> deploymentService.deployApplication(NAMESPACE, APP_NAME, + new DeployCommand(ENV, DeployMode.IMMEDIATE, new GitDeployStrategyParam("main")), OPERATOR)); + verify(pipelineRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/github/wellch4n/oops/application/service/PipelineHealthVerificationTests.java b/src/test/java/com/github/wellch4n/oops/application/service/PipelineHealthVerificationTests.java index c558618d..50704bbf 100644 --- a/src/test/java/com/github/wellch4n/oops/application/service/PipelineHealthVerificationTests.java +++ b/src/test/java/com/github/wellch4n/oops/application/service/PipelineHealthVerificationTests.java @@ -63,7 +63,8 @@ void setUp() { applicationRepository, userService, eventPublisher, - artifactDeploymentExecutor, + new ArtifactDeployRunner(pipelineRepository, environmentService, eventPublisher, + artifactDeploymentExecutor, PipelineStateMachine.getInstance()), pipelineJobGateway, pipelineLogStreamGateway, PipelineStateMachine.getInstance(), diff --git a/src/test/java/com/github/wellch4n/oops/application/service/PipelineRollbackTests.java b/src/test/java/com/github/wellch4n/oops/application/service/PipelineRollbackTests.java index 5465be6c..308bf927 100644 --- a/src/test/java/com/github/wellch4n/oops/application/service/PipelineRollbackTests.java +++ b/src/test/java/com/github/wellch4n/oops/application/service/PipelineRollbackTests.java @@ -66,7 +66,8 @@ void setUp() { applicationRepository, userService, eventPublisher, - artifactDeploymentExecutor, + new ArtifactDeployRunner(pipelineRepository, environmentService, eventPublisher, + artifactDeploymentExecutor, PipelineStateMachine.getInstance()), pipelineJobGateway, pipelineLogStreamGateway, PipelineStateMachine.getInstance(), diff --git a/src/test/java/com/github/wellch4n/oops/application/service/PipelineStopTests.java b/src/test/java/com/github/wellch4n/oops/application/service/PipelineStopTests.java index eeb82885..85fff665 100644 --- a/src/test/java/com/github/wellch4n/oops/application/service/PipelineStopTests.java +++ b/src/test/java/com/github/wellch4n/oops/application/service/PipelineStopTests.java @@ -69,7 +69,8 @@ void setUp() { applicationRepository, userService, eventPublisher, - mock(ArtifactDeploymentExecutor.class), + new ArtifactDeployRunner(pipelineRepository, environmentService, eventPublisher, + mock(ArtifactDeploymentExecutor.class), PipelineStateMachine.getInstance()), pipelineJobGateway, mock(PipelineLogStreamGateway.class), PipelineStateMachine.getInstance(), diff --git a/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicyTests.java b/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicyTests.java index 689ae8f0..8900fff3 100644 --- a/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicyTests.java +++ b/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationBuildConfigPolicyTests.java @@ -64,4 +64,33 @@ void buildSourceConfigReturnsZipConfig() { void buildSourceConfigDefaultsNullToGit() { assertInstanceOf(GitSourceConfig.class, policy.buildSourceConfig(null, "repo")); } + + @Test + void validateRequiresImageRepositoryWithoutTagForImage() { + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, " ", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, "nginx:1.27", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, "nginx@sha256:abc", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, "ghcr.io/org/", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, "ghcr.io/org/app x", null, null)); + // a registry port is not a tag + policy.validate(ApplicationSourceType.IMAGE, "registry.local:5000/org/app", null, null); + policy.validate(ApplicationSourceType.IMAGE, "nginx", null, null); + } + + @Test + void validateIgnoresDockerfileForImage() { + policy.validate(ApplicationSourceType.IMAGE, "nginx", DockerFileType.USER, " "); + } + + @Test + void buildSourceConfigReturnsImageConfigTrimmed() { + SourceConfig config = policy.buildSourceConfig(ApplicationSourceType.IMAGE, " ghcr.io/org/app "); + ImageSourceConfig imageConfig = assertInstanceOf(ImageSourceConfig.class, config); + assertEquals("ghcr.io/org/app", imageConfig.repository()); + } } diff --git a/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationTests.java b/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationTests.java index 8a1b45e3..1b676355 100644 --- a/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationTests.java +++ b/src/test/java/com/github/wellch4n/oops/domain/application/ApplicationTests.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; +import com.github.wellch4n.oops.domain.shared.DockerFileType; import com.github.wellch4n.oops.shared.exception.BizException; import java.util.Arrays; import java.util.List; @@ -135,4 +136,33 @@ void sourceTypeReflectsConfiguredValue() { application.setBuildConfig(buildConfig); assertEquals(ApplicationSourceType.ZIP, application.sourceType()); } + + @Test + void updateBuildConfigToImageDropsBuildOnlySettings() { + Application application = application("owner-1"); + ApplicationBuildConfig gitRequest = new ApplicationBuildConfig(); + gitRequest.setSourceType(ApplicationSourceType.GIT); + gitRequest.setSourceConfig(new GitSourceConfig("git@host:repo.git")); + gitRequest.setBuildImage("maven:3"); + ApplicationBuildConfig.DockerFileConfig dockerFile = new ApplicationBuildConfig.DockerFileConfig(); + dockerFile.setType(DockerFileType.USER); + dockerFile.setContent("FROM scratch"); + gitRequest.setDockerFileConfig(dockerFile); + application.updateBuildConfig(gitRequest, new ApplicationBuildConfigPolicy()); + + ApplicationBuildConfig imageRequest = new ApplicationBuildConfig(); + imageRequest.setSourceType(ApplicationSourceType.IMAGE); + imageRequest.setSourceConfig(new ImageSourceConfig("ghcr.io/org/app")); + // a stale Dockerfile on the request is ignored for IMAGE, not validated and not kept + imageRequest.setDockerFileConfig(dockerFile); + imageRequest.setBuildImage("maven:3"); + application.updateBuildConfig(imageRequest, new ApplicationBuildConfigPolicy()); + + ApplicationBuildConfig stored = application.getBuildConfig(); + assertEquals(ApplicationSourceType.IMAGE, application.sourceType()); + assertEquals("ghcr.io/org/app", stored.repository()); + assertNull(stored.getBuildImage()); + assertNull(stored.getDockerFileConfig()); + assertNull(stored.getEnvironmentConfigs()); + } } diff --git a/src/test/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicyTests.java b/src/test/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicyTests.java index b80d0cca..242c42ed 100644 --- a/src/test/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicyTests.java +++ b/src/test/java/com/github/wellch4n/oops/domain/delivery/DeployStrategyPolicyTests.java @@ -89,4 +89,31 @@ void resolveZipLegacyNonUrlTreatedAsObjectKey() { assertEquals("uploads/a.zip", config.objectKey()); assertNull(config.url()); } + + @Test + void resolveImageJoinsRepositoryAndTag() { + ImagePublishConfig config = policy.resolveImagePublishConfig("ghcr.io/org/app", " 1.2.3 "); + assertEquals("ghcr.io/org/app", config.repository()); + assertEquals("1.2.3", config.tag()); + assertEquals("ghcr.io/org/app:1.2.3", config.artifact()); + } + + @Test + void resolveImageRequiresTag() { + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig("ghcr.io/org/app", null)); + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig("ghcr.io/org/app", " ")); + } + + @Test + void resolveImageRequiresRepositoryFromBuildConfig() { + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig(null, "1.0")); + } + + @Test + void resolveImageRejectsTagsThatSmuggleAnotherImage() { + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig("app", "other/image:1.0")); + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig("app", "1.0@sha256:abc")); + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig("app", ".hidden")); + assertThrows(BizException.class, () -> policy.resolveImagePublishConfig("app", "a".repeat(129))); + } } diff --git a/src/test/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachineTests.java b/src/test/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachineTests.java index 3e45c20f..4fc6d911 100644 --- a/src/test/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachineTests.java +++ b/src/test/java/com/github/wellch4n/oops/domain/delivery/PipelineStateMachineTests.java @@ -17,6 +17,12 @@ void allowsInitializedToDeployingForRollback() { PipelineStatus.INITIALIZED, PipelineStatus.DEPLOYING)); } + @Test + void allowsInitializedToBuildSucceededForManualImagePublish() { + assertDoesNotThrow(() -> stateMachine.ensureCanTransition( + PipelineStatus.INITIALIZED, PipelineStatus.BUILD_SUCCEEDED)); + } + @Test void stillAllowsNormalBuildPath() { assertDoesNotThrow(() -> stateMachine.ensureCanTransition( @@ -62,7 +68,7 @@ void rejectsIllegalTransitionFromInitialized() { assertThrows(BizException.class, () -> stateMachine.ensureCanTransition( PipelineStatus.INITIALIZED, PipelineStatus.SUCCEEDED)); assertThrows(BizException.class, () -> stateMachine.ensureCanTransition( - PipelineStatus.INITIALIZED, PipelineStatus.BUILD_SUCCEEDED)); + PipelineStatus.INITIALIZED, PipelineStatus.ROLLING_OUT)); } @Test diff --git a/tests/integration/oops_client.py b/tests/integration/oops_client.py index 2686a01e..5a7230f8 100644 --- a/tests/integration/oops_client.py +++ b/tests/integration/oops_client.py @@ -158,9 +158,11 @@ def delete_application(self, namespace: str, name: str, def get_application(self, namespace: str, name: str, **kwargs) -> Response: return self.get(f"/api/namespaces/{namespace}/applications/{name}", **kwargs) - def put_build_config(self, namespace: str, name: str, config: dict) -> Response: + def put_build_config(self, namespace: str, name: str, config: dict, + **kwargs) -> Response: return self.put( - f"/api/namespaces/{namespace}/applications/{name}/build/config", config) + f"/api/namespaces/{namespace}/applications/{name}/build/config", config, + **kwargs) def put_service_config(self, namespace: str, name: str, config: dict) -> Response: return self.put( diff --git a/tests/integration/test_application_config.py b/tests/integration/test_application_config.py index d97413a4..8704a158 100644 --- a/tests/integration/test_application_config.py +++ b/tests/integration/test_application_config.py @@ -84,6 +84,53 @@ def test_build_config_round_trips(client, namespace, application, environment): f"the per-environment build command was lost; got {commands}") +def test_image_build_config_keeps_only_the_image(client, namespace, application, + environment): + """An IMAGE source names the image without its tag — the tag travels per + deploy — and is never built, so the build-only settings sent along with it + are dropped rather than stored invisibly.""" + client.put_build_config(namespace, application, { + "namespace": namespace, + "applicationName": application, + "sourceType": "IMAGE", + "repository": "ghcr.io/example/service", + "buildImage": "node:20-slim", + "dockerFileConfig": {"type": "USER", "content": "FROM alpine:3.20\n"}, + "environmentConfigs": [ + {"environment": environment, "buildCommand": "npm run build"}, + ], + }) + + stored = client.get( + f"/api/namespaces/{namespace}/applications/{application}/build/config").data + assert stored["sourceType"] == "IMAGE" + assert stored["repository"] == "ghcr.io/example/service" + assert not stored.get("buildImage") + assert not stored.get("dockerFileConfig") + + listed = client.get_application(namespace, application).data + assert listed["sourceType"] == "IMAGE" + + +def test_image_build_config_rejects_a_tag_in_the_image_name(client, namespace, + application): + for image in ("nginx:1.27", "ghcr.io/example/service@sha256:0123", " ", "ghcr.io/example/"): + response = client.put_build_config(namespace, application, { + "namespace": namespace, + "applicationName": application, + "sourceType": "IMAGE", + "repository": image, + }, expect_success=False) + assert response.success is False, f"{image!r} should have been rejected" + # a registry port is not a tag + client.put_build_config(namespace, application, { + "namespace": namespace, + "applicationName": application, + "sourceType": "IMAGE", + "repository": "registry.local:5000/example/service", + }) + + def test_per_environment_build_configs_can_be_written_separately( client, namespace, application, environment): """The per-environment collection has its own endpoint as well as riding diff --git a/tests/integration/test_deploy.py b/tests/integration/test_deploy.py index 4264abc2..c9adaa22 100644 --- a/tests/integration/test_deploy.py +++ b/tests/integration/test_deploy.py @@ -29,6 +29,10 @@ SOURCE_REPOSITORY = os.environ.get( "OOPS_TEST_REPOSITORY", "https://github.com/docker/welcome-to-docker.git") SOURCE_BRANCH = os.environ.get("OOPS_TEST_BRANCH", "main") +# A small public image for the image-publish path, split into the name the build +# config stores and the tag a publish names. Override for an offline cluster. +PUBLIC_IMAGE = os.environ.get("OOPS_TEST_IMAGE", "nginx") +PUBLIC_IMAGE_TAG = os.environ.get("OOPS_TEST_IMAGE_TAG", "alpine") DEPLOY_TIMEOUT = int(os.environ.get("OOPS_TEST_DEPLOY_TIMEOUT", "900")) @@ -87,6 +91,22 @@ def git_strategy(branch: str = SOURCE_BRANCH) -> dict: return {"type": "GIT", "branch": branch} +def configure_for_image(client, namespace, application, environment): + """Like `configure_for_build`, for an application that deploys a prebuilt + image: the build config carries only the image name, the tag comes per deploy.""" + configure_for_build(client, namespace, application, environment) + client.put_build_config(namespace, application, { + "namespace": namespace, + "applicationName": application, + "sourceType": "IMAGE", + "repository": PUBLIC_IMAGE, + }) + + +def image_strategy(tag: str = PUBLIC_IMAGE_TAG) -> dict: + return {"type": "IMAGE", "tag": tag} + + def poll_pipeline(client, namespace, application, pipeline_id, seen: list) -> dict: """Fetch the pipeline and record every distinct status it passes through.""" pipeline = client.get_pipeline(namespace, application, pipeline_id) @@ -239,6 +259,73 @@ def built(): f"MANUAL mode should hold at BUILD_SUCCEEDED, got {pipeline['status']}") +def test_image_publish_deploys_without_a_build(client, namespace, application, + environment): + """An image publish has its artifact before it starts: no build job, so the + pipeline never passes through RUNNING, and what reaches the cluster is the + configured image name joined with the tag the publish named.""" + configure_for_image(client, namespace, application, environment) + pipeline_id = client.deploy(namespace, application, environment, + strategy=image_strategy()) + seen: list[str] = [] + + def finished(): + pipeline = poll_pipeline(client, namespace, application, pipeline_id, seen) + return pipeline if pipeline["status"] in TERMINAL_STATUSES else None + + pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, + description=f"image pipeline {pipeline_id} to reach a terminal state") + + assert pipeline["status"] == "SUCCEEDED", ( + f"image publish ended as {pipeline['status']} after passing through {seen}: " + f"{pipeline.get('message')}") + assert "RUNNING" not in seen, f"an image publish must not build, but passed through {seen}" + assert pipeline["artifact"] == f"{PUBLIC_IMAGE}:{PUBLIC_IMAGE_TAG}" + assert pipeline["publishType"] == "IMAGE" + assert pipeline["publishConfig"] == { + "type": "IMAGE", "repository": PUBLIC_IMAGE, "tag": PUBLIC_IMAGE_TAG} + assert pipeline["triggerType"] == "RELEASE" + + status = client.get( + f"/api/namespaces/{namespace}/applications/{application}/status" + f"?environment={environment}").data + images = {container["image"] for pod in status for container in pod.get("containers", [])} + # The runtime reports the reference it resolved, so a bare `nginx:alpine` comes back as + # `docker.io/library/nginx:alpine`; only the trailing name:tag is ours to check. + published = f"{PUBLIC_IMAGE}:{PUBLIC_IMAGE_TAG}" + assert any(image == published or image.endswith("/" + published) for image in images), ( + f"the running pods should carry the published image {published}; saw {images}") + + +def test_image_publish_in_manual_mode_waits_for_the_deploy_call( + client, namespace, application, environment): + configure_for_image(client, namespace, application, environment) + pipeline_id = client.deploy(namespace, application, environment, + deploy_mode="MANUAL", strategy=image_strategy()) + + pipeline = client.get_pipeline(namespace, application, pipeline_id) + assert pipeline["status"] == "BUILD_SUCCEEDED", ( + f"a MANUAL image publish should park at BUILD_SUCCEEDED at once, got {pipeline['status']}") + + client.put(f"/api/namespaces/{namespace}/applications/{application}" + f"/pipelines/{pipeline_id}/deploy") + pipeline = wait_for_terminal(client, namespace, application, pipeline_id, + "the manually deployed image publish to finish") + assert pipeline["status"] == "SUCCEEDED", pipeline.get("message") + + +def test_image_publish_requires_a_tag_and_matching_strategy( + client, namespace, application, environment): + configure_for_image(client, namespace, application, environment) + for strategy in (image_strategy(""), image_strategy("other/image:1.0"), + image_strategy("1.0@sha256:abc"), git_strategy()): + response = client.post( + f"/api/namespaces/{namespace}/applications/{application}/deployments", + {"environment": environment, "deployMode": "IMMEDIATE", "strategy": strategy}, + expect_success=False) + assert response.success is False, f"{strategy} should have been rejected" + + def test_pipeline_listing_accepts_the_all_scope(client, namespace, application, environment): """Namespace `all` spans namespaces and a blank environment means no filter.""" diff --git a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx index 5a47e722..3d6878ca 100644 --- a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx +++ b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx @@ -23,7 +23,7 @@ import { DataTable } from "@/components/ui/data-table" import { getPipelineStatusColumns, imageTag } from "../columns" import { toast } from "sonner" import dayjs from "dayjs" -import { AlertTriangle, ExternalLink, Check, ArrowUpRight, Rocket, Ban, FileText, ChevronDown, Undo2, Loader2, X, Radio } from "lucide-react" +import { AlertTriangle, ExternalLink, Check, ArrowUpRight, Rocket, Ban, FileText, ChevronDown, Undo2, Container, Loader2, X, Radio } from "lucide-react" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import Link from "next/link" import { useLanguage } from "@/contexts/language-context" @@ -136,9 +136,12 @@ export default function PipelineDetailPage({ params }: PageProps) { const [clusterDomain, setClusterDomain] = useState(null) const { t } = useLanguage() - // A rollback reuses a historic artifact and runs no build job, so it has neither steps nor build logs. + // A rollback reuses a historic artifact and an image publish deploys one the operator named; neither + // runs a build job, so neither has steps or build logs. const isRollback = pipeline?.triggerType === "ROLLBACK" - const buildLogAvailable = pipeline !== null && !isRollback + const isImagePublish = pipeline?.publishType === "IMAGE" + const hasBuild = pipeline !== null && !isRollback && !isImagePublish + const buildLogAvailable = hasBuild const fetchPipeline = useCallback(async () => { try { @@ -406,6 +409,17 @@ export default function PipelineDetailPage({ params }: PageProps) { {/* With the build log gone, this badge is the only thing on the page saying why. The header is already a dense row of labelled badges, so the source id stays in the tooltip and the badge itself links to it. */} + {/* Same role as the rollback badge: with no build log on the page, this is what + says which image went out and why there is nothing to watch. */} + {isImagePublish && pipeline?.artifact && ( + + + + {t("pipelines.col.imageTag")} + + + + )} {isRollback && ( pipeline?.rollbackFromPipelineId ? ( @@ -483,8 +497,8 @@ export default function PipelineDetailPage({ params }: PageProps) { panel on the guess and pulling it away a moment later reads as a glitch. */} {pipeline && (
- {/* Left column: steps + logs — a rollback runs no build job, so it has no log stream */} - {!isRollback && ( + {/* Left column: steps + logs — a rollback or image publish runs no build job, so it has no log stream */} + {hasBuild && (
{/* Steps Progress Bar — driven by the build pod's container statuses, not by which log happens to be arriving, so every step reads right even when its log is not loaded. */} diff --git a/web/app/apps/[namespace]/[name]/publish/page.tsx b/web/app/apps/[namespace]/[name]/publish/page.tsx index e4d86627..b66ca650 100644 --- a/web/app/apps/[namespace]/[name]/publish/page.tsx +++ b/web/app/apps/[namespace]/[name]/publish/page.tsx @@ -9,6 +9,7 @@ import { createApplicationBuildSourceUpload, getApplication, getApplicationBuild import { Application, ApplicationEnvironment, ApplicationRuntimeSpec, ApplicationSourceType, DeployMode, DeployStrategyParam, LastSuccessfulPipelineInfo } from "@/lib/api/types" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group" import { Skeleton } from "@/components/ui/skeleton" import { AlertDialog, @@ -29,6 +30,9 @@ import { useRecentAppStore } from "@/store/recent-app" import { useFeaturesStore } from "@/store/features" import { cn } from "@/lib/utils" +// OCI distribution tag grammar; the backend applies the same rule. +const IMAGE_TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/ + interface PageProps { params: Promise<{ namespace: string @@ -55,6 +59,9 @@ export default function PublishPage({ params }: PageProps) { const [resolvedBranch, setResolvedBranch] = useState(null) const [branchesLoading, setBranchesLoading] = useState(false) const [publishRepository, setPublishRepository] = useState("") + // IMAGE source: the image name is the application's build config, only the tag is chosen here. + const [imageRepository, setImageRepository] = useState("") + const [imageTag, setImageTag] = useState("") const [lastSuccessfulPipeline, setLastSuccessfulPipeline] = useState(null) const [deployMode, setDeployMode] = useState("MANUAL") const [runtimeSpec, setRuntimeSpec] = useState(null) @@ -72,6 +79,9 @@ export default function PublishPage({ params }: PageProps) { const lastGitBranch = lastSuccessfulPipeline?.publishConfig?.type === "GIT" ? normalizeText(lastSuccessfulPipeline.publishConfig.branch) : "" + const lastImageTag = lastSuccessfulPipeline?.publishConfig?.type === "IMAGE" + ? normalizeText(lastSuccessfulPipeline.publishConfig.tag) + : "" useEffect(() => { const fetchData = async () => { @@ -113,6 +123,7 @@ export default function PublishPage({ params }: PageProps) { if (buildConfigRes.data?.sourceType) { setSourceType(buildConfigRes.data.sourceType) } + setImageRepository(buildConfigRes.data?.repository ?? "") if (runtimeSpecRes.data) { setRuntimeSpec(runtimeSpecRes.data) } @@ -127,6 +138,9 @@ export default function PublishPage({ params }: PageProps) { if (currentSourceType === "ZIP" && lastPublishConfig?.type === "ZIP") { setPublishRepository(normalizeText(lastPublishConfig.objectKey) || normalizeText(lastPublishConfig.url)) } + if (currentSourceType === "IMAGE" && lastPublishConfig?.type === "IMAGE") { + setImageTag(normalizeText(lastPublishConfig.tag)) + } } } catch { toast.error(t("apps.publish.fetchError")) @@ -144,6 +158,20 @@ export default function PublishPage({ params }: PageProps) { toast.error(t("apps.publish.zipRequired")) return } + if (sourceType === "IMAGE") { + if (!imageRepository) { + toast.error(t("apps.publish.imageRepositoryMissing")) + return + } + if (!imageTag.trim()) { + toast.error(t("apps.publish.imageTagRequired")) + return + } + if (!IMAGE_TAG_PATTERN.test(imageTag.trim())) { + toast.error(t("apps.publish.imageTagInvalid")) + return + } + } // Re-fetch the runtime spec so the replica check reflects the latest // configuration at the moment of publishing, not the page-load snapshot. @@ -181,7 +209,9 @@ export default function PublishPage({ params }: PageProps) { ? (zipSource.startsWith("http://") || zipSource.startsWith("https://") ? { type: "ZIP", url: zipSource } : { type: "ZIP", objectKey: zipSource }) - : { type: "GIT", branch: branch.trim() || "main" } + : sourceType === "IMAGE" + ? { type: "IMAGE", tag: imageTag.trim() } + : { type: "GIT", branch: branch.trim() || "main" } const res = await deployApplication( namespace, name, @@ -451,6 +481,60 @@ export default function PublishPage({ params }: PageProps) {
)} + {sourceType === "IMAGE" && ( +
+ + {imageRepository ? ( + <> + {/* The image name is fixed by the build config, so it is shown as a prefix the + operator cannot edit: what is decided here is only which tag goes out. */} + + + {imageRepository}: + + setImageTag(e.target.value)} + placeholder={lastImageTag || t("apps.publish.imageTagPlaceholder")} + /> + +

+ {t("apps.publish.imageWillDeploy")} + {imageTag.trim() ? `${imageRepository}:${imageTag.trim()}` : "—"} +

+ + ) : ( +

+ {t("apps.publish.imageRepositoryMissingPrefix")} + + {t("apps.publish.imageRepositoryMissingLink")} + + + {t("apps.publish.imageRepositoryMissingSuffix")} +

+ )} +
+ )} +