Files
agiledancers/ci/Pipeline.java
Wanja Krah 7e8b3a2cc7
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
S8 apply Palantir formatting to maintained Java sources
2026-10-30 15:50:51 +01:00

438 lines
18 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");
var docker = result(false, "docker", "info", "--format", "{{.Architecture}}");
check(
docker.code() == 0,
"Docker is unavailable; start Docker Desktop before running CI (details in verify.log)");
String arch = docker.output().strip();
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", "-Dformat.mode=check", "clean", "verify");
CiTests.run();
Path frontend = root.resolve("agiledancers-fe");
verifyFrontend();
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 verifyFrontend() throws Exception {
Path frontend = root.resolve("agiledancers-fe");
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "lint"), 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();
}
void waitForApplication() throws Exception {
for (int i = 0; i < 60; i++) {
if (result(
false,
"docker",
"exec",
runName + "-frontend",
"wget",
"-q",
"-O",
"-",
"http://127.0.0.1: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);
}
}
}