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 env, String key) { String value = env.get(key); if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key); return value; } static String secret(Map env, String key) throws IOException { Path path = Path.of(required(env, key)); Set 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 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)); IO.println("Actions, project registry secrets and platform configured; using the shared runner."); } static String runnerName(final Map 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 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 env) throws Exception { final String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"); final Map 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"))); IO.println("Shared runner registration token saved outside Git; run install-runner once on the workstation."); } static void checkRunner(final Map 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 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 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 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 response, final String label) { for (final Object entry : (List) response.getOrDefault("runners", List.of())) { final Map 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 { Map 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 registries = new ArrayList<>((List) entry); if (registries.contains(registry)) { IO.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); } IO.println("Internal registry added; restart/apply Docker Engine configuration to activate"); } static void installRunner(Path root, Map 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/" + 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); 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")) .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"), 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(runnerName(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) { IO.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 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", runnerName(env), "--labels", env.getOrDefault("RUNNER_LABEL", "mini-pc") + ":host", "--token", token), runnerDir, env, null, true) .checked(); Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------")); } if (desktop) { commands.execute( List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"), root, env, null, true) .checked(); // Replace any previous default.target link with the desktop-login target. commands.execute( List.of("systemctl", "--user", "disable", runnerName(env) + "-runner.service"), root, env, null, true) .checked(); } commands.execute( List.of("systemctl", "--user", "enable", "--now", runnerName(env) + "-runner.service"), root, env, null, true) .checked(); IO.println("Runner registered and started; keep the mini-PC and Docker available."); } static Path executable(Map 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); } }