Run CI orchestration and setup as Java 25 source programs
Some checks failed
Build, test and publish / verify (push) Failing after 11s
Some checks failed
Build, test and publish / verify (push) Failing after 11s
This commit is contained in:
@@ -22,18 +22,18 @@ jobs:
|
|||||||
- name: Identify workflow attempt
|
- name: Identify workflow attempt
|
||||||
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
|
||||||
# container names and diagnostic artifacts, including reruns of one SHA.
|
# container names and diagnostic artifacts, including reruns of one SHA.
|
||||||
run: printf 'CI_RUN_ATTEMPT=%s\n' "$(date -u +%Y%m%d%H%M%S%N)" >> "$GITHUB_ENV"
|
run: java ci/Ci.java attempt
|
||||||
- name: Build and test both images
|
- name: Build and test both images
|
||||||
run: timeout --signal=TERM --kill-after=60s 75m bash ci/verify.sh
|
run: java ci/Ci.java verify
|
||||||
- name: Publish verified main images
|
- name: Publish verified main images
|
||||||
if: success() && gitea.ref == 'refs/heads/main'
|
if: success() && gitea.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: timeout --signal=TERM --kill-after=60s 15m bash ci/publish.sh
|
run: java ci/Ci.java publish
|
||||||
- name: Collect diagnostics and clean up test containers
|
- name: Collect diagnostics and clean up test containers
|
||||||
if: always()
|
if: always()
|
||||||
run: bash ci/cleanup.sh
|
run: java ci/Ci.java cleanup
|
||||||
- name: Retain diagnostics for fourteen days
|
- name: Retain diagnostics for fourteen days
|
||||||
if: always()
|
if: always()
|
||||||
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||||
|
|||||||
54
ci/Ci.java
Normal file
54
ci/Ci.java
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.*;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Run from the repository root with JDK 25: java ci/Ci.java <command>. */
|
||||||
|
public class Ci {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
try {
|
||||||
|
if (Runtime.version().feature() < 25) throw new IllegalArgumentException("JDK 25 or newer is required");
|
||||||
|
if (args.length == 0) throw new IllegalArgumentException(usage());
|
||||||
|
var env = new HashMap<>(System.getenv());
|
||||||
|
Path root = Path.of("").toAbsolutePath();
|
||||||
|
var commands = new Commands(Duration.ofMinutes(args[0].equals("publish") ? 15 : 75));
|
||||||
|
switch (args[0]) {
|
||||||
|
case "verify", "publish", "cleanup" -> {
|
||||||
|
var pipeline = new Pipeline(root, env, commands);
|
||||||
|
switch (args[0]) {
|
||||||
|
case "verify" -> pipeline.verify();
|
||||||
|
case "publish" -> pipeline.publish();
|
||||||
|
default -> pipeline.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.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 "test" -> CiTests.run();
|
||||||
|
default -> throw new IllegalArgumentException(usage());
|
||||||
|
}
|
||||||
|
} catch (Commands.Failure e) {
|
||||||
|
System.err.println(e.getMessage());
|
||||||
|
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.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]";
|
||||||
|
}
|
||||||
|
}
|
||||||
156
ci/CiTests.java
Normal file
156
ci/CiTests.java
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Dependency-free behavior tests: java ci/Ci.java test. */
|
||||||
|
class CiTests {
|
||||||
|
static final String SHA = "a".repeat(40);
|
||||||
|
static int count;
|
||||||
|
interface Test { void run() throws Exception; }
|
||||||
|
static void test(String name, Test test) throws Exception {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
static void failure(Test action) throws Exception {
|
||||||
|
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"));
|
||||||
|
});
|
||||||
|
test("JSON rejects malformed/ambiguous input", () -> {
|
||||||
|
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", () -> {
|
||||||
|
Path dir = Files.createTempDirectory("ci-docker-test-");
|
||||||
|
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); }
|
||||||
|
});
|
||||||
|
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); }
|
||||||
|
});
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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")); }
|
||||||
|
});
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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")); }
|
||||||
|
});
|
||||||
|
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")); }
|
||||||
|
});
|
||||||
|
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(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
|
||||||
|
equal(0L,f.calls("build")); equal(0L,f.calls("push"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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); } }");
|
||||||
|
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);
|
||||||
|
throw new AssertionError("Expected timeout");
|
||||||
|
} 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");
|
||||||
|
}
|
||||||
|
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<>();
|
||||||
|
String head = SHA, pushFailure = "", pullFailure = "";
|
||||||
|
boolean manifestError, mavenFailure;
|
||||||
|
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.getFirst().equals("docker")) return new Result(0,"");
|
||||||
|
String command = args.get(1), ref = args.getLast();
|
||||||
|
if (command.equals("info")) return new Result(0,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 (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 (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,"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
ci/Commands.java
Normal file
71
ci/Commands.java
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
|
/** Executes argument lists directly, without shell interpolation or credential logging. */
|
||||||
|
class Commands {
|
||||||
|
record Result(int code, String output) {
|
||||||
|
String checked() {
|
||||||
|
if (code != 0) throw new Failure(code, "Command failed with exit status " + code);
|
||||||
|
return output.strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static class Failure extends RuntimeException {
|
||||||
|
final int 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); }
|
||||||
|
|
||||||
|
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);
|
||||||
|
builder.environment().clear();
|
||||||
|
builder.environment().putAll(env);
|
||||||
|
Process process = builder.start();
|
||||||
|
active = process;
|
||||||
|
ByteArrayOutputStream captured = new ByteArrayOutputStream();
|
||||||
|
Path destination = log;
|
||||||
|
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)) {
|
||||||
|
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(); }
|
||||||
|
file.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
} 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)) {
|
||||||
|
stop(process);
|
||||||
|
throw new Failure(124, "CI time limit exceeded");
|
||||||
|
}
|
||||||
|
reader.get(10, TimeUnit.SECONDS);
|
||||||
|
return new Result(process.exitValue(), captured.toString(StandardCharsets.UTF_8));
|
||||||
|
} finally {
|
||||||
|
if (process.isAlive()) stop(process);
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void stop(Process process) {
|
||||||
|
process.descendants().forEach(ProcessHandle::destroyForcibly);
|
||||||
|
process.destroyForcibly();
|
||||||
|
}
|
||||||
|
}
|
||||||
102
ci/Json.java
Normal file
102
ci/Json.java
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Small dependency-free JSON codec for API/configuration objects, not application data. */
|
||||||
|
final class Json {
|
||||||
|
static Object parse(String source) {
|
||||||
|
Parser parser = new Parser(source);
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.append('"').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();
|
||||||
|
}
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
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; }
|
||||||
|
Object value() {
|
||||||
|
space(); if (pos >= source.length()) throw invalid();
|
||||||
|
char c = source.charAt(pos);
|
||||||
|
if (c == '"') return string();
|
||||||
|
if (take('{')) {
|
||||||
|
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();
|
||||||
|
out.put(key, value());
|
||||||
|
} while (take(','));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
for (String token : List.of("true", "false", "null")) {
|
||||||
|
if (source.startsWith(token, pos)) {
|
||||||
|
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);
|
||||||
|
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();
|
||||||
|
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 (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 'u' -> {
|
||||||
|
if (pos + 4 > source.length()) throw invalid();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
default -> throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
ci/Pipeline.java
Normal file
216
ci/Pipeline.java
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import java.io.*;
|
||||||
|
import java.nio.channels.*;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Verification, publication and cleanup shared by local use and Gitea Actions. */
|
||||||
|
class Pipeline {
|
||||||
|
final Path root, artifacts;
|
||||||
|
final Map<String,String> env;
|
||||||
|
final Commands commands;
|
||||||
|
final String commit, runName, backendImage, frontendImage;
|
||||||
|
Path isolatedConfig;
|
||||||
|
|
||||||
|
Pipeline(Path root, Map<String,String> environment, Commands commands) throws Exception {
|
||||||
|
this.root = root;
|
||||||
|
this.env = new HashMap<>(environment);
|
||||||
|
this.commands = commands;
|
||||||
|
commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD"));
|
||||||
|
String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1");
|
||||||
|
check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA");
|
||||||
|
check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier");
|
||||||
|
runName = "agiledancers-" + id + "-" + attempt;
|
||||||
|
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
|
||||||
|
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
|
||||||
|
artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts);
|
||||||
|
}
|
||||||
|
interface Value { String get() throws Exception; }
|
||||||
|
String value(String key, Value defaultValue) throws Exception {
|
||||||
|
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
|
||||||
|
return env.get(key);
|
||||||
|
}
|
||||||
|
static void check(boolean condition, String message) {
|
||||||
|
if (!condition) throw new IllegalArgumentException(message);
|
||||||
|
}
|
||||||
|
Commands.Result result(boolean echo, String... args) throws Exception {
|
||||||
|
return commands.execute(List.of(args), root, env, null, echo);
|
||||||
|
}
|
||||||
|
String capture(String... args) throws Exception { return result(false, args).checked(); }
|
||||||
|
void run(String... args) throws Exception { result(true, args).checked(); }
|
||||||
|
void requirePlatform() throws Exception {
|
||||||
|
String target = Setup.required(env,"TARGET_PLATFORM");
|
||||||
|
String arch = capture("docker", "info", "--format", "{{.Architecture}}");
|
||||||
|
arch = switch (arch) { case "x86_64" -> "amd64"; case "aarch64" -> "arm64"; default -> arch; };
|
||||||
|
String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch;
|
||||||
|
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
|
||||||
|
}
|
||||||
|
void isolateDocker() throws Exception {
|
||||||
|
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
|
||||||
|
env.remove("DOCKER_CONTEXT");
|
||||||
|
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
|
||||||
|
env.put("DOCKER_CONFIG", isolatedConfig.toString());
|
||||||
|
}
|
||||||
|
void verify() throws Exception {
|
||||||
|
Files.deleteIfExists(artifacts.resolve("verified-commit"));
|
||||||
|
env.put("CI", "true");
|
||||||
|
commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, "");
|
||||||
|
Thread shutdown = new Thread(() -> {
|
||||||
|
commands.cancel();
|
||||||
|
try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); }
|
||||||
|
}, "ci-cleanup");
|
||||||
|
Runtime.getRuntime().addShutdownHook(shutdown);
|
||||||
|
Exception failure = null;
|
||||||
|
try {
|
||||||
|
requirePlatform(); isolateDocker();
|
||||||
|
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
|
||||||
|
run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify");
|
||||||
|
CiTests.run();
|
||||||
|
Path frontend = root.resolve("agiledancers-fe");
|
||||||
|
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
|
||||||
|
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit",
|
||||||
|
"--output-file=unit-results/junit.xml"), frontend, env, null, true).checked();
|
||||||
|
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked();
|
||||||
|
String platform = env.get("TARGET_PLATFORM");
|
||||||
|
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
|
||||||
|
"org.opencontainers.image.revision=" + commit, "-t", backendImage, ".");
|
||||||
|
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
|
||||||
|
"org.opencontainers.image.revision=" + commit, "-t", frontendImage, "agiledancers-fe");
|
||||||
|
Files.writeString(artifacts.resolve("containers-started"), "");
|
||||||
|
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
|
||||||
|
run("docker", "run", "-d", "--name", runName + "-backend", "--label", "agiledancers.ci.run=" + runName,
|
||||||
|
"--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", backendImage);
|
||||||
|
run("docker", "run", "-d", "--name", runName + "-frontend", "--label", "agiledancers.ci.run=" + runName,
|
||||||
|
"--platform", platform, "--network", runName, "--network-alias", "frontend", "--memory", "256m", "--cpus", "1", frontendImage);
|
||||||
|
waitForApplication();
|
||||||
|
String user = capture("id", "-u") + ":" + capture("id", "-g");
|
||||||
|
run("docker", "run", "--name", runName + "-browser", "--label", "agiledancers.ci.run=" + runName,
|
||||||
|
"--platform", platform, "--network", runName, "--memory", "2g", "--cpus", "2", "--shm-size=1g",
|
||||||
|
"--user", user, "--env", "HOME=/tmp", "--env", "CI=true", "--env", "PLAYWRIGHT_BASE_URL=http://frontend:8080",
|
||||||
|
"--volume", frontend + ":/workspace", "--workdir", "/workspace", "mcr.microsoft.com/playwright:v1.64.1-noble",
|
||||||
|
"npm", "run", "e2e");
|
||||||
|
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
|
||||||
|
} catch (Exception e) { failure = e; throw e; }
|
||||||
|
finally {
|
||||||
|
try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; }
|
||||||
|
finally {
|
||||||
|
closeDockerConfig();
|
||||||
|
try { Runtime.getRuntime().removeShutdownHook(shutdown); } catch (IllegalStateException ignored) { /* shutting down */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void waitForApplication() throws Exception {
|
||||||
|
for (int i = 0; i < 60; i++) {
|
||||||
|
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-",
|
||||||
|
"http://localhost:8080/api/dance-schools").code() == 0) return;
|
||||||
|
Thread.sleep(2000);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Application readiness timed out");
|
||||||
|
}
|
||||||
|
synchronized void cleanup() throws Exception {
|
||||||
|
// A separate cleanup budget remains available after the build deadline.
|
||||||
|
commands.renew(Duration.ofMinutes(2));
|
||||||
|
List<String> failures = new ArrayList<>();
|
||||||
|
for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports",
|
||||||
|
"agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) {
|
||||||
|
if (Files.isDirectory(root.resolve(path))) {
|
||||||
|
try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result(false,"docker", "info").code() == 0) {
|
||||||
|
for (String role : List.of("backend", "frontend", "browser")) {
|
||||||
|
String container = runName + "-" + role;
|
||||||
|
if (result(false,"docker", "container", "inspect", container).code() == 0) {
|
||||||
|
var logs = result(false,"docker", "logs", container);
|
||||||
|
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
|
||||||
|
if (logs.code() != 0) failures.add(role + " logs");
|
||||||
|
if (result(false,"docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result(false,"docker", "network", "inspect", runName).code() == 0
|
||||||
|
&& result(false,"docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
|
||||||
|
if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started"));
|
||||||
|
} else if (Files.exists(artifacts.resolve("containers-started"))) failures.add("Docker unavailable during cleanup");
|
||||||
|
check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures));
|
||||||
|
}
|
||||||
|
void publish() throws Exception {
|
||||||
|
if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; }
|
||||||
|
check(Files.exists(artifacts.resolve("verified-commit"))
|
||||||
|
&& Files.readString(artifacts.resolve("verified-commit")).strip().equals(commit), "This commit has not passed verification");
|
||||||
|
String user = Setup.required(env,"REGISTRY_USER"), token = Setup.required(env,"REGISTRY_TOKEN");
|
||||||
|
String server = Setup.required(env,"GITEA_SERVER_URL");
|
||||||
|
String repository = Setup.required(env,"CI_REPOSITORY");
|
||||||
|
requirePlatform();
|
||||||
|
commands.log = artifacts.resolve("publish.log"); Files.writeString(commands.log, "");
|
||||||
|
var uri = java.net.URI.create(server);
|
||||||
|
check(Set.of("http", "https").contains(uri.getScheme()) && (uri.getPath().isEmpty() || uri.getPath().equals("/")),
|
||||||
|
"Registry must be hosted at the Gitea server root");
|
||||||
|
String registry = uri.getRawAuthority();
|
||||||
|
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
|
||||||
|
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
|
||||||
|
Path lockDir = Path.of(env.getOrDefault("CI_PUBLISH_LOCK_DIR",
|
||||||
|
env.getOrDefault("XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
|
||||||
|
Files.createDirectories(lockDir);
|
||||||
|
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
|
||||||
|
try (FileChannel channel = FileChannel.open(lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||||
|
FileLock lock = awaitLock(channel)) {
|
||||||
|
isolateDocker();
|
||||||
|
try {
|
||||||
|
commands.execute(List.of("docker", "login", registry, "--username", user, "--password-stdin"), root, env, token, true).checked();
|
||||||
|
String prefix = registry + "/" + owner + "/agiledancers-";
|
||||||
|
ensureImage(prefix + "backend", backendImage);
|
||||||
|
ensureImage(prefix + "frontend", frontendImage);
|
||||||
|
for (String role : List.of("backend", "frontend")) {
|
||||||
|
if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; }
|
||||||
|
String image = prefix + role;
|
||||||
|
run("docker", "tag", image + ":" + commit, image + ":main");
|
||||||
|
run("docker", "push", image + ":main");
|
||||||
|
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
|
||||||
|
}
|
||||||
|
} finally { closeDockerConfig(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileLock awaitLock(FileChannel channel) throws Exception {
|
||||||
|
Instant end = Instant.now().plusSeconds(600);
|
||||||
|
while (Instant.now().isBefore(end)) {
|
||||||
|
FileLock lock = channel.tryLock(); if (lock != null) return lock;
|
||||||
|
Thread.sleep(250);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Publication lock timed out");
|
||||||
|
}
|
||||||
|
String mainHead() throws Exception {
|
||||||
|
Map<String,Object> branch = Json.object(Setup.api(env.get("GITEA_SERVER_URL"),
|
||||||
|
env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null));
|
||||||
|
return Objects.toString(Json.object(branch.get("commit")).get("id"));
|
||||||
|
}
|
||||||
|
void ensureImage(String repository, String source) throws Exception {
|
||||||
|
String ref = repository + ":" + commit;
|
||||||
|
var manifest = result(false,"docker", "manifest", "inspect", "--insecure", ref);
|
||||||
|
if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
|
||||||
|
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
|
||||||
|
run("docker", "tag", source, ref); run("docker", "push", ref);
|
||||||
|
} else throw new IllegalArgumentException("Registry manifest lookup failed; authentication/network errors are not missing tags");
|
||||||
|
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref);
|
||||||
|
check(commit.equals(capture("docker", "image", "inspect", "--format", "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", ref)),
|
||||||
|
"Existing image has an unexpected source revision");
|
||||||
|
String digests = capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref);
|
||||||
|
check(digests.contains(repository + "@sha256:"), "Missing registry digest");
|
||||||
|
Files.writeString(artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), digests + "\n");
|
||||||
|
}
|
||||||
|
synchronized void closeDockerConfig() throws IOException {
|
||||||
|
if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; }
|
||||||
|
}
|
||||||
|
static void copyTree(Path source, Path destination) throws IOException {
|
||||||
|
try (var paths = Files.walk(source)) {
|
||||||
|
for (Path path : paths.toList()) {
|
||||||
|
Path target = destination.resolve(source.relativize(path));
|
||||||
|
if (Files.isDirectory(path)) Files.createDirectories(target);
|
||||||
|
else Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void deleteTree(Path root) throws IOException {
|
||||||
|
if (!Files.exists(root)) return;
|
||||||
|
try (var paths = Files.walk(root)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
146
ci/Setup.java
Normal file
146
ci/Setup.java
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import java.io.*;
|
||||||
|
import java.net.*;
|
||||||
|
import java.net.http.*;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.nio.file.attribute.*;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
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) {
|
||||||
|
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));
|
||||||
|
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body)))
|
||||||
|
.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());
|
||||||
|
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");
|
||||||
|
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));
|
||||||
|
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));
|
||||||
|
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<>();
|
||||||
|
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 (Files.exists(file)) {
|
||||||
|
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
|
||||||
|
if (!Files.exists(backup)) Files.copy(file,backup,StandardCopyOption.COPY_ATTRIBUTES);
|
||||||
|
}
|
||||||
|
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); }
|
||||||
|
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")),
|
||||||
|
"Installer targets the existing Linux x86_64 mini-PC");
|
||||||
|
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);
|
||||||
|
String javaHome = System.getProperty("java.home");
|
||||||
|
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,
|
||||||
|
"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();
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
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");
|
||||||
|
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-------"));
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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("\"","\\\"") + "\"";
|
||||||
|
}
|
||||||
|
static void privateDirectory(Path path) throws IOException {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
source "$(dirname -- "$0")/common.sh"
|
|
||||||
status=0
|
|
||||||
# Copy only test diagnostics, never environment dumps, Docker inspect or credentials.
|
|
||||||
for report in agiledancers-be/target/surefire-reports agiledancers-be/target/failsafe-reports \
|
|
||||||
agiledancers-fe/unit-results agiledancers-fe/test-results agiledancers-fe/playwright-report; do
|
|
||||||
if [[ -d "$report" ]]; then
|
|
||||||
destination="$ARTIFACTS/$report"
|
|
||||||
mkdir -p "$destination"
|
|
||||||
cp -R "$report/." "$destination/" || status=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if docker info >/dev/null 2>&1; then
|
|
||||||
for role in backend frontend browser; do
|
|
||||||
container="$RUN_NAME-$role"
|
|
||||||
if docker container inspect "$container" >/dev/null 2>&1; then
|
|
||||||
docker logs "$container" > "$ARTIFACTS/$role.log" 2>&1 || status=1
|
|
||||||
docker rm -f "$container" >/dev/null || status=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if docker network inspect "$RUN_NAME" >/dev/null 2>&1; then
|
|
||||||
docker network rm "$RUN_NAME" >/dev/null || status=1
|
|
||||||
fi
|
|
||||||
rm -f "$ARTIFACTS/containers-started"
|
|
||||||
elif [[ -f "$ARTIFACTS/containers-started" ]]; then
|
|
||||||
echo "Docker is unavailable; cleanup could not be confirmed" >&2
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
40
ci/common.sh
40
ci/common.sh
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Shared by host-runner scripts. No credentials are persisted in the workspace.
|
|
||||||
set -Eeuo pipefail
|
|
||||||
CI_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
|
||||||
cd "$CI_ROOT"
|
|
||||||
ARTIFACTS="$CI_ROOT/ci/artifacts"
|
|
||||||
mkdir -p "$ARTIFACTS"
|
|
||||||
: "${CI_COMMIT:=$(git rev-parse HEAD)}"
|
|
||||||
: "${CI_RUN_ID:=local}"
|
|
||||||
: "${CI_RUN_ATTEMPT:=1}"
|
|
||||||
[[ "$CI_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid commit SHA' >&2; exit 1; }
|
|
||||||
[[ "$CI_RUN_ID-$CI_RUN_ATTEMPT" =~ ^[a-zA-Z0-9-]+$ ]] || { echo 'Invalid run identifier' >&2; exit 1; }
|
|
||||||
RUN_NAME="agiledancers-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
|
|
||||||
BACKEND_IMAGE="agiledancers-ci-backend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
|
|
||||||
FRONTEND_IMAGE="agiledancers-ci-frontend:${CI_COMMIT}-${CI_RUN_ID}-${CI_RUN_ATTEMPT}"
|
|
||||||
|
|
||||||
require_platform() {
|
|
||||||
: "${TARGET_PLATFORM:?Set TARGET_PLATFORM to the verified Synology platform (for example linux/amd64)}"
|
|
||||||
local arch native
|
|
||||||
arch=$(docker info --format '{{.Architecture}}')
|
|
||||||
case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
|
|
||||||
native="$(docker info --format '{{.OSType}}')/$arch"
|
|
||||||
[[ "$TARGET_PLATFORM" == "$native" ]] || {
|
|
||||||
echo "Target $TARGET_PLATFORM differs from Docker host $native. Resolve and test the architecture before running CI." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Anonymous build pulls must not invoke the developer's desktop credential helper.
|
|
||||||
# Publication uses the same isolation and logs in only to our registry.
|
|
||||||
isolate_docker_config() {
|
|
||||||
if [[ -z "${DOCKER_HOST:-}" ]]; then
|
|
||||||
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
|
|
||||||
export DOCKER_HOST
|
|
||||||
fi
|
|
||||||
unset DOCKER_CONTEXT
|
|
||||||
CI_DOCKER_CONFIG=$(mktemp -d)
|
|
||||||
DOCKER_CONFIG=$CI_DOCKER_CONFIG
|
|
||||||
export DOCKER_CONFIG
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Configure repository Actions through the Gitea API; read tokens from local files."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BASE = 'http://192.168.2.28:3000/api/v1/repos/wanjakrah/agiledancers'
|
|
||||||
|
|
||||||
def read_secret(variable):
|
|
||||||
path = Path(os.environ[variable]).expanduser()
|
|
||||||
if path.stat().st_mode & 0o077:
|
|
||||||
raise ValueError(f'{variable} must refer to a file readable only by its owner (chmod 600)')
|
|
||||||
value = path.read_text().strip()
|
|
||||||
if not value:
|
|
||||||
raise ValueError(f'{variable} is empty')
|
|
||||||
return value
|
|
||||||
|
|
||||||
def main():
|
|
||||||
admin = read_secret('GITEA_ADMIN_TOKEN_FILE')
|
|
||||||
registry = read_secret('REGISTRY_TOKEN_FILE')
|
|
||||||
user = os.environ['REGISTRY_USER']
|
|
||||||
platform = os.environ['TARGET_PLATFORM']
|
|
||||||
if platform not in ('linux/amd64', 'linux/arm64'):
|
|
||||||
raise ValueError('Set TARGET_PLATFORM from the verified Synology architecture')
|
|
||||||
|
|
||||||
def api(method, path, body):
|
|
||||||
request = urllib.request.Request(BASE + path, method=method,
|
|
||||||
data=json.dumps(body).encode() if body is not None else None, headers={
|
|
||||||
'Authorization': 'token ' + admin, 'Content-Type': 'application/json'})
|
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
|
||||||
data = response.read()
|
|
||||||
return json.loads(data) if data else None
|
|
||||||
|
|
||||||
api('PATCH', '', {'has_actions': True})
|
|
||||||
for name, value in [('REGISTRY_USER', user), ('REGISTRY_TOKEN', registry)]:
|
|
||||||
api('PUT', '/actions/secrets/' + name, {'data': value})
|
|
||||||
try:
|
|
||||||
api('GET', '/actions/variables/TARGET_PLATFORM', None)
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
if error.code != 404:
|
|
||||||
raise
|
|
||||||
api('POST', '/actions/variables/TARGET_PLATFORM', {'value': platform})
|
|
||||||
else:
|
|
||||||
api('PUT', '/actions/variables/TARGET_PLATFORM', {'value': platform})
|
|
||||||
# Store registration credentials outside Git; never print API responses/tokens.
|
|
||||||
result = api('POST', '/actions/runners/registration-token', None)
|
|
||||||
token_path = Path.home() / '.config/agiledancers-runner/registration-token'
|
|
||||||
token_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
fd = os.open(token_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
||||||
os.fchmod(fd, 0o600)
|
|
||||||
with os.fdopen(fd, 'w') as output:
|
|
||||||
output.write(result['token'])
|
|
||||||
print('Actions, registry secrets and target platform configured; runner token saved outside Git.')
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
print(f'Gitea configuration failed: HTTP {error.code}', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
except (OSError, ValueError, KeyError, urllib.error.URLError) as error:
|
|
||||||
# Do not include request bodies, headers, response bodies or token values.
|
|
||||||
print(f'Gitea configuration failed ({type(error).__name__}); check local files and connection.', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Read the current main head without placing an access token in arguments/logs."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
url = os.environ['GITEA_SERVER_URL'].rstrip('/') + '/api/v1/repos/' + os.environ['CI_REPOSITORY'] + '/branches/main'
|
|
||||||
request = urllib.request.Request(url, headers={'Authorization': 'token ' + os.environ['REGISTRY_TOKEN']})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=30) as response:
|
|
||||||
print(json.load(response)['commit']['id'])
|
|
||||||
except (urllib.error.URLError, KeyError, ValueError) as error:
|
|
||||||
print(f'Cannot verify current main head ({type(error).__name__})', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
source "$(dirname -- "$0")/common.sh"
|
|
||||||
# Also enforce this inside the script, independently of the workflow expression.
|
|
||||||
[[ "${CI_REF:-}" == refs/heads/main ]] || { echo 'Branch is not main; no publication'; exit 0; }
|
|
||||||
[[ -f "$ARTIFACTS/verified-commit" && "$(cat "$ARTIFACTS/verified-commit")" == "$CI_COMMIT" ]] || {
|
|
||||||
echo 'This commit has not passed all verification steps' >&2; exit 1;
|
|
||||||
}
|
|
||||||
: "${REGISTRY_USER:?Missing REGISTRY_USER secret}"
|
|
||||||
: "${REGISTRY_TOKEN:?Missing REGISTRY_TOKEN secret}"
|
|
||||||
: "${GITEA_SERVER_URL:?Missing Gitea server URL}"
|
|
||||||
: "${CI_REPOSITORY:?Missing repository}"
|
|
||||||
require_platform
|
|
||||||
exec > >(tee "$ARTIFACTS/publish.log") 2>&1
|
|
||||||
REGISTRY=${GITEA_SERVER_URL#http://}
|
|
||||||
REGISTRY=${REGISTRY#https://}
|
|
||||||
REGISTRY=${REGISTRY%/}
|
|
||||||
[[ "$REGISTRY" != */* ]] || { echo 'Registry must be hosted at the Gitea server root'; exit 1; }
|
|
||||||
OWNER=${CI_REPOSITORY%%/*}
|
|
||||||
OWNER=${OWNER,,}
|
|
||||||
[[ "$OWNER" =~ ^[a-z0-9][a-z0-9_.-]*$ ]] || { echo 'Invalid registry namespace'; exit 1; }
|
|
||||||
# Gitea 1.25 ignores workflow concurrency. All publications on the sole runner
|
|
||||||
# share this lock, outside checkout directories and across workflow attempts.
|
|
||||||
lock_dir=${CI_PUBLISH_LOCK_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/agiledancers-ci}
|
|
||||||
mkdir -p "$lock_dir"
|
|
||||||
exec 9>"$lock_dir/publish.lock"
|
|
||||||
flock -w 600 9
|
|
||||||
# Preserve the selected daemon and isolate credentials from the developer's config.
|
|
||||||
isolate_docker_config
|
|
||||||
trap 'rm -rf -- "$DOCKER_CONFIG"' EXIT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
trap 'exit 130' INT
|
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin
|
|
||||||
|
|
||||||
ensure_commit_image() {
|
|
||||||
local role=$1 source=$2 repository="$REGISTRY/$OWNER/agiledancers-$1" ref error
|
|
||||||
ref="$repository:$CI_COMMIT"
|
|
||||||
error=$(mktemp)
|
|
||||||
if docker manifest inspect --insecure "$ref" > /dev/null 2>"$error"; then
|
|
||||||
echo "Preserving existing $ref"
|
|
||||||
elif grep -Eqi 'manifest unknown|no such manifest' "$error"; then
|
|
||||||
docker tag "$source" "$ref"
|
|
||||||
docker push "$ref"
|
|
||||||
else
|
|
||||||
# Authentication/network/server errors are never treated as missing tags.
|
|
||||||
cat "$error" >&2
|
|
||||||
rm -f "$error"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
rm -f "$error"
|
|
||||||
docker pull --platform "$TARGET_PLATFORM" "$ref"
|
|
||||||
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$ref")" == "$CI_COMMIT" ]] || {
|
|
||||||
echo "Existing $role image has an unexpected source revision" >&2; return 1;
|
|
||||||
}
|
|
||||||
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$ref" \
|
|
||||||
> "$ARTIFACTS/$role-digests.txt"
|
|
||||||
grep -Fq "$repository@sha256:" "$ARTIFACTS/$role-digests.txt" || { echo 'Missing registry digest'; return 1; }
|
|
||||||
}
|
|
||||||
ensure_commit_image backend "$BACKEND_IMAGE"
|
|
||||||
ensure_commit_image frontend "$FRONTEND_IMAGE"
|
|
||||||
# Both immutable images are now pull-verified. Only current main may move aliases.
|
|
||||||
head=$(python3 ci/main-head.py)
|
|
||||||
if [[ "$head" != "$CI_COMMIT" ]]; then
|
|
||||||
echo 'A newer main commit exists; leaving moving tags unchanged.'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
for role in backend frontend; do
|
|
||||||
repository="$REGISTRY/$OWNER/agiledancers-$role"
|
|
||||||
# Recheck under the publication lock before each moving-tag write.
|
|
||||||
[[ "$(python3 ci/main-head.py)" == "$CI_COMMIT" ]] || {
|
|
||||||
echo 'Main advanced during publication; leaving remaining moving tags unchanged.'
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
docker tag "$repository:$CI_COMMIT" "$repository:main"
|
|
||||||
docker push "$repository:main"
|
|
||||||
docker pull --platform "$TARGET_PLATFORM" "$repository:main"
|
|
||||||
done
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Merge this project's HTTP registry into a selected daemon JSON file."""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
sys.exit('Usage: python3 ci/runner/configure-docker.py <daemon.json>')
|
|
||||||
path = Path(sys.argv[1]).expanduser()
|
|
||||||
data = json.loads(path.read_text()) if path.exists() else {}
|
|
||||||
registries = data.setdefault('insecure-registries', [])
|
|
||||||
if not isinstance(registries, list):
|
|
||||||
sys.exit('Existing insecure-registries must be an array; configuration left unchanged')
|
|
||||||
if '192.168.2.28:3000' in registries:
|
|
||||||
print('Internal registry already configured; no change')
|
|
||||||
sys.exit(0)
|
|
||||||
registries.append('192.168.2.28:3000')
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if path.exists():
|
|
||||||
backup = path.with_name(path.name + '.before-agiledancers')
|
|
||||||
if not backup.exists():
|
|
||||||
shutil.copy2(path, backup)
|
|
||||||
# Write in the same directory, then replace atomically.
|
|
||||||
temporary = path.with_name(path.name + '.agiledancers.tmp')
|
|
||||||
with temporary.open('x') as output:
|
|
||||||
json.dump(data, output, indent=2)
|
|
||||||
output.write('\n')
|
|
||||||
if path.exists():
|
|
||||||
shutil.copymode(path, temporary)
|
|
||||||
temporary.replace(path)
|
|
||||||
print('Internal registry added; restart/apply Docker Engine configuration to activate')
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Run as the development user, not root. Configuration stays outside the checkout.
|
|
||||||
set -Eeuo pipefail
|
|
||||||
script_dir=$(cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
runner_dir="$HOME/.local/share/agiledancers-runner"
|
|
||||||
config_dir="$HOME/.config/agiledancers-runner"
|
|
||||||
install -d -m 700 "$runner_dir" "$config_dir" "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
|
||||||
[[ "$(uname -s)/$(uname -m)" == Linux/x86_64 ]] || { echo 'This installer targets the existing Linux x86_64 mini-PC'; exit 1; }
|
|
||||||
for tool in node java mvn docker curl python3 flock timeout; do command -v "$tool" >/dev/null; done
|
|
||||||
: "${JAVA_HOME:?Set JAVA_HOME to your JDK 25 directory}"
|
|
||||||
"$JAVA_HOME/bin/java" -version 2>&1 | grep -Eq 'version "25[.\"]' || { echo 'JDK 25 required'; exit 1; }
|
|
||||||
node -e 'const [a,b,c]=process.versions.node.split(".").map(Number); if (!((a===22&&(b>22||(b===22&&c>=3)))||(a===24&&(b>15||(b===15&&c>=0)))||a>=26)) process.exit(1)'
|
|
||||||
if [[ ! -x "$HOME/.local/bin/act_runner" ]]; then
|
|
||||||
download_dir=$(mktemp -d)
|
|
||||||
trap 'rm -rf -- "$download_dir"' EXIT
|
|
||||||
curl --fail --location --retry 3 \
|
|
||||||
https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64 -o "$download_dir/act_runner"
|
|
||||||
printf '%s %s\n' 3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2 "$download_dir/act_runner" | sha256sum --check
|
|
||||||
install -m 755 "$download_dir/act_runner" "$HOME/.local/bin/act_runner"
|
|
||||||
fi
|
|
||||||
"$HOME/.local/bin/act_runner" --version | grep -F 'v0.2.13'
|
|
||||||
install -m 600 "$script_dir/config.yaml" "$runner_dir/config.yaml"
|
|
||||||
install -m 644 "$script_dir/agiledancers-runner.service" "$HOME/.config/systemd/user/agiledancers-runner.service"
|
|
||||||
# Resolve paths now: user services do not inherit an interactive shell's PATH/context.
|
|
||||||
node_dir=$(dirname -- "$(command -v node)")
|
|
||||||
maven_dir=$(dirname -- "$(command -v mvn)")
|
|
||||||
docker_host=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}
|
|
||||||
umask 077
|
|
||||||
printf 'JAVA_HOME=%s\nPATH=%s/bin:%s:%s:/usr/local/bin:/usr/bin:/bin\nDOCKER_HOST=%s\n' \
|
|
||||||
"$JAVA_HOME" "$JAVA_HOME" "$node_dir" "$maven_dir" "$docker_host" > "$config_dir/environment"
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
if [[ "${1:-}" == --prepare-only ]]; then
|
|
||||||
echo 'Runner installed; registration and startup still required.'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
docker info >/dev/null
|
|
||||||
if [[ ! -f "$runner_dir/.runner" ]]; then
|
|
||||||
token_file=${RUNNER_REGISTRATION_TOKEN_FILE:-$config_dir/registration-token}
|
|
||||||
[[ -f "$token_file" ]] || { echo "Place the repository runner registration token in $token_file (mode 600), then rerun."; exit 1; }
|
|
||||||
registration_token=$(cat "$token_file")
|
|
||||||
[[ -n "$registration_token" ]] || { echo 'Empty registration token'; exit 1; }
|
|
||||||
(
|
|
||||||
cd "$runner_dir"
|
|
||||||
"$HOME/.local/bin/act_runner" register --no-interactive \
|
|
||||||
--instance http://192.168.2.28:3000 --name agiledancers-mini-pc \
|
|
||||||
--labels agiledancers-mini-pc:host --token "$registration_token"
|
|
||||||
chmod 600 .runner
|
|
||||||
)
|
|
||||||
unset registration_token
|
|
||||||
fi
|
|
||||||
systemctl --user enable --now agiledancers-runner.service
|
|
||||||
echo 'Runner registered and started. The mini-PC and Docker must remain available.'
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
SCRIPT = Path(__file__).resolve().parents[1] / 'runner/configure-docker.py'
|
|
||||||
|
|
||||||
class DockerConfigurationTest(unittest.TestCase):
|
|
||||||
def test_merge_preserves_existing_settings_and_is_idempotent(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
path = Path(directory) / 'daemon.json'
|
|
||||||
original = {'features': {'buildkit': True}, 'insecure-registries': ['existing:5000']}
|
|
||||||
path.write_text(json.dumps(original))
|
|
||||||
for _ in range(2):
|
|
||||||
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
actual = json.loads(path.read_text())
|
|
||||||
self.assertEqual(original['features'], actual['features'])
|
|
||||||
self.assertEqual(['existing:5000', '192.168.2.28:3000'], actual['insecure-registries'])
|
|
||||||
self.assertEqual(original, json.loads(path.with_name('daemon.json.before-agiledancers').read_text()))
|
|
||||||
def test_invalid_configuration_is_left_unchanged(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
path = Path(directory) / 'daemon.json'
|
|
||||||
original = '{"insecure-registries": "invalid"}'
|
|
||||||
path.write_text(original)
|
|
||||||
result = subprocess.run(['python3', str(SCRIPT), str(path)], capture_output=True, text=True)
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual(original, path.read_text())
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""Exercise real shell control flow against a stateful registry/daemon double."""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
SHA = 'a' * 40
|
|
||||||
REGISTRY = '192.168.2.28:3000/wanjakrah/agiledancers-'
|
|
||||||
|
|
||||||
DOCKER = r'''#!/usr/bin/env python3
|
|
||||||
import json, os, pathlib, sys
|
|
||||||
p = pathlib.Path(os.environ['FAKE_STATE'])
|
|
||||||
s = json.loads(p.read_text())
|
|
||||||
a = sys.argv[1:]
|
|
||||||
s['calls'].append(a)
|
|
||||||
p.write_text(json.dumps(s))
|
|
||||||
if a[0] == 'info':
|
|
||||||
print('amd64' if 'Architecture' in ' '.join(a) else 'linux')
|
|
||||||
elif a[0] == 'context': print('unix:///tmp/test-docker.sock')
|
|
||||||
elif a[0] == 'login': sys.stdin.read()
|
|
||||||
elif a[:2] == ['manifest', 'inspect']:
|
|
||||||
if os.environ.get('FAKE_MANIFEST_ERROR'):
|
|
||||||
print('unauthorized: authentication required', file=sys.stderr); sys.exit(1)
|
|
||||||
if a[-1] not in s['images']:
|
|
||||||
print('manifest unknown', file=sys.stderr); sys.exit(1)
|
|
||||||
elif a[0] == 'push':
|
|
||||||
if os.environ.get('FAKE_PUSH_FAILURE', '') in a[-1] and os.environ.get('FAKE_PUSH_FAILURE'):
|
|
||||||
sys.exit(1)
|
|
||||||
s['images'][a[-1]] = os.environ['CI_COMMIT']; p.write_text(json.dumps(s))
|
|
||||||
elif a[0] == 'pull':
|
|
||||||
if os.environ.get('FAKE_PULL_FAILURE', '') in a[-1] and os.environ.get('FAKE_PULL_FAILURE'):
|
|
||||||
sys.exit(1)
|
|
||||||
elif a[:2] == ['image', 'inspect']:
|
|
||||||
if 'RepoDigests' in ' '.join(a): print(a[-1].rsplit(':', 1)[0] + '@sha256:' + 'b' * 64)
|
|
||||||
else: print(s['images'].get(a[-1], 'missing'))
|
|
||||||
elif a[:2] == ['container', 'inspect'] or a[:2] == ['network', 'inspect']: sys.exit(1)
|
|
||||||
'''
|
|
||||||
|
|
||||||
class PublicationTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.temp.cleanup)
|
|
||||||
self.root = Path(self.temp.name)
|
|
||||||
shutil.copytree(ROOT / 'ci', self.root / 'ci', ignore=shutil.ignore_patterns('artifacts', '__pycache__'))
|
|
||||||
(self.root / 'ci/main-head.py').write_text("import os; print(os.environ.get('FAKE_MAIN_HEAD', os.environ['CI_COMMIT']))\n")
|
|
||||||
self.bin = self.root / 'bin'; self.bin.mkdir()
|
|
||||||
(self.bin / 'docker').write_text(DOCKER); (self.bin / 'docker').chmod(0o755)
|
|
||||||
self.state = self.root / 'state.json'
|
|
||||||
self.state.write_text(json.dumps({'images': {}, 'calls': []}))
|
|
||||||
artifacts = self.root / 'ci/artifacts'; artifacts.mkdir()
|
|
||||||
(artifacts / 'verified-commit').write_text(SHA + '\n')
|
|
||||||
self.env = dict(os.environ, PATH=str(self.bin) + ':' + os.environ['PATH'],
|
|
||||||
CI_COMMIT=SHA, CI_REF='refs/heads/main', CI_REPOSITORY='wanjakrah/agiledancers',
|
|
||||||
CI_RUN_ID='test', CI_RUN_ATTEMPT='1', TARGET_PLATFORM='linux/amd64',
|
|
||||||
GITEA_SERVER_URL='http://192.168.2.28:3000', REGISTRY_USER='test', REGISTRY_TOKEN='not-a-secret',
|
|
||||||
CI_PUBLISH_LOCK_DIR=str(self.root / 'lock'), FAKE_STATE=str(self.state))
|
|
||||||
def run_script(self, script='publish.sh', **extra):
|
|
||||||
return subprocess.run(['bash', str(self.root / 'ci' / script)], cwd=self.root,
|
|
||||||
env=dict(self.env, **extra), capture_output=True, text=True, timeout=20)
|
|
||||||
def calls(self, command):
|
|
||||||
return [a for a in json.loads(self.state.read_text())['calls'] if a[0] == command]
|
|
||||||
def test_feature_branch_does_not_contact_registry(self):
|
|
||||||
result = self.run_script(CI_REF='refs/heads/feature/test')
|
|
||||||
self.assertEqual(0, result.returncode, result.stderr)
|
|
||||||
self.assertEqual([], self.calls('login'))
|
|
||||||
self.assertEqual([], self.calls('push'))
|
|
||||||
def test_unverified_commit_cannot_publish(self):
|
|
||||||
(self.root / 'ci/artifacts/verified-commit').unlink()
|
|
||||||
self.assertNotEqual(0, self.run_script().returncode)
|
|
||||||
self.assertEqual([], self.calls('push'))
|
|
||||||
def test_publish_pull_and_rerun_preserves_commit_tags(self):
|
|
||||||
result = self.run_script()
|
|
||||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
|
||||||
self.assertEqual(4, len(self.calls('push')))
|
|
||||||
result = self.run_script()
|
|
||||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
|
||||||
self.assertEqual(6, len(self.calls('push'))) # only main aliases on rerun
|
|
||||||
self.assertEqual(8, len(self.calls('pull')))
|
|
||||||
def test_partial_publication_is_completed_without_overwriting_backend(self):
|
|
||||||
self.assertNotEqual(0, self.run_script(FAKE_PUSH_FAILURE='frontend:' + SHA).returncode)
|
|
||||||
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
|
|
||||||
result = self.run_script()
|
|
||||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
|
||||||
backend_pushes = [a for a in self.calls('push') if a[-1] == REGISTRY + 'backend:' + SHA]
|
|
||||||
self.assertEqual(1, len(backend_pushes))
|
|
||||||
def test_registry_auth_failure_is_not_treated_as_missing_image(self):
|
|
||||||
self.assertNotEqual(0, self.run_script(FAKE_MANIFEST_ERROR='1').returncode)
|
|
||||||
self.assertEqual([], self.calls('push'))
|
|
||||||
def test_pull_failure_blocks_main_aliases(self):
|
|
||||||
self.assertNotEqual(0, self.run_script(FAKE_PULL_FAILURE='backend:' + SHA).returncode)
|
|
||||||
self.assertFalse(any(a[-1].endswith(':main') for a in self.calls('push')))
|
|
||||||
def test_old_commit_does_not_move_main(self):
|
|
||||||
result = self.run_script(FAKE_MAIN_HEAD='c' * 40)
|
|
||||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
|
||||||
self.assertEqual(2, len(self.calls('push')))
|
|
||||||
self.assertTrue(all(a[-1].endswith(':' + SHA) for a in self.calls('push')))
|
|
||||||
def test_architecture_mismatch_fails_before_publication(self):
|
|
||||||
self.assertNotEqual(0, self.run_script(TARGET_PLATFORM='linux/arm64').returncode)
|
|
||||||
self.assertEqual([], self.calls('push'))
|
|
||||||
def test_failed_verification_removes_stale_success_marker_and_never_builds_images(self):
|
|
||||||
for command, content in [('java', 'exit 0'), ('node', 'exit 0'), ('mvn', 'exit 42'),
|
|
||||||
('git', 'echo ' + SHA)]:
|
|
||||||
path = self.bin / command; path.write_text('#!/bin/sh\n' + content + '\n'); path.chmod(0o755)
|
|
||||||
result = self.run_script('verify.sh')
|
|
||||||
self.assertEqual(42, result.returncode, result.stdout + result.stderr)
|
|
||||||
self.assertFalse((self.root / 'ci/artifacts/verified-commit').exists())
|
|
||||||
self.assertEqual([], self.calls('build'))
|
|
||||||
self.assertEqual([], self.calls('push'))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
58
ci/verify.sh
58
ci/verify.sh
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
source "$(dirname -- "$0")/common.sh"
|
|
||||||
export CI=true
|
|
||||||
# Start fresh: an earlier attempt's success marker must never authorize publishing.
|
|
||||||
rm -f "$ARTIFACTS/verified-commit"
|
|
||||||
exec > >(tee "$ARTIFACTS/verify.log") 2>&1
|
|
||||||
finish() {
|
|
||||||
local status=$?
|
|
||||||
trap - EXIT
|
|
||||||
bash "$CI_ROOT/ci/cleanup.sh" || { (( status != 0 )) || status=1; }
|
|
||||||
if [[ -n "${CI_DOCKER_CONFIG:-}" ]]; then rm -rf -- "$CI_DOCKER_CONFIG"; fi
|
|
||||||
exit "$status"
|
|
||||||
}
|
|
||||||
trap finish EXIT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
trap 'exit 130' INT
|
|
||||||
require_platform
|
|
||||||
isolate_docker_config
|
|
||||||
[[ "$(git rev-parse HEAD)" == "$CI_COMMIT" ]] || { echo 'Checkout does not match pushed commit'; exit 1; }
|
|
||||||
java -version
|
|
||||||
node --version
|
|
||||||
mvn -B -ntp clean verify
|
|
||||||
python3 -m unittest discover -s ci/tests -v
|
|
||||||
(
|
|
||||||
cd agiledancers-fe
|
|
||||||
npm ci
|
|
||||||
npm test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml
|
|
||||||
npm run build
|
|
||||||
)
|
|
||||||
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
|
|
||||||
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$BACKEND_IMAGE" .
|
|
||||||
docker build --platform "$TARGET_PLATFORM" --target prebuilt \
|
|
||||||
--label "org.opencontainers.image.revision=$CI_COMMIT" -t "$FRONTEND_IMAGE" agiledancers-fe
|
|
||||||
|
|
||||||
touch "$ARTIFACTS/containers-started"
|
|
||||||
docker network create --label "agiledancers.ci.run=$RUN_NAME" "$RUN_NAME"
|
|
||||||
docker run -d --name "$RUN_NAME-backend" --label "agiledancers.ci.run=$RUN_NAME" \
|
|
||||||
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias backend \
|
|
||||||
--memory 1g --cpus 2 "$BACKEND_IMAGE"
|
|
||||||
docker run -d --name "$RUN_NAME-frontend" --label "agiledancers.ci.run=$RUN_NAME" \
|
|
||||||
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --network-alias frontend \
|
|
||||||
--memory 256m --cpus 1 "$FRONTEND_IMAGE"
|
|
||||||
# The browser, tests, readiness checks and API cleanup all use the frontend proxy.
|
|
||||||
# Install this test container's browser dependencies for the exact lockfile version.
|
|
||||||
docker run --name "$RUN_NAME-browser" --label "agiledancers.ci.run=$RUN_NAME" \
|
|
||||||
--platform "$TARGET_PLATFORM" --network "$RUN_NAME" --memory 2g --cpus 2 --shm-size=1g \
|
|
||||||
--user "$(id -u):$(id -g)" --env HOME=/tmp --env CI=true \
|
|
||||||
--env PLAYWRIGHT_BASE_URL=http://frontend:8080 \
|
|
||||||
--volume "$CI_ROOT/agiledancers-fe:/workspace" --workdir /workspace \
|
|
||||||
mcr.microsoft.com/playwright:v1.64.1-noble bash -euc '
|
|
||||||
for attempt in $(seq 1 60); do
|
|
||||||
if curl --fail --silent http://frontend:8080/api/dance-schools >/dev/null; then break; fi
|
|
||||||
if [ "$attempt" = 60 ]; then echo "Application readiness timed out" >&2; exit 1; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
npm run e2e
|
|
||||||
'
|
|
||||||
printf '%s\n' "$CI_COMMIT" > "$ARTIFACTS/verified-commit"
|
|
||||||
@@ -5,6 +5,26 @@ backend en frontend, bouwt twee images en draait Playwright tegen die containers
|
|||||||
Alleen `main` mag daarna images publiceren. Er vindt geen deployment plaats.
|
Alleen `main` mag daarna images publiceren. Er vindt geen deployment plaats.
|
||||||
Tagpushes en aparte pull-requestevents starten geen extra workflow.
|
Tagpushes en aparte pull-requestevents starten geen extra workflow.
|
||||||
|
|
||||||
|
## Java-bronbestanden uitvoeren
|
||||||
|
|
||||||
|
De CI- en setuphulpmiddelen zijn Java 25-bronbestanden. Start ze direct met
|
||||||
|
`java ci/Ci.java <opdracht>` vanuit de repositoryroot. De Java-launcher compileert
|
||||||
|
de benodigde bronbestanden in het geheugen. Er is geen aparte `javac`-stap,
|
||||||
|
Maven-module, Python-installatie of scriptingframework nodig. De helper gebruikt
|
||||||
|
alleen de JDK, inclusief de HTTP-client, process builder en bestandsloten.
|
||||||
|
|
||||||
|
Beschikbare opdrachten: `verify`, `publish`, `cleanup`, `attempt`, `test`,
|
||||||
|
`configure-gitea`, `configure-docker <daemon.json>` en
|
||||||
|
`install-runner [--prepare-only]`. `verify` heeft een intern tijdsbudget van
|
||||||
|
75 minuten; `publish` 15 minuten. Opruimen krijgt een apart budget van 2 minuten.
|
||||||
|
|
||||||
|
Zorg dat `java` naar JDK 25 verwijst, bijvoorbeeld met:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
## Machines en voorwaarden
|
## Machines en voorwaarden
|
||||||
|
|
||||||
Gitea 1.25.4 en de registry staan op de bestaande Synology,
|
Gitea 1.25.4 en de registry staan op de bestaande Synology,
|
||||||
@@ -14,8 +34,8 @@ wakker blijven; Docker en de runner moeten draaien. Anders blijven jobs wachten.
|
|||||||
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
|
Een host-runner voert repositorycode uit als de ontwikkelgebruiker: gebruik deze
|
||||||
runner alleen voor deze vertrouwde repository.
|
runner alleen voor deze vertrouwde repository.
|
||||||
|
|
||||||
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm,
|
De runner heeft Git, Bash, JDK 25, Maven 3.9+, Node 22.22.3+/24.15.0+/26+, npm
|
||||||
Python 3, curl, flock (util-linux), timeout (coreutils) en een werkende Docker-daemon
|
en een werkende Docker-daemon
|
||||||
nodig. De huidige mini-pc heeft de ontwikkeltools en Docker Desktop al geïnstalleerd.
|
nodig. De huidige mini-pc heeft de ontwikkeltools en Docker Desktop al geïnstalleerd.
|
||||||
De workflow gebruikt één job tegelijk (`capacity: 1`), één Playwright-worker en
|
De workflow gebruikt één job tegelijk (`capacity: 1`), één Playwright-worker en
|
||||||
CPU/geheugenlimieten voor de tijdelijke applicatie- en browsercontainers.
|
CPU/geheugenlimieten voor de tijdelijke applicatie- en browsercontainers.
|
||||||
@@ -52,7 +72,7 @@ Je kunt de instelling ook samenvoegen met behoud van bestaande instellingen en
|
|||||||
backup via:
|
backup via:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 ci/runner/configure-docker.py "$HOME/.docker/daemon.json"
|
java ci/Ci.java configure-docker "$HOME/.docker/daemon.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
Voor Docker Engine op Linux is dat dezelfde toevoeging aan
|
Voor Docker Engine op Linux is dat dezelfde toevoeging aan
|
||||||
@@ -88,7 +108,7 @@ Het gewone jobtoken van Gitea 1.25 biedt geen packagepublicatie; gebruik een PAT
|
|||||||
Gebruik een repository-runnerregistratietoken, geen instancebrede registratie.
|
Gebruik een repository-runnerregistratietoken, geen instancebrede registratie.
|
||||||
Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs.
|
Sla tokenwaarden buiten Git op en plaats ze niet in opdrachten, screenshots of logs.
|
||||||
|
|
||||||
Voor reproduceerbare configuratie bestaat `ci/configure-gitea.py`. Maak twee
|
Voor reproduceerbare configuratie bestaat `java ci/Ci.java configure-gitea`. Maak twee
|
||||||
bestanden buiten de repository met mode `600`: één voor een PAT met voldoende
|
bestanden buiten de repository met mode `600`: één voor een PAT met voldoende
|
||||||
repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie),
|
repositorybeheerrechten (repository Actions, secrets, variabelen en runnerregistratie),
|
||||||
en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden
|
en één voor de publicatie-PAT. De beheer-PAT hoeft niet als workflowsecret te worden
|
||||||
@@ -100,7 +120,7 @@ export GITEA_ADMIN_TOKEN_FILE="$HOME/.config/agiledancers-runner/admin-token"
|
|||||||
export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token"
|
export REGISTRY_TOKEN_FILE="$HOME/.config/agiledancers-runner/registry-token"
|
||||||
export REGISTRY_USER=wanjakrah
|
export REGISTRY_USER=wanjakrah
|
||||||
export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld
|
export TARGET_PLATFORM=linux/amd64 # alleen als dit op de Synology is vastgesteld
|
||||||
python3 ci/configure-gitea.py
|
java ci/Ci.java configure-gitea
|
||||||
```
|
```
|
||||||
|
|
||||||
Het script stelt de repository-instellingen in via de API en schrijft het
|
Het script stelt de repository-instellingen in via de API en schrijft het
|
||||||
@@ -118,9 +138,9 @@ testdiagnostiek; gepubliceerde containerimages worden niet automatisch verwijder
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||||
bash ci/runner/install.sh --prepare-only
|
java ci/Ci.java install-runner --prepare-only
|
||||||
# Na Dockerconfiguratie en het opslaan van het registratietoken:
|
# Na Dockerconfiguratie en het opslaan van het registratietoken:
|
||||||
bash ci/runner/install.sh
|
java ci/Ci.java install-runner
|
||||||
systemctl --user status agiledancers-runner
|
systemctl --user status agiledancers-runner
|
||||||
journalctl --user -u agiledancers-runner -f
|
journalctl --user -u agiledancers-runner -f
|
||||||
```
|
```
|
||||||
@@ -168,8 +188,8 @@ De frontend luistert op containerpoort 8080, ondersteunt SPA-routes en proxy't
|
|||||||
8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline;
|
8080. Voorbeelden voor eventueel handmatig starten staan los van de pipeline;
|
||||||
de pipeline start geen blijvende applicatie op de NAS.
|
de pipeline start geen blijvende applicatie op de NAS.
|
||||||
|
|
||||||
`ci/verify.sh` maakt een succesmarker pas na alle builds en tests.
|
`java ci/Ci.java verify` maakt een succesmarker pas na alle builds en tests.
|
||||||
`ci/publish.sh` vereist die marker en hoofdbranchref. Onder een gedeeld `flock`
|
`java ci/Ci.java publish` vereist die marker en hoofdbranchref. Onder een gedeeld Java-bestandslot (`FileChannel`)
|
||||||
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
|
buiten de checkout worden bestaande commitimages hergebruikt en ontbrekende
|
||||||
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
|
images geüpload. Iedere image wordt teruggehaald, op revisielabel gecontroleerd en
|
||||||
met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen
|
met digest vastgelegd in de diagnostiek. Authenticatie- of netwerkfouten tellen
|
||||||
@@ -192,7 +212,7 @@ niet in; de workflow maakt daarom zelf een unieke identificatie per poging. Geen
|
|||||||
inspect met mogelijke credentials wordt opgenomen.
|
inspect met mogelijke credentials wordt opgenomen.
|
||||||
|
|
||||||
Bij mislukking blijven de oorspronkelijke exitstatus en rapporten behouden.
|
Bij mislukking blijven de oorspronkelijke exitstatus en rapporten behouden.
|
||||||
Een trap én een `always()`-stap verzamelen diagnostiek en verwijderen de tijdelijke
|
Een Java-`finally`/shutdown-hook én een `always()`-stap verzamelen diagnostiek en verwijderen de tijdelijke
|
||||||
containers en het netwerk met hun unieke runnaam. Een harde machine-uitval kan
|
containers en het netwerk met hun unieke runnaam. Een harde machine-uitval kan
|
||||||
opruimen onderbreken: controleer daarna uitsluitend achtergebleven resources met
|
opruimen onderbreken: controleer daarna uitsluitend achtergebleven resources met
|
||||||
label `agiledancers.ci.run`; gebruik geen algemene Docker-prune op de ontwikkel-PC.
|
label `agiledancers.ci.run`; gebruik geen algemene Docker-prune op de ontwikkel-PC.
|
||||||
@@ -206,7 +226,7 @@ blijven de committags bruikbaar en worden de oude `main`-updates overgeslagen.
|
|||||||
Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen:
|
Lokale controle van publicatie-/foutgedrag zonder registrywijzigingen:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m unittest discover -s ci/tests -v
|
java ci/Ci.java test
|
||||||
```
|
```
|
||||||
|
|
||||||
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
|
Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
|
||||||
@@ -214,7 +234,7 @@ Volledige lokale verificatie met Docker en de vastgestelde platformwaarde:
|
|||||||
```sh
|
```sh
|
||||||
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
export JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64
|
||||||
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
|
export TARGET_PLATFORM=linux/amd64 # eerst de Synology controleren
|
||||||
bash ci/verify.sh
|
java ci/Ci.java verify
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operationele oplevering
|
## Operationele oplevering
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# S4 verification evidence
|
# S4 verification evidence
|
||||||
|
|
||||||
Date: 2026-10-28. Status: repository implementation and local container verification
|
Date: 2026-10-28. Status: repository implementation and local container verification
|
||||||
passed; operational Gitea acceptance remains pending. S4 is **not complete**.
|
passed; Gitea and runner configuration are operational. Main publication is
|
||||||
|
awaiting approval to merge PR #1. S4 is **not complete**.
|
||||||
|
|
||||||
## Verified locally
|
## Verified locally
|
||||||
|
|
||||||
@@ -56,7 +57,8 @@ Local image IDs (not registry digests):
|
|||||||
`linux/amd64`; repository variable `TARGET_PLATFORM=linux/amd64` configured.
|
`linux/amd64`; repository variable `TARGET_PLATFORM=linux/amd64` configured.
|
||||||
- [x] Activate and verify the Docker Desktop HTTP-registry setting.
|
- [x] Activate and verify the Docker Desktop HTTP-registry setting.
|
||||||
- [x] Repository `REGISTRY_USER`/`REGISTRY_TOKEN` secrets configured using the
|
- [x] Repository `REGISTRY_USER`/`REGISTRY_TOKEN` secrets configured using the
|
||||||
supplied token files. Registry access is verified by actual publication below.
|
supplied token files. A temporary Docker login with the registry token
|
||||||
|
succeeded; actual publication still awaits a main-branch merge.
|
||||||
- [x] Runner ID 1 (`agiledancers-mini-pc`) registered, service started and
|
- [x] Runner ID 1 (`agiledancers-mini-pc`) registered, service started and
|
||||||
online in Gitea. Real feature/failure jobs ran on this runner.
|
online in Gitea. Real feature/failure jobs ran on this runner.
|
||||||
- [ ] Confirm server settings permit every branchpush (including messages with
|
- [ ] Confirm server settings permit every branchpush (including messages with
|
||||||
@@ -86,3 +88,30 @@ attempt identifier for containers and diagnostic artifact names. The Gitea v3
|
|||||||
artifact-list API returns an empty list despite successful uploads; artifacts
|
artifact-list API returns an empty list despite successful uploads; artifacts
|
||||||
were verified by downloading the run-page archive URLs. This is separate from
|
were verified by downloading the run-page archive URLs. This is separate from
|
||||||
artifact availability and does not require a server upgrade.
|
artifact availability and does not require a server upgrade.
|
||||||
|
|
||||||
|
|
||||||
|
## Ready for review; main merge not authorized
|
||||||
|
|
||||||
|
[PR #1](http://192.168.2.28:3000/wanjakrah/agiledancers/pulls/1) remains open at
|
||||||
|
the maintainer's explicit request. No main merge or application image publication
|
||||||
|
was performed.
|
||||||
|
|
||||||
|
[Feature run #3](http://192.168.2.28:3000/wanjakrah/agiledancers/actions/runs/3),
|
||||||
|
API run/job ID 4, commit `834584dae06b5976936a5e739871f1164cf81bcd`, succeeded.
|
||||||
|
The two container E2E tests passed and diagnostics were uploaded as
|
||||||
|
`diagnostics-4-20261028164607460012324`, confirming unique attempt names work.
|
||||||
|
|
||||||
|
The main branch currently contains only its initial commit. PR #1 therefore
|
||||||
|
includes the prior application history from `feature/setup` as well as S4. Its
|
||||||
|
scope is documented in the PR description. A main merge needs explicit approval;
|
||||||
|
only then can real main publication/pullback/rerun acceptance be completed.
|
||||||
|
|
||||||
|
|
||||||
|
## Java 25 helper migration
|
||||||
|
|
||||||
|
CI orchestration, setup and behavior tests now run through `java ci/Ci.java`.
|
||||||
|
The shell/Python helpers have been removed. The source launcher compiles helper
|
||||||
|
classes in memory without external dependencies or preview flags. Fourteen Java
|
||||||
|
behavior tests pass, including JSON/configuration preservation, publication gates,
|
||||||
|
partial recovery and process deadlines. A real feature run validates this revision
|
||||||
|
before updating the existing open PR; no merge to main is authorized.
|
||||||
|
|||||||
@@ -175,7 +175,12 @@ image builds and both Playwright tests against the containers passed. Eleven
|
|||||||
isolated CI publication/configuration tests pass. The runner binary and user-service
|
isolated CI publication/configuration tests pass. The runner binary and user-service
|
||||||
configuration are installed on the mini-PC.
|
configuration are installed on the mini-PC.
|
||||||
|
|
||||||
Operational acceptance is still pending: authenticated Synology architecture
|
The maintainer confirmed the Synology as `x86_64` / `linux/amd64`. Gitea secrets
|
||||||
verification, Gitea credentials/secrets, runner registration/startup and real
|
and the target-platform variable are configured; the runner is registered and
|
||||||
feature/failure/main pipeline runs with registry pullback evidence. The spec stays
|
online. Registry login succeeded. Real feature runs #1 and #3 passed, including
|
||||||
IN_PROGRESS until those requirements are met.
|
container E2E; deliberate failure run #2 blocked the build/publication path and
|
||||||
|
provided downloadable diagnostics.
|
||||||
|
|
||||||
|
PR #1 is open. The maintainer explicitly declined merging it for now. Main image
|
||||||
|
publication, pullback and rerun acceptance therefore remain pending. The spec
|
||||||
|
stays IN_PROGRESS until those requirements are met.
|
||||||
|
|||||||
Reference in New Issue
Block a user