S19: establish engineering repository
This commit is contained in:
62
resources/ci/Ci.java
Normal file
62
resources/ci/Ci.java
Normal file
@@ -0,0 +1,62 @@
|
||||
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());
|
||||
Path root = Path.of("").toAbsolutePath();
|
||||
var env = Recipe.environment(root, System.getenv());
|
||||
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.CREATE, StandardOpenOption.APPEND);
|
||||
}
|
||||
case "configure-gitea" -> Setup.configureGitea(env);
|
||||
case "configure-docker" -> {
|
||||
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
|
||||
Setup.configureDocker(Path.of(args[1]), Setup.required(env, "INSECURE_REGISTRY"));
|
||||
}
|
||||
case "install-runner" ->
|
||||
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
|
||||
case "test" -> CiTests.run();
|
||||
case "deploy", "rollback" -> Release.execute(root, env, args[0]);
|
||||
case "release-test" -> ReleaseTests.run();
|
||||
case "configure-deployment" -> ReleaseSetup.configure(root, env);
|
||||
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]|deploy|rollback|release-test|configure-deployment";
|
||||
}
|
||||
}
|
||||
358
resources/ci/CiTests.java
Normal file
358
resources/ci/CiTests.java
Normal file
@@ -0,0 +1,358 @@
|
||||
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("recipe supports backend-only and frontend-only projects", () -> {
|
||||
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
|
||||
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
|
||||
failure(() -> Recipe.roles(Map.of()));
|
||||
failure(() -> Recipe.project(Map.of("PROJECT_NAME", "../unsafe")));
|
||||
});
|
||||
test("project configuration is overridden by environment and rejects escaping modules", () -> {
|
||||
final Path root = Files.createTempDirectory("recipe-config-");
|
||||
try {
|
||||
Files.writeString(
|
||||
root.resolve("engineering.properties"), "PROJECT_NAME=sample\nBACKEND_MODULE=server\n");
|
||||
equal(
|
||||
"override",
|
||||
Recipe.environment(root, Map.of("PROJECT_NAME", "override"))
|
||||
.get("PROJECT_NAME"));
|
||||
failure(() -> Recipe.environment(root, Map.of("BACKEND_MODULE", "../outside")));
|
||||
} finally {
|
||||
Pipeline.deleteTree(root);
|
||||
}
|
||||
});
|
||||
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, "registry.example:3000");
|
||||
String first = Files.readString(file);
|
||||
Setup.configureDocker(file, "registry.example:3000");
|
||||
equal(first, Files.readString(file));
|
||||
equal(
|
||||
List.of("existing:5000", "registry.example:3000"),
|
||||
Json.object(Json.parse(first)).get("insecure-registries"));
|
||||
equal(original, Files.readString(dir.resolve("daemon.json.before-engineering")));
|
||||
} 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, "registry.example:3000"));
|
||||
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("Unavailable Docker fails before build with a concrete cause", () -> {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.fake.dockerAvailable = false;
|
||||
try {
|
||||
f.pipeline.verify();
|
||||
throw new AssertionError("Expected failure");
|
||||
} catch (IllegalArgumentException error) {
|
||||
equal(true, error.getMessage().contains("Docker is unavailable"));
|
||||
}
|
||||
equal(
|
||||
0L,
|
||||
f.fake.calls.stream()
|
||||
.filter(a -> a.getFirst().equals("mvn"))
|
||||
.count());
|
||||
}
|
||||
});
|
||||
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(
|
||||
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
|
||||
f.fake.calls.stream()
|
||||
.filter(a -> a.getFirst().equals("mvn"))
|
||||
.findFirst()
|
||||
.orElseThrow());
|
||||
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
|
||||
equal(0L, f.calls("build"));
|
||||
equal(0L, f.calls("push"));
|
||||
}
|
||||
});
|
||||
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.fake.lintFailure = true;
|
||||
try {
|
||||
f.pipeline.verifyFrontend();
|
||||
throw new AssertionError("Expected failure");
|
||||
} catch (Commands.Failure error) {
|
||||
equal(1, error.exitCode);
|
||||
}
|
||||
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
|
||||
}
|
||||
});
|
||||
test("Frontend verification checks before testing and building", () -> {
|
||||
try (Fixture f = new Fixture()) {
|
||||
f.pipeline.verifyFrontend();
|
||||
equal(
|
||||
List.of(
|
||||
"ci",
|
||||
"run lint",
|
||||
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
|
||||
"run build"),
|
||||
f.fake.calls.stream()
|
||||
.map(a -> String.join(" ", a.subList(1, a.size())))
|
||||
.toList());
|
||||
}
|
||||
});
|
||||
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",
|
||||
"owner/recipe-demo",
|
||||
"TARGET_PLATFORM",
|
||||
"linux/amd64",
|
||||
"GITEA_SERVER_URL",
|
||||
"http://registry.example:3000",
|
||||
"REGISTRY_USER",
|
||||
"test",
|
||||
"REGISTRY_TOKEN",
|
||||
"dummy-token",
|
||||
"CI_PUBLISH_LOCK_DIR",
|
||||
root.resolve("lock").toString()));
|
||||
env.put("PROJECT_NAME", "recipe-demo");
|
||||
env.put("BACKEND_MODULE", "backend");
|
||||
env.put("FRONTEND_MODULE", "frontend");
|
||||
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, lintFailure;
|
||||
boolean dockerAvailable = true;
|
||||
|
||||
FakeCommands() {
|
||||
super(Duration.ofMinutes(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) {
|
||||
calls.add(List.copyOf(args));
|
||||
if (args.getFirst().equals("git")) return new Result(0, SHA);
|
||||
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
|
||||
if (args.equals(List.of("npm", "run", "lint")) && lintFailure)
|
||||
return new Result(1, "deliberate lint failure");
|
||||
if (!args.getFirst().equals("docker")) return new Result(0, "");
|
||||
String command = args.get(1), ref = args.getLast();
|
||||
if (command.equals("info"))
|
||||
return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux");
|
||||
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
|
||||
if (command.equals("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, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
96
resources/ci/Commands.java
Normal file
96
resources/ci/Commands.java
Normal file
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
154
resources/ci/Json.java
Normal file
154
resources/ci/Json.java
Normal file
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
513
resources/ci/Pipeline.java
Normal file
513
resources/ci/Pipeline.java
Normal file
@@ -0,0 +1,513 @@
|
||||
import java.io.*;
|
||||
import java.nio.channels.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Verification, publication and cleanup shared by local use and Gitea Actions. */
|
||||
class Pipeline {
|
||||
final Path root, artifacts;
|
||||
final Map<String, String> env;
|
||||
final Commands commands;
|
||||
final String commit, runName, backendImage, frontendImage;
|
||||
Path isolatedConfig;
|
||||
|
||||
Pipeline(Path root, Map<String, String> environment, Commands commands) throws Exception {
|
||||
this.root = root;
|
||||
this.env = Recipe.environment(root, environment);
|
||||
this.commands = commands;
|
||||
commit = value("CI_COMMIT", () -> capture("git", "rev-parse", "HEAD"));
|
||||
String id = value("CI_RUN_ID", () -> "local"), attempt = value("CI_RUN_ATTEMPT", () -> "1");
|
||||
check(commit.matches("[0-9a-f]{40}"), "Invalid commit SHA");
|
||||
check((id + "-" + attempt).matches("[a-zA-Z0-9-]+"), "Invalid run identifier");
|
||||
runName = Recipe.project(env) + "-" + id + "-" + attempt;
|
||||
backendImage = Recipe.project(env) + "-ci-backend:" + commit + "-" + id + "-" + attempt;
|
||||
frontendImage = Recipe.project(env) + "-ci-frontend:" + commit + "-" + id + "-" + attempt;
|
||||
artifacts = root.resolve("ci/artifacts");
|
||||
Files.createDirectories(artifacts);
|
||||
}
|
||||
|
||||
interface Value {
|
||||
String get() throws Exception;
|
||||
}
|
||||
|
||||
String value(String key, Value defaultValue) throws Exception {
|
||||
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
|
||||
return env.get(key);
|
||||
}
|
||||
|
||||
static void check(boolean condition, String message) {
|
||||
if (!condition) throw new IllegalArgumentException(message);
|
||||
}
|
||||
|
||||
Commands.Result result(boolean echo, String... args) throws Exception {
|
||||
return commands.execute(List.of(args), root, env, null, echo);
|
||||
}
|
||||
|
||||
String capture(String... args) throws Exception {
|
||||
return result(false, args).checked();
|
||||
}
|
||||
|
||||
void run(String... args) throws Exception {
|
||||
result(true, args).checked();
|
||||
}
|
||||
|
||||
void requirePlatform() throws Exception {
|
||||
String target = Setup.required(env, "TARGET_PLATFORM");
|
||||
var docker = result(false, "docker", "info", "--format", "{{.Architecture}}");
|
||||
check(
|
||||
docker.code() == 0,
|
||||
"Docker is unavailable; start Docker Desktop before running CI (details in verify.log)");
|
||||
String arch = docker.output().strip();
|
||||
arch = switch (arch) {
|
||||
case "x86_64" -> "amd64";
|
||||
case "aarch64" -> "arm64";
|
||||
default -> arch;
|
||||
};
|
||||
String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch;
|
||||
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
|
||||
}
|
||||
|
||||
void isolateDocker() throws Exception {
|
||||
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
|
||||
env.remove("DOCKER_CONTEXT");
|
||||
isolatedConfig = Files.createTempDirectory(Recipe.project(env) + "-docker-");
|
||||
env.put("DOCKER_CONFIG", isolatedConfig.toString());
|
||||
}
|
||||
|
||||
void verify() throws Exception {
|
||||
Files.deleteIfExists(artifacts.resolve("verified-commit"));
|
||||
env.put("CI", "true");
|
||||
commands.log = artifacts.resolve("verify.log");
|
||||
Files.writeString(commands.log, "");
|
||||
Thread shutdown = new Thread(
|
||||
() -> {
|
||||
commands.cancel();
|
||||
try {
|
||||
cleanup();
|
||||
closeDockerConfig();
|
||||
} catch (Exception e) {
|
||||
System.err.println("CI shutdown cleanup failed");
|
||||
}
|
||||
},
|
||||
"ci-cleanup");
|
||||
Runtime.getRuntime().addShutdownHook(shutdown);
|
||||
Exception failure = null;
|
||||
try {
|
||||
requirePlatform();
|
||||
isolateDocker();
|
||||
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
|
||||
run("java", "-version");
|
||||
if (Recipe.roles(env).contains("frontend")) run("node", "--version");
|
||||
if (Recipe.roles(env).contains("backend"))
|
||||
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
|
||||
CiTests.run();
|
||||
ReleaseTests.run();
|
||||
Path frontend = root.resolve(env.getOrDefault("FRONTEND_MODULE", "."));
|
||||
if (Recipe.roles(env).contains("frontend")) verifyFrontend();
|
||||
String platform = env.get("TARGET_PLATFORM");
|
||||
for (String role : Recipe.roles(env)) {
|
||||
run(
|
||||
"docker",
|
||||
"build",
|
||||
"--platform",
|
||||
platform,
|
||||
"--target",
|
||||
"prebuilt",
|
||||
"--label",
|
||||
"org.opencontainers.image.revision=" + commit,
|
||||
"-t",
|
||||
role.equals("backend") ? backendImage : frontendImage,
|
||||
env.getOrDefault(
|
||||
role.toUpperCase() + "_CONTEXT",
|
||||
role.equals("backend") ? "." : env.get("FRONTEND_MODULE")));
|
||||
}
|
||||
if (Recipe.enabled(env, "BROWSER_TESTS")) {
|
||||
Files.writeString(artifacts.resolve("containers-started"), "");
|
||||
run("docker", "network", "create", "--label", Recipe.project(env) + ".ci.run=" + runName, runName);
|
||||
if (Recipe.enabled(env, "CI_DATABASE")) {
|
||||
run(
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
runName + "-database",
|
||||
"--network",
|
||||
runName,
|
||||
"--network-alias",
|
||||
"database",
|
||||
"--env",
|
||||
"POSTGRES_USER=" + env.get("DATABASE_USER"),
|
||||
"--env",
|
||||
"POSTGRES_PASSWORD=ci-only",
|
||||
"--env",
|
||||
"POSTGRES_DB=" + env.get("DATABASE_NAME"),
|
||||
"postgres:17.6");
|
||||
for (int attempt = 0; attempt < 60; attempt++) {
|
||||
if (result(
|
||||
false,
|
||||
"docker",
|
||||
"exec",
|
||||
runName + "-database",
|
||||
"pg_isready",
|
||||
"-U",
|
||||
env.get("DATABASE_USER"))
|
||||
.code()
|
||||
== 0) break;
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
run(
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
runName + "-backend",
|
||||
"--label",
|
||||
Recipe.project(env) + ".ci.run=" + runName,
|
||||
"--platform",
|
||||
platform,
|
||||
"--network",
|
||||
runName,
|
||||
"--network-alias",
|
||||
"backend",
|
||||
"--memory",
|
||||
"1g",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--volume",
|
||||
root.resolve(env.get("BACKEND_MODULE") + "/target/coverage-tools/agent.jar")
|
||||
+ ":/jacoco-agent.jar:ro",
|
||||
"--env",
|
||||
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
|
||||
"--env",
|
||||
"DATABASE_URL=jdbc:postgresql://database:5432/" + env.get("DATABASE_NAME"),
|
||||
"--env",
|
||||
"DATABASE_USER=" + env.get("DATABASE_USER"),
|
||||
"--env",
|
||||
"DATABASE_PASSWORD=ci-only",
|
||||
backendImage);
|
||||
run(
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
runName + "-frontend",
|
||||
"--label",
|
||||
Recipe.project(env) + ".ci.run=" + runName,
|
||||
"--platform",
|
||||
platform,
|
||||
"--network",
|
||||
runName,
|
||||
"--network-alias",
|
||||
"frontend",
|
||||
"--memory",
|
||||
"256m",
|
||||
"--cpus",
|
||||
"1",
|
||||
frontendImage);
|
||||
waitForApplication();
|
||||
String user = capture("id", "-u") + ":" + capture("id", "-g");
|
||||
run(
|
||||
"docker",
|
||||
"run",
|
||||
"--name",
|
||||
runName + "-browser",
|
||||
"--label",
|
||||
Recipe.project(env) + ".ci.run=" + runName,
|
||||
"--platform",
|
||||
platform,
|
||||
"--network",
|
||||
runName,
|
||||
"--memory",
|
||||
"2g",
|
||||
"--cpus",
|
||||
"2",
|
||||
"--shm-size=1g",
|
||||
"--user",
|
||||
user,
|
||||
"--env",
|
||||
"HOME=/tmp",
|
||||
"--env",
|
||||
"CI=true",
|
||||
"--env",
|
||||
"PLAYWRIGHT_BASE_URL=http://frontend:8080",
|
||||
"--volume",
|
||||
frontend + ":/workspace",
|
||||
"--workdir",
|
||||
"/workspace",
|
||||
env.getOrDefault("PLAYWRIGHT_IMAGE", "mcr.microsoft.com/playwright:v1.64.1-noble"),
|
||||
"npm",
|
||||
"run",
|
||||
"e2e");
|
||||
run(
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
runName,
|
||||
"--user",
|
||||
user,
|
||||
"--volume",
|
||||
root.resolve(env.get("BACKEND_MODULE") + "/target") + ":/coverage",
|
||||
"eclipse-temurin:25-jre",
|
||||
"java",
|
||||
"-jar",
|
||||
"/coverage/coverage-tools/cli.jar",
|
||||
"dump",
|
||||
"--address",
|
||||
"backend",
|
||||
"--destfile",
|
||||
"/coverage/playwright.exec");
|
||||
check(
|
||||
Files.size(root.resolve(env.get("BACKEND_MODULE") + "/target/playwright.exec")) > 0,
|
||||
"Playwright backend coverage is missing");
|
||||
run(
|
||||
"mvn",
|
||||
"-B",
|
||||
"-ntp",
|
||||
"-pl",
|
||||
env.get("BACKEND_MODULE"),
|
||||
"jacoco:merge@combined",
|
||||
"jacoco:report@combined-report",
|
||||
"exec:exec@combined-coverage");
|
||||
}
|
||||
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
|
||||
} catch (Exception e) {
|
||||
failure = e;
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (Exception e) {
|
||||
if (failure != null) failure.addSuppressed(e);
|
||||
else throw e;
|
||||
} finally {
|
||||
closeDockerConfig();
|
||||
try {
|
||||
Runtime.getRuntime().removeShutdownHook(shutdown);
|
||||
} catch (IllegalStateException ignored) {
|
||||
/* shutting down */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void verifyFrontend() throws Exception {
|
||||
Path frontend = root.resolve(env.get("FRONTEND_MODULE"));
|
||||
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
|
||||
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
|
||||
.checked();
|
||||
final Object configured = Json.parse(
|
||||
env.getOrDefault(
|
||||
"FRONTEND_TEST_COMMAND",
|
||||
"[\"npm\",\"test\",\"--\",\"--reporters=default\",\"--reporters=junit\",\"--output-file=unit-results/junit.xml\"]"));
|
||||
final List<String> test =
|
||||
((List<?>) configured).stream().map(Object::toString).toList();
|
||||
commands.execute(test, frontend, env, null, true).checked();
|
||||
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true)
|
||||
.checked();
|
||||
}
|
||||
|
||||
void waitForApplication() throws Exception {
|
||||
for (int i = 0; i < 60; i++) {
|
||||
if (result(
|
||||
false,
|
||||
"docker",
|
||||
"exec",
|
||||
runName + "-frontend",
|
||||
"wget",
|
||||
"-q",
|
||||
"-O",
|
||||
"-",
|
||||
Setup.required(env, "CI_READINESS_URL"))
|
||||
.code()
|
||||
== 0) return;
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
throw new IllegalArgumentException("Application readiness timed out");
|
||||
}
|
||||
|
||||
synchronized void cleanup() throws Exception {
|
||||
// A separate cleanup budget remains available after the build deadline.
|
||||
commands.renew(Duration.ofMinutes(2));
|
||||
List<String> failures = new ArrayList<>();
|
||||
for (String path : List.of(
|
||||
env.get("BACKEND_MODULE") + "/target/surefire-reports",
|
||||
env.get("BACKEND_MODULE") + "/target/standards",
|
||||
"generated/tooling/standards/target/surefire-reports",
|
||||
env.get("BACKEND_MODULE") + "/target/site/jacoco",
|
||||
env.get("BACKEND_MODULE") + "/target/site/jacoco-combined",
|
||||
env.get("BACKEND_MODULE") + "/target/failsafe-reports",
|
||||
env.get("FRONTEND_MODULE") + "/unit-results",
|
||||
env.get("FRONTEND_MODULE") + "/test-results",
|
||||
env.get("FRONTEND_MODULE") + "/playwright-report")) {
|
||||
if (Files.isDirectory(root.resolve(path))) {
|
||||
try {
|
||||
copyTree(root.resolve(path), artifacts.resolve(path));
|
||||
} catch (IOException e) {
|
||||
failures.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String data : List.of("jacoco.exec", "playwright.exec", "jacoco-combined.exec")) {
|
||||
final Path source =
|
||||
root.resolve(env.get("BACKEND_MODULE") + "/target").resolve(data);
|
||||
if (Files.isRegularFile(source))
|
||||
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
if (result(false, "docker", "info").code() == 0) {
|
||||
for (String role : List.of("backend", "frontend", "browser", "database")) {
|
||||
String container = runName + "-" + role;
|
||||
if (result(false, "docker", "container", "inspect", container).code() == 0) {
|
||||
var logs = result(false, "docker", "logs", container);
|
||||
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
|
||||
if (logs.code() != 0) failures.add(role + " logs");
|
||||
if (result(false, "docker", "rm", "-f", "-v", container).code() != 0)
|
||||
failures.add(role + " cleanup");
|
||||
}
|
||||
}
|
||||
if (result(false, "docker", "network", "inspect", runName).code() == 0
|
||||
&& result(false, "docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
|
||||
if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started"));
|
||||
} else if (Files.exists(artifacts.resolve("containers-started")))
|
||||
failures.add("Docker unavailable during cleanup");
|
||||
check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures));
|
||||
}
|
||||
|
||||
void publish() throws Exception {
|
||||
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
|
||||
System.out.println("Branch is not main; no publication");
|
||||
return;
|
||||
}
|
||||
check(
|
||||
Files.exists(artifacts.resolve("verified-commit"))
|
||||
&& Files.readString(artifacts.resolve("verified-commit"))
|
||||
.strip()
|
||||
.equals(commit),
|
||||
"This commit has not passed verification");
|
||||
String user = Setup.required(env, "REGISTRY_USER"), token = Setup.required(env, "REGISTRY_TOKEN");
|
||||
String server = Setup.required(env, "GITEA_SERVER_URL");
|
||||
String repository = Setup.required(env, "CI_REPOSITORY");
|
||||
requirePlatform();
|
||||
commands.log = artifacts.resolve("publish.log");
|
||||
Files.writeString(commands.log, "");
|
||||
var uri = java.net.URI.create(server);
|
||||
check(
|
||||
Set.of("http", "https").contains(uri.getScheme())
|
||||
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
|
||||
"Registry must be hosted at the Gitea server root");
|
||||
String registry = uri.getRawAuthority();
|
||||
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
|
||||
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
|
||||
Path lockDir = Path.of(env.getOrDefault(
|
||||
"CI_PUBLISH_LOCK_DIR",
|
||||
env.getOrDefault(
|
||||
"XDG_STATE_HOME",
|
||||
Setup.home().resolve(".local/state").toString()) + "/" + Recipe.project(env) + "-ci"));
|
||||
Files.createDirectories(lockDir);
|
||||
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||
FileLock lock = awaitLock(channel)) {
|
||||
isolateDocker();
|
||||
try {
|
||||
commands.execute(
|
||||
List.of("docker", "login", registry, "--username", user, "--password-stdin"),
|
||||
root,
|
||||
env,
|
||||
token,
|
||||
true)
|
||||
.checked();
|
||||
String prefix = registry + "/" + owner + "/" + Recipe.project(env) + "-";
|
||||
for (String role : Recipe.roles(env)) {
|
||||
ensureImage(prefix + role, role.equals("backend") ? backendImage : frontendImage);
|
||||
}
|
||||
for (String role : Recipe.roles(env)) {
|
||||
if (!commit.equals(mainHead())) {
|
||||
System.out.println("Newer main exists; moving tags unchanged");
|
||||
return;
|
||||
}
|
||||
String image = prefix + role;
|
||||
run("docker", "tag", image + ":" + commit, image + ":main");
|
||||
run("docker", "push", image + ":main");
|
||||
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
|
||||
}
|
||||
} finally {
|
||||
closeDockerConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileLock awaitLock(FileChannel channel) throws Exception {
|
||||
Instant end = Instant.now().plusSeconds(600);
|
||||
while (Instant.now().isBefore(end)) {
|
||||
FileLock lock = channel.tryLock();
|
||||
if (lock != null) return lock;
|
||||
Thread.sleep(250);
|
||||
}
|
||||
throw new IllegalArgumentException("Publication lock timed out");
|
||||
}
|
||||
|
||||
String mainHead() throws Exception {
|
||||
Map<String, Object> branch = Json.object(Setup.api(
|
||||
env.get("GITEA_SERVER_URL"),
|
||||
env.get("REGISTRY_TOKEN"),
|
||||
"GET",
|
||||
"/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main",
|
||||
null));
|
||||
return Objects.toString(Json.object(branch.get("commit")).get("id"));
|
||||
}
|
||||
|
||||
void ensureImage(String repository, String source) throws Exception {
|
||||
String ref = repository + ":" + commit;
|
||||
var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
|
||||
if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
|
||||
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
|
||||
run("docker", "tag", source, ref);
|
||||
run("docker", "push", ref);
|
||||
} else
|
||||
throw new IllegalArgumentException(
|
||||
"Registry manifest lookup failed; authentication/network errors are not missing tags");
|
||||
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref);
|
||||
check(
|
||||
commit.equals(capture(
|
||||
"docker",
|
||||
"image",
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{index .Config.Labels \"org.opencontainers.image.revision\"}}",
|
||||
ref)),
|
||||
"Existing image has an unexpected source revision");
|
||||
String digests =
|
||||
capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref);
|
||||
check(digests.contains(repository + "@sha256:"), "Missing registry digest");
|
||||
Files.writeString(
|
||||
artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"),
|
||||
digests + "\n");
|
||||
}
|
||||
|
||||
synchronized void closeDockerConfig() throws IOException {
|
||||
if (isolatedConfig != null) {
|
||||
deleteTree(isolatedConfig);
|
||||
isolatedConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
static void copyTree(Path source, Path destination) throws IOException {
|
||||
try (var paths = Files.walk(source)) {
|
||||
for (Path path : paths.toList()) {
|
||||
Path target = destination.resolve(source.relativize(path));
|
||||
if (Files.isDirectory(path)) Files.createDirectories(target);
|
||||
else Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void deleteTree(Path root) throws IOException {
|
||||
if (!Files.exists(root)) return;
|
||||
try (var paths = Files.walk(root)) {
|
||||
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
resources/ci/Recipe.java
Normal file
61
resources/ci/Recipe.java
Normal file
@@ -0,0 +1,61 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
/** Project-owned configuration for the shared engineering mechanisms. */
|
||||
final class Recipe {
|
||||
private Recipe() {}
|
||||
|
||||
static Map<String, String> environment(final Path root, final Map<String, String> input) throws Exception {
|
||||
final var result = new HashMap<String, String>(input);
|
||||
final Path file = root.resolve("engineering.properties");
|
||||
if (Files.exists(file)) {
|
||||
final var properties = new Properties();
|
||||
try (final var stream = Files.newInputStream(file)) {
|
||||
properties.load(stream);
|
||||
}
|
||||
for (final String key : properties.stringPropertyNames()) {
|
||||
result.putIfAbsent(
|
||||
key, properties.getProperty(key).replace("${user.home}", System.getProperty("user.home")));
|
||||
}
|
||||
}
|
||||
if (result.containsKey("PROJECT_NAME")) {
|
||||
project(result);
|
||||
for (final String role : roles(result)) {
|
||||
final Path module = Path.of(result.get(role.toUpperCase() + "_MODULE"));
|
||||
Pipeline.check(
|
||||
!module.isAbsolute() && !module.normalize().startsWith(".."),
|
||||
"Module must stay in the project");
|
||||
}
|
||||
Pipeline.check(
|
||||
!enabled(result, "BROWSER_TESTS") || roles(result).size() == 2,
|
||||
"Browser coverage requires both backend and frontend");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String project(final Map<String, String> env) {
|
||||
final String name = Setup.required(env, "PROJECT_NAME");
|
||||
Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid project name");
|
||||
return name;
|
||||
}
|
||||
|
||||
static List<String> roles(final Map<String, String> env) {
|
||||
final var roles = new ArrayList<String>();
|
||||
for (final String role : List.of("backend", "frontend")) {
|
||||
if (!env.getOrDefault(role.toUpperCase() + "_MODULE", "").isBlank()) {
|
||||
roles.add(role);
|
||||
}
|
||||
}
|
||||
Pipeline.check(!roles.isEmpty(), "Configure BACKEND_MODULE, FRONTEND_MODULE, or both");
|
||||
return roles;
|
||||
}
|
||||
|
||||
static boolean enabled(final Map<String, String> env, final String key) {
|
||||
return Boolean.parseBoolean(env.getOrDefault(key, "false"));
|
||||
}
|
||||
}
|
||||
236
resources/ci/Release.java
Normal file
236
resources/ci/Release.java
Normal file
@@ -0,0 +1,236 @@
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Manual release coordinator. All mutation decisions are made under the NAS lock. */
|
||||
class Release {
|
||||
final Path root, artifacts;
|
||||
final Map<String, String> env;
|
||||
final ReleaseApi api;
|
||||
final ReleaseRemote remote;
|
||||
final String mode, increment, retry, pinned, attempt;
|
||||
ReleaseState.Deployment selected;
|
||||
boolean locked, pending;
|
||||
|
||||
static void execute(Path root, Map<String, String> env, String operation) throws Exception {
|
||||
validate(env, operation);
|
||||
String attempt = "release-" + env.get("CI_RUN_ID") + "-" + UUID.randomUUID();
|
||||
Path artifacts = root.resolve("ci/artifacts/deployment");
|
||||
Files.createDirectories(artifacts);
|
||||
var commands = new Commands(Duration.ofMinutes(15));
|
||||
commands.log = artifacts.resolve(attempt + ".log");
|
||||
Files.writeString(commands.log, "");
|
||||
try (var remote = new ReleaseRemote(root, env, commands, attempt)) {
|
||||
new Release(root, env, new ReleaseApi(env), remote, operation, attempt).run();
|
||||
}
|
||||
}
|
||||
|
||||
static void validate(Map<String, String> env, String operation) {
|
||||
Pipeline.check("refs/heads/main".equals(env.get("CI_REF")), "Production workflows must run from main");
|
||||
Pipeline.check(
|
||||
"workflow_dispatch".equals(env.get("CI_EVENT")), "Production requires a manual workflow dispatch");
|
||||
Pipeline.check(Setup.required(env, "CI_RUN_ID").matches("[0-9]+"), "Invalid Gitea run ID");
|
||||
ReleaseApi.sha(env.get("CI_COMMIT"));
|
||||
Pipeline.check(List.of("deploy", "rollback").contains(operation), "Unknown release operation");
|
||||
if (operation.equals("rollback")) return;
|
||||
String mode = Setup.required(env, "RELEASE_MODE");
|
||||
String increment = env.getOrDefault("RELEASE_INCREMENT", "");
|
||||
String retry = env.getOrDefault("RELEASE_VERSION", "");
|
||||
if (mode.equals("new")) {
|
||||
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
|
||||
Pipeline.check(retry.isBlank(), "New release cannot specify an existing version");
|
||||
} else {
|
||||
Pipeline.check(mode.equals("retry"), "Choose new or retry release mode");
|
||||
ReleaseVersion.parse(retry);
|
||||
// Gitea shows all inputs; the new-release increment is ignored for retries.
|
||||
}
|
||||
}
|
||||
|
||||
Release(Path root, Map<String, String> env, ReleaseApi api, ReleaseRemote remote, String operation, String attempt)
|
||||
throws Exception {
|
||||
this.root = root;
|
||||
this.env = env;
|
||||
this.api = api;
|
||||
this.remote = remote;
|
||||
this.attempt = attempt;
|
||||
artifacts = root.resolve("ci/artifacts/deployment");
|
||||
Files.createDirectories(artifacts);
|
||||
mode = operation.equals("rollback") ? "rollback" : env.get("RELEASE_MODE");
|
||||
increment = env.getOrDefault("RELEASE_INCREMENT", "");
|
||||
retry = env.getOrDefault("RELEASE_VERSION", "");
|
||||
pinned = ReleaseApi.sha(env.get("CI_COMMIT"));
|
||||
}
|
||||
|
||||
void run() throws Exception {
|
||||
String outcome = "rejected";
|
||||
try {
|
||||
remote.acquire();
|
||||
locked = true;
|
||||
remote.call("preflight", null);
|
||||
ReleaseState before = ReleaseState.parse(remote.call("state", null));
|
||||
selected = select(before);
|
||||
System.out.println("Selected " + selected.version() + " at " + selected.sha());
|
||||
// Validate the recovery pair before any replacement too.
|
||||
if (before.current() != null) ensure(before.current());
|
||||
if (mode.equals("new")) {
|
||||
api.create(selected.version(), selected.sha(), attempt);
|
||||
Pipeline.check(
|
||||
selected.sha().equals(api.head()),
|
||||
"Main advanced; release tagged but production unchanged. Retry explicitly.");
|
||||
}
|
||||
var journal = new LinkedHashMap<String, Object>();
|
||||
journal.put("attempt", attempt);
|
||||
journal.put("mode", mode);
|
||||
journal.put("candidate", selected.json());
|
||||
journal.put("before", Json.parse(before.current() == null ? "{}" : before.json()));
|
||||
remote.call("pending", Json.write(journal));
|
||||
pending = true;
|
||||
outcome = "replacement-started";
|
||||
try {
|
||||
remote.call("apply", null, selected.directory());
|
||||
} catch (Exception failure) {
|
||||
diagnose(selected);
|
||||
outcome = recover(before);
|
||||
throw new IllegalArgumentException(
|
||||
"Deployment failed: " + outcome + ". See deployment diagnostics and recovery runbook.");
|
||||
}
|
||||
// A lost reply here is ambiguous: retain journal and lock, never guess
|
||||
// whether a state commit happened and launch an overlapping recovery.
|
||||
outcome = "healthy-state-commit-pending";
|
||||
remote.call("commit", before.success(selected).json());
|
||||
outcome = "success";
|
||||
record(outcome);
|
||||
remote.call("finish", null);
|
||||
pending = false;
|
||||
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
|
||||
} finally {
|
||||
try {
|
||||
record(outcome);
|
||||
} finally {
|
||||
if (locked && !pending) remote.release();
|
||||
else if (locked)
|
||||
System.err.println("NAS lock and pending journal retained. Follow docs/production-deployment.md for"
|
||||
+ " recovery.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseState.Deployment select(ReleaseState before) throws Exception {
|
||||
if (mode.equals("rollback")) {
|
||||
Pipeline.check(before.previous() != null, "No previous successful production release is available");
|
||||
ensure(before.previous());
|
||||
return before.previous();
|
||||
}
|
||||
List<ReleaseApi.Tag> tags = api.tags();
|
||||
ReleaseVersion version;
|
||||
String commit;
|
||||
if (mode.equals("new")) {
|
||||
Pipeline.check(
|
||||
pinned.equals(api.head()), "Main advanced since this dispatch; start a new release workflow");
|
||||
Pipeline.check(
|
||||
tags.stream().noneMatch(t -> t.sha().equals(pinned)),
|
||||
"This commit already has a release tag; use retry or rollback");
|
||||
version = ReleaseVersion.next(
|
||||
tags.stream().map(ReleaseApi.Tag::version).toList(), increment);
|
||||
commit = pinned;
|
||||
} else {
|
||||
version = ReleaseVersion.parse(retry);
|
||||
var tag = tags.stream()
|
||||
.filter(t -> t.version().equals(version))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Retry release tag does not exist"));
|
||||
api.annotated(tag);
|
||||
commit = tag.sha();
|
||||
}
|
||||
Pipeline.check(
|
||||
before.highest() == null || version.compareTo(before.highest()) >= (mode.equals("new") ? 1 : 0),
|
||||
"A newer release has already succeeded; use explicit rollback for older versions");
|
||||
String build = api.verified(commit);
|
||||
remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml")));
|
||||
remote.call("stage-health", Files.readString(root.resolve("deploy/health.sh")));
|
||||
String prepared = remote.call("prepare", null, version.toString(), commit, prefix());
|
||||
return new ReleaseState.Deployment(
|
||||
version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build);
|
||||
}
|
||||
|
||||
String prefix() {
|
||||
var uri = URI.create(api.server);
|
||||
String authority = uri.getRawAuthority();
|
||||
String registry = env.getOrDefault("DEPLOY_REGISTRY", "").strip();
|
||||
if (registry.isEmpty()) registry = authority;
|
||||
int port = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
|
||||
Pipeline.check(
|
||||
registry.equals(authority) || registry.equals("127.0.0.1:" + port),
|
||||
"Deployment registry must be the existing Gitea registry or its NAS loopback alias");
|
||||
String owner = Setup.required(env, "CI_REPOSITORY").split("/")[0].toLowerCase(java.util.Locale.ROOT);
|
||||
return registry + "/" + owner + "/" + Recipe.project(env) + "-";
|
||||
}
|
||||
|
||||
void ensure(ReleaseState.Deployment release) throws Exception {
|
||||
remote.call(
|
||||
"ensure",
|
||||
null,
|
||||
release.directory(),
|
||||
release.version().toString(),
|
||||
release.sha(),
|
||||
release.backend(),
|
||||
release.frontend());
|
||||
}
|
||||
|
||||
String digest(String response, String role) {
|
||||
if (!Recipe.roles(env).contains(role.toLowerCase(java.util.Locale.ROOT))) return "-";
|
||||
String value = response.lines()
|
||||
.filter(line -> line.startsWith("S6_" + role + "="))
|
||||
.map(line -> line.substring(("S6_" + role + "=").length()))
|
||||
.reduce((a, b) -> {
|
||||
throw new IllegalArgumentException("Ambiguous prepared image digest");
|
||||
})
|
||||
.orElseThrow(() -> new IllegalArgumentException("Missing prepared image digest"));
|
||||
Pipeline.check(
|
||||
value.startsWith(prefix() + role.toLowerCase(java.util.Locale.ROOT) + "@sha256:"),
|
||||
"Unexpected image repository");
|
||||
return value;
|
||||
}
|
||||
|
||||
String recover(ReleaseState before) throws Exception {
|
||||
if (before.current() == null) return "first-release-failed-manual-recovery-required";
|
||||
try {
|
||||
remote.call("apply", null, before.current().directory());
|
||||
} catch (Exception failure) {
|
||||
diagnose(before.current());
|
||||
return "recovery-failed-manual-recovery-required";
|
||||
}
|
||||
record("failed-previous-release-restored");
|
||||
remote.call("finish", null);
|
||||
pending = false;
|
||||
return "previous-release-restored";
|
||||
}
|
||||
|
||||
void diagnose(ReleaseState.Deployment release) {
|
||||
try {
|
||||
Files.writeString(
|
||||
artifacts.resolve(attempt + "-" + release.directory() + "-containers.log"),
|
||||
remote.call("diagnose", null, release.directory()));
|
||||
} catch (Exception ignored) {
|
||||
System.err.println("Container diagnostics unavailable; use the NAS recovery runbook");
|
||||
}
|
||||
}
|
||||
|
||||
void record(String outcome) throws Exception {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("attempt", attempt);
|
||||
data.put("mode", mode);
|
||||
data.put("outcome", outcome);
|
||||
data.put("time", Instant.now().toString());
|
||||
data.put("release", selected == null ? null : selected.json());
|
||||
String json = Json.write(data) + "\n";
|
||||
Files.writeString(artifacts.resolve(attempt + ".json"), json);
|
||||
if (locked) remote.call("event", json);
|
||||
}
|
||||
}
|
||||
124
resources/ci/ReleaseApi.java
Normal file
124
resources/ci/ReleaseApi.java
Normal file
@@ -0,0 +1,124 @@
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Gitea 1.25 API adapter. Never include response bodies or tokens in errors. */
|
||||
class ReleaseApi {
|
||||
record Tag(ReleaseVersion version, String sha, String object) {}
|
||||
|
||||
final String server, token, base;
|
||||
|
||||
ReleaseApi(Map<String, String> env) {
|
||||
server = Setup.required(env, "GITEA_SERVER_URL");
|
||||
var uri = URI.create(server);
|
||||
Pipeline.check(
|
||||
List.of("http", "https").contains(uri.getScheme())
|
||||
&& uri.getUserInfo() == null
|
||||
&& uri.getQuery() == null
|
||||
&& uri.getFragment() == null
|
||||
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
|
||||
"Gitea must use a server-root URL");
|
||||
String repository = Setup.required(env, "CI_REPOSITORY");
|
||||
Pipeline.check(repository.matches("[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+"), "Invalid repository name");
|
||||
base = "/api/v1/repos/" + repository;
|
||||
token = Setup.required(env, "RELEASE_TOKEN");
|
||||
}
|
||||
|
||||
Object request(String method, String path, Object body) throws Exception {
|
||||
return Setup.api(server, token, method, base + path, body);
|
||||
}
|
||||
|
||||
String head() throws Exception {
|
||||
return sha(
|
||||
Json.object(Json.object(request("GET", "/branches/main", null)).get("commit"))
|
||||
.get("id"));
|
||||
}
|
||||
|
||||
static String sha(Object value) {
|
||||
String result = Objects.toString(value, "");
|
||||
Pipeline.check(result.matches("[0-9a-f]{40}"), "Invalid release commit SHA");
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Tag> tags() throws Exception {
|
||||
List<Tag> tags = new ArrayList<>();
|
||||
var seen = new java.util.HashSet<String>();
|
||||
for (int page = 1; ; page++) {
|
||||
var entries = (List<?>) request("GET", "/tags?limit=50&page=" + page, null);
|
||||
if (entries.isEmpty()) return tags;
|
||||
for (Object entry : entries) {
|
||||
var tag = Json.object(entry);
|
||||
String name = Objects.toString(tag.get("name"), "");
|
||||
Pipeline.check(
|
||||
seen.add(name),
|
||||
"Remote tag pagination repeated a tag; cannot establish a safe version baseline");
|
||||
// Unknown version-looking tags require explicit reconciliation, not a second baseline.
|
||||
if (!name.matches("v?[0-9].*")) continue;
|
||||
ReleaseVersion version = ReleaseVersion.parse(name);
|
||||
String commit = sha(Json.object(tag.get("commit")).get("sha"));
|
||||
String object = sha(tag.get("id"));
|
||||
tags.add(new Tag(version, commit, object));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void annotated(Tag tag) throws Exception {
|
||||
var data = Json.object(request("GET", "/git/tags/" + tag.object(), null));
|
||||
Pipeline.check(
|
||||
tag.version().toString().equals(data.get("tag"))
|
||||
&& tag.sha().equals(Json.object(data.get("object")).get("sha")),
|
||||
"Release tag must be annotated and point directly to its recorded commit");
|
||||
}
|
||||
|
||||
void create(ReleaseVersion version, String commit, String run) throws Exception {
|
||||
request(
|
||||
"POST",
|
||||
"/tags",
|
||||
Map.of(
|
||||
"tag_name",
|
||||
version.toString(),
|
||||
"target",
|
||||
commit,
|
||||
"message",
|
||||
"Release " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
|
||||
Tag tag = tags().stream()
|
||||
.filter(t -> t.version().equals(version))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Pipeline.check(tag.sha().equals(commit), "Created release tag does not match selected commit");
|
||||
annotated(tag);
|
||||
}
|
||||
|
||||
String verified(String commit) throws Exception {
|
||||
// A successful build.yaml main-push run includes verification AND successful image publication.
|
||||
// Do not accept another workflow, a branch build, or merely an existing image/tag.
|
||||
var seen = new java.util.HashSet<String>();
|
||||
for (int page = 1; ; page++) {
|
||||
var response = Json.object(request(
|
||||
"GET",
|
||||
"/actions/runs?head_sha=" + commit + "&event=push&branch=main&status=success&limit=50&page=" + page,
|
||||
null));
|
||||
var runs = (List<?>) response.get("workflow_runs");
|
||||
if (runs.isEmpty()) break;
|
||||
boolean fresh = false;
|
||||
for (Object entry : runs) {
|
||||
var run = Json.object(entry);
|
||||
fresh |= seen.add(Objects.toString(run.get("id")));
|
||||
if (commit.equals(run.get("head_sha"))
|
||||
&& "push".equals(run.get("event"))
|
||||
&& List.of("main", "refs/heads/main").contains(Objects.toString(run.get("head_branch")))
|
||||
&& "completed".equals(run.get("status"))
|
||||
&& "success".equals(run.get("conclusion"))
|
||||
&& "build.yaml@refs/heads/main".equals(run.get("path"))) {
|
||||
return Objects.toString(run.get("id"));
|
||||
}
|
||||
}
|
||||
// Older Gitea installations may ignore some list filters/pagination.
|
||||
// Validate each entry locally and stop if a page makes no progress.
|
||||
if (!fresh) break;
|
||||
}
|
||||
throw new IllegalArgumentException("No successful build.yaml main-push run verifies and publishes this SHA");
|
||||
}
|
||||
}
|
||||
125
resources/ci/ReleaseRemote.java
Normal file
125
resources/ci/ReleaseRemote.java
Normal file
@@ -0,0 +1,125 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Argument-safe SSH adapter; server-side mkdir lock is shared by every runner. */
|
||||
class ReleaseRemote implements AutoCloseable {
|
||||
final Path root, credentials;
|
||||
final Map<String, String> env;
|
||||
final Commands commands;
|
||||
final String owner, directory, host, user, port, script;
|
||||
|
||||
ReleaseRemote(Path root, Map<String, String> env, Commands commands, String owner) throws Exception {
|
||||
this.root = root;
|
||||
this.env = env;
|
||||
this.commands = commands;
|
||||
this.owner = owner;
|
||||
directory = Setup.required(env, "DEPLOY_DIRECTORY");
|
||||
host = Setup.required(env, "DEPLOY_HOST");
|
||||
user = Setup.required(env, "DEPLOY_USER");
|
||||
port = env.getOrDefault("DEPLOY_SSH_PORT", "22");
|
||||
Pipeline.check(
|
||||
directory.matches("/[a-zA-Z0-9_./-]+") && !directory.contains("..") && !directory.equals("/"),
|
||||
"Use an absolute dedicated deployment directory without spaces or parent traversal");
|
||||
Pipeline.check(
|
||||
host.matches("[a-zA-Z0-9][a-zA-Z0-9.-]*")
|
||||
&& user.matches("[a-zA-Z0-9_][a-zA-Z0-9_-]*")
|
||||
&& port.matches("[0-9]{1,5}")
|
||||
&& Integer.parseInt(port) > 0
|
||||
&& Integer.parseInt(port) <= 65535,
|
||||
"Invalid deployment SSH host, user or port");
|
||||
script = "COMPOSE_PROJECT=" + quote(Setup.required(env, "COMPOSE_PROJECT")) + "\n"
|
||||
+ "DEPLOY_ROLES=" + quote(String.join(" ", Recipe.roles(env))) + "\n"
|
||||
+ "DEPENDENCY_SERVICES=" + quote(env.getOrDefault("DEPENDENCY_SERVICES", "")) + "\n"
|
||||
+ "TARGET_PLATFORM=" + quote(env.getOrDefault("TARGET_PLATFORM", "linux/amd64")) + "\n"
|
||||
+ "REQUIRED_SECRETS=" + quote(env.getOrDefault("REQUIRED_DEPLOY_SECRETS", "")) + "\n"
|
||||
+ Files.readString(root.resolve("deploy/health.sh")) + "\n"
|
||||
+ Files.readString(root.resolve("generated/deploy/remote.sh"));
|
||||
credentials = Files.createTempDirectory("engineering-deploy-ssh-");
|
||||
try {
|
||||
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
|
||||
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");
|
||||
} catch (Exception e) {
|
||||
close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
void writePrivate(String name, String value) throws Exception {
|
||||
Path file = credentials.resolve(name);
|
||||
Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
Files.writeString(file, value);
|
||||
}
|
||||
|
||||
static String quote(String value) {
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
String call(String action, String input, String... arguments) throws Exception {
|
||||
int seconds = switch (action) {
|
||||
case "apply" -> 300;
|
||||
case "prepare", "ensure" -> 600;
|
||||
default -> 60;
|
||||
};
|
||||
var parameters = new ArrayList<>(List.of(action, directory, owner));
|
||||
parameters.addAll(List.of(arguments));
|
||||
return executeScript(script, input, seconds, parameters);
|
||||
}
|
||||
|
||||
String executeScript(String source, String input, int seconds, List<String> parameters) throws Exception {
|
||||
String remote = "timeout -k 5 " + seconds + " sh -c " + quote(source) + " --";
|
||||
for (String argument : parameters) remote += " " + quote(argument);
|
||||
var args = new ArrayList<>(List.of(
|
||||
"ssh",
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-T",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"-o",
|
||||
"IdentitiesOnly=yes",
|
||||
"-o",
|
||||
"IdentityAgent=none",
|
||||
"-o",
|
||||
"ForwardAgent=no",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"ServerAliveInterval=10",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
"-o",
|
||||
"LogLevel=ERROR",
|
||||
"-o",
|
||||
"UserKnownHostsFile=" + credentials.resolve("known_hosts"),
|
||||
"-o",
|
||||
"GlobalKnownHostsFile=/dev/null",
|
||||
"-i",
|
||||
credentials.resolve("identity").toString(),
|
||||
"-p",
|
||||
port,
|
||||
user + "@" + host,
|
||||
remote));
|
||||
commands.renew(Duration.ofSeconds(seconds + 30));
|
||||
return commands.execute(args, root, env, input, false).checked();
|
||||
}
|
||||
|
||||
void acquire() throws Exception {
|
||||
call("lock", null);
|
||||
}
|
||||
|
||||
void release() throws Exception {
|
||||
call("unlock", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
Pipeline.deleteTree(credentials);
|
||||
}
|
||||
}
|
||||
105
resources/ci/ReleaseSetup.java
Normal file
105
resources/ci/ReleaseSetup.java
Normal file
@@ -0,0 +1,105 @@
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** One-time provisioning; secrets stay in owner-only files or Gitea secret storage. */
|
||||
class ReleaseSetup {
|
||||
static void configure(Path root, Map<String, String> input) throws Exception {
|
||||
var env = new HashMap<>(input);
|
||||
env.putIfAbsent("DEPLOY_SSH_PORT", "22");
|
||||
Path config = Setup.home().resolve(".config/" + Recipe.project(env) + "-runner");
|
||||
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
|
||||
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("release-token").toString());
|
||||
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("pull-token").toString());
|
||||
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("deploy-key").toString());
|
||||
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
|
||||
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
|
||||
env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_TOKEN_FILE"));
|
||||
env.put("DEPLOY_SSH_KEY", Setup.secret(env, "DEPLOY_SSH_KEY_FILE"));
|
||||
String bind = env.get("DEPLOY_BIND_IP"), port = env.get("DEPLOY_FRONTEND_PORT");
|
||||
Pipeline.check(
|
||||
bind.matches("[0-9.]+")
|
||||
&& port.matches("[0-9]{4,5}")
|
||||
&& Integer.parseInt(port) >= 1024
|
||||
&& Integer.parseInt(port) <= 65535,
|
||||
"Invalid private bind address or port");
|
||||
var commands = new Commands(Duration.ofMinutes(15));
|
||||
Path diagnostics = root.resolve("ci/artifacts/deployment");
|
||||
Files.createDirectories(diagnostics);
|
||||
commands.log = diagnostics.resolve("setup.log");
|
||||
Files.writeString(commands.log, "");
|
||||
String host = env.get("DEPLOY_HOST");
|
||||
String lookup = env.get("DEPLOY_SSH_PORT").equals("22") ? host : "[" + host + "]:" + env.get("DEPLOY_SSH_PORT");
|
||||
env.put(
|
||||
"DEPLOY_KNOWN_HOSTS",
|
||||
commands.execute(
|
||||
List.of(
|
||||
"ssh-keygen",
|
||||
"-F",
|
||||
lookup,
|
||||
"-f",
|
||||
Setup.home().resolve(".ssh/known_hosts").toString()),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked());
|
||||
Pipeline.check(!env.get("DEPLOY_KNOWN_HOSTS").isBlank(), "A previously trusted NAS hostkey is required");
|
||||
var api = new ReleaseApi(env);
|
||||
api.verified(api.head());
|
||||
var uri = URI.create(api.server);
|
||||
String registry = env.get("DEPLOY_REGISTRY");
|
||||
int registryPort = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
|
||||
Pipeline.check(
|
||||
registry.equals(uri.getRawAuthority()) || registry.equals("127.0.0.1:" + registryPort),
|
||||
"Deployment registry must be the Gitea registry or its NAS loopback alias");
|
||||
String registryUser =
|
||||
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
|
||||
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
|
||||
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
|
||||
System.out.println(remote.executeScript(
|
||||
Files.readString(root.resolve("generated/deploy/provision.sh")),
|
||||
pull + "\n",
|
||||
300,
|
||||
List.of(
|
||||
remote.directory,
|
||||
registryUser,
|
||||
registry,
|
||||
bind,
|
||||
port,
|
||||
Setup.required(env, "DEPLOY_DOCKER_BIN"))));
|
||||
remote.acquire();
|
||||
try {
|
||||
remote.call("preflight", null);
|
||||
} finally {
|
||||
remote.release();
|
||||
}
|
||||
}
|
||||
// Provision credentials only after successful NAS login and release-read verification.
|
||||
for (String secret : List.of("RELEASE_TOKEN", "DEPLOY_SSH_KEY", "DEPLOY_KNOWN_HOSTS"))
|
||||
Setup.api(
|
||||
api.server, admin, "PUT", api.base + "/actions/secrets/" + secret, Map.of("data", env.get(secret)));
|
||||
for (String variable :
|
||||
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
|
||||
variable(api, admin, variable, env.get(variable));
|
||||
System.out.println(
|
||||
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
|
||||
}
|
||||
|
||||
static void variable(ReleaseApi api, String admin, String name, String value) throws Exception {
|
||||
String path = api.base + "/actions/variables/" + name;
|
||||
boolean exists;
|
||||
try {
|
||||
Setup.api(api.server, admin, "GET", path, null);
|
||||
exists = true;
|
||||
} catch (Setup.ApiFailure error) {
|
||||
if (error.status != 404) throw error;
|
||||
exists = false;
|
||||
}
|
||||
Setup.api(api.server, admin, exists ? "PUT" : "POST", path, Map.of("value", value));
|
||||
}
|
||||
}
|
||||
244
resources/ci/ReleaseShellTests.java
Normal file
244
resources/ci/ReleaseShellTests.java
Normal file
@@ -0,0 +1,244 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Runs the actual NAS script with disposable command adapters, no Docker daemon. */
|
||||
class ReleaseShellTests {
|
||||
static void run() throws Exception {
|
||||
ReleaseTests.test("actual NAS script prepares digest images and checks the published endpoint", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
f.call("pending", "{}", "one").checked();
|
||||
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
|
||||
String calls = Files.readString(f.directory.resolve("calls"));
|
||||
CiTests.equal(true, calls.contains("http://backend:8080/q/health/ready"));
|
||||
CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/items"));
|
||||
CiTests.equal(false, calls.contains(" down"));
|
||||
CiTests.equal(false, calls.contains("prune"));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects a missing database password before pulling images", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
Path config = f.directory.resolve("production.env");
|
||||
Files.writeString(
|
||||
config, Files.readString(config).replace("DATABASE_PASSWORD=fixture-only-password\n", ""));
|
||||
f.call("lock", null, "one").checked();
|
||||
Commands.Result result = f.call("preflight", null, "one");
|
||||
CiTests.equal(1, result.code());
|
||||
CiTests.equal(true, result.output().contains("Missing DATABASE_PASSWORD"));
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script passes database configuration through sudo", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.executable("sudo", "#!/bin/sh\n[ \"$1\" != -n ] || shift\nunset DATABASE_PASSWORD\nexec \"$@\"\n");
|
||||
Path config = f.directory.resolve("production.env");
|
||||
Files.writeString(config, Files.readString(config).replace("DOCKER_SUDO=0", "DOCKER_SUDO=1"));
|
||||
f.prepare();
|
||||
CiTests.equal(
|
||||
false,
|
||||
Files.readString(f.directory.resolve("releases/one/release.env"))
|
||||
.contains("PASSWORD"));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects wrong image revision before replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.env.put("WRONG_REVISION", "1");
|
||||
CiTests.failure(f::prepare);
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects unavailable image before replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.env.put("FAIL_PULL", "1");
|
||||
CiTests.failure(f::prepare);
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script rejects a changed retained release record", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
CiTests.failure(() -> f.call(
|
||||
"ensure", null, "one", "one", "v9.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
|
||||
.checked());
|
||||
f.call("ensure", null, "one", "one", "v1.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
|
||||
.checked();
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS replacement timeout is bounded and leaves recovery journal", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
f.call("pending", "{}", "one").checked();
|
||||
f.env.put("FAIL_HEALTH", "1");
|
||||
CiTests.equal(1, f.call("apply", null, "one", "one", "1").code());
|
||||
CiTests.equal(true, Files.exists(f.directory.resolve("pending.json")));
|
||||
// Termination releases only the operation fence, not the deployment lock.
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("deploy.lock/operation")));
|
||||
CiTests.equal(true, Files.exists(f.directory.resolve("deploy.lock/owner")));
|
||||
f.env.remove("FAIL_HEALTH");
|
||||
f.call("apply", null, "one", "one").checked();
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script fences overlapping remote replacements", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.prepare();
|
||||
Files.createDirectory(f.directory.resolve("deploy.lock/operation"));
|
||||
CiTests.failure(() -> f.call("apply", null, "one", "one").checked());
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
|
||||
}
|
||||
});
|
||||
ReleaseTests.test("actual NAS script stores state atomically and archives resolved journal", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.call("lock", null, "one").checked();
|
||||
f.call("pending", "{\"attempt\":\"one\"}", "one").checked();
|
||||
f.call("commit", "{\"current\":\"one\"}", "one").checked();
|
||||
CiTests.equal(
|
||||
"{\"current\":\"one\"}", f.call("state", null, "one").checked());
|
||||
f.call("event", "{\"outcome\":\"success\"}", "one").checked();
|
||||
f.call("finish", null, "one").checked();
|
||||
f.call("unlock", null, "one").checked();
|
||||
CiTests.equal(true, Files.exists(f.directory.resolve("history/one.json")));
|
||||
CiTests.equal(false, Files.exists(f.directory.resolve("pending.json")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static class Fixture implements AutoCloseable {
|
||||
final Path directory = Files.createTempDirectory("nas-script-test-");
|
||||
final Map<String, String> env = new HashMap<>(System.getenv());
|
||||
final String health = "application_health() {\n"
|
||||
+ "frontend=$(compose ps -q frontend)\n"
|
||||
+ "docker_cmd exec \"$frontend\" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return"
|
||||
+ " 1\n"
|
||||
+ "curl --fail http://127.0.0.1:8088/api/items -o /dev/null\n"
|
||||
+ "}\n";
|
||||
final String script = "COMPOSE_PROJECT=recipe-demo-test\n"
|
||||
+ "DEPLOY_ROLES='backend frontend'\n"
|
||||
+ "DEPENDENCY_SERVICES=database\n"
|
||||
+ "TARGET_PLATFORM=linux/amd64\n"
|
||||
+ "REQUIRED_SECRETS=DATABASE_PASSWORD\n"
|
||||
+ health + "\n"
|
||||
+ Files.readString(Path.of("generated/deploy/remote.sh"));
|
||||
final Commands commands = new Commands(Duration.ofMinutes(1));
|
||||
|
||||
Fixture() throws Exception {
|
||||
Files.createDirectory(directory.resolve("bin"));
|
||||
executable("docker", DOCKER);
|
||||
executable(
|
||||
"curl",
|
||||
"#!/bin/sh\nprintf 'curl %s\\n' \"$*\" >> \"$FIXTURE/calls\"\n[ \"${FAIL_HEALTH:-0}\" = 0 ]\n");
|
||||
env.put("PATH", directory.resolve("bin") + ":" + env.get("PATH"));
|
||||
env.put("FIXTURE", directory.toString());
|
||||
Files.createDirectory(directory.resolve("docker-auth"));
|
||||
Files.writeString(directory.resolve("docker-auth/config.json"), "{}");
|
||||
Files.writeString(
|
||||
directory.resolve("production.env"),
|
||||
"DATABASE_PASSWORD=fixture-only-password\n"
|
||||
+ "BIND_IP=127.0.0.1\n"
|
||||
+ "FRONTEND_PORT=8088\n"
|
||||
+ "DOCKER_SUDO=0\n"
|
||||
+ "DOCKER_BIN="
|
||||
+ directory.resolve("bin/docker") + "\nDOCKER_CONFIG=" + directory.resolve("docker-auth")
|
||||
+ "\n");
|
||||
}
|
||||
|
||||
void executable(String name, String body) throws Exception {
|
||||
Path path = directory.resolve("bin").resolve(name);
|
||||
Files.writeString(path, body);
|
||||
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
|
||||
}
|
||||
|
||||
Commands.Result call(String action, String input, String owner, String... args) throws Exception {
|
||||
var command = new ArrayList<>(
|
||||
List.of("timeout", "-k", "1", "10", "sh", "-c", script, "--", action, directory.toString(), owner));
|
||||
command.addAll(List.of(args));
|
||||
commands.renew(Duration.ofSeconds(15));
|
||||
return commands.execute(command, directory, env, input, false);
|
||||
}
|
||||
|
||||
String backend() {
|
||||
return "nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64);
|
||||
}
|
||||
|
||||
String frontend() {
|
||||
return "nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64);
|
||||
}
|
||||
|
||||
void prepare() throws Exception {
|
||||
call("lock", null, "one").checked();
|
||||
call("preflight", null, "one").checked();
|
||||
call("stage", "services: {}\n", "one").checked();
|
||||
call("stage-health", health, "one").checked();
|
||||
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/recipe-demo-")
|
||||
.checked();
|
||||
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
|
||||
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
Pipeline.deleteTree(directory);
|
||||
}
|
||||
}
|
||||
|
||||
static final String DOCKER = """
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
printf 'docker %s\n' "$*" >> "$FIXTURE/calls"
|
||||
[ "$1" != --config ] || shift 2
|
||||
case "$1" in
|
||||
info) echo linux/x86_64;;
|
||||
pull) [ "${FAIL_PULL:-0}" = 0 ];;
|
||||
image)
|
||||
format=$4; image=$5
|
||||
case "$image" in *backend*) role=backend; digit=1;; *) role=frontend; digit=2;; esac
|
||||
case "$format" in
|
||||
*revision*)
|
||||
if [ "${WRONG_REVISION:-0}" = 1 ]; then echo wrong; else printf '%040d\n' 0 | tr 0 a; fi;;
|
||||
*Architecture*) echo linux/amd64;;
|
||||
*RepoDigests*)
|
||||
printf '%s@sha256:' "${image%:*}"; printf '%064d\n' 0 | tr 0 "$digit";;
|
||||
*Id*) echo "sha256:$role";;
|
||||
*) exit 1;;
|
||||
esac;;
|
||||
compose)
|
||||
shift
|
||||
[ "$1" != version ] || exit 0
|
||||
shift 4
|
||||
[ "$1" = --env-file ] || exit 1
|
||||
production=$2
|
||||
shift 2
|
||||
[ "$1" = --env-file ] || exit 1
|
||||
file=$2
|
||||
shift 4
|
||||
# Compose must read the secret from disk, even after sudo clears the environment.
|
||||
test -f "$production"
|
||||
. "$production"
|
||||
[ "$DATABASE_PASSWORD" = fixture-only-password ] || exit 15
|
||||
case "$1" in
|
||||
config) test -f "$file";;
|
||||
up) cp "$file" "$FIXTURE/live.env";;
|
||||
ps) eval 'role=${'"$#"'}'; echo "$role";;
|
||||
logs) echo 'sample container diagnostics';;
|
||||
*) exit 1;;
|
||||
esac;;
|
||||
inspect)
|
||||
. "$FIXTURE/live.env"
|
||||
format=$3; id=$4
|
||||
case "$format" in
|
||||
*State.Running*) echo true;;
|
||||
*Image*) echo "sha256:$id";;
|
||||
*revision*) echo "$RELEASE_SHA";;
|
||||
*release*) echo "$RELEASE_VERSION";;
|
||||
*) exit 1;;
|
||||
esac;;
|
||||
exec) [ "${FAIL_HEALTH:-0}" = 0 ];;
|
||||
*) exit 1;;
|
||||
esac
|
||||
""";
|
||||
}
|
||||
83
resources/ci/ReleaseState.java
Normal file
83
resources/ci/ReleaseState.java
Normal file
@@ -0,0 +1,83 @@
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Successful deployment state is separate from immutable allocated version tags. */
|
||||
record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment previous, ReleaseVersion highest) {
|
||||
record Deployment(
|
||||
ReleaseVersion version, String sha, String backend, String frontend, String directory, String build) {
|
||||
Deployment {
|
||||
ReleaseApi.sha(sha);
|
||||
Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory");
|
||||
Pipeline.check(!backend.equals("-") || !frontend.equals("-"), "A release must contain an image");
|
||||
for (String image : new String[] {backend, frontend})
|
||||
Pipeline.check(
|
||||
(image.equals("-") || image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}")),
|
||||
"Invalid retained image digest");
|
||||
}
|
||||
|
||||
Map<String, Object> json() {
|
||||
return Map.of(
|
||||
"version",
|
||||
version.toString(),
|
||||
"sha",
|
||||
sha,
|
||||
"backend",
|
||||
backend,
|
||||
"frontend",
|
||||
frontend,
|
||||
"directory",
|
||||
directory,
|
||||
"build",
|
||||
build);
|
||||
}
|
||||
|
||||
static Deployment parse(Object value) {
|
||||
if (value == null) return null;
|
||||
var data = Json.object(value);
|
||||
return new Deployment(
|
||||
ReleaseVersion.parse(text(data, "version")),
|
||||
text(data, "sha"),
|
||||
text(data, "backend"),
|
||||
text(data, "frontend"),
|
||||
text(data, "directory"),
|
||||
text(data, "build"));
|
||||
}
|
||||
}
|
||||
|
||||
static String text(Map<String, Object> data, String key) {
|
||||
return Objects.toString(data.get(key), "");
|
||||
}
|
||||
|
||||
static ReleaseState parse(String value) {
|
||||
var data = Json.object(Json.parse(value));
|
||||
if (data.isEmpty()) return new ReleaseState(null, null, null);
|
||||
Pipeline.check("1".equals(text(data, "schema")), "Unsupported deployment state schema");
|
||||
Deployment current = Deployment.parse(data.get("current"));
|
||||
Deployment previous = Deployment.parse(data.get("previous"));
|
||||
ReleaseVersion highest = ReleaseVersion.parse(text(data, "highest"));
|
||||
Pipeline.check(
|
||||
current != null
|
||||
&& highest.compareTo(current.version()) >= 0
|
||||
&& (previous == null || highest.compareTo(previous.version()) >= 0),
|
||||
"Inconsistent successful release state");
|
||||
return new ReleaseState(current, previous, highest);
|
||||
}
|
||||
|
||||
String json() {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("schema", "1");
|
||||
data.put("current", current == null ? null : current.json());
|
||||
data.put("previous", previous == null ? null : previous.json());
|
||||
data.put("highest", highest == null ? null : highest.toString());
|
||||
return Json.write(data) + "\n";
|
||||
}
|
||||
|
||||
ReleaseState success(Deployment selected) {
|
||||
ReleaseVersion watermark =
|
||||
highest == null || selected.version().compareTo(highest) > 0 ? selected.version() : highest;
|
||||
// A redeploy of the same release must not discard the distinct rollback target.
|
||||
Deployment fallback = current != null && current.version().equals(selected.version()) ? previous : current;
|
||||
return new ReleaseState(selected, fallback, watermark);
|
||||
}
|
||||
}
|
||||
555
resources/ci/ReleaseTests.java
Normal file
555
resources/ci/ReleaseTests.java
Normal file
@@ -0,0 +1,555 @@
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Release state-machine and real POSIX remote-script tests, without server writes. */
|
||||
class ReleaseTests {
|
||||
static final String SHA = "a".repeat(40), OLD = "b".repeat(40);
|
||||
static int count;
|
||||
|
||||
static void test(String name, CiTests.Test test) throws Exception {
|
||||
test.run();
|
||||
count++;
|
||||
System.out.println("PASS release: " + name);
|
||||
}
|
||||
|
||||
static Map<String, String> environment() {
|
||||
var env = new HashMap<>(System.getenv());
|
||||
env.putAll(Map.of(
|
||||
"CI_REF",
|
||||
"refs/heads/main",
|
||||
"CI_EVENT",
|
||||
"workflow_dispatch",
|
||||
"CI_RUN_ID",
|
||||
"123",
|
||||
"CI_COMMIT",
|
||||
SHA,
|
||||
"RELEASE_MODE",
|
||||
"new",
|
||||
"RELEASE_INCREMENT",
|
||||
"patch",
|
||||
"RELEASE_VERSION",
|
||||
"",
|
||||
"GITEA_SERVER_URL",
|
||||
"http://nas:3000",
|
||||
"CI_REPOSITORY",
|
||||
"owner/recipe-demo",
|
||||
"RELEASE_TOKEN",
|
||||
"test"));
|
||||
env.putAll(Map.of(
|
||||
"DEPLOY_HOST",
|
||||
"nas",
|
||||
"DEPLOY_USER",
|
||||
"deploy",
|
||||
"DEPLOY_DIRECTORY",
|
||||
"/tmp/production",
|
||||
"DEPLOY_SSH_KEY",
|
||||
"test-private-key",
|
||||
"DEPLOY_KNOWN_HOSTS",
|
||||
"test-pinned-host"));
|
||||
return env;
|
||||
}
|
||||
|
||||
static ReleaseState.Deployment deployment(String version, String sha, String directory) {
|
||||
return new ReleaseState.Deployment(
|
||||
ReleaseVersion.parse(version),
|
||||
sha,
|
||||
"nas:3000/owner/recipe-demo-backend@sha256:" + "1".repeat(64),
|
||||
"nas:3000/owner/recipe-demo-frontend@sha256:" + "2".repeat(64),
|
||||
directory,
|
||||
"10");
|
||||
}
|
||||
|
||||
static ReleaseState prior() {
|
||||
return new ReleaseState(
|
||||
deployment("v1.0.1", OLD, "previous-current"),
|
||||
deployment("v1.0.0", OLD, "previous-old"),
|
||||
ReleaseVersion.parse("v1.0.1"));
|
||||
}
|
||||
|
||||
static void run() throws Exception {
|
||||
count = 0;
|
||||
test("first release and all increments reset the correct components", () -> {
|
||||
CiTests.equal("v1.0.0", ReleaseVersion.next(List.of(), "major").toString());
|
||||
var versions = List.of(ReleaseVersion.parse("v1.2.3"), ReleaseVersion.parse("v1.1.9"));
|
||||
CiTests.equal("v1.2.4", ReleaseVersion.next(versions, "patch").toString());
|
||||
CiTests.equal("v1.3.0", ReleaseVersion.next(versions, "minor").toString());
|
||||
CiTests.equal("v2.0.0", ReleaseVersion.next(versions, "major").toString());
|
||||
CiTests.equal(
|
||||
"v1.0.100000000000000000000",
|
||||
ReleaseVersion.next(List.of(ReleaseVersion.parse("v1.0.99999999999999999999")), "patch")
|
||||
.toString());
|
||||
for (String invalid : List.of("1.0.0", "v01.0.0", "v1.0.0-rc1", "v1.0.0+build", "v1.0", "v-1.0.0"))
|
||||
CiTests.failure(() -> ReleaseVersion.parse(invalid));
|
||||
});
|
||||
test("branch, nonmanual, malformed input and conflicting new-release inputs are rejected", () -> {
|
||||
var env = environment();
|
||||
Release.validate(env, "deploy");
|
||||
env.put("CI_REF", "refs/heads/feature/S6");
|
||||
CiTests.failure(() -> Release.validate(env, "deploy"));
|
||||
env.put("CI_REF", "refs/heads/main");
|
||||
env.put("CI_EVENT", "push");
|
||||
CiTests.failure(() -> Release.validate(env, "rollback"));
|
||||
env.put("CI_EVENT", "workflow_dispatch");
|
||||
env.put("RELEASE_VERSION", "v1.0.0");
|
||||
CiTests.failure(() -> Release.validate(env, "deploy"));
|
||||
env.put("RELEASE_MODE", "retry");
|
||||
Release.validate(env, "deploy");
|
||||
env.put("RELEASE_VERSION", "bad");
|
||||
CiTests.failure(() -> Release.validate(env, "deploy"));
|
||||
});
|
||||
test("successful release stages images before tag, checks main again and commits state", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.release.run();
|
||||
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
|
||||
CiTests.equal(false, f.remote.locked);
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
CiTests.equal(true, f.events.indexOf("prepare") < f.events.indexOf("tag"));
|
||||
CiTests.equal(true, f.events.indexOf("tag") < f.events.lastIndexOf("head"));
|
||||
CiTests.equal(true, f.events.lastIndexOf("head") < f.events.indexOf("apply:attempt"));
|
||||
}
|
||||
});
|
||||
test("unverified commit never creates a tag or replaces production", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.verified = false;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(false, f.events.contains("apply:attempt"));
|
||||
CiTests.equal(false, f.remote.locked);
|
||||
}
|
||||
});
|
||||
test("missing images prevent tag allocation and replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.remote.failAction = "prepare";
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
}
|
||||
});
|
||||
test("a tagged commit cannot receive another new version", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("prepare"));
|
||||
}
|
||||
});
|
||||
test("stale dispatch is rejected before pulling or tagging", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.head = OLD;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("prepare"));
|
||||
}
|
||||
});
|
||||
test("main advancing after tag creation preserves tag without changing production", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.api.advanceAfterTag = true;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(1, f.api.tags.size());
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
CiTests.equal(false, f.events.contains("apply:attempt"));
|
||||
}
|
||||
});
|
||||
test("unavailable recovery images block replacement", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failAction = "ensure";
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
}
|
||||
});
|
||||
test("failed candidate restores prior version and still fails the workflow", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failedDirectories.add("attempt");
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(
|
||||
List.of("apply:attempt", "apply:previous-current"),
|
||||
f.events.stream().filter(e -> e.startsWith("apply:")).toList());
|
||||
CiTests.equal(prior(), f.remote.state);
|
||||
CiTests.equal(false, f.remote.pending);
|
||||
CiTests.equal(false, f.remote.locked);
|
||||
CiTests.equal(2, f.api.tags.size());
|
||||
}
|
||||
});
|
||||
test("failed first release retains journal and lock for manual recovery", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.remote.failedDirectories.add("attempt");
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(true, f.remote.pending);
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
CiTests.equal(null, f.remote.state.current());
|
||||
}
|
||||
});
|
||||
test("failed automatic recovery retains journal and lock without retry loop", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failedDirectories.addAll(List.of("attempt", "previous-current"));
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(
|
||||
2L,
|
||||
f.events.stream().filter(e -> e.startsWith("apply:")).count());
|
||||
CiTests.equal(true, f.remote.pending);
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
}
|
||||
});
|
||||
test("ambiguous state commit never launches speculative rollback", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.withPrior();
|
||||
f.remote.failAction = "commit";
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(
|
||||
1L,
|
||||
f.events.stream().filter(e -> e.startsWith("apply:")).count());
|
||||
CiTests.equal(true, f.remote.pending);
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
}
|
||||
});
|
||||
test("retry keeps existing version and SHA after main advances", () -> {
|
||||
try (var f = new Fixture("retry")) {
|
||||
f.api.head = OLD;
|
||||
f.release.run();
|
||||
CiTests.equal(SHA, f.remote.state.current().sha());
|
||||
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(true, f.events.contains("annotated"));
|
||||
}
|
||||
});
|
||||
test("retry cannot cross the successful-version high watermark after rollback", () -> {
|
||||
try (var f = new Fixture("retry")) {
|
||||
f.remote.state = prior().success(prior().previous());
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("apply:attempt"));
|
||||
}
|
||||
});
|
||||
test("manual rollback preserves version baseline and creates no tag", () -> {
|
||||
try (var f = new Fixture("rollback")) {
|
||||
f.remote.state = prior();
|
||||
f.release.run();
|
||||
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
|
||||
CiTests.equal("v1.0.1", f.remote.state.previous().version().toString());
|
||||
CiTests.equal("v1.0.1", f.remote.state.highest().toString());
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
}
|
||||
});
|
||||
test("redeploying current version preserves a distinct rollback target", () -> {
|
||||
var state = prior();
|
||||
CiTests.equal(state.previous(), state.success(state.current()).previous());
|
||||
CiTests.equal(state, ReleaseState.parse(state.json()));
|
||||
});
|
||||
test("NAS lock contention cannot allocate tags or replace production", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.remote.locked = true;
|
||||
CiTests.failure(f.release::run);
|
||||
CiTests.equal(false, f.events.contains("tag"));
|
||||
CiTests.equal(true, f.remote.locked);
|
||||
}
|
||||
});
|
||||
apiTests();
|
||||
shellTests();
|
||||
ReleaseShellTests.run();
|
||||
System.out.println(count + " release behavior tests passed");
|
||||
}
|
||||
|
||||
static class FakeApi extends ReleaseApi {
|
||||
final List<String> events;
|
||||
final List<Tag> tags = new ArrayList<>();
|
||||
String head = SHA;
|
||||
boolean verified = true, advanceAfterTag;
|
||||
|
||||
FakeApi(Map<String, String> env, List<String> events) {
|
||||
super(env);
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Override
|
||||
String head() {
|
||||
events.add("head");
|
||||
return head;
|
||||
}
|
||||
|
||||
@Override
|
||||
List<Tag> tags() {
|
||||
return List.copyOf(tags);
|
||||
}
|
||||
|
||||
@Override
|
||||
String verified(String sha) {
|
||||
Pipeline.check(verified, "Unverified test commit");
|
||||
events.add("verified");
|
||||
return "10";
|
||||
}
|
||||
|
||||
@Override
|
||||
void annotated(Tag tag) {
|
||||
events.add("annotated");
|
||||
}
|
||||
|
||||
@Override
|
||||
void create(ReleaseVersion version, String sha, String attempt) {
|
||||
events.add("tag");
|
||||
tags.add(new Tag(version, sha, OLD));
|
||||
if (advanceAfterTag) head = OLD;
|
||||
}
|
||||
}
|
||||
|
||||
static class FakeRemote extends ReleaseRemote {
|
||||
final List<String> events;
|
||||
final List<String> failedDirectories = new ArrayList<>();
|
||||
ReleaseState state = new ReleaseState(null, null, null);
|
||||
boolean locked, pending;
|
||||
String failAction = "";
|
||||
|
||||
FakeRemote(Path root, Map<String, String> env, List<String> events) throws Exception {
|
||||
super(root, env, new Commands(Duration.ofMinutes(1)), "attempt");
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Override
|
||||
String call(String action, String input, String... args) {
|
||||
events.add(action.equals("apply") ? action + ":" + args[0] : action);
|
||||
Pipeline.check(!action.equals(failAction), "Injected remote failure");
|
||||
switch (action) {
|
||||
case "lock" -> {
|
||||
Pipeline.check(!locked && !pending, "Locked");
|
||||
locked = true;
|
||||
}
|
||||
case "unlock" -> {
|
||||
Pipeline.check(!pending, "Pending");
|
||||
locked = false;
|
||||
}
|
||||
case "state" -> {
|
||||
return state.current() == null ? "{}" : state.json();
|
||||
}
|
||||
case "pending" -> pending = true;
|
||||
case "finish" -> pending = false;
|
||||
case "commit" -> state = ReleaseState.parse(input);
|
||||
case "prepare" -> {
|
||||
return "S6_BACKEND=" + deployment("v1.0.0", SHA, "attempt").backend() + "\nS6_FRONTEND="
|
||||
+ deployment("v1.0.0", SHA, "attempt").frontend();
|
||||
}
|
||||
case "apply" -> Pipeline.check(!failedDirectories.contains(args[0]), "Injected unhealthy release");
|
||||
default -> {}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static class Fixture implements AutoCloseable {
|
||||
final Path root = Files.createTempDirectory("release-test-");
|
||||
final Map<String, String> env = environment();
|
||||
final List<String> events = new ArrayList<>();
|
||||
final FakeApi api;
|
||||
final FakeRemote remote;
|
||||
final Release release;
|
||||
|
||||
Fixture() throws Exception {
|
||||
this("new");
|
||||
}
|
||||
|
||||
Fixture(String mode) throws Exception {
|
||||
Files.createDirectories(root.resolve("deploy"));
|
||||
Files.createDirectories(root.resolve("generated/deploy"));
|
||||
env.put("PROJECT_NAME", "recipe-demo");
|
||||
env.put("BACKEND_MODULE", "backend");
|
||||
env.put("FRONTEND_MODULE", "frontend");
|
||||
env.put("COMPOSE_PROJECT", "recipe-demo-test");
|
||||
Files.writeString(root.resolve("generated/deploy/remote.sh"), "");
|
||||
Files.writeString(root.resolve("deploy/compose.yaml"), "services: {}\n");
|
||||
Files.writeString(root.resolve("deploy/health.sh"), "application_health() { true; }\n");
|
||||
env.put("RELEASE_MODE", mode);
|
||||
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
|
||||
api = new FakeApi(env, events);
|
||||
if (mode.equals("retry")) api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
|
||||
remote = new FakeRemote(root, env, events);
|
||||
release = new Release(root, env, api, remote, mode.equals("rollback") ? "rollback" : "deploy", "attempt");
|
||||
}
|
||||
|
||||
void withPrior() {
|
||||
remote.state = prior();
|
||||
api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.1"), OLD, OLD));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
remote.close();
|
||||
Pipeline.deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
static void apiTests() throws Exception {
|
||||
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
|
||||
try (var f = new Fixture()) {
|
||||
f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000");
|
||||
CiTests.equal("127.0.0.1:3000/owner/recipe-demo-", f.release.prefix());
|
||||
f.env.put("DEPLOY_REGISTRY", "other-registry:3000");
|
||||
CiTests.failure(f.release::prefix);
|
||||
}
|
||||
});
|
||||
test("repeating remote tag pages fail closed instead of allocating from incomplete history", () -> {
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return List.of(Map.of("name", "v1.0.0", "id", OLD, "commit", Map.of("sha", SHA)));
|
||||
}
|
||||
};
|
||||
CiTests.failure(api::tags);
|
||||
});
|
||||
test("annotated release tag must identify the recorded version and exact SHA", () -> {
|
||||
var tag = new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD);
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("tag", "v1.0.0", "object", Map.of("sha", SHA));
|
||||
}
|
||||
};
|
||||
api.annotated(tag);
|
||||
var wrong = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("tag", "v1.0.0", "object", Map.of("sha", OLD));
|
||||
}
|
||||
};
|
||||
CiTests.failure(() -> wrong.annotated(tag));
|
||||
});
|
||||
test("Gitea API eligibility validates SHA, event, branch, workflow path and successful conclusion", () -> {
|
||||
var valid = Map.<String, Object>of(
|
||||
"id",
|
||||
1,
|
||||
"head_sha",
|
||||
SHA,
|
||||
"event",
|
||||
"push",
|
||||
"head_branch",
|
||||
"main",
|
||||
"path",
|
||||
"build.yaml@refs/heads/main",
|
||||
"status",
|
||||
"completed",
|
||||
"conclusion",
|
||||
"success");
|
||||
for (String field : List.of("head_sha", "event", "head_branch", "path", "status", "conclusion")) {
|
||||
var invalid = new HashMap<>(valid);
|
||||
invalid.put(field, "wrong");
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("workflow_runs", List.of(invalid));
|
||||
}
|
||||
};
|
||||
CiTests.failure(() -> api.verified(SHA));
|
||||
}
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return Map.of("workflow_runs", List.of(valid));
|
||||
}
|
||||
};
|
||||
CiTests.equal("1", api.verified(SHA));
|
||||
});
|
||||
test("remote tag pagination includes failed-release baseline and rejects ambiguous tag formats", () -> {
|
||||
var api = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return path.endsWith("page=1")
|
||||
? List.of(Map.of("name", "v1.2.3", "id", OLD, "commit", Map.of("sha", SHA)))
|
||||
: List.of();
|
||||
}
|
||||
};
|
||||
CiTests.equal(
|
||||
"v1.2.4",
|
||||
ReleaseVersion.next(
|
||||
api.tags().stream()
|
||||
.map(ReleaseApi.Tag::version)
|
||||
.toList(),
|
||||
"patch")
|
||||
.toString());
|
||||
var invalid = new ReleaseApi(environment()) {
|
||||
@Override
|
||||
Object request(String method, String path, Object body) {
|
||||
return List.of(Map.of("name", "1.0", "id", OLD, "commit", Map.of("sha", SHA)));
|
||||
}
|
||||
};
|
||||
CiTests.failure(invalid::tags);
|
||||
});
|
||||
}
|
||||
|
||||
static void shellTests() throws Exception {
|
||||
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
|
||||
Path directory = Files.createTempDirectory("release-shell-test-");
|
||||
try {
|
||||
String script = Files.readString(Path.of("generated/deploy/remote.sh"));
|
||||
var commands = new Commands(Duration.ofSeconds(30));
|
||||
var env = environment();
|
||||
var first = commands.execute(
|
||||
List.of("sh", "-c", script, "--", "lock", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false);
|
||||
first.checked();
|
||||
CiTests.equal(
|
||||
1,
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "lock", directory.toString(), "two"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.code());
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "pending", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
"{\"candidate\":\"test\"}",
|
||||
false)
|
||||
.checked();
|
||||
CiTests.equal(
|
||||
1,
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.code());
|
||||
CiTests.equal(true, Files.exists(directory.resolve("pending.json")));
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "finish", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked();
|
||||
commands.execute(
|
||||
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
|
||||
directory,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked();
|
||||
CiTests.equal(false, Files.exists(directory.resolve("deploy.lock")));
|
||||
CiTests.equal(true, Files.exists(directory.resolve("history/one.json")));
|
||||
} finally {
|
||||
Pipeline.deleteTree(directory);
|
||||
}
|
||||
});
|
||||
test("SSH shell quoting preserves metacharacters as data", () -> {
|
||||
String value = "quote' dollar$ backtick` newline\n";
|
||||
var commands = new Commands(Duration.ofSeconds(5));
|
||||
CiTests.equal(
|
||||
value,
|
||||
commands.execute(
|
||||
List.of("sh", "-c", "printf '%s' " + ReleaseRemote.quote(value)),
|
||||
Path.of("."),
|
||||
environment(),
|
||||
null,
|
||||
false)
|
||||
.output());
|
||||
});
|
||||
}
|
||||
}
|
||||
39
resources/ci/ReleaseVersion.java
Normal file
39
resources/ci/ReleaseVersion.java
Normal file
@@ -0,0 +1,39 @@
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Strict stable release versions; arbitrary-size integers avoid overflow/reuse. */
|
||||
record ReleaseVersion(BigInteger major, BigInteger minor, BigInteger patch) implements Comparable<ReleaseVersion> {
|
||||
private static final Pattern FORMAT = Pattern.compile("v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)");
|
||||
|
||||
static ReleaseVersion parse(String value) {
|
||||
var match = FORMAT.matcher(value);
|
||||
Pipeline.check(match.matches(), "Expected release version vMAJOR.MINOR.PATCH");
|
||||
return new ReleaseVersion(
|
||||
new BigInteger(match.group(1)), new BigInteger(match.group(2)), new BigInteger(match.group(3)));
|
||||
}
|
||||
|
||||
static ReleaseVersion next(List<ReleaseVersion> versions, String increment) {
|
||||
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
|
||||
var highest = versions.stream().max(ReleaseVersion::compareTo);
|
||||
if (highest.isEmpty()) return parse("v1.0.0");
|
||||
var current = highest.get();
|
||||
return switch (increment) {
|
||||
case "major" -> new ReleaseVersion(current.major.add(BigInteger.ONE), BigInteger.ZERO, BigInteger.ZERO);
|
||||
case "minor" -> new ReleaseVersion(current.major, current.minor.add(BigInteger.ONE), BigInteger.ZERO);
|
||||
default -> new ReleaseVersion(current.major, current.minor, current.patch.add(BigInteger.ONE));
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ReleaseVersion other) {
|
||||
int value = major.compareTo(other.major);
|
||||
if (value == 0) value = minor.compareTo(other.minor);
|
||||
return value == 0 ? patch.compareTo(other.patch) : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "v" + major + "." + minor + "." + patch;
|
||||
}
|
||||
}
|
||||
287
resources/ci/Setup.java
Normal file
287
resources/ci/Setup.java
Normal file
@@ -0,0 +1,287 @@
|
||||
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 = required(env, "GITEA_SERVER_URL"), base = "/api/v1/repos/" + required(env, "CI_REPOSITORY");
|
||||
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/" + Recipe.project(env) + "-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, String registry) 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(registry)) {
|
||||
System.out.println("Internal registry already configured; no change");
|
||||
return;
|
||||
}
|
||||
registries.add(registry);
|
||||
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-engineering");
|
||||
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/" + Recipe.project(env) + "-runner"),
|
||||
configDir = home().resolve(".config/" + Recipe.project(env) + "-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/runner.service"),
|
||||
units.resolve(Recipe.project(env) + "-runner.service"),
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.copy(
|
||||
root.resolve("generated/ci/runner/WaitForDocker.java"),
|
||||
runnerDir.resolve("WaitForDocker.java"),
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
String dockerHost = env.get("DOCKER_HOST");
|
||||
if (dockerHost == null || dockerHost.isBlank())
|
||||
dockerHost = commands.execute(
|
||||
List.of("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
false)
|
||||
.checked();
|
||||
String path = javaHome + "/bin:" + executable(env, "node").getParent() + ":"
|
||||
+ executable(env, "mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
|
||||
privateWrite(
|
||||
configDir.resolve("environment"),
|
||||
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
|
||||
+ "\n");
|
||||
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
|
||||
Path overrides = units.resolve(Recipe.project(env) + "-runner.service.d");
|
||||
Path desktopOverride = overrides.resolve("10-docker-desktop.conf");
|
||||
if (desktop) {
|
||||
Files.createDirectories(overrides);
|
||||
Files.copy(
|
||||
root.resolve("ci/runner/docker-desktop.conf"),
|
||||
desktopOverride,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
Files.deleteIfExists(desktopOverride);
|
||||
}
|
||||
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
|
||||
.checked();
|
||||
if (prepareOnly) {
|
||||
System.out.println("Runner installed; registration and startup still required.");
|
||||
return;
|
||||
}
|
||||
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
|
||||
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",
|
||||
required(env, "GITEA_SERVER_URL"),
|
||||
"--name",
|
||||
required(env, "RUNNER_LABEL"),
|
||||
"--labels",
|
||||
required(env, "RUNNER_LABEL") + ":host",
|
||||
"--token",
|
||||
token),
|
||||
runnerDir,
|
||||
env,
|
||||
null,
|
||||
true)
|
||||
.checked();
|
||||
Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------"));
|
||||
}
|
||||
if (desktop) {
|
||||
commands.execute(
|
||||
List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
true)
|
||||
.checked();
|
||||
// Replace any previous default.target link with the desktop-login target.
|
||||
commands.execute(
|
||||
List.of("systemctl", "--user", "disable", Recipe.project(env) + "-runner.service"),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
true)
|
||||
.checked();
|
||||
}
|
||||
commands.execute(
|
||||
List.of("systemctl", "--user", "enable", "--now", Recipe.project(env) + "-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);
|
||||
}
|
||||
}
|
||||
35
resources/ci/runner/WaitForDocker.java
Normal file
35
resources/ci/runner/WaitForDocker.java
Normal file
@@ -0,0 +1,35 @@
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Source-launcher startup probe; uses the service's configured DOCKER_HOST. */
|
||||
class WaitForDocker {
|
||||
public static void main(String[] args) throws Exception {
|
||||
long seconds = args.length == 0 ? 120 : Long.parseLong(args[0]);
|
||||
if (seconds < 1 || seconds > 300) throw new IllegalArgumentException("Timeout must be 1–300 seconds");
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
|
||||
System.out.println("Waiting for the configured Docker engine...");
|
||||
while (System.nanoTime() < deadline) {
|
||||
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start();
|
||||
try {
|
||||
long remaining = Math.max(1, deadline - System.nanoTime());
|
||||
if (process.waitFor(Math.min(remaining, TimeUnit.SECONDS.toNanos(5)), TimeUnit.NANOSECONDS)
|
||||
&& process.exitValue() == 0) {
|
||||
System.out.println("Docker is available; starting the Gitea runner.");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
if (process.isAlive()) {
|
||||
process.destroyForcibly();
|
||||
process.waitFor();
|
||||
}
|
||||
}
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining > 0) TimeUnit.NANOSECONDS.sleep(Math.min(remaining, TimeUnit.SECONDS.toNanos(2)));
|
||||
}
|
||||
System.err.println("Docker did not become available within " + seconds + " seconds; runner not started.");
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user