diff --git a/AGENTS.md b/AGENTS.md index f9df320d..a5119ee8 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.image`, a field of its own rather than a reuse of the Git `repository`, so an application switching between the two sources keeps both values instead of offering a Git URL as an image name — `ApplicationBuildConfig.repository()` and `.image()` each answer for one source and `null` for the others), 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 and list show no step bar and no special mark (`Pipeline.hasBuild()` is false, as for a rollback, but an image publish is an ordinary release, not a rollback, so it gets no badge or icon); 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. @@ -202,8 +204,10 @@ Deployment triggering logic lives in `DeploymentService` (not `PipelineService`) The K8s client is created per-task and closed via try-with-resources in `ArtifactDeployTask.call()`. +**One editor tab, one request**: every tab of the application editor saves through exactly one endpoint, in one transaction — the build tab's per-environment build commands ride inside `PUT .../build/config` as `environmentConfigs`, and the basic-info tab's environment bindings ride inside `PUT .../applications/{name}` as `environments`. In both, an omitted list means unchanged, so an OpenAPI caller that does not know about the list (a profile PUT built from an older client, or the CLI's `app build set` without `--build-command`) cannot wipe it; `PUT .../environments` still exists for changing the bindings alone. Never chain a second request to save part of a form: the first call can wipe what the second restores, and a failed second call leaves a half-saved record behind a generic error toast. + **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. @@ -420,6 +424,12 @@ OOPS uses Flyway to apply schema and data migrations automatically during applic `environmentName` JSON keys to `environment`. Its column renames are guarded on `information_schema`, because a database that ran the 3.0 Go release already carries the new names while its `flyway_schema_history` stopped at V21 — the migration must be a no-op there +- `V23__widen_enum_columns_to_varchar.sql` turns the `@Enumerated(STRING)` columns + (`source_type`, `publish_type`, `cert_mode`, `provider`, `role`) into `varchar(255)`. A database + that predates Flyway got them from Hibernate's DDL as native MySQL `ENUM(...)` columns frozen at + the constants of the day, so adding a constant (`IMAGE`) failed with "Data truncated for column" + and rolled the save back, even though `V1__baseline_schema.sql` documents them as varchar. Never + declare a new enum-backed column as `ENUM`, for the same reason ## Configuration Notes diff --git a/skills/oops/SKILL.md b/skills/oops/SKILL.md index 4130f73f..7a00acec 100644 --- a/skills/oops/SKILL.md +++ b/skills/oops/SKILL.md @@ -124,6 +124,29 @@ python skills/oops/scripts/oops.py app build set -n \ `builtin` reads the real Dockerfile from the repository. +Per-environment build commands are set with `--build-command =` (repeatable): + +```bash +python skills/oops/scripts/oops.py app build set -n \ + --source git --repository "https://github.com/owner/repo.git" \ + --build-command "dev=npm ci && npm run build" \ + --build-command "prod=npm ci && npm run build -- --mode production" +``` + +Without any `--build-command` the existing commands are left as they are; to clear them all, pass `--build-command ""`. + +#### Image source (prebuilt image, nothing is built) + +```bash +python skills/oops/scripts/oops.py app build set -n \ + --source image \ + --image "ghcr.io/owner/app" +``` + +`--image` is the image name **without a tag or digest** — the tag is chosen at each deploy +(step 7). Dockerfile, build image and build commands do not apply and are dropped. Only images +pullable without credentials are supported. + ### Step 4 — Bind to environments ```bash @@ -185,6 +208,16 @@ python skills/oops/scripts/oops.py deploy git -n --env --branch Uses the repository configured in step 3; `--branch` is optional. +#### Image mode + +```bash +python skills/oops/scripts/oops.py deploy image -n --env --tag 1.2.3 --wait +``` + +Deploys `:` with the image name from step 3; `--tag` defaults to `latest`. No +build runs, so `--wait` only follows the rollout. A tag that does not exist fails the rollout as +`ImagePullBackOff`, not at trigger time, and re-deploying the same tag changes nothing. + ### Step 8 — Verify ```bash diff --git a/skills/oops/scripts/oops.py b/skills/oops/scripts/oops.py index c0a3dd0e..934c879b 100755 --- a/skills/oops/scripts/oops.py +++ b/skills/oops/scripts/oops.py @@ -251,18 +251,42 @@ def cmd_app_build_get(client: Client, args: argparse.Namespace) -> None: def human(c): print(f"Source: {dash(c.get('sourceType'))}") print(f"Repository: {dash(c.get('repository'))}") + print(f"Image: {dash(c.get('image'))}") print(f"Build image: {dash(c.get('buildImage'))}") df = c.get("dockerFileConfig") or {} print(f"Dockerfile: type={dash(df.get('type'))}, path={dash(df.get('path'))}") + for item in c.get("environmentConfigs") or []: + print(f"Build cmd: {item.get('environment')}={dash(item.get('buildCommand'))}") render(args.json, config, human) +def parse_build_commands(values: Optional[List[str]]) -> Optional[List[Dict[str, str]]]: + """`ENV=COMMAND` pairs → environmentConfigs. None (flag absent) leaves the stored commands + alone; a lone empty string clears them all.""" + if values is None: + return None + if values == [""]: + return [] + configs = [] + for value in values: + if "=" not in value: + die(f"--build-command expects ENV=COMMAND, got {value!r}") + env, command = value.split("=", 1) + if not env.strip(): + die(f"--build-command has an empty environment name: {value!r}") + configs.append({"environment": env.strip(), "buildCommand": command}) + return configs + + def cmd_app_build_set(client: Client, args: argparse.Namespace) -> None: body = { "namespace": args.namespace, "applicationName": args.name, "sourceType": args.source.upper(), + # Both travel every time: the Git URL and the image name are separate fields, so an + # application that switches source keeps the one it is not using. "repository": args.repository or "", + "image": args.image or "", "dockerFileConfig": { "type": args.dockerfile_type.upper(), "path": args.dockerfile_path, @@ -270,6 +294,9 @@ def cmd_app_build_set(client: Client, args: argparse.Namespace) -> None: }, "buildImage": args.build_image or "", } + build_commands = parse_build_commands(args.build_commands) + if build_commands is not None: + body["environmentConfigs"] = build_commands client.put(f"/openapi/namespaces/{args.namespace}/applications/{args.name}/build/config", body) render(args.json, {"updated": True}, lambda _: print(f"Build config updated for {args.namespace}/{args.name}")) @@ -544,6 +571,12 @@ def cmd_deploy_zip(client: Client, args: argparse.Namespace) -> None: _trigger_deploy(client, args.namespace, args.name, args.env, args.mode, strategy, args.wait, args.json) +def cmd_deploy_image(client: Client, args: argparse.Namespace) -> None: + # Nothing is built: the image name comes from the build config and only the tag is chosen here. + strategy = {"type": "IMAGE", "tag": args.tag} + _trigger_deploy(client, args.namespace, args.name, args.env, args.mode, strategy, args.wait, args.json) + + # --------------------------------------------------------------------------- # Argument parser # --------------------------------------------------------------------------- @@ -606,12 +639,17 @@ def build_parser() -> argparse.ArgumentParser: build_set = build_sub.add_parser("set") build_set.add_argument("-n", "--namespace", required=True) build_set.add_argument("name") - build_set.add_argument("--source", default="git", choices=["git", "zip"]) - build_set.add_argument("--repository", default="") + build_set.add_argument("--source", default="git", choices=["git", "zip", "image"]) + build_set.add_argument("--repository", default="", help="Git URL, for --source git") + build_set.add_argument("--image", default="", + help="image name without a tag, for --source image") build_set.add_argument("--dockerfile-type", default="user", choices=["builtin", "user"], dest="dockerfile_type") build_set.add_argument("--dockerfile-path", default="Dockerfile", dest="dockerfile_path") build_set.add_argument("--dockerfile-content", dest="dockerfile_content") build_set.add_argument("--build-image", dest="build_image") + build_set.add_argument("--build-command", action="append", dest="build_commands", metavar="ENV=COMMAND", + help="per-environment build command (repeatable); omit to keep the existing ones, " + "pass an empty string to clear them all") # app service service_p = app_sub.add_parser("service") @@ -736,6 +774,14 @@ def build_parser() -> argparse.ArgumentParser: dz.add_argument("--mode", default="immediate", choices=["immediate", "manual"]) dz.add_argument("--wait", action="store_true", default=False) + di = deploy_sub.add_parser("image") + di.add_argument("-n", "--namespace", required=True) + di.add_argument("name") + di.add_argument("--env", required=True) + di.add_argument("--tag", default="latest", help="tag of the image configured in the build config") + di.add_argument("--mode", default="immediate", choices=["immediate", "manual"]) + di.add_argument("--wait", action="store_true", default=False) + return root @@ -793,6 +839,7 @@ def main() -> None: ("pipeline", "watch"): lambda: cmd_pipeline_watch(client, args), ("deploy", "git"): lambda: cmd_deploy_git(client, args), ("deploy", "zip"): lambda: cmd_deploy_zip(client, args), + ("deploy", "image"): lambda: cmd_deploy_image(client, args), } if args.command == "app": 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..6f9d7192 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; @@ -19,6 +20,11 @@ public final class ApplicationConfigDto { private ApplicationConfigDto() { } + /** + * @param environments the environment bindings, saved together with the profile because the + * basic-info editor edits both on one form. {@code null} on update means + * "leave the bindings as they are"; ignored on create. + */ public record Profile( String id, LocalDateTime createdTime, @@ -27,7 +33,8 @@ public record Profile( String icon, String namespace, String owner, - List collaborators + List collaborators, + List environments ) { public Application toDomain() { Application application = new Application(); @@ -48,7 +55,13 @@ public record BuildConfig( String namespace, String applicationName, ApplicationSourceType sourceType, + /** Git URL, used when sourceType is GIT. */ String repository, + /** + * Image name without a tag, used when sourceType is IMAGE. Separate from + * {@code repository} so an application that switches between the two sources keeps both. + */ + String image, DockerFileConfig dockerFileConfig, String buildImage, List environmentConfigs @@ -64,6 +77,7 @@ public static BuildConfig from(ApplicationBuildConfig config) { config.getApplicationName(), config.getSourceType(), config.repository(), + config.image(), DockerFileConfig.from(config.getDockerFileConfig()), config.getBuildImage(), map(config.getEnvironmentConfigs(), BuildEnvironmentConfig::from) @@ -77,9 +91,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(image); + }); 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/ApplicationService.java b/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java index b63a276a..1bf78b60 100644 --- a/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java +++ b/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java @@ -158,6 +158,12 @@ public Boolean updateApplication(String namespace, String name, ApplicationConfi Application exist = requireAggregate(namespace, name); exist.changeProfile(request.description(), normalizeOwner(request.owner()), request.icon()); exist.changeCollaborators(normalizeCollaborators(request.collaborators(), exist.getOwner())); + // The basic-info editor saves the profile and the environment bindings as one form, so they + // are one request and one transaction. An OpenAPI caller that omits the field leaves the + // bindings alone; `PUT .../environments` remains for changing them on their own. + if (request.environments() != null) { + exist.bindEnvironments(toEnvironmentDomains(request.environments())); + } applicationRepository.saveAggregate(exist); return true; } @@ -315,20 +321,6 @@ public ApplicationConfigDto.BuildConfig getApplicationBuildConfig(String namespa return ApplicationConfigDto.BuildConfig.from(buildConfig); } - public List getApplicationBuildEnvironmentConfigs(String namespace, String name) { - Application application = applicationRepository.findAggregate(namespace, name); - return application != null - ? application.buildEnvironmentConfigs().stream().map(ApplicationConfigDto.BuildEnvironmentConfig::from).toList() - : Collections.emptyList(); - } - - public List getApplicationRuntimeSpecEnvironmentConfigs(String namespace, String name) { - Application application = applicationRepository.findAggregate(namespace, name); - return application != null - ? application.runtimeEnvironmentConfigs().stream().map(ApplicationConfigDto.RuntimeEnvironmentConfig::from).toList() - : Collections.emptyList(); - } - public ApplicationConfigDto.RuntimeSpec getApplicationRuntimeSpec(String namespace, String name) { Application application = applicationRepository.findAggregate(namespace, name); if (application == null) { @@ -337,32 +329,6 @@ public ApplicationConfigDto.RuntimeSpec getApplicationRuntimeSpec(String namespa return ApplicationConfigDto.RuntimeSpec.from(application.runtimeSpecOrDefault(healthCheckPolicy)); } - @Transactional - public Boolean updateApplicationBuildEnvironmentConfigs( - String namespace, - String appName, - List configs - ) { - Application application = requireAggregate(namespace, appName); - application.updateBuildEnvironmentConfigs(toBuildEnvironmentConfigDomains(configs)); - applicationRepository.saveAggregate(application); - return true; - } - - public Boolean updateApplicationRuntimeSpecEnvironmentConfigs( - String namespace, - String appName, - List configs - ) { - Application application = requireAggregate(namespace, appName); - List existingConfigs = application.runtimeEnvironmentConfigs(); - application.updateRuntimeEnvironmentConfigs(toRuntimeEnvironmentConfigDomains(configs), healthCheckPolicy); - applicationRepository.saveAggregate(application); - applyRuntimeSpecEnvironmentConfigUpdates( - namespace, appName, application.runtimeEnvironmentConfigs(), existingConfigs); - return true; - } - public Boolean updateApplicationRuntimeSpec(String namespace, String appName, ApplicationConfigDto.RuntimeSpec request) { Application application = requireAggregate(namespace, appName); List existingConfigs = application.runtimeEnvironmentConfigs(); @@ -741,28 +707,6 @@ private Application requireAggregate(String namespace, String name) { return application; } - private List toBuildEnvironmentConfigDomains( - List configs - ) { - if (configs == null) { - return null; - } - return configs.stream() - .map(ApplicationConfigDto.BuildEnvironmentConfig::toDomain) - .toList(); - } - - private List toRuntimeEnvironmentConfigDomains( - List configs - ) { - if (configs == null) { - return null; - } - return configs.stream() - .map(ApplicationConfigDto.RuntimeEnvironmentConfig::toDomain) - .toList(); - } - private List toEnvironmentDomains(List configs) { if (configs == null) { return Collections.emptyList(); 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..36b1b3ec 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.image() : 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..2b1a0dea 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,20 +74,32 @@ public void updateBuildConfig( ApplicationBuildConfig target = ensureBuildConfig(); var dockerFileConfig = request.getDockerFileConfig(); ApplicationSourceType sourceType = buildConfigPolicy.normalizeSourceType(request.getSourceType()); + String repository = request.repository(); + String image = request.image(); buildConfigPolicy.validate( sourceType, - request.repository(), + repository, + image, 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()); - } - - public void updateBuildEnvironmentConfigs(List configs) { - ensureBuildConfig().setEnvironmentConfigs(configs); + target.setSourceConfig(buildConfigPolicy.buildSourceConfig(sourceType, repository, image)); + 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()); + // The editor always sends the full list; an OpenAPI caller that leaves the field out + // (the CLI's `app build set` without --build-command) is not asking to clear the + // per-environment build commands, so absent means unchanged. + if (request.getEnvironmentConfigs() != null) { + target.setEnvironmentConfigs(request.getEnvironmentConfigs()); + } + } } public void updateRuntimeSpec( @@ -101,17 +113,6 @@ public void updateRuntimeSpec( target.setHealthCheck(normalizeHealthCheck(request.getHealthCheck(), healthCheckPolicy)); } - public void updateRuntimeEnvironmentConfigs( - List configs, - HealthCheckPolicy healthCheckPolicy - ) { - ApplicationRuntimeSpec target = ensureRuntimeSpec(); - ApplicationRuntimeSpec request = new ApplicationRuntimeSpec(); - request.setEnvironmentConfigs(configs); - request.setHealthCheck(target.getHealthCheck()); - updateRuntimeSpec(request, healthCheckPolicy); - } - public void bindEnvironments(List configs) { this.environments = configs == null ? Collections.emptyList() : configs; this.environments.forEach(config -> { @@ -181,13 +182,6 @@ public ApplicationExpertConfig.EnvironmentConfig expertEnvironmentConfigOrDefaul .orElseGet(ApplicationExpertConfig.EnvironmentConfig::new); } - public List buildEnvironmentConfigs() { - if (buildConfig == null || buildConfig.getEnvironmentConfigs() == null) { - return Collections.emptyList(); - } - return buildConfig.getEnvironmentConfigs(); - } - public List runtimeEnvironmentConfigs() { if (runtimeSpec == null || runtimeSpec.getEnvironmentConfigs() == null) { return Collections.emptyList(); 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..f0219e17 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,25 @@ 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. + * The Git repository URL, or {@code null} for any other source. 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. + * + *

Deliberately not "the source location, whatever the type": a caller that wants the Git URL + * and a caller that wants the image name want different things, and one accessor serving both + * is how a Git URL ends up being deployed as an image name. */ public String repository() { - return sourceConfig instanceof GitSourceConfig gitSourceConfig ? gitSourceConfig.repository() : null; + return sourceConfig instanceof GitSourceConfig gitSourceConfig + ? gitSourceConfig.repository() + : null; + } + + /** The image name without a tag, or {@code null} for any other source. See {@link #repository()}. */ + public String image() { + return sourceConfig instanceof ImageSourceConfig imageSourceConfig + ? imageSourceConfig.image() + : 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..3637a087 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,23 +10,57 @@ public ApplicationSourceType normalizeSourceType(ApplicationSourceType sourceTyp return sourceType != null ? sourceType : ApplicationSourceType.GIT; } + /** + * {@code repository} is the Git URL and {@code image} the image name; they are separate fields + * so that switching an application between the two sources keeps both, and only the one the + * chosen source uses is validated. + */ public void validate(ApplicationSourceType sourceType, String repository, + String image, DockerFileType dockerFileType, String dockerFileContent) { ApplicationSourceType normalized = normalizeSourceType(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) { + ensureImageWithoutTag(image); + } + 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(); + public SourceConfig buildSourceConfig(ApplicationSourceType sourceType, String repository, String image) { + return switch (normalizeSourceType(sourceType)) { + case GIT -> new GitSourceConfig(repository); + case ZIP -> new ZipSourceConfig(); + case IMAGE -> new ImageSourceConfig(image.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 ensureImageWithoutTag(String image) { + if (isBlank(image)) { + throw new BizException("Image is required when source type is IMAGE"); + } + String trimmed = image.trim(); + if (trimmed.chars().anyMatch(Character::isWhitespace)) { + throw new BizException("Image must not contain whitespace"); + } + String lastSegment = trimmed.substring(trimmed.lastIndexOf('/') + 1); + if (lastSegment.isEmpty()) { + throw new BizException("Image must not end with '/'"); + } + if (lastSegment.indexOf(':') >= 0 || lastSegment.indexOf('@') >= 0) { + throw new BizException("Image 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..70cd043b --- /dev/null +++ b/src/main/java/com/github/wellch4n/oops/domain/application/ImageSourceConfig.java @@ -0,0 +1,13 @@ +package com.github.wellch4n.oops.domain.application; + +/** + * Prebuilt-image build source. {@code image} 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. + * + *

Named {@code image} rather than {@code repository} because the two are edited and stored + * separately — an application that switches between GIT and IMAGE keeps both values, instead of + * showing a Git URL in the image box. + */ +public record ImageSourceConfig(String image) 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..e802db7d 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 image, String tag) { + if (image == null || image.isBlank()) { + throw new BizException("Image 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(image.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/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java b/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java index a7eaf986..7d970f97 100644 --- a/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java +++ b/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java @@ -148,44 +148,12 @@ public Result updateApplicationBuildConfig(@PathVariable String namespa return Result.success(applicationService.updateApplicationBuildConfig(namespace, name, request)); } - @GetMapping("/{name}/environments/build/configs") - public Result> getApplicationBuildEnvironmentConfigs( - @PathVariable String namespace, - @PathVariable String name - ) { - return Result.success(applicationService.getApplicationBuildEnvironmentConfigs(namespace, name)); - } - - @GetMapping("/{name}/environments/runtime-specs") - public Result> getApplicationRuntimeSpecEnvironmentConfigs( - @PathVariable String namespace, - @PathVariable String name - ) { - return Result.success(applicationService.getApplicationRuntimeSpecEnvironmentConfigs(namespace, name)); - } - @GetMapping("/{name}/runtime-spec") public Result getApplicationRuntimeSpec(@PathVariable String namespace, @PathVariable String name) { return Result.success(applicationService.getApplicationRuntimeSpec(namespace, name)); } - @PutMapping("/{name}/environments/build/configs") - @PreAuthorize("isAuthenticated()") - public Result updateApplicationBuildEnvironmentConfigs(@PathVariable String namespace, - @PathVariable String name, - @RequestBody List configs) { - return Result.success(applicationService.updateApplicationBuildEnvironmentConfigs(namespace, name, configs)); - } - - @PutMapping("/{name}/environments/runtime-specs") - @PreAuthorize("isAuthenticated()") - public Result updateApplicationRuntimeSpecEnvironmentConfigs(@PathVariable String namespace, - @PathVariable String name, - @RequestBody List configs) { - return Result.success(applicationService.updateApplicationRuntimeSpecEnvironmentConfigs(namespace, name, configs)); - } - @PutMapping("/{name}/runtime-spec") @PreAuthorize("isAuthenticated()") public Result updateApplicationRuntimeSpec(@PathVariable String namespace, diff --git a/src/main/resources/db/migration/V23__widen_enum_columns_to_varchar.sql b/src/main/resources/db/migration/V23__widen_enum_columns_to_varchar.sql new file mode 100644 index 00000000..915f7e5a --- /dev/null +++ b/src/main/resources/db/migration/V23__widen_enum_columns_to_varchar.sql @@ -0,0 +1,15 @@ +-- Databases that predate Flyway were created by Hibernate's DDL, and Hibernate 6 maps an +-- @Enumerated(STRING) field on MySQL to a native ENUM column whose value set is frozen at +-- whatever the Java enum held at the time: application_build_config.source_type and +-- pipeline.publish_type became ENUM('GIT','ZIP'), so writing the new IMAGE source fails with +-- "Data truncated for column 'source_type'" and the whole save rolls back. The V1 baseline +-- declares every one of these columns as varchar(255), which is what a fresh database gets; +-- this brings the baselined databases in line so a new enum constant never needs a migration. +-- MODIFY keeps the stored labels (an ENUM converts to its string values, not its indexes) and +-- is a no-op on a column that is already varchar(255). + +ALTER TABLE `application_build_config` MODIFY COLUMN `source_type` varchar(255) DEFAULT NULL; +ALTER TABLE `pipeline` MODIFY COLUMN `publish_type` varchar(255) DEFAULT NULL; +ALTER TABLE `domain` MODIFY COLUMN `cert_mode` varchar(255) DEFAULT NULL; +ALTER TABLE `external_account` MODIFY COLUMN `provider` varchar(255) DEFAULT NULL; +ALTER TABLE `user` MODIFY COLUMN `role` varchar(255) DEFAULT NULL; 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..7ec0e38e 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 @@ -22,34 +22,34 @@ void normalizeSourceTypeDefaultsToGit() { @Test void validateRequiresRepositoryForGit() { assertThrows(BizException.class, - () -> policy.validate(ApplicationSourceType.GIT, " ", null, null)); + () -> policy.validate(ApplicationSourceType.GIT, " ", null, null, null)); assertThrows(BizException.class, - () -> policy.validate(null, null, null, null)); + () -> policy.validate(null, null, null, null, null)); // valid git - policy.validate(ApplicationSourceType.GIT, "git@host:repo.git", null, null); + policy.validate(ApplicationSourceType.GIT, "git@host:repo.git", null, null, null); } @Test void validateDoesNotRequireRepositoryForZip() { - policy.validate(ApplicationSourceType.ZIP, null, null, null); + policy.validate(ApplicationSourceType.ZIP, null, null, null, null); } @Test void validateRequiresContentForUserDockerfile() { assertThrows(BizException.class, - () -> policy.validate(ApplicationSourceType.ZIP, null, DockerFileType.USER, " ")); + () -> policy.validate(ApplicationSourceType.ZIP, null, null, DockerFileType.USER, " ")); // with content it is fine - policy.validate(ApplicationSourceType.ZIP, null, DockerFileType.USER, "FROM scratch"); + policy.validate(ApplicationSourceType.ZIP, null, null, DockerFileType.USER, "FROM scratch"); } @Test void validateAllowsBuiltinDockerfileWithoutContent() { - policy.validate(ApplicationSourceType.GIT, "repo", DockerFileType.BUILTIN, null); + policy.validate(ApplicationSourceType.GIT, "repo", null, DockerFileType.BUILTIN, null); } @Test void buildSourceConfigReturnsGitConfigWithRepository() { - SourceConfig config = policy.buildSourceConfig(ApplicationSourceType.GIT, "repo-url"); + SourceConfig config = policy.buildSourceConfig(ApplicationSourceType.GIT, "repo-url", null); GitSourceConfig gitConfig = assertInstanceOf(GitSourceConfig.class, config); assertEquals("repo-url", gitConfig.repository()); } @@ -57,11 +57,40 @@ void buildSourceConfigReturnsGitConfigWithRepository() { @Test void buildSourceConfigReturnsZipConfig() { assertInstanceOf(ZipSourceConfig.class, - policy.buildSourceConfig(ApplicationSourceType.ZIP, null)); + policy.buildSourceConfig(ApplicationSourceType.ZIP, null, null)); } @Test void buildSourceConfigDefaultsNullToGit() { - assertInstanceOf(GitSourceConfig.class, policy.buildSourceConfig(null, "repo")); + assertInstanceOf(GitSourceConfig.class, policy.buildSourceConfig(null, "repo", null)); + } + + @Test + void validateRequiresImageRepositoryWithoutTagForImage() { + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, null, " ", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, null, "nginx:1.27", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, null, "nginx@sha256:abc", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, null, "ghcr.io/org/", null, null)); + assertThrows(BizException.class, + () -> policy.validate(ApplicationSourceType.IMAGE, null, "ghcr.io/org/app x", null, null)); + // a registry port is not a tag + policy.validate(ApplicationSourceType.IMAGE, null, "registry.local:5000/org/app", null, null); + policy.validate(ApplicationSourceType.IMAGE, null, "nginx", null, null); + } + + @Test + void validateIgnoresDockerfileForImage() { + policy.validate(ApplicationSourceType.IMAGE, null, "nginx", DockerFileType.USER, " "); + } + + @Test + void buildSourceConfigReturnsImageConfigTrimmed() { + SourceConfig config = policy.buildSourceConfig(ApplicationSourceType.IMAGE, null, " ghcr.io/org/app "); + ImageSourceConfig imageConfig = assertInstanceOf(ImageSourceConfig.class, config); + assertEquals("ghcr.io/org/app", imageConfig.image()); } } 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..9dce5438 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,63 @@ 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.image()); + assertNull(stored.getBuildImage()); + assertNull(stored.getDockerFileConfig()); + assertNull(stored.getEnvironmentConfigs()); + } + + @Test + void updateBuildConfigWithoutEnvironmentConfigsKeepsTheStoredOnes() { + Application application = application("owner-1"); + ApplicationBuildConfig.EnvironmentConfig devCommand = new ApplicationBuildConfig.EnvironmentConfig(); + devCommand.setEnvironment("dev"); + devCommand.setBuildCommand("make release"); + ApplicationBuildConfig first = new ApplicationBuildConfig(); + first.setSourceType(ApplicationSourceType.GIT); + first.setSourceConfig(new GitSourceConfig("git@host:repo.git")); + first.setEnvironmentConfigs(List.of(devCommand)); + application.updateBuildConfig(first, new ApplicationBuildConfigPolicy()); + + // an OpenAPI caller that leaves the list out is not asking to clear it + ApplicationBuildConfig withoutList = new ApplicationBuildConfig(); + withoutList.setSourceType(ApplicationSourceType.GIT); + withoutList.setSourceConfig(new GitSourceConfig("git@host:repo.git")); + withoutList.setBuildImage("node:22"); + application.updateBuildConfig(withoutList, new ApplicationBuildConfigPolicy()); + assertEquals("node:22", application.getBuildConfig().getBuildImage()); + assertEquals(List.of(devCommand), application.getBuildConfig().getEnvironmentConfigs()); + + // an explicit empty list does clear it + ApplicationBuildConfig emptyList = new ApplicationBuildConfig(); + emptyList.setSourceType(ApplicationSourceType.GIT); + emptyList.setSourceConfig(new GitSourceConfig("git@host:repo.git")); + emptyList.setEnvironmentConfigs(List.of()); + application.updateBuildConfig(emptyList, new ApplicationBuildConfigPolicy()); + assertTrue(application.getBuildConfig().getEnvironmentConfigs().isEmpty()); + } } 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/src/test/java/com/github/wellch4n/oops/infrastructure/persistence/jpa/PersistenceMapperTests.java b/src/test/java/com/github/wellch4n/oops/infrastructure/persistence/jpa/PersistenceMapperTests.java index cb96d6d5..7738b048 100644 --- a/src/test/java/com/github/wellch4n/oops/infrastructure/persistence/jpa/PersistenceMapperTests.java +++ b/src/test/java/com/github/wellch4n/oops/infrastructure/persistence/jpa/PersistenceMapperTests.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; 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.DomainCertMode; @@ -42,10 +43,29 @@ void mapsEmptyZipSourceConfigBetweenDomainAndEntity() { assertNull(roundTrip.repository()); } + @Test + void mapsImageSourceConfigBetweenDomainAndEntity() { + com.github.wellch4n.oops.domain.application.ApplicationBuildConfig domain = + new com.github.wellch4n.oops.domain.application.ApplicationBuildConfig(); + domain.setSourceType(ApplicationSourceType.IMAGE); + domain.setSourceConfig(new ImageSourceConfig("nginx")); + + ApplicationBuildConfig entity = PersistenceMapper.toEntity(domain); + var roundTrip = PersistenceMapper.toDomain(entity); + + assertInstanceOf(ImageSourceConfig.class, entity.getSourceConfig()); + assertEquals(ApplicationSourceType.IMAGE, roundTrip.getSourceType()); + assertEquals("nginx", roundTrip.image()); + } + @Test void sourceConfigConverterRoundTripsBothVariants() { SourceConfigConverter converter = new SourceConfigConverter(); + String imageJson = converter.convertToDatabaseColumn(new ImageSourceConfig("nginx")); + assertEquals("{\"type\":\"IMAGE\",\"image\":\"nginx\"}", imageJson); + assertEquals("nginx", ((ImageSourceConfig) converter.convertToEntityAttribute(imageJson)).image()); + String gitJson = converter.convertToDatabaseColumn(new GitSourceConfig("git@example.com:repo.git")); assertInstanceOf(GitSourceConfig.class, converter.convertToEntityAttribute(gitJson)); assertEquals("git@example.com:repo.git", 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/routes.json b/tests/integration/routes.json index 1bcabdeb..7d4f0edf 100644 --- a/tests/integration/routes.json +++ b/tests/integration/routes.json @@ -161,18 +161,6 @@ "key": "GET /api/namespaces/{}/applications/{}/environments", "controller": "ApplicationController" }, - { - "method": "GET", - "path": "/api/namespaces/{namespace}/applications/{name}/environments/build/configs", - "key": "GET /api/namespaces/{}/applications/{}/environments/build/configs", - "controller": "ApplicationController" - }, - { - "method": "GET", - "path": "/api/namespaces/{namespace}/applications/{name}/environments/runtime-specs", - "key": "GET /api/namespaces/{}/applications/{}/environments/runtime-specs", - "controller": "ApplicationController" - }, { "method": "GET", "path": "/api/namespaces/{namespace}/applications/{name}/events", @@ -257,6 +245,12 @@ "key": "GET /api/namespaces/{}/applications/{}/pods/{}/files/download", "controller": "PodFileSystemController" }, + { + "method": "GET", + "path": "/api/namespaces/{namespace}/applications/{name}/pods/{pod}/log", + "key": "GET /api/namespaces/{}/applications/{}/pods/{}/log", + "controller": "ApplicationController" + }, { "method": "GET", "path": "/api/namespaces/{namespace}/applications/{name}/resources", @@ -587,18 +581,6 @@ "key": "PUT /api/namespaces/{}/applications/{}/environments", "controller": "ApplicationController" }, - { - "method": "PUT", - "path": "/api/namespaces/{namespace}/applications/{name}/environments/build/configs", - "key": "PUT /api/namespaces/{}/applications/{}/environments/build/configs", - "controller": "ApplicationController" - }, - { - "method": "PUT", - "path": "/api/namespaces/{namespace}/applications/{name}/environments/runtime-specs", - "key": "PUT /api/namespaces/{}/applications/{}/environments/runtime-specs", - "controller": "ApplicationController" - }, { "method": "PUT", "path": "/api/namespaces/{namespace}/applications/{name}/expert-config", diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 47a6ed8e..1a121d9d 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -69,7 +69,9 @@ usage: ./run.sh [options] [-- pytest arguments] --contract every module, but only the tests that need no cluster --keep leave the stack running afterwards -- everything after this goes straight to pytest - (e.g. -- -k basic_auth -x) + (e.g. -- -k basic_auth) + + The run stops at the first failure. Pass -- --maxfail=0 to see every one. ./run.sh the whole suite ./run.sh --module ingress iterate on one module @@ -328,6 +330,12 @@ step_done PYTEST_ARGS=(-p no:cacheprovider -p "no:randomly" -p "pytest_steps" + # Stop at the first failure. A full run is half an hour of real + # builds, and once one has failed the rest is time spent on a + # result nobody will act on before that failure is fixed. Anything + # after `--` comes later on the command line and wins, so + # `-- --maxfail=0` still runs the suite out. + -x --tb=short --html="${REPORTS}/report-${STAMP}.html" --self-contained-html --junitxml="${REPORTS}/junit-${STAMP}.xml") @@ -402,6 +410,9 @@ if [ "$SUMMARY" -ne 0 ] || [ "$STATUS" -ne 0 ]; then # Only the failures, not the whole log. sed -n '/=\{5,\} FAILURES/,/=\{5,\} \(warnings\|short test summary\)/p' \ "${LOGS}/pytest.log" | head -60 + # Say it, because the counts above are of what ran, not of the suite. + printf "%sstopped at the first failure; the rest of the suite did not run.%s\n" \ + "$DIM" "$RESET" printf "%sfull output: %s%s\n" "$DIM" "${REPORTS}/report-${STAMP}.html" "$RESET" printf "%slogs: %s%s\n" "$DIM" "$LOGS" "$RESET" exit 1 diff --git a/tests/integration/test_application_config.py b/tests/integration/test_application_config.py index d97413a4..762a2a55 100644 --- a/tests/integration/test_application_config.py +++ b/tests/integration/test_application_config.py @@ -75,29 +75,91 @@ def test_build_config_round_trips(client, namespace, application, environment): assert stored["repository"] == "https://example.invalid/team/service.git" assert stored["buildImage"] == "node:20-slim" - per_environment = client.get( - f"/api/namespaces/{namespace}/applications/{application}" - f"/environments/build/configs").data commands = {item["environment"]: item["buildCommand"] - for item in per_environment} + for item in stored["environmentConfigs"]} assert commands.get(environment) == "npm run build", ( f"the per-environment build command was lost; got {commands}") -def test_per_environment_build_configs_can_be_written_separately( +def test_build_config_without_environment_configs_keeps_the_commands( client, namespace, application, environment): - """The per-environment collection has its own endpoint as well as riding - along inside the build config, and both have to agree.""" - client.put( - f"/api/namespaces/{namespace}/applications/{application}" - f"/environments/build/configs", - [{"environment": environment, "buildCommand": "make release"}]) + """An OpenAPI caller that leaves `environmentConfigs` out (the CLI's + `app build set` without --build-command) is not asking to clear the + per-environment build commands; only an explicit empty list does that.""" + base = { + "namespace": namespace, + "applicationName": application, + "sourceType": "GIT", + "repository": "https://example.invalid/team/service.git", + } + client.put_build_config(namespace, application, { + **base, + "environmentConfigs": [ + {"environment": environment, "buildCommand": "make release"}, + ], + }) + client.put_build_config(namespace, application, {**base, "buildImage": "node:22-slim"}) stored = client.get( - f"/api/namespaces/{namespace}/applications/{application}" - f"/environments/build/configs").data - commands = {item["environment"]: item["buildCommand"] for item in stored} - assert commands.get(environment) == "make release" + f"/api/namespaces/{namespace}/applications/{application}/build/config").data + assert stored["buildImage"] == "node:22-slim" + commands = {item["environment"]: item["buildCommand"] + for item in stored["environmentConfigs"] or []} + assert commands.get(environment) == "make release", ( + "a build config update without environmentConfigs wiped the build commands") + + client.put_build_config(namespace, application, {**base, "environmentConfigs": []}) + stored = client.get( + f"/api/namespaces/{namespace}/applications/{application}/build/config").data + assert not stored["environmentConfigs"], ( + "an explicit empty environmentConfigs must clear the build 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", + "image": "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["image"] == "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", + "image": 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", + "image": "registry.local:5000/example/service", + }) def test_service_config_round_trips(client, namespace, application, environment): @@ -149,11 +211,6 @@ def test_runtime_spec_round_trips(client, namespace, application, environment): "the memory limit was rewritten in storage; it must round trip as the " "bare number the caller sent") - per_environment = client.get( - f"/api/namespaces/{namespace}/applications/{application}" - f"/environments/runtime-specs").data - assert any(item["environment"] == environment for item in per_environment) - @pytest.mark.cluster def test_saving_a_runtime_spec_before_the_first_deploy_is_quiet( @@ -201,22 +258,6 @@ def test_saving_a_runtime_spec_before_the_first_deploy_is_quiet( "not supposed to deploy it") -def test_per_environment_runtime_specs_can_be_written_separately( - client, namespace, application, environment): - client.put( - f"/api/namespaces/{namespace}/applications/{application}" - f"/environments/runtime-specs", - [{"environment": environment, "replicas": 3, - "cpuRequest": "50m", "cpuLimit": "500m", - "memoryRequest": "64", "memoryLimit": "256"}]) - - stored = client.get( - f"/api/namespaces/{namespace}/applications/{application}" - f"/environments/runtime-specs").data - configs = {item["environment"]: item for item in stored} - assert configs[environment]["replicas"] == 3 - - def test_expert_config_round_trips(client, namespace, application, environment): """Service account, priority and the scheduled restart cron.""" client.put( @@ -260,6 +301,36 @@ def test_environment_bindings_round_trip(client, namespace, application, "which clusters to clean up") +def test_profile_update_carries_the_environment_bindings( + client, namespace, application, environment): + """The basic-info editor saves the profile and the bindings as one request. + + `environments` on the profile replaces the bindings in the same + transaction; leaving the field out keeps them, so a CLI `app update` that + knows nothing about bindings cannot wipe them. + """ + profile_path = f"/api/namespaces/{namespace}/applications/{application}" + profile = client.get(profile_path).data + + client.put(profile_path, { + **profile, + "description": "bound through the profile", + "environments": [{"environment": environment}], + }) + bound = client.get(f"{profile_path}/environments").data + assert [item["environment"] for item in bound] == [environment] + assert client.get(profile_path).data["description"] == "bound through the profile" + + client.put(profile_path, {**profile, "description": "bindings untouched"}) + bound = client.get(f"{profile_path}/environments").data + assert [item["environment"] for item in bound] == [environment], ( + "a profile update without `environments` must leave the bindings alone") + + client.put(profile_path, {**profile, "environments": []}) + assert client.get(f"{profile_path}/environments").data == [], ( + "an empty `environments` list must unbind every environment") + + def test_reading_an_unconfigured_application_is_empty_not_an_error( client, namespace, application): """Every config surface has to answer for an application with nothing set. @@ -268,8 +339,7 @@ def test_reading_an_unconfigured_application_is_empty_not_an_error( the blob column is null and nobody checked. """ for suffix in ("/build/config", "/service", "/runtime-spec", "/expert-config", - "/environments", "/environments/build/configs", - "/environments/runtime-specs", "/last-successful-pipeline"): + "/environments", "/last-successful-pipeline"): result = client.get( f"/api/namespaces/{namespace}/applications/{application}{suffix}", expect_success=False) @@ -308,7 +378,8 @@ def test_cluster_facing_reads_answer_for_a_deployed_application( workload and each needs the same expensive deploy to exist first. """ from test_deploy import configure_for_build, git_strategy - from test_deploy import DEPLOY_TIMEOUT, TERMINAL_STATUSES + from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, + require_successful_deploy) from oops_client import wait_until configure_for_build(client, namespace, application, environment) @@ -321,8 +392,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the deploy to finish") - if pipeline["status"] != "SUCCEEDED": - pytest.skip(f"deploy ended as {pipeline['status']}") + require_successful_deploy(client, pipeline, "the workload these reads describe") reads = { "/status": "pod status", diff --git a/tests/integration/test_deletion.py b/tests/integration/test_deletion.py index 50d841f2..23d2fc2b 100644 --- a/tests/integration/test_deletion.py +++ b/tests/integration/test_deletion.py @@ -16,7 +16,7 @@ from oops_client import wait_until from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, configure_for_build, - git_strategy) + git_strategy, require_successful_deploy) def deploy_and_wait(client, namespace, application, environment) -> str: @@ -30,8 +30,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the deploy to finish before deleting it") - if pipeline["status"] != "SUCCEEDED": - pytest.skip(f"deploy ended as {pipeline['status']}, nothing to delete") + require_successful_deploy(client, pipeline, "the workload to delete") return pipeline_id diff --git a/tests/integration/test_deploy.py b/tests/integration/test_deploy.py index 4264abc2..945effc2 100644 --- a/tests/integration/test_deploy.py +++ b/tests/integration/test_deploy.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json import os import pytest @@ -29,9 +30,60 @@ 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")) +def build_log_tail(client, pipeline: dict, lines: int = 30) -> str: + """The tail of every build step's log, for a failure message. + + A failed build stores only "look at the pipeline log", and by the time + anyone reads CI the cluster has been torn down — so the reason has to + travel with the failure or it is gone. Best effort throughout: a build that + failed before its pod existed has no log to fetch, and that must never + replace the real failure with an error raised in here. + """ + base = (f"/api/namespaces/{pipeline['namespace']}/applications/" + f"{pipeline['applicationName']}/pipelines/{pipeline['id']}") + try: + steps = next((json.loads(event.data) + for event in client.sse(f"{base}/steps/watch", timeout=60) + if event.event == "steps"), []) + except Exception as error: + return f"(could not list the build steps: {error})" + + sections = [] + for step in steps: + try: + tail = [line["text"] + for event in client.sse(f"{base}/log?container={step}", timeout=60) + if event.event == "log" + for line in json.loads(event.data)["lines"]][-lines:] + except Exception as error: + tail = [f"(could not read this step's log: {error})"] + if tail: + sections.append(f"--- {step} ---\n" + "\n".join(tail)) + return "\n".join(sections) or "(the build produced no log at all)" + + +def require_successful_deploy(client, pipeline: dict, needed_for: str) -> dict: + """The setup deploy a scenario does for itself has to succeed, or it fails. + + Skipping instead takes the whole scenario out of the run without a sound, + and the endpoints only it reaches are then reported by the coverage test as + covered by no scenario at all — a red build naming the wrong file, twenty + minutes after the deploy that actually broke. + """ + if pipeline["status"] == "SUCCEEDED": + return pipeline + pytest.fail(f"the deploy this scenario needs for {needed_for} ended as " + f"{pipeline['status']}: {pipeline.get('message') or 'no message'}\n" + f"{build_log_tail(client, pipeline)}") + + def configure_for_build(client, namespace, application, environment): """Give an application the minimum it needs to build and serve. @@ -87,6 +139,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", + "image": 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 +307,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/tests/integration/test_ingress.py b/tests/integration/test_ingress.py index 160c5eea..ef68ed2d 100644 --- a/tests/integration/test_ingress.py +++ b/tests/integration/test_ingress.py @@ -25,7 +25,7 @@ from oops_client import wait_until from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, configure_for_build, - git_strategy) + git_strategy, require_successful_deploy) pytestmark = pytest.mark.cluster @@ -79,8 +79,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the deploy to finish") - if pipeline["status"] != "SUCCEEDED": - pytest.skip(f"deploy ended as {pipeline['status']}, nothing was exposed") + require_successful_deploy(client, pipeline, "the routes to inspect") return pipeline diff --git a/tests/integration/test_operations.py b/tests/integration/test_operations.py index c5c25f66..75bd06f4 100644 --- a/tests/integration/test_operations.py +++ b/tests/integration/test_operations.py @@ -89,7 +89,8 @@ def test_a_pod_can_be_restarted(client, namespace, environment, application, a call that quietly did nothing. """ from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, - configure_for_build, git_strategy) + configure_for_build, git_strategy, + require_successful_deploy) from test_streams import first_running_pod configure_for_build(client, namespace, application, environment) @@ -102,8 +103,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the deploy to finish") - if pipeline["status"] != "SUCCEEDED": - pytest.skip(f"deploy ended as {pipeline['status']}, nothing to restart") + require_successful_deploy(client, pipeline, "the pods to restart") original = wait_until( lambda: first_running_pod(client, namespace, application, environment), @@ -137,7 +137,8 @@ def test_an_application_can_be_moved_to_another_namespace( namespace while its StatefulSet kept running under the old one. """ from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, - configure_for_build, git_strategy) + configure_for_build, git_strategy, + require_successful_deploy) application = application_factory(prefix="migrate") target = f"{namespace}-target" @@ -157,8 +158,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the deploy to finish") - if pipeline["status"] != "SUCCEEDED": - pytest.skip(f"deploy ended as {pipeline['status']}, nothing to migrate") + require_successful_deploy(client, pipeline, "the workload to migrate") result = client.post( f"/api/namespaces/{namespace}/applications/{application}" diff --git a/tests/integration/test_pipeline_ops.py b/tests/integration/test_pipeline_ops.py index da4bd8c4..0bd7cb0f 100644 --- a/tests/integration/test_pipeline_ops.py +++ b/tests/integration/test_pipeline_ops.py @@ -12,7 +12,8 @@ from oops_client import wait_until from test_deploy import (ACTIVE_STATUSES, DEPLOY_TIMEOUT, TERMINAL_STATUSES, - configure_for_build, git_strategy) + configure_for_build, git_strategy, + require_successful_deploy) pytestmark = pytest.mark.cluster @@ -84,7 +85,7 @@ def built(): status = wait_until(built, timeout=DEPLOY_TIMEOUT, description="the manual build to finish") if status != "BUILD_SUCCEEDED": - pytest.skip(f"the manual build ended as {status}") + pytest.fail(f"the manual build this scenario deploys ended as {status}") client.put(f"/api/namespaces/{namespace}/applications/{application}" f"/pipelines/{pipeline_id}/deploy") @@ -105,8 +106,7 @@ def test_a_successful_pipeline_can_be_rolled_back(client, namespace, application """ pipeline_id = deploy(client, namespace, application, environment) first = wait_for_terminal(client, namespace, application, pipeline_id) - if first["status"] != "SUCCEEDED": - pytest.skip(f"the first deploy ended as {first['status']}, nothing to roll back") + require_successful_deploy(client, first, "the release to roll back to") rollback_id = client.post( f"/api/namespaces/{namespace}/applications/{application}" diff --git a/tests/integration/test_podfs.py b/tests/integration/test_podfs.py index dcf07e7a..a997b040 100644 --- a/tests/integration/test_podfs.py +++ b/tests/integration/test_podfs.py @@ -23,7 +23,7 @@ from oops_client import wait_until from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, configure_for_build, - git_strategy) + git_strategy, require_successful_deploy) from test_streams import first_running_pod pytestmark = pytest.mark.cluster @@ -113,9 +113,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the fixture deploy to finish") - if pipeline["status"] != "SUCCEEDED": - pytest.skip(f"fixture deploy ended as {pipeline['status']}, so there " - f"is no container to browse") + require_successful_deploy(client, pipeline, "the container to browse") pod = wait_until( lambda: first_running_pod(client, namespace, application, environment), diff --git a/tests/integration/test_streams.py b/tests/integration/test_streams.py index e7f23a7d..c719b456 100644 --- a/tests/integration/test_streams.py +++ b/tests/integration/test_streams.py @@ -21,7 +21,7 @@ from oops_client import read_until_closed, wait_until from test_deploy import (DEPLOY_TIMEOUT, TERMINAL_STATUSES, configure_for_build, - git_strategy) + git_strategy, require_successful_deploy) pytestmark = pytest.mark.cluster @@ -48,9 +48,7 @@ def finished(): pipeline = wait_until(finished, timeout=DEPLOY_TIMEOUT, description="the fixture deploy to finish") - if pipeline["status"] != "SUCCEEDED": - pytest.skip( - f"fixture deploy ended as {pipeline['status']}, cannot stream") + require_successful_deploy(client, pipeline, "the pipeline and pod to stream") yield application, pipeline_id finally: try: @@ -189,53 +187,54 @@ def test_pipeline_step_log_of_an_unknown_step_is_an_error_then_end( assert "no-such-step" in events[0].data, events[0].data -def test_pod_log_socket_answers_ping_with_pong(client, namespace, environment, - deployed_application): - application, _ = deployed_application - pod = wait_until( - lambda: first_running_pod(client, namespace, application, environment), - timeout=180, interval=5, description="a running pod to appear") +def pod_log_path(namespace, application, pod, environment): + return (f"/api/namespaces/{namespace}/applications/{application}/pods/{pod}/log" + f"?environment={environment}") - socket = client.websocket( - f"/api/namespaces/{namespace}/applications/{application}/pods/{pod}/log" - f"?environment={environment}") - try: - socket.send("ping") - frames_seen = [] - for frame in read_until_closed(socket, timeout=15): - frames_seen.append(frame) - if frame == "pong": - break - assert "pong" in frames_seen, ( - "the pod log socket did not answer a text ping with pong; the browser " - f"keepalive depends on it. Frames seen: {frames_seen[:5]}") - finally: - socket.close() - -def test_pod_log_socket_streams_text_lines(client, namespace, environment, - deployed_application): +def test_pod_log_stream_replays_stamped_batches(client, namespace, environment, + deployed_application): + """The pod log is the same SSE shape as a pipeline step log, but a running + pod's stream follows the container and never ends on its own, so read the + first batch and hang up the way a browser leaving the page would.""" application, _ = deployed_application pod = wait_until( lambda: first_running_pod(client, namespace, application, environment), timeout=180, interval=5, description="a running pod to appear") - socket = client.websocket( - f"/api/namespaces/{namespace}/applications/{application}/pods/{pod}/log" - f"?environment={environment}") - try: - frames = [frame for _, frame in zip(range(3), read_until_closed(socket, 20))] - finally: - socket.close() + first_batch = None + for event in client.sse(pod_log_path(namespace, application, pod, environment), + timeout=30): + assert event.event != "error", f"the pod log stream failed: {event.data}" + if event.event == "log": + first_batch = event + break + assert first_batch is not None, "the pod log stream delivered no log batch" # Content depends on the image, so assert the transport rather than the text: - # lines arrive as text frames and are split, never glued together by a redraw. - assert frames, "the pod log socket delivered nothing" - for frame in frames: - assert isinstance(frame, str) - assert "\n" not in frame.rstrip("\n"), ( - f"a log frame carried an embedded newline, lines are not being split: " - f"{frame[:200]!r}") + # stamped lines, split on newlines, with the batch id set to the last stamp so + # a reconnect can resume after it. + lines = json.loads(first_batch.data)["lines"] + assert lines, "an empty log batch was sent" + for line in lines: + assert set(line) >= {"time", "text"}, line + assert "\n" not in line["text"], ( + f"a log line carried an embedded newline, lines are not being split: " + f"{line['text'][:200]!r}") + assert first_batch.id == lines[-1]["time"], (first_batch.id, lines[-1]) + + +def test_pod_log_stream_of_an_unknown_pod_is_an_error_then_end( + client, namespace, environment, deployed_application): + """A pod that is gone gets a reason and an `end`, so the browser's + EventSource does not keep reconnecting to a log that will never exist.""" + application, _ = deployed_application + events = [event for event in client.sse( + pod_log_path(namespace, application, "no-such-pod", environment), timeout=30) + if event.event != "heartbeat"] + + assert [event.event for event in events] == ["error", "end"], events + assert "no-such-pod" in events[0].data, events[0].data def test_terminal_socket_does_not_answer_ping(client, namespace, environment, diff --git a/web/app/apps/[namespace]/[name]/page.tsx b/web/app/apps/[namespace]/[name]/page.tsx index bac8178b..657640f5 100644 --- a/web/app/apps/[namespace]/[name]/page.tsx +++ b/web/app/apps/[namespace]/[name]/page.tsx @@ -7,7 +7,6 @@ import { ApplicationForm } from "@/app/apps/application-form" import { getApplication, getApplicationBuildConfig, - getApplicationBuildEnvConfigs, getApplicationRuntimeSpec, getApplicationService, getApplicationExpertConfig @@ -15,7 +14,6 @@ import { import { Application, ApplicationBuildConfig, - ApplicationBuildEnvironmentConfig, ApplicationRuntimeSpec, ApplicationServiceConfig, ApplicationExpertConfig @@ -34,7 +32,6 @@ export default function EditAppPage() { const [application, setApplication] = useState(null) const [buildConfig, setBuildConfig] = useState(undefined) - const [buildEnvConfigs, setBuildEnvConfigs] = useState([]) const [runtimeSpec, setRuntimeSpec] = useState(undefined) const [serviceConfig, setServiceConfig] = useState(undefined) const [expertConfig, setExpertConfig] = useState(undefined) @@ -46,10 +43,9 @@ export default function EditAppPage() { useEffect(() => { const fetchApp = async () => { try { - const [appRes, buildConfigRes, buildEnvRes, runtimeSpecRes, serviceRes, expertRes] = await Promise.all([ + const [appRes, buildConfigRes, runtimeSpecRes, serviceRes, expertRes] = await Promise.all([ getApplication(namespace, name), getApplicationBuildConfig(namespace, name), - getApplicationBuildEnvConfigs(namespace, name), getApplicationRuntimeSpec(namespace, name), getApplicationService(namespace, name), getApplicationExpertConfig(namespace, name), @@ -70,10 +66,6 @@ export default function EditAppPage() { setBuildConfig(buildConfigRes.data) } - if (buildEnvRes.data) { - setBuildEnvConfigs(buildEnvRes.data) - } - if (runtimeSpecRes.data) { setRuntimeSpec(runtimeSpecRes.data) } @@ -112,7 +104,6 @@ export default function EditAppPage() { loading={loading} initialData={application ?? undefined} initialBuildConfig={buildConfig} - initialBuildEnvConfigs={buildEnvConfigs} initialRuntimeSpec={runtimeSpec} initialServiceConfig={serviceConfig} initialExpertConfig={expertConfig} diff --git a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx index 5a47e722..68d3f9b0 100644 --- a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx +++ b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx @@ -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 { @@ -483,8 +486,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..716cc89a 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,10 @@ 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("") + // `latest` until a previous image publish names a tag, the way the branch defaults to `main`. + const [imageTag, setImageTag] = useState("latest") const [lastSuccessfulPipeline, setLastSuccessfulPipeline] = useState(null) const [deployMode, setDeployMode] = useState("MANUAL") const [runtimeSpec, setRuntimeSpec] = useState(null) @@ -72,6 +80,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 +124,7 @@ export default function PublishPage({ params }: PageProps) { if (buildConfigRes.data?.sourceType) { setSourceType(buildConfigRes.data.sourceType) } + setImageRepository(buildConfigRes.data?.image ?? "") if (runtimeSpecRes.data) { setRuntimeSpec(runtimeSpecRes.data) } @@ -127,6 +139,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) || "latest") + } } } catch { toast.error(t("apps.publish.fetchError")) @@ -144,6 +159,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 +210,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 +482,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")} +

+ )} +
+ )} +