Files
agiledancers/ci/Pipeline.java
Wanja Krah 069b62ae92
Some checks failed
Build, test and publish / verify (push) Failing after 11s
Run CI orchestration and setup as Java 25 source programs
2026-10-29 09:07:41 +01:00

217 lines
14 KiB
Java

import java.io.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
/** Verification, publication and cleanup shared by local use and Gitea Actions. */
class Pipeline {
final Path root, artifacts;
final Map<String,String> env;
final Commands commands;
final String commit, runName, backendImage, frontendImage;
Path isolatedConfig;
Pipeline(Path root, Map<String,String> environment, Commands commands) throws Exception {
this.root = root;
this.env = new HashMap<>(environment);
this.commands = commands;
commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD"));
String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1");
check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA");
check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier");
runName = "agiledancers-" + id + "-" + attempt;
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts);
}
interface Value { String get() throws Exception; }
String value(String key, Value defaultValue) throws Exception {
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
return env.get(key);
}
static void check(boolean condition, String message) {
if (!condition) throw new IllegalArgumentException(message);
}
Commands.Result result(boolean echo, String... args) throws Exception {
return commands.execute(List.of(args), root, env, null, echo);
}
String capture(String... args) throws Exception { return result(false, args).checked(); }
void run(String... args) throws Exception { result(true, args).checked(); }
void requirePlatform() throws Exception {
String target = Setup.required(env,"TARGET_PLATFORM");
String arch = capture("docker", "info", "--format", "{{.Architecture}}");
arch = switch (arch) { case "x86_64" -> "amd64"; case "aarch64" -> "arm64"; default -> arch; };
String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch;
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
}
void isolateDocker() throws Exception {
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
env.remove("DOCKER_CONTEXT");
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
env.put("DOCKER_CONFIG", isolatedConfig.toString());
}
void verify() throws Exception {
Files.deleteIfExists(artifacts.resolve("verified-commit"));
env.put("CI", "true");
commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, "");
Thread shutdown = new Thread(() -> {
commands.cancel();
try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); }
}, "ci-cleanup");
Runtime.getRuntime().addShutdownHook(shutdown);
Exception failure = null;
try {
requirePlatform(); isolateDocker();
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify");
CiTests.run();
Path frontend = root.resolve("agiledancers-fe");
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit",
"--output-file=unit-results/junit.xml"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked();
String platform = env.get("TARGET_PLATFORM");
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
"org.opencontainers.image.revision=" + commit, "-t", backendImage, ".");
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
"org.opencontainers.image.revision=" + commit, "-t", frontendImage, "agiledancers-fe");
Files.writeString(artifacts.resolve("containers-started"), "");
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
run("docker", "run", "-d", "--name", runName + "-backend", "--label", "agiledancers.ci.run=" + runName,
"--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", backendImage);
run("docker", "run", "-d", "--name", runName + "-frontend", "--label", "agiledancers.ci.run=" + runName,
"--platform", platform, "--network", runName, "--network-alias", "frontend", "--memory", "256m", "--cpus", "1", frontendImage);
waitForApplication();
String user = capture("id", "-u") + ":" + capture("id", "-g");
run("docker", "run", "--name", runName + "-browser", "--label", "agiledancers.ci.run=" + runName,
"--platform", platform, "--network", runName, "--memory", "2g", "--cpus", "2", "--shm-size=1g",
"--user", user, "--env", "HOME=/tmp", "--env", "CI=true", "--env", "PLAYWRIGHT_BASE_URL=http://frontend:8080",
"--volume", frontend + ":/workspace", "--workdir", "/workspace", "mcr.microsoft.com/playwright:v1.64.1-noble",
"npm", "run", "e2e");
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
} catch (Exception e) { failure = e; throw e; }
finally {
try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; }
finally {
closeDockerConfig();
try { Runtime.getRuntime().removeShutdownHook(shutdown); } catch (IllegalStateException ignored) { /* shutting down */ }
}
}
}
void waitForApplication() throws Exception {
for (int i = 0; i < 60; i++) {
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-",
"http://localhost:8080/api/dance-schools").code() == 0) return;
Thread.sleep(2000);
}
throw new IllegalArgumentException("Application readiness timed out");
}
synchronized void cleanup() throws Exception {
// A separate cleanup budget remains available after the build deadline.
commands.renew(Duration.ofMinutes(2));
List<String> failures = new ArrayList<>();
for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports",
"agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) {
if (Files.isDirectory(root.resolve(path))) {
try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); }
}
}
if (result(false,"docker", "info").code() == 0) {
for (String role : List.of("backend", "frontend", "browser")) {
String container = runName + "-" + role;
if (result(false,"docker", "container", "inspect", container).code() == 0) {
var logs = result(false,"docker", "logs", container);
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
if (logs.code() != 0) failures.add(role + " logs");
if (result(false,"docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup");
}
}
if (result(false,"docker", "network", "inspect", runName).code() == 0
&& result(false,"docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started"));
} else if (Files.exists(artifacts.resolve("containers-started"))) failures.add("Docker unavailable during cleanup");
check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures));
}
void publish() throws Exception {
if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; }
check(Files.exists(artifacts.resolve("verified-commit"))
&& Files.readString(artifacts.resolve("verified-commit")).strip().equals(commit), "This commit has not passed verification");
String user = Setup.required(env,"REGISTRY_USER"), token = Setup.required(env,"REGISTRY_TOKEN");
String server = Setup.required(env,"GITEA_SERVER_URL");
String repository = Setup.required(env,"CI_REPOSITORY");
requirePlatform();
commands.log = artifacts.resolve("publish.log"); Files.writeString(commands.log, "");
var uri = java.net.URI.create(server);
check(Set.of("http", "https").contains(uri.getScheme()) && (uri.getPath().isEmpty() || uri.getPath().equals("/")),
"Registry must be hosted at the Gitea server root");
String registry = uri.getRawAuthority();
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
Path lockDir = Path.of(env.getOrDefault("CI_PUBLISH_LOCK_DIR",
env.getOrDefault("XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
Files.createDirectories(lockDir);
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
try (FileChannel channel = FileChannel.open(lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock lock = awaitLock(channel)) {
isolateDocker();
try {
commands.execute(List.of("docker", "login", registry, "--username", user, "--password-stdin"), root, env, token, true).checked();
String prefix = registry + "/" + owner + "/agiledancers-";
ensureImage(prefix + "backend", backendImage);
ensureImage(prefix + "frontend", frontendImage);
for (String role : List.of("backend", "frontend")) {
if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; }
String image = prefix + role;
run("docker", "tag", image + ":" + commit, image + ":main");
run("docker", "push", image + ":main");
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
}
} finally { closeDockerConfig(); }
}
}
FileLock awaitLock(FileChannel channel) throws Exception {
Instant end = Instant.now().plusSeconds(600);
while (Instant.now().isBefore(end)) {
FileLock lock = channel.tryLock(); if (lock != null) return lock;
Thread.sleep(250);
}
throw new IllegalArgumentException("Publication lock timed out");
}
String mainHead() throws Exception {
Map<String,Object> branch = Json.object(Setup.api(env.get("GITEA_SERVER_URL"),
env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null));
return Objects.toString(Json.object(branch.get("commit")).get("id"));
}
void ensureImage(String repository, String source) throws Exception {
String ref = repository + ":" + commit;
var manifest = result(false,"docker", "manifest", "inspect", "--insecure", ref);
if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
run("docker", "tag", source, ref); run("docker", "push", ref);
} else throw new IllegalArgumentException("Registry manifest lookup failed; authentication/network errors are not missing tags");
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref);
check(commit.equals(capture("docker", "image", "inspect", "--format", "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", ref)),
"Existing image has an unexpected source revision");
String digests = capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref);
check(digests.contains(repository + "@sha256:"), "Missing registry digest");
Files.writeString(artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), digests + "\n");
}
synchronized void closeDockerConfig() throws IOException {
if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; }
}
static void copyTree(Path source, Path destination) throws IOException {
try (var paths = Files.walk(source)) {
for (Path path : paths.toList()) {
Path target = destination.resolve(source.relativize(path));
if (Files.isDirectory(path)) Files.createDirectories(target);
else Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
static void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) return;
try (var paths = Files.walk(root)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); }
}
}