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/dance-schools")); 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 env = new HashMap<>(System.getenv()); final String script = Files.readString(Path.of("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/agiledancers-backend@sha256:" + "1".repeat(64); } String frontend() { return "nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64); } void prepare() throws Exception { call("lock", null, "one").checked(); call("preflight", null, "one").checked(); call("stage", Files.readString(Path.of("deploy/compose.yaml")), "one") .checked(); String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/agiledancers-") .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 """; }