S19: establish engineering repository

This commit is contained in:
2026-11-08 15:15:50 +01:00
commit c25ce11a1b
84 changed files with 6599 additions and 0 deletions

513
resources/ci/Pipeline.java Normal file
View File

@@ -0,0 +1,513 @@
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 = Recipe.environment(root, 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 = Recipe.project(env) + "-" + id + "-" + attempt;
backendImage = Recipe.project(env) + "-ci-backend:" + commit + "-" + id + "-" + attempt;
frontendImage = Recipe.project(env) + "-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(Recipe.project(env) + "-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");
if (Recipe.roles(env).contains("frontend")) run("node", "--version");
if (Recipe.roles(env).contains("backend"))
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
CiTests.run();
ReleaseTests.run();
Path frontend = root.resolve(env.getOrDefault("FRONTEND_MODULE", "."));
if (Recipe.roles(env).contains("frontend")) verifyFrontend();
String platform = env.get("TARGET_PLATFORM");
for (String role : Recipe.roles(env)) {
run(
"docker",
"build",
"--platform",
platform,
"--target",
"prebuilt",
"--label",
"org.opencontainers.image.revision=" + commit,
"-t",
role.equals("backend") ? backendImage : frontendImage,
env.getOrDefault(
role.toUpperCase() + "_CONTEXT",
role.equals("backend") ? "." : env.get("FRONTEND_MODULE")));
}
if (Recipe.enabled(env, "BROWSER_TESTS")) {
Files.writeString(artifacts.resolve("containers-started"), "");
run("docker", "network", "create", "--label", Recipe.project(env) + ".ci.run=" + runName, runName);
if (Recipe.enabled(env, "CI_DATABASE")) {
run(
"docker",
"run",
"-d",
"--name",
runName + "-database",
"--network",
runName,
"--network-alias",
"database",
"--env",
"POSTGRES_USER=" + env.get("DATABASE_USER"),
"--env",
"POSTGRES_PASSWORD=ci-only",
"--env",
"POSTGRES_DB=" + env.get("DATABASE_NAME"),
"postgres:17.6");
for (int attempt = 0; attempt < 60; attempt++) {
if (result(
false,
"docker",
"exec",
runName + "-database",
"pg_isready",
"-U",
env.get("DATABASE_USER"))
.code()
== 0) break;
Thread.sleep(1000);
}
}
run(
"docker",
"run",
"-d",
"--name",
runName + "-backend",
"--label",
Recipe.project(env) + ".ci.run=" + runName,
"--platform",
platform,
"--network",
runName,
"--network-alias",
"backend",
"--memory",
"1g",
"--cpus",
"2",
"--volume",
root.resolve(env.get("BACKEND_MODULE") + "/target/coverage-tools/agent.jar")
+ ":/jacoco-agent.jar:ro",
"--env",
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
"--env",
"DATABASE_URL=jdbc:postgresql://database:5432/" + env.get("DATABASE_NAME"),
"--env",
"DATABASE_USER=" + env.get("DATABASE_USER"),
"--env",
"DATABASE_PASSWORD=ci-only",
backendImage);
run(
"docker",
"run",
"-d",
"--name",
runName + "-frontend",
"--label",
Recipe.project(env) + ".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",
Recipe.project(env) + ".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",
env.getOrDefault("PLAYWRIGHT_IMAGE", "mcr.microsoft.com/playwright:v1.64.1-noble"),
"npm",
"run",
"e2e");
run(
"docker",
"run",
"--rm",
"--network",
runName,
"--user",
user,
"--volume",
root.resolve(env.get("BACKEND_MODULE") + "/target") + ":/coverage",
"eclipse-temurin:25-jre",
"java",
"-jar",
"/coverage/coverage-tools/cli.jar",
"dump",
"--address",
"backend",
"--destfile",
"/coverage/playwright.exec");
check(
Files.size(root.resolve(env.get("BACKEND_MODULE") + "/target/playwright.exec")) > 0,
"Playwright backend coverage is missing");
run(
"mvn",
"-B",
"-ntp",
"-pl",
env.get("BACKEND_MODULE"),
"jacoco:merge@combined",
"jacoco:report@combined-report",
"exec:exec@combined-coverage");
}
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(env.get("FRONTEND_MODULE"));
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
.checked();
final Object configured = Json.parse(
env.getOrDefault(
"FRONTEND_TEST_COMMAND",
"[\"npm\",\"test\",\"--\",\"--reporters=default\",\"--reporters=junit\",\"--output-file=unit-results/junit.xml\"]"));
final List<String> test =
((List<?>) configured).stream().map(Object::toString).toList();
commands.execute(test, 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",
"-",
Setup.required(env, "CI_READINESS_URL"))
.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(
env.get("BACKEND_MODULE") + "/target/surefire-reports",
env.get("BACKEND_MODULE") + "/target/standards",
"generated/tooling/standards/target/surefire-reports",
env.get("BACKEND_MODULE") + "/target/site/jacoco",
env.get("BACKEND_MODULE") + "/target/site/jacoco-combined",
env.get("BACKEND_MODULE") + "/target/failsafe-reports",
env.get("FRONTEND_MODULE") + "/unit-results",
env.get("FRONTEND_MODULE") + "/test-results",
env.get("FRONTEND_MODULE") + "/playwright-report")) {
if (Files.isDirectory(root.resolve(path))) {
try {
copyTree(root.resolve(path), artifacts.resolve(path));
} catch (IOException e) {
failures.add(path);
}
}
}
for (String data : List.of("jacoco.exec", "playwright.exec", "jacoco-combined.exec")) {
final Path source =
root.resolve(env.get("BACKEND_MODULE") + "/target").resolve(data);
if (Files.isRegularFile(source))
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
}
if (result(false, "docker", "info").code() == 0) {
for (String role : List.of("backend", "frontend", "browser", "database")) {
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", "-v", 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()) + "/" + Recipe.project(env) + "-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 + "/" + Recipe.project(env) + "-";
for (String role : Recipe.roles(env)) {
ensureImage(prefix + role, role.equals("backend") ? backendImage : frontendImage);
}
for (String role : Recipe.roles(env)) {
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);
}
}
}