All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
438 lines
18 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|