S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s

This commit is contained in:
2026-10-30 15:50:51 +01:00
parent 327a365550
commit 7e8b3a2cc7
55 changed files with 1907 additions and 713 deletions

View File

@@ -24,16 +24,18 @@ public class Ci {
case "attempt" -> {
Path file = Path.of(Setup.required(env, "GITHUB_ENV"));
String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS")
.withZone(ZoneOffset.UTC).format(Instant.now());
Files.writeString(file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
.withZone(ZoneOffset.UTC)
.format(Instant.now());
Files.writeString(
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
case "configure-gitea" -> Setup.configureGitea(env);
case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1]));
}
case "install-runner" -> Setup.installRunner(root, env, commands,
args.length == 2 && args[1].equals("--prepare-only"));
case "install-runner" ->
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run();
default -> throw new IllegalArgumentException(usage());
}
@@ -42,13 +44,16 @@ public class Ci {
System.exit(e.exitCode);
} catch (Exception e) {
// Never dump HTTP bodies, process arguments, environment or tokens.
System.err.println(e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure ? e.getMessage()
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
System.err.println(
e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure
? e.getMessage()
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
System.exit(1);
}
}
static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker <path>|install-runner [--prepare-only]";
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
+ " <path>|install-runner [--prepare-only]";
}
}

View File

@@ -6,26 +6,41 @@ import java.util.*;
class CiTests {
static final String SHA = "a".repeat(40);
static int count;
interface Test { void run() throws Exception; }
interface Test {
void run() throws Exception;
}
static void test(String name, Test test) throws Exception {
test.run(); count++; System.out.println("PASS " + name);
test.run();
count++;
System.out.println("PASS " + name);
}
static void equal(Object expected, Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
}
static void failure(Test action) throws Exception {
try { action.run(); } catch (Exception expected) { return; }
try {
action.run();
} catch (Exception expected) {
return;
}
throw new AssertionError("Expected failure");
}
static void run() throws Exception {
count = 0;
test("JSON preserves configuration values and escaped text", () -> {
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
Object data = Json.parse(json); equal(data,Json.parse(Json.write(data)));
equal("line\nquote\"€",Json.object(data).get("text"));
Object data = Json.parse(json);
equal(data, Json.parse(Json.write(data)));
equal("line\nquote\"€", Json.object(data).get("text"));
});
test("JSON rejects malformed/ambiguous input", () -> {
for (String value : List.of("{\"a\":null,\"a\":1}","{} []","[1,]","01","\"\\uZZZZ\"","\"line\n\"","truefalse","+1"))
for (String value : List.of(
"{\"a\":null,\"a\":1}", "{} []", "[1,]", "01", "\"\\uZZZZ\"", "\"line\n\"", "truefalse", "+1"))
failure(() -> Json.parse(value));
});
test("Docker configuration merge is idempotent and preserves backup", () -> {
@@ -33,151 +48,288 @@ class CiTests {
try {
Path file = dir.resolve("daemon.json");
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
Files.writeString(file,original);
Setup.configureDocker(file); String first = Files.readString(file); Setup.configureDocker(file);
equal(first,Files.readString(file));
equal(List.of("existing:5000","192.168.2.28:3000"),Json.object(Json.parse(first)).get("insecure-registries"));
equal(original,Files.readString(dir.resolve("daemon.json.before-agiledancers")));
} finally { Pipeline.deleteTree(dir); }
Files.writeString(file, original);
Setup.configureDocker(file);
String first = Files.readString(file);
Setup.configureDocker(file);
equal(first, Files.readString(file));
equal(
List.of("existing:5000", "192.168.2.28:3000"),
Json.object(Json.parse(first)).get("insecure-registries"));
equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers")));
} finally {
Pipeline.deleteTree(dir);
}
});
test("Invalid Docker configuration is unchanged", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-");
try {
Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file,original); failure(() -> Setup.configureDocker(file)); equal(original,Files.readString(file));
} finally { Pipeline.deleteTree(dir); }
Path file = dir.resolve("daemon.json");
String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file, original);
failure(() -> Setup.configureDocker(file));
equal(original, Files.readString(file));
} finally {
Pipeline.deleteTree(dir);
}
});
test("Feature branches never contact registry", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.env.put("CI_REF","refs/heads/feature/test"); f.pipeline.publish(); equal(0L,f.calls("login")); equal(0L,f.calls("push"));
f.pipeline.env.put("CI_REF", "refs/heads/feature/test");
f.pipeline.publish();
equal(0L, f.calls("login"));
equal(0L, f.calls("push"));
}
});
test("Unverified commit cannot publish", () -> {
try (Fixture f = new Fixture()) {
Files.delete(f.root.resolve("ci/artifacts/verified-commit")); failure(f.pipeline::publish); equal(0L,f.calls("push"));
Files.delete(f.root.resolve("ci/artifacts/verified-commit"));
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
});
test("Publication pullback and rerun preserve commit tags", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.publish(); equal(4L,f.calls("push")); f.pipeline.publish(); equal(6L,f.calls("push")); equal(8L,f.calls("pull"));
f.pipeline.publish();
equal(4L, f.calls("push"));
f.pipeline.publish();
equal(6L, f.calls("push"));
equal(8L, f.calls("pull"));
}
});
test("Partial publication is completed without overwriting backend", () -> {
try (Fixture f = new Fixture()) {
f.fake.pushFailure = "frontend:" + SHA; failure(f.pipeline::publish);
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
f.fake.pushFailure = ""; f.pipeline.publish();
equal(1L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA)).count());
f.fake.pushFailure = "frontend:" + SHA;
failure(f.pipeline::publish);
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
.count());
f.fake.pushFailure = "";
f.pipeline.publish();
equal(
1L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA))
.count());
}
});
test("Authentication error is not a missing tag", () -> {
try (Fixture f = new Fixture()) { f.fake.manifestError = true; failure(f.pipeline::publish); equal(0L,f.calls("push")); }
try (Fixture f = new Fixture()) {
f.fake.manifestError = true;
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
});
test("Pull failure blocks moving tags", () -> {
try (Fixture f = new Fixture()) {
f.fake.pullFailure = "backend:" + SHA; failure(f.pipeline::publish);
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
f.fake.pullFailure = "backend:" + SHA;
failure(f.pipeline::publish);
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
.count());
}
});
test("Older commit cannot move main", () -> {
try (Fixture f = new Fixture()) { f.fake.head = "c".repeat(40); f.pipeline.publish(); equal(2L,f.calls("push")); }
try (Fixture f = new Fixture()) {
f.fake.head = "c".repeat(40);
f.pipeline.publish();
equal(2L, f.calls("push"));
}
});
test("Unavailable Docker fails before build with a concrete cause", () -> {
try (Fixture f = new Fixture()) {
f.fake.dockerAvailable = false;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
catch (IllegalArgumentException error) { equal(true,error.getMessage().contains("Docker is unavailable")); }
equal(0L,f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).count());
try {
f.pipeline.verify();
throw new AssertionError("Expected failure");
} catch (IllegalArgumentException error) {
equal(true, error.getMessage().contains("Docker is unavailable"));
}
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.count());
}
});
test("Architecture mismatch blocks publication", () -> {
try (Fixture f = new Fixture()) { f.pipeline.env.put("TARGET_PLATFORM","linux/arm64"); failure(f.pipeline::publish); equal(0L,f.calls("push")); }
try (Fixture f = new Fixture()) {
f.pipeline.env.put("TARGET_PLATFORM", "linux/arm64");
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
});
test("Failed verification removes success marker and preserves exit status", () -> {
try (Fixture f = new Fixture()) {
f.fake.mavenFailure = true;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(42,error.exitCode); }
equal(List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).findFirst().orElseThrow());
equal(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L,f.calls("build")); equal(0L,f.calls("push"));
try {
f.pipeline.verify();
throw new AssertionError("Expected failure");
} catch (Commands.Failure error) {
equal(42, error.exitCode);
}
equal(
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.findFirst()
.orElseThrow());
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L, f.calls("build"));
equal(0L, f.calls("push"));
}
});
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
try (Fixture f = new Fixture()) {
f.fake.lintFailure = true;
try { f.pipeline.verifyFrontend(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(1,error.exitCode); }
try {
f.pipeline.verifyFrontend();
throw new AssertionError("Expected failure");
} catch (Commands.Failure error) {
equal(1, error.exitCode);
}
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
}
});
test("Frontend verification checks before testing and building", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.verifyFrontend();
equal(List.of("ci", "run lint", "test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml", "run build"),
f.fake.calls.stream().map(a -> String.join(" ", a.subList(1,a.size()))).toList());
equal(
List.of(
"ci",
"run lint",
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
"run build"),
f.fake.calls.stream()
.map(a -> String.join(" ", a.subList(1, a.size())))
.toList());
}
});
test("Process execution enforces its deadline", () -> {
Path dir = Files.createTempDirectory("ci-process-test-");
try {
Path file = dir.resolve("Sleep.java");
Files.writeString(file,"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000); } }");
Files.writeString(
file,
"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000);"
+ " } }");
Commands runner = new Commands(Duration.ofMillis(100));
try {
runner.execute(List.of(System.getProperty("java.home") + "/bin/java",file.toString()),dir,System.getenv(),null,false);
runner.execute(
List.of(System.getProperty("java.home") + "/bin/java", file.toString()),
dir,
System.getenv(),
null,
false);
throw new AssertionError("Expected timeout");
} catch (Commands.Failure error) { equal(124,error.exitCode); }
} finally { Pipeline.deleteTree(dir); }
} catch (Commands.Failure error) {
equal(124, error.exitCode);
}
} finally {
Pipeline.deleteTree(dir);
}
});
System.out.println(count + " CI tests passed");
}
static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("ci-publication-test-");
final FakeCommands fake = new FakeCommands();
final Pipeline pipeline;
Fixture() throws Exception {
Map<String,String> env = new HashMap<>(System.getenv());
env.putAll(Map.of("CI_COMMIT",SHA,"CI_RUN_ID","test","CI_RUN_ATTEMPT","1","CI_REF","refs/heads/main",
"CI_REPOSITORY","wanjakrah/agiledancers","TARGET_PLATFORM","linux/amd64","GITEA_SERVER_URL","http://192.168.2.28:3000",
"REGISTRY_USER","test","REGISTRY_TOKEN","dummy-token","CI_PUBLISH_LOCK_DIR",root.resolve("lock").toString()));
pipeline = new Pipeline(root,env,fake) { @Override String mainHead() { return fake.head; } };
Files.writeString(root.resolve("ci/artifacts/verified-commit"),SHA + "\n");
Map<String, String> env = new HashMap<>(System.getenv());
env.putAll(Map.of(
"CI_COMMIT",
SHA,
"CI_RUN_ID",
"test",
"CI_RUN_ATTEMPT",
"1",
"CI_REF",
"refs/heads/main",
"CI_REPOSITORY",
"wanjakrah/agiledancers",
"TARGET_PLATFORM",
"linux/amd64",
"GITEA_SERVER_URL",
"http://192.168.2.28:3000",
"REGISTRY_USER",
"test",
"REGISTRY_TOKEN",
"dummy-token",
"CI_PUBLISH_LOCK_DIR",
root.resolve("lock").toString()));
pipeline = new Pipeline(root, env, fake) {
@Override
String mainHead() {
return fake.head;
}
};
Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n");
}
long calls(String verb) {
return fake.calls.stream()
.filter(a -> a.size() > 1
&& a.getFirst().equals("docker")
&& a.get(1).equals(verb))
.count();
}
public void close() throws Exception {
pipeline.closeDockerConfig();
Pipeline.deleteTree(root);
}
long calls(String verb) { return fake.calls.stream().filter(a -> a.size()>1 && a.getFirst().equals("docker") && a.get(1).equals(verb)).count(); }
public void close() throws Exception { pipeline.closeDockerConfig(); Pipeline.deleteTree(root); }
}
static class FakeCommands extends Commands {
final List<List<String>> calls = new ArrayList<>();
final Map<String,String> images = new HashMap<>(), tags = new HashMap<>();
final Map<String, String> images = new HashMap<>(), tags = new HashMap<>();
String head = SHA, pushFailure = "", pullFailure = "";
boolean manifestError, mavenFailure, lintFailure;
boolean dockerAvailable = true;
FakeCommands() { super(Duration.ofMinutes(1)); }
@Override Result execute(List<String> args, Path cwd, Map<String,String> env, String input, boolean echo) {
FakeCommands() {
super(Duration.ofMinutes(1));
}
@Override
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) {
calls.add(List.copyOf(args));
if (args.getFirst().equals("git")) return new Result(0,SHA);
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42,"deliberate failure");
if (args.equals(List.of("npm", "run", "lint")) && lintFailure) return new Result(1,"deliberate lint failure");
if (!args.getFirst().equals("docker")) return new Result(0,"");
if (args.getFirst().equals("git")) return new Result(0, SHA);
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
if (args.equals(List.of("npm", "run", "lint")) && lintFailure)
return new Result(1, "deliberate lint failure");
if (!args.getFirst().equals("docker")) return new Result(0, "");
String command = args.get(1), ref = args.getLast();
if (command.equals("info")) return new Result(dockerAvailable ? 0 : 1,args.contains("{{.Architecture}}") ? "amd64" : "linux");
if (command.equals("context")) return new Result(0,"unix:///tmp/test-docker.sock");
if (command.equals("info"))
return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux");
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
if (command.equals("manifest")) {
if (manifestError) return new Result(1,"unauthorized");
return images.containsKey(ref) ? new Result(0,"{}") : new Result(1,"manifest unknown");
if (manifestError) return new Result(1, "unauthorized");
return images.containsKey(ref) ? new Result(0, "{}") : new Result(1, "manifest unknown");
}
if (command.equals("tag")) tags.put(ref,images.getOrDefault(args.get(2),SHA));
if (command.equals("tag")) tags.put(ref, images.getOrDefault(args.get(2), SHA));
if (command.equals("push")) {
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1,"push failed");
images.put(ref,tags.getOrDefault(ref,SHA));
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
images.put(ref, tags.getOrDefault(ref, SHA));
}
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure)) return new Result(1,"pull failed");
if (command.equals("image")) return new Result(0,args.stream().anyMatch(a -> a.contains("RepoDigests"))
? ref.substring(0,ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64) : images.getOrDefault(ref,"missing"));
if (command.equals("container") || command.equals("network") && args.contains("inspect")) return new Result(1,"");
return new Result(0,"");
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure))
return new Result(1, "pull failed");
if (command.equals("image"))
return new Result(
0,
args.stream().anyMatch(a -> a.contains("RepoDigests"))
? ref.substring(0, ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64)
: images.getOrDefault(ref, "missing"));
if (command.equals("container") || command.equals("network") && args.contains("inspect"))
return new Result(1, "");
return new Result(0, "");
}
}
}

View File

@@ -13,20 +13,37 @@ class Commands {
return output.strip();
}
}
static class Failure extends RuntimeException {
final int exitCode;
Failure(int exitCode, String message) { super(message); this.exitCode = exitCode; }
Failure(int exitCode, String message) {
super(message);
this.exitCode = exitCode;
}
}
Instant deadline;
volatile Process active;
Path log;
void renew(Duration timeout) { deadline = Instant.now().plus(timeout); }
void cancel() { Process process = active; if (process != null) stop(process); }
Commands(Duration timeout) { deadline = Instant.now().plus(timeout); }
void renew(Duration timeout) {
deadline = Instant.now().plus(timeout);
}
void cancel() {
Process process = active;
if (process != null) stop(process);
}
Commands(Duration timeout) {
deadline = Instant.now().plus(timeout);
}
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) throws Exception {
if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded");
ProcessBuilder builder = new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true);
ProcessBuilder builder =
new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true);
builder.environment().clear();
builder.environment().putAll(env);
Process process = builder.start();
@@ -36,22 +53,30 @@ class Commands {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> reader = executor.submit(() -> {
try (InputStream stream = process.getInputStream();
OutputStream file = destination == null ? OutputStream.nullOutputStream()
: Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
OutputStream file = destination == null
? OutputStream.nullOutputStream()
: Files.newOutputStream(
destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
byte[] buffer = new byte[8192];
int count;
while ((count = stream.read(buffer)) != -1) {
captured.write(buffer, 0, count);
if (echo) { System.out.write(buffer, 0, count); System.out.flush(); }
if (echo) {
System.out.write(buffer, 0, count);
System.out.flush();
}
file.write(buffer, 0, count);
}
} catch (IOException e) { throw new UncheckedIOException(e); }
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
try {
try (OutputStream stdin = process.getOutputStream()) {
if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8));
}
if (!process.waitFor(Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) {
if (!process.waitFor(
Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) {
stop(process);
throw new Failure(124, "CI time limit exceeded");
}

View File

@@ -5,93 +5,145 @@ import java.util.*;
final class Json {
static Object parse(String source) {
Parser parser = new Parser(source);
Object result = parser.value(); parser.space();
Object result = parser.value();
parser.space();
if (parser.pos != source.length()) throw parser.invalid();
return result;
}
@SuppressWarnings("unchecked")
static Map<String, Object> object(Object value) {
if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object");
return (Map<String, Object>) value;
}
static String write(Object value) {
if (value == null) return "null";
if (value instanceof String text) {
StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n"); case '\r' -> out.append("\\r"); case '\t' -> out.append("\\t");
default -> { if (c < 32) out.append(String.format("\\u%04x", (int)c)); else out.append(c); }
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 32) out.append(String.format("\\u%04x", (int) c));
else out.append(c);
}
}
}
return out.append('"').toString();
}
if (value instanceof Boolean || value instanceof BigDecimal || value instanceof Integer || value instanceof Long)
return value.toString();
if (value instanceof Boolean
|| value instanceof BigDecimal
|| value instanceof Integer
|| value instanceof Long) return value.toString();
if (value instanceof Map<?, ?> map) {
StringJoiner out = new StringJoiner(",", "{", "}");
map.forEach((k,v) -> out.add(write(Objects.toString(k)) + ":" + write(v))); return out.toString();
map.forEach((k, v) -> out.add(write(Objects.toString(k)) + ":" + write(v)));
return out.toString();
}
if (value instanceof List<?> list) {
StringJoiner out = new StringJoiner(",", "[", "]"); list.forEach(v -> out.add(write(v))); return out.toString();
StringJoiner out = new StringJoiner(",", "[", "]");
list.forEach(v -> out.add(write(v)));
return out.toString();
}
throw new IllegalArgumentException("Unsupported JSON value");
}
private static final class Parser {
final String source; int pos;
Parser(String source) { this.source = source; }
IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at offset " + pos); }
void space() { while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++; }
boolean take(char c) { space(); if (pos < source.length() && source.charAt(pos) == c) { pos++; return true; } return false; }
final String source;
int pos;
Parser(String source) {
this.source = source;
}
IllegalArgumentException invalid() {
return new IllegalArgumentException("Invalid JSON at offset " + pos);
}
void space() {
while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++;
}
boolean take(char c) {
space();
if (pos < source.length() && source.charAt(pos) == c) {
pos++;
return true;
}
return false;
}
Object value() {
space(); if (pos >= source.length()) throw invalid();
space();
if (pos >= source.length()) throw invalid();
char c = source.charAt(pos);
if (c == '"') return string();
if (take('{')) {
Map<String,Object> out = new LinkedHashMap<>();
Map<String, Object> out = new LinkedHashMap<>();
if (take('}')) return out;
do {
space(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
String key = string(); if (!take(':') || out.containsKey(key)) throw invalid();
space();
if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
String key = string();
if (!take(':') || out.containsKey(key)) throw invalid();
out.put(key, value());
} while (take(','));
if (!take('}')) throw invalid(); return out;
if (!take('}')) throw invalid();
return out;
}
if (take('[')) {
List<Object> out = new ArrayList<>(); if (take(']')) return out;
do { out.add(value()); } while (take(','));
if (!take(']')) throw invalid(); return out;
List<Object> out = new ArrayList<>();
if (take(']')) return out;
do {
out.add(value());
} while (take(','));
if (!take(']')) throw invalid();
return out;
}
for (String token : List.of("true", "false", "null")) {
if (source.startsWith(token, pos)) {
pos += token.length(); return token.equals("null") ? null : Boolean.valueOf(token);
pos += token.length();
return token.equals("null") ? null : Boolean.valueOf(token);
}
}
int start = pos;
while (pos < source.length() && "-+0123456789.eE".indexOf(source.charAt(pos)) >= 0) pos++;
String number = source.substring(start,pos);
String number = source.substring(start, pos);
if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid();
return new BigDecimal(number);
}
String string() {
pos++; StringBuilder out = new StringBuilder();
pos++;
StringBuilder out = new StringBuilder();
while (pos < source.length()) {
char c = source.charAt(pos++);
if (c == '"') return out.toString();
if (c < 32) throw invalid();
if (c != '\\') { out.append(c); continue; }
if (c != '\\') {
out.append(c);
continue;
}
if (pos >= source.length()) throw invalid();
char escaped = source.charAt(pos++);
switch (escaped) {
case '"', '\\', '/' -> out.append(escaped);
case 'b' -> out.append('\b'); case 'f' -> out.append('\f');
case 'n' -> out.append('\n'); case 'r' -> out.append('\r'); case 't' -> out.append('\t');
case 'b' -> out.append('\b');
case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
case 'u' -> {
if (pos + 4 > source.length()) throw invalid();
String digits = source.substring(pos,pos+4);
String digits = source.substring(pos, pos + 4);
if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid();
out.append((char) Integer.parseInt(digits,16)); pos += 4;
out.append((char) Integer.parseInt(digits, 16));
pos += 4;
}
default -> throw invalid();
}

View File

@@ -7,12 +7,12 @@ 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 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 {
Pipeline(Path root, Map<String, String> environment, Commands commands) throws Exception {
this.root = root;
this.env = new HashMap<>(environment);
this.commands = commands;
@@ -23,190 +23,401 @@ class Pipeline {
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);
artifacts = root.resolve("ci/artifacts");
Files.createDirectories(artifacts);
}
interface Value { String get() throws Exception; }
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(); }
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 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; };
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");
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();
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");
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");
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);
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");
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 {
} 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 */ }
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();
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;
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")) {
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");
try {
copyTree(root.resolve(path), artifacts.resolve(path));
} catch (IOException e) {
failures.add(path);
}
}
if (result(false,"docker", "network", "inspect", runName).code() == 0
&& result(false,"docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
}
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");
} 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");
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, "");
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("/")),
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"));
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)) {
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();
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; }
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(); }
} 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;
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));
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);
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", "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)),
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);
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");
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; }
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()) {
@@ -216,8 +427,11 @@ class Pipeline {
}
}
}
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); }
try (var paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
}
}

View File

@@ -8,154 +8,280 @@ import java.time.Duration;
import java.util.*;
class Setup {
static Path home() { return Path.of(System.getProperty("user.home")); }
static String required(Map<String,String> env, String key) {
static Path home() {
return Path.of(System.getProperty("user.home"));
}
static String required(Map<String, String> env, String key) {
String value = env.get(key);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
return value;
}
static String secret(Map<String,String> env, String key) throws IOException {
Path path = Path.of(required(env,key));
static String secret(Map<String, String> env, String key) throws IOException {
Path path = Path.of(required(env, key));
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
if (permissions.stream().anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_")))
if (permissions.stream()
.anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_")))
throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)");
String token = Files.readString(path).strip();
if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty");
return token;
}
static class ApiFailure extends IOException {
final int status;
ApiFailure(int status) { super("Gitea HTTP " + status); this.status = status; }
ApiFailure(int status) {
super("Gitea HTTP " + status);
this.status = status;
}
}
static Object api(String server, String token, String method, String path, Object body) throws Exception {
Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format");
HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path))
.timeout(Duration.ofSeconds(30)).header("Authorization", "token " + token)
.timeout(Duration.ofSeconds(30))
.header("Authorization", "token " + token)
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body)))
.method(
method,
body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(Json.write(body)))
.build();
try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
try (HttpClient client =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) throw new ApiFailure(response.statusCode());
if (response.statusCode() < 200 || response.statusCode() >= 300)
throw new ApiFailure(response.statusCode());
return response.body().isBlank() ? null : Json.parse(response.body());
}
}
static void configureGitea(Map<String,String> env) throws Exception {
String admin = secret(env,"GITEA_ADMIN_TOKEN_FILE"), registry = secret(env,"REGISTRY_TOKEN_FILE");
String user = required(env,"REGISTRY_USER"), platform = required(env,"TARGET_PLATFORM");
Pipeline.check(Set.of("linux/amd64","linux/arm64").contains(platform), "Set TARGET_PLATFORM from the verified Synology architecture");
static void configureGitea(Map<String, String> env) throws Exception {
String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"), registry = secret(env, "REGISTRY_TOKEN_FILE");
String user = required(env, "REGISTRY_USER"), platform = required(env, "TARGET_PLATFORM");
Pipeline.check(
Set.of("linux/amd64", "linux/arm64").contains(platform),
"Set TARGET_PLATFORM from the verified Synology architecture");
String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers";
api(server,admin,"PATCH",base,Map.of("has_actions",true));
api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_USER",Map.of("data",user));
api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_TOKEN",Map.of("data",registry));
api(server, admin, "PATCH", base, Map.of("has_actions", true));
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user));
api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
String variable = base + "/actions/variables/TARGET_PLATFORM";
boolean exists;
try { api(server,admin,"GET",variable,null); exists = true; }
catch (ApiFailure e) { if (e.status != 404) throw e; exists = false; }
api(server,admin,exists ? "PUT" : "POST",variable,Map.of("value",platform));
Map<String,Object> response = Json.object(api(server,admin,"POST",base + "/actions/runners/registration-token",null));
try {
api(server, admin, "GET", variable, null);
exists = true;
} catch (ApiFailure e) {
if (e.status != 404) throw e;
exists = false;
}
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
Map<String, Object> response =
Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null));
Path directory = home().resolve(".config/agiledancers-runner");
privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
}
static void configureDocker(Path file) throws IOException {
Map<String,Object> data = Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
Map<String, Object> data =
Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
if (!(entry instanceof List<?>)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>)entry);
if (registries.contains("192.168.2.28:3000")) { System.out.println("Internal registry already configured; no change"); return; }
registries.add("192.168.2.28:3000"); data.put("insecure-registries",registries);
Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); Files.createDirectories(parent);
if (!(entry instanceof List<?>))
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>) entry);
if (registries.contains("192.168.2.28:3000")) {
System.out.println("Internal registry already configured; no change");
return;
}
registries.add("192.168.2.28:3000");
data.put("insecure-registries", registries);
Path absolute = file.toAbsolutePath(), parent = absolute.getParent();
Files.createDirectories(parent);
if (Files.exists(file)) {
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
if (!Files.exists(backup)) Files.copy(file,backup,StandardCopyOption.COPY_ATTRIBUTES);
if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
}
Path temp = Files.createTempFile(parent,"daemon-", ".json");
Path temp = Files.createTempFile(parent, "daemon-", ".json");
try {
Files.writeString(temp,Json.write(data) + "\n");
if (Files.exists(file)) Files.setPosixFilePermissions(temp,Files.getPosixFilePermissions(file));
Files.move(temp,absolute,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);
} finally { Files.deleteIfExists(temp); }
Files.writeString(temp, Json.write(data) + "\n");
if (Files.exists(file)) Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(file));
Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temp);
}
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
}
static void installRunner(Path root, Map<String,String> env, Commands commands, boolean prepareOnly) throws Exception {
Pipeline.check(System.getProperty("os.name").equals("Linux") && Set.of("amd64","x86_64").contains(System.getProperty("os.arch")),
static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly)
throws Exception {
Pipeline.check(
System.getProperty("os.name").equals("Linux")
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
"Installer targets the existing Linux x86_64 mini-PC");
Path runnerDir = home().resolve(".local/share/agiledancers-runner"), configDir = home().resolve(".config/agiledancers-runner");
Path runnerDir = home().resolve(".local/share/agiledancers-runner"),
configDir = home().resolve(".config/agiledancers-runner");
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
for (Path path : List.of(runnerDir,configDir,bin,units)) privateDirectory(path);
for (String tool : List.of("node","java","mvn","docker","git","systemctl")) executable(env,tool);
for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
String javaHome = System.getProperty("java.home");
String version = commands.execute(List.of("node","--version"),root,env,null,false).checked();
String version = commands.execute(List.of("node", "--version"), root, env, null, false)
.checked();
String[] parts = version.replaceFirst("^v", "").split("\\.");
int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]);
Pipeline.check((major == 22 && (minor > 22 || minor == 22 && patch >= 3)) || (major == 24 && minor >= 15) || major >= 26,
Pipeline.check(
(major == 22 && (minor > 22 || minor == 22 && patch >= 3))
|| (major == 24 && minor >= 15)
|| major >= 26,
"Supported Node version required");
Path runner = bin.resolve("act_runner");
if (!Files.isExecutable(runner)) {
URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64");
byte[] bytes;
try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) {
var response = client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofMinutes(2)).build(),HttpResponse.BodyHandlers.ofByteArray());
Pipeline.check(response.statusCode() == 200, "Runner download failed"); bytes = response.body();
try (HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build()) {
var response = client.send(
HttpRequest.newBuilder(uri)
.timeout(Duration.ofMinutes(2))
.build(),
HttpResponse.BodyHandlers.ofByteArray());
Pipeline.check(response.statusCode() == 200, "Runner download failed");
bytes = response.body();
}
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
Pipeline.check(hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),"Runner checksum mismatch");
Files.write(runner,bytes); Files.setPosixFilePermissions(runner,PosixFilePermissions.fromString("rwxr-xr-x"));
String hash = HexFormat.of()
.formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
Pipeline.check(
hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),
"Runner checksum mismatch");
Files.write(runner, bytes);
Files.setPosixFilePermissions(runner, PosixFilePermissions.fromString("rwxr-xr-x"));
}
String actual = commands.execute(List.of(runner.toString(),"--version"),root,env,null,false).checked();
String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false)
.checked();
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
privateWrite(runnerDir.resolve("config.yaml"),Files.readString(root.resolve("ci/runner/config.yaml")));
Files.copy(root.resolve("ci/runner/agiledancers-runner.service"),units.resolve("agiledancers-runner.service"),StandardCopyOption.REPLACE_EXISTING);
Files.copy(root.resolve("ci/runner/WaitForDocker.java"),runnerDir.resolve("WaitForDocker.java"),StandardCopyOption.REPLACE_EXISTING);
privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml")));
Files.copy(
root.resolve("ci/runner/agiledancers-runner.service"),
units.resolve("agiledancers-runner.service"),
StandardCopyOption.REPLACE_EXISTING);
Files.copy(
root.resolve("ci/runner/WaitForDocker.java"),
runnerDir.resolve("WaitForDocker.java"),
StandardCopyOption.REPLACE_EXISTING);
String dockerHost = env.get("DOCKER_HOST");
if (dockerHost == null || dockerHost.isBlank()) dockerHost = commands.execute(List.of("docker","context","inspect","--format","{{.Endpoints.docker.Host}}"),root,env,null,false).checked();
String path = javaHome + "/bin:" + executable(env,"node").getParent() + ":" + executable(env,"mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
privateWrite(configDir.resolve("environment"),"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + "\n");
if (dockerHost == null || dockerHost.isBlank())
dockerHost = commands.execute(
List.of("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"),
root,
env,
null,
false)
.checked();
String path = javaHome + "/bin:" + executable(env, "node").getParent() + ":"
+ executable(env, "mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
privateWrite(
configDir.resolve("environment"),
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
+ "\n");
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
Path overrides = units.resolve("agiledancers-runner.service.d");
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
if (desktop) {
Files.createDirectories(overrides);
Files.copy(root.resolve("ci/runner/docker-desktop.conf"),desktopOverride,StandardCopyOption.REPLACE_EXISTING);
Files.copy(
root.resolve("ci/runner/docker-desktop.conf"),
desktopOverride,
StandardCopyOption.REPLACE_EXISTING);
} else {
Files.deleteIfExists(desktopOverride);
}
commands.execute(List.of("systemctl","--user","daemon-reload"),root,env,null,true).checked();
if (prepareOnly) { System.out.println("Runner installed; registration and startup still required."); return; }
commands.execute(List.of("docker","info"),root,env,null,false).checked();
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
.checked();
if (prepareOnly) {
System.out.println("Runner installed; registration and startup still required.");
return;
}
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
if (!Files.exists(runnerDir.resolve(".runner"))) {
Path tokenPath = Path.of(env.getOrDefault("RUNNER_REGISTRATION_TOKEN_FILE",configDir.resolve("registration-token").toString()));
Map<String,String> tokenEnv = Map.of("RUNNER_TOKEN_FILE",tokenPath.toString());
String token = secret(tokenEnv,"RUNNER_TOKEN_FILE");
commands.execute(List.of(runner.toString(),"register","--no-interactive","--instance","http://192.168.2.28:3000",
"--name","agiledancers-mini-pc","--labels","agiledancers-mini-pc:host","--token",token),runnerDir,env,null,true).checked();
Files.setPosixFilePermissions(runnerDir.resolve(".runner"),PosixFilePermissions.fromString("rw-------"));
Path tokenPath = Path.of(env.getOrDefault(
"RUNNER_REGISTRATION_TOKEN_FILE",
configDir.resolve("registration-token").toString()));
Map<String, String> tokenEnv = Map.of("RUNNER_TOKEN_FILE", tokenPath.toString());
String token = secret(tokenEnv, "RUNNER_TOKEN_FILE");
commands.execute(
List.of(
runner.toString(),
"register",
"--no-interactive",
"--instance",
"http://192.168.2.28:3000",
"--name",
"agiledancers-mini-pc",
"--labels",
"agiledancers-mini-pc:host",
"--token",
token),
runnerDir,
env,
null,
true)
.checked();
Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------"));
}
if (desktop) {
commands.execute(List.of("systemctl","--user","enable","--now","docker-desktop.service"),root,env,null,true).checked();
commands.execute(
List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"),
root,
env,
null,
true)
.checked();
// Replace any previous default.target link with the desktop-login target.
commands.execute(List.of("systemctl","--user","disable","agiledancers-runner.service"),root,env,null,true).checked();
commands.execute(
List.of("systemctl", "--user", "disable", "agiledancers-runner.service"),
root,
env,
null,
true)
.checked();
}
commands.execute(List.of("systemctl","--user","enable","--now","agiledancers-runner.service"),root,env,null,true).checked();
commands.execute(
List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"),
root,
env,
null,
true)
.checked();
System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
}
static Path executable(Map<String,String> env, String name) {
static Path executable(Map<String, String> env, String name) {
for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) {
Path path = Path.of(dir,name); if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath();
Path path = Path.of(dir, name);
if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath();
}
throw new IllegalArgumentException("Missing executable: " + name);
}
static String quoted(String value) {
Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value");
return "\"" + value.replace("\\","\\\\").replace("\"","\\\"") + "\"";
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
static void privateDirectory(Path path) throws IOException {
Files.createDirectories(path); Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rwx------"));
Files.createDirectories(path);
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
}
static void privateWrite(Path path, String text) throws IOException {
if (!Files.exists(path)) Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rw-------")); Files.writeString(path,text);
if (!Files.exists(path))
Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
Files.writeString(path, text);
}
}