S19: share workstation runner and move agent workflows to landscape
This commit is contained in:
@@ -3,9 +3,15 @@ import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/** Run from the repository root with JDK 25: java ci/Ci.java <command>. */
|
||||
/** Run from the repository root with JDK 25: java generated/ci/Ci.java <command>. */
|
||||
public class Ci {
|
||||
public static void main(String[] args) {
|
||||
/**
|
||||
* Runs a CI, release or workstation setup command from the consumer repository root.
|
||||
*
|
||||
* @param args command followed by its optional arguments; configuration comes from the project and environment
|
||||
* @implNote Requires JDK 25. Failures exit nonzero with redacted diagnostics; setup commands can change local or Gitea state.
|
||||
*/
|
||||
public static void main(final 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());
|
||||
@@ -30,6 +36,8 @@ public class Ci {
|
||||
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
}
|
||||
case "configure-gitea" -> Setup.configureGitea(env);
|
||||
case "configure-runner" -> Setup.configureRunner(env);
|
||||
case "check-runner" -> Setup.checkRunner(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"));
|
||||
@@ -56,7 +64,8 @@ public class Ci {
|
||||
}
|
||||
|
||||
static String usage() {
|
||||
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
|
||||
return "Usage: java generated/ci/Ci.java"
|
||||
+ " verify|publish|cleanup|attempt|test|configure-gitea|configure-runner|check-runner|configure-docker"
|
||||
+ " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class CiTests {
|
||||
static void test(String name, Test test) throws Exception {
|
||||
test.run();
|
||||
count++;
|
||||
System.out.println("PASS " + name);
|
||||
IO.println("PASS " + name);
|
||||
}
|
||||
|
||||
static void equal(Object expected, Object actual) {
|
||||
@@ -32,6 +32,55 @@ class CiTests {
|
||||
|
||||
static void run() throws Exception {
|
||||
count = 0;
|
||||
test("shared runner scope and identity are independent of the application", () -> {
|
||||
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "first")));
|
||||
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "second")));
|
||||
equal("/api/v1/user/actions/runners/registration-token", Setup.runnerRegistrationPath(Map.of()));
|
||||
equal(
|
||||
"/api/v1/admin/actions/runners/registration-token",
|
||||
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "instance")));
|
||||
equal(
|
||||
"/api/v1/orgs/team/actions/runners/registration-token",
|
||||
Setup.runnerRegistrationPath(
|
||||
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "team")));
|
||||
equal(
|
||||
"/api/v1/repos/owner/app/actions/runners/registration-token",
|
||||
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "repository", "CI_REPOSITORY", "owner/app")));
|
||||
failure(() -> Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "unknown")));
|
||||
failure(() -> Setup.runnerRegistrationPath(
|
||||
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "../unsafe")));
|
||||
failure(() -> Setup.runnerName(Map.of("RUNNER_NAME", "../unsafe")));
|
||||
});
|
||||
test("runner preflight rejects unavailable offline and differently labelled runners", () -> {
|
||||
equal(false, Setup.hasRunner(Map.of("runners", List.of()), "mini-pc"));
|
||||
equal(
|
||||
false,
|
||||
Setup.hasRunner(
|
||||
Map.of(
|
||||
"runners",
|
||||
List.of(Map.of("status", "offline", "labels", List.of(Map.of("name", "mini-pc"))))),
|
||||
"mini-pc"));
|
||||
equal(
|
||||
false,
|
||||
Setup.hasRunner(
|
||||
Map.of(
|
||||
"runners",
|
||||
List.of(Map.of("status", "online", "labels", List.of(Map.of("name", "other"))))),
|
||||
"mini-pc"));
|
||||
equal(
|
||||
true,
|
||||
Setup.hasRunner(
|
||||
Map.of(
|
||||
"runners",
|
||||
List.of(Map.of(
|
||||
"status",
|
||||
"online",
|
||||
"busy",
|
||||
true,
|
||||
"labels",
|
||||
List.of(Map.of("name", "mini-pc"))))),
|
||||
"mini-pc"));
|
||||
});
|
||||
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")));
|
||||
@@ -253,7 +302,7 @@ class CiTests {
|
||||
Pipeline.deleteTree(dir);
|
||||
}
|
||||
});
|
||||
System.out.println(count + " CI tests passed");
|
||||
IO.println(count + " CI tests passed");
|
||||
}
|
||||
|
||||
static class Fixture implements AutoCloseable {
|
||||
|
||||
@@ -377,7 +377,7 @@ class Pipeline {
|
||||
|
||||
void publish() throws Exception {
|
||||
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
|
||||
System.out.println("Branch is not main; no publication");
|
||||
IO.println("Branch is not main; no publication");
|
||||
return;
|
||||
}
|
||||
check(
|
||||
@@ -425,7 +425,7 @@ class Pipeline {
|
||||
}
|
||||
for (String role : Recipe.roles(env)) {
|
||||
if (!commit.equals(mainHead())) {
|
||||
System.out.println("Newer main exists; moving tags unchanged");
|
||||
IO.println("Newer main exists; moving tags unchanged");
|
||||
return;
|
||||
}
|
||||
String image = prefix + role;
|
||||
@@ -462,7 +462,7 @@ class Pipeline {
|
||||
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);
|
||||
if (manifest.code() == 0) IO.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);
|
||||
|
||||
@@ -75,7 +75,7 @@ class Release {
|
||||
remote.call("preflight", null);
|
||||
ReleaseState before = ReleaseState.parse(remote.call("state", null));
|
||||
selected = select(before);
|
||||
System.out.println("Selected " + selected.version() + " at " + selected.sha());
|
||||
IO.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")) {
|
||||
@@ -108,7 +108,7 @@ class Release {
|
||||
record(outcome);
|
||||
remote.call("finish", null);
|
||||
pending = false;
|
||||
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
|
||||
IO.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
|
||||
} finally {
|
||||
try {
|
||||
record(outcome);
|
||||
|
||||
@@ -61,7 +61,7 @@ class ReleaseSetup {
|
||||
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(
|
||||
IO.println(remote.executeScript(
|
||||
Files.readString(root.resolve("generated/deploy/provision.sh")),
|
||||
pull + "\n",
|
||||
300,
|
||||
@@ -86,7 +86,7 @@ class ReleaseSetup {
|
||||
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(
|
||||
IO.println(
|
||||
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class ReleaseTests {
|
||||
static void test(String name, CiTests.Test test) throws Exception {
|
||||
test.run();
|
||||
count++;
|
||||
System.out.println("PASS release: " + name);
|
||||
IO.println("PASS release: " + name);
|
||||
}
|
||||
|
||||
static Map<String, String> environment() {
|
||||
@@ -252,7 +252,7 @@ class ReleaseTests {
|
||||
apiTests();
|
||||
shellTests();
|
||||
ReleaseShellTests.run();
|
||||
System.out.println(count + " release behavior tests passed");
|
||||
IO.println(count + " release behavior tests passed");
|
||||
}
|
||||
|
||||
static class FakeApi extends ReleaseApi {
|
||||
|
||||
@@ -79,12 +79,79 @@ class Setup {
|
||||
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");
|
||||
IO.println("Actions, project registry secrets and platform configured; using the shared runner.");
|
||||
}
|
||||
|
||||
static String runnerName(final Map<String, String> env) {
|
||||
final String name = env.getOrDefault("RUNNER_NAME", env.getOrDefault("RUNNER_LABEL", "mini-pc"));
|
||||
Pipeline.check(name.matches("[a-z][a-z0-9-]*"), "Invalid runner name");
|
||||
return name;
|
||||
}
|
||||
|
||||
static String runnerRegistrationPath(final Map<String, String> env) {
|
||||
return switch (env.getOrDefault("RUNNER_SCOPE", "user")) {
|
||||
case "user" -> "/api/v1/user/actions/runners/registration-token";
|
||||
case "instance" -> "/api/v1/admin/actions/runners/registration-token";
|
||||
case "organization" -> {
|
||||
final String organization = required(env, "RUNNER_ORGANIZATION");
|
||||
Pipeline.check(organization.matches("[a-zA-Z0-9_-]+"), "Invalid runner organization");
|
||||
yield "/api/v1/orgs/" + organization + "/actions/runners/registration-token";
|
||||
}
|
||||
case "repository" ->
|
||||
"/api/v1/repos/" + required(env, "CI_REPOSITORY") + "/actions/runners/registration-token";
|
||||
default ->
|
||||
throw new IllegalArgumentException("Runner scope must be user, organization, instance or repository");
|
||||
};
|
||||
}
|
||||
|
||||
static void configureRunner(final Map<String, String> env) throws Exception {
|
||||
final String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE");
|
||||
final Map<String, Object> response =
|
||||
Json.object(api(required(env, "GITEA_SERVER_URL"), admin, "POST", runnerRegistrationPath(env), null));
|
||||
final Path directory = home().resolve(".config/" + runnerName(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.");
|
||||
IO.println("Shared runner registration token saved outside Git; run install-runner once on the workstation.");
|
||||
}
|
||||
|
||||
static void checkRunner(final Map<String, String> env) throws Exception {
|
||||
final String token = secret(env, "GITEA_ADMIN_TOKEN_FILE");
|
||||
final String repository = required(env, "CI_REPOSITORY");
|
||||
final String server = required(env, "GITEA_SERVER_URL");
|
||||
final Map<String, Object> project = Json.object(api(server, token, "GET", "/api/v1/repos/" + repository, null));
|
||||
Pipeline.check(Boolean.TRUE.equals(project.get("has_actions")), "Enable Actions for the repository first");
|
||||
if ("user".equals(env.getOrDefault("RUNNER_SCOPE", "user"))) {
|
||||
final Map<String, Object> user = Json.object(api(server, token, "GET", "/api/v1/user", null));
|
||||
Pipeline.check(
|
||||
repository.split("/", 2)[0].equalsIgnoreCase(Objects.toString(user.get("login"))),
|
||||
"User-scoped runner must belong to the repository owner; use organization scope for an"
|
||||
+ " organization");
|
||||
}
|
||||
// Gitea's repository runner list excludes inherited user/organization runners.
|
||||
final String base = runnerRegistrationPath(env).replace("/registration-token", "");
|
||||
final Map<String, Object> response =
|
||||
Json.object(api(required(env, "GITEA_SERVER_URL"), token, "GET", base, null));
|
||||
Pipeline.check(
|
||||
hasRunner(response, env.getOrDefault("RUNNER_LABEL", "mini-pc")),
|
||||
"No online shared runner with the configured label is available to this repository");
|
||||
IO.println("Shared runner is online and available to " + required(env, "CI_REPOSITORY"));
|
||||
}
|
||||
|
||||
static boolean hasRunner(final Map<String, Object> response, final String label) {
|
||||
for (final Object entry : (List<?>) response.getOrDefault("runners", List.of())) {
|
||||
final Map<String, Object> runner = Json.object(entry);
|
||||
if (!"online".equals(runner.get("status"))
|
||||
&& !"idle".equals(runner.get("status"))
|
||||
&& !"active".equals(runner.get("status"))) {
|
||||
continue;
|
||||
}
|
||||
for (final Object item : (List<?>) runner.getOrDefault("labels", List.of())) {
|
||||
if (label.equals(Json.object(item).get("name"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void configureDocker(Path file, String registry) throws IOException {
|
||||
@@ -95,7 +162,7 @@ class Setup {
|
||||
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");
|
||||
IO.println("Internal registry already configured; no change");
|
||||
return;
|
||||
}
|
||||
registries.add(registry);
|
||||
@@ -114,7 +181,7 @@ class Setup {
|
||||
} finally {
|
||||
Files.deleteIfExists(temp);
|
||||
}
|
||||
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
|
||||
IO.println("Internal registry added; restart/apply Docker Engine configuration to activate");
|
||||
}
|
||||
|
||||
static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly)
|
||||
@@ -123,8 +190,8 @@ class Setup {
|
||||
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 runnerDir = home().resolve(".local/share/" + runnerName(env) + "-runner"),
|
||||
configDir = home().resolve(".config/" + runnerName(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);
|
||||
@@ -164,11 +231,14 @@ class Setup {
|
||||
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);
|
||||
privateWrite(
|
||||
runnerDir.resolve("config.yaml"),
|
||||
Files.readString(root.resolve("ci/runner/config.yaml"))
|
||||
.replace("@RUNNER@", env.getOrDefault("RUNNER_LABEL", "mini-pc")));
|
||||
privateWrite(
|
||||
units.resolve(runnerName(env) + "-runner.service"),
|
||||
Files.readString(root.resolve("ci/runner/runner.service")).replace("@RUNNER_NAME@", runnerName(env)));
|
||||
|
||||
Files.copy(
|
||||
root.resolve("generated/ci/runner/WaitForDocker.java"),
|
||||
runnerDir.resolve("WaitForDocker.java"),
|
||||
@@ -189,7 +259,7 @@ class Setup {
|
||||
"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 overrides = units.resolve(runnerName(env) + "-runner.service.d");
|
||||
Path desktopOverride = overrides.resolve("10-docker-desktop.conf");
|
||||
if (desktop) {
|
||||
Files.createDirectories(overrides);
|
||||
@@ -203,7 +273,7 @@ class Setup {
|
||||
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.");
|
||||
IO.println("Runner installed; registration and startup still required.");
|
||||
return;
|
||||
}
|
||||
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
|
||||
@@ -221,9 +291,9 @@ class Setup {
|
||||
"--instance",
|
||||
required(env, "GITEA_SERVER_URL"),
|
||||
"--name",
|
||||
required(env, "RUNNER_LABEL"),
|
||||
runnerName(env),
|
||||
"--labels",
|
||||
required(env, "RUNNER_LABEL") + ":host",
|
||||
env.getOrDefault("RUNNER_LABEL", "mini-pc") + ":host",
|
||||
"--token",
|
||||
token),
|
||||
runnerDir,
|
||||
@@ -243,7 +313,7 @@ class Setup {
|
||||
.checked();
|
||||
// Replace any previous default.target link with the desktop-login target.
|
||||
commands.execute(
|
||||
List.of("systemctl", "--user", "disable", Recipe.project(env) + "-runner.service"),
|
||||
List.of("systemctl", "--user", "disable", runnerName(env) + "-runner.service"),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
@@ -251,13 +321,13 @@ class Setup {
|
||||
.checked();
|
||||
}
|
||||
commands.execute(
|
||||
List.of("systemctl", "--user", "enable", "--now", Recipe.project(env) + "-runner.service"),
|
||||
List.of("systemctl", "--user", "enable", "--now", runnerName(env) + "-runner.service"),
|
||||
root,
|
||||
env,
|
||||
null,
|
||||
true)
|
||||
.checked();
|
||||
System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
|
||||
IO.println("Runner registered and started; keep the mini-PC and Docker available.");
|
||||
}
|
||||
|
||||
static Path executable(Map<String, String> env, String name) {
|
||||
|
||||
@@ -3,11 +3,18 @@ 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 {
|
||||
/**
|
||||
* Waits for the configured Docker daemon before allowing the runner service to start.
|
||||
*
|
||||
* @param args optional timeout in seconds, from 1 through 300; defaults to 120
|
||||
* @throws Exception if the timeout is invalid or the Docker probe cannot run
|
||||
* @implNote Exits nonzero if Docker does not become ready before the deadline.
|
||||
*/
|
||||
public static void main(final 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...");
|
||||
IO.println("Waiting for the configured Docker engine...");
|
||||
while (System.nanoTime() < deadline) {
|
||||
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
@@ -17,7 +24,7 @@ class WaitForDocker {
|
||||
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.");
|
||||
IO.println("Docker is available; starting the Gitea runner.");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user