245 lines
12 KiB
Java
245 lines
12 KiB
Java
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
|
|
""";
|
|
}
|