- Added functionality to persist `.env` files to a private sidecar directory before Git checkout replacement. - Enhanced recovery of `.env` files from the sidecar after cloning, with fallback to templates if necessary. - Introduced validation for `envFilePath` to ensure it is an absolute path and integrated it into Docker run commands. - Updated deployment routes and rollback services to support the new environment file handling. - Added regression tests to verify the persistence and recovery of environment variables across rebuilds.
239 lines
8.6 KiB
TypeScript
239 lines
8.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { toImmutableBuildImageTag } from "../../src/server/services/ssh-services/docker-containers";
|
|
|
|
const CONTAINER_ROUTES = "src/server/routes/containers.ts";
|
|
const APP_ROUTES = "src/server/routes/apps.ts";
|
|
const DOCKER_CONTAINERS =
|
|
"src/server/services/ssh-services/docker-containers.ts";
|
|
const APP_REBUILD_SERVICE =
|
|
"src/server/services/app-install-rebuild.service.ts";
|
|
const RUNTIME_ORCHESTRATOR =
|
|
"src/server/services/deployment-rollback.service.ts";
|
|
|
|
function readSource(path: string) {
|
|
return readFileSync(path, "utf8");
|
|
}
|
|
|
|
function sourceBlock(source: string, startPattern: string, endPattern: string) {
|
|
const start = source.indexOf(startPattern);
|
|
assert.notEqual(start, -1, `Missing source block start: ${startPattern}`);
|
|
|
|
const end = source.indexOf(endPattern, start);
|
|
assert.notEqual(end, -1, `Missing source block end: ${endPattern}`);
|
|
|
|
return source.slice(start, end);
|
|
}
|
|
|
|
test("Git rebuild follows the deployment history lifecycle", () => {
|
|
const source = readSource(CONTAINER_ROUTES);
|
|
const rebuildRoute = sourceBlock(
|
|
source,
|
|
'"/:id/rebuild"',
|
|
"GET /containers/:id/logs",
|
|
);
|
|
|
|
const acquireIndex = rebuildRoute.indexOf("acquireDeploymentLock");
|
|
const buildingIndex = rebuildRoute.indexOf('status: buildType === "COMPOSE" ? "RUNNING" : "BUILDING"');
|
|
const mutationIndex = rebuildRoute.indexOf("deployContainerFromGitSource");
|
|
const orchestrationIndex = rebuildRoute.indexOf(
|
|
"orchestratePreparedRuntimeReplacement",
|
|
);
|
|
const successIndex = rebuildRoute.indexOf('status: "SUCCESS"', mutationIndex);
|
|
|
|
assert.ok(acquireIndex >= 0);
|
|
assert.ok(buildingIndex > acquireIndex);
|
|
assert.ok(mutationIndex > buildingIndex);
|
|
assert.ok(orchestrationIndex > mutationIndex);
|
|
assert.ok(successIndex > mutationIndex);
|
|
assert.match(rebuildRoute, /startRuntime: buildType === "COMPOSE" \? undefined : false/);
|
|
assert.match(rebuildRoute, /immutableImageTag: buildType === "COMPOSE" \? undefined : true/);
|
|
assert.doesNotMatch(
|
|
rebuildRoute.slice(buildingIndex, mutationIndex),
|
|
/dockerAction\([^)]*,\s*"(?:stop|rm)"/,
|
|
);
|
|
assert.doesNotMatch(rebuildRoute, /waitForDeploymentHealth/);
|
|
assert.match(
|
|
rebuildRoute,
|
|
/status: runtimePreserved \? "FAILED_ROLLED_BACK" : "FAILED"/,
|
|
);
|
|
assert.match(rebuildRoute, /releaseDeploymentLock/);
|
|
assert.match(rebuildRoute, /commitSha: gitDeploymentResult\.commitSha/);
|
|
assert.match(rebuildRoute, /imageDigest/);
|
|
});
|
|
|
|
test("Fresh App Installer creates a provisional parent and RUNNING deployment before Docker mutation", () => {
|
|
const source = readSource(APP_ROUTES);
|
|
const installRoute = sourceBlock(
|
|
source,
|
|
'app.post("/install"',
|
|
'"/installs/:id/action"',
|
|
);
|
|
|
|
const provisionalIndex = installRoute.indexOf(
|
|
"const createdContainer = await tx.container.create",
|
|
);
|
|
const runningIndex = installRoute.indexOf('status: "RUNNING"');
|
|
const mutationIndex = installRoute.indexOf(
|
|
"const dockerId = await ssh.runContainer",
|
|
);
|
|
|
|
assert.ok(provisionalIndex >= 0);
|
|
assert.ok(runningIndex > provisionalIndex);
|
|
assert.ok(mutationIndex > runningIndex);
|
|
assert.match(installRoute, /where: \{ id: deployment\.id \}/);
|
|
assert.match(installRoute, /status: "FAILED"/);
|
|
assert.match(installRoute, /where: \{ id: provisionalContainer\.id \}/);
|
|
});
|
|
|
|
test("Git deployment resolves and returns the cloned commit SHA", () => {
|
|
const source = readSource(DOCKER_CONTAINERS);
|
|
const start = source.indexOf(
|
|
"export async function deployContainerFromGitSource",
|
|
);
|
|
assert.notEqual(start, -1);
|
|
const gitDeployFunction = source.slice(start);
|
|
|
|
assert.match(gitDeployFunction, /git -C .* rev-parse HEAD/);
|
|
assert.match(gitDeployFunction, /commitSha: string/);
|
|
assert.match(gitDeployFunction, /commitSha,/);
|
|
assert.match(gitDeployFunction, /toImmutableBuildImageTag/);
|
|
assert.match(gitDeployFunction, /opts\.startRuntime === false/);
|
|
assert.match(gitDeployFunction, /preparedRuntime/);
|
|
});
|
|
|
|
test("Git rebuild preserves the project env outside the replaceable checkout", () => {
|
|
const source = readSource(DOCKER_CONTAINERS);
|
|
const start = source.indexOf(
|
|
"export async function deployContainerFromGitSource",
|
|
);
|
|
assert.notEqual(start, -1);
|
|
const gitDeployFunction = source.slice(start);
|
|
|
|
const persistIndex = gitDeployFunction.indexOf(
|
|
'persist_env "$ROOT_ENV" "$ROOT_MANAGED_ENV"',
|
|
);
|
|
const removeIndex = gitDeployFunction.indexOf('rm -rf "$DEPLOY_PATH"');
|
|
const cloneIndex = gitDeployFunction.indexOf("cloneCommand,", removeIndex);
|
|
const restoreIndex = gitDeployFunction.indexOf(
|
|
'restore_env "$ROOT_MANAGED_ENV" "$ROOT_ENV"',
|
|
);
|
|
const templateIndex = gitDeployFunction.indexOf(
|
|
'if [ -f "$TARGET_DIR/.env.example" ]',
|
|
);
|
|
|
|
assert.ok(persistIndex >= 0);
|
|
assert.ok(removeIndex > persistIndex);
|
|
assert.ok(cloneIndex > removeIndex);
|
|
assert.ok(restoreIndex > cloneIndex);
|
|
assert.ok(templateIndex > restoreIndex);
|
|
assert.match(gitDeployFunction, /managedEnvDirectory = `\$\{deploymentPath\}\.doktainer`/);
|
|
assert.match(gitDeployFunction, /chmod 700 .*MANAGED_ENV_DIR/);
|
|
assert.match(gitDeployFunction, /chmod 600 .*TEMP_ENV/);
|
|
assert.match(gitDeployFunction, /BUILD_PATH/);
|
|
assert.match(gitDeployFunction, /envFilePath: runtimeEnvFilePath/);
|
|
});
|
|
|
|
test("Git container env edits are mirrored to the deployment checkout", () => {
|
|
const source = readSource(CONTAINER_ROUTES);
|
|
const readRouteIndex = source.indexOf('"/:id/project-env"');
|
|
const writeRouteIndex = source.indexOf(
|
|
'"/:id/project-env"',
|
|
readRouteIndex + 1,
|
|
);
|
|
const nextRouteIndex = source.indexOf('"/:id/exec"', writeRouteIndex);
|
|
assert.ok(readRouteIndex >= 0);
|
|
assert.ok(writeRouteIndex > readRouteIndex);
|
|
assert.ok(nextRouteIndex > writeRouteIndex);
|
|
const envRoute = source.slice(writeRouteIndex, nextRouteIndex);
|
|
const containerWriteIndex = envRoute.indexOf("ssh.writeContainerFile");
|
|
const gitSourceCheckIndex = envRoute.indexOf(
|
|
"isGitRedeploySourceType(container.sourceType)",
|
|
);
|
|
const deploymentWriteIndex = envRoute.indexOf(
|
|
"ssh.writeDeploymentEnvFile",
|
|
containerWriteIndex,
|
|
);
|
|
|
|
assert.ok(containerWriteIndex >= 0);
|
|
assert.ok(gitSourceCheckIndex > containerWriteIndex);
|
|
assert.ok(deploymentWriteIndex > gitSourceCheckIndex);
|
|
assert.match(envRoute, /normalizeDeploymentChildPath/);
|
|
assert.match(
|
|
envRoute,
|
|
/active container \.env was updated, but Doktainer could not persist it for the next rebuild/,
|
|
);
|
|
});
|
|
|
|
test("Git rebuild applies the managed env file to target and recovery runtimes", () => {
|
|
const source = readSource(CONTAINER_ROUTES);
|
|
const rebuildRoute = sourceBlock(
|
|
source,
|
|
'"/:id/rebuild"',
|
|
"GET /containers/:id/logs",
|
|
);
|
|
|
|
assert.match(rebuildRoute, /recoverablePreviousRuntime/);
|
|
assert.match(
|
|
rebuildRoute,
|
|
/prepared\.envFilePath \?\? previousRuntime\.envFilePath/,
|
|
);
|
|
assert.match(
|
|
rebuildRoute,
|
|
/previousRuntime: recoverablePreviousRuntime/,
|
|
);
|
|
});
|
|
|
|
test("Git rebuild image tags are immutable and preserve registry ports", () => {
|
|
assert.equal(
|
|
toImmutableBuildImageTag(
|
|
"registry.example.test:5000/team/app:latest",
|
|
"abcdef1234567890",
|
|
),
|
|
"registry.example.test:5000/team/app:git-abcdef123456",
|
|
);
|
|
assert.equal(
|
|
toImmutableBuildImageTag("team/app", "0123456789abcdef"),
|
|
"team/app:git-0123456789ab",
|
|
);
|
|
assert.throws(
|
|
() => toImmutableBuildImageTag("team/app:latest", "not-a-sha"),
|
|
/valid Git commit SHA/,
|
|
);
|
|
});
|
|
|
|
test("App Installer rebuild prepares an immutable image before orchestration", () => {
|
|
const source = readSource(APP_REBUILD_SERVICE);
|
|
const pullIndex = source.indexOf("dockerPullImage");
|
|
const immutableIndex = source.indexOf(
|
|
"The rebuilt image could not be resolved to an immutable Docker image ID",
|
|
);
|
|
const orchestrationIndex = source.indexOf(
|
|
"orchestratePreparedRuntimeReplacement",
|
|
immutableIndex,
|
|
);
|
|
|
|
assert.ok(pullIndex >= 0);
|
|
assert.ok(immutableIndex > pullIndex);
|
|
assert.ok(orchestrationIndex > immutableIndex);
|
|
assert.match(source, /status: "BUILDING"/);
|
|
assert.match(source, /status: runtimePreserved \? "FAILED_ROLLED_BACK" : "FAILED"/);
|
|
});
|
|
|
|
test("stored rollback and redeploy share the prepared runtime orchestrator", () => {
|
|
const source = readSource(RUNTIME_ORCHESTRATOR);
|
|
const storedRevision = sourceBlock(
|
|
source,
|
|
"async function deployStoredRevision",
|
|
"export async function rollbackContainerToDeployment",
|
|
);
|
|
|
|
assert.match(storedRevision, /resolveRuntimeReplacementPlan/);
|
|
assert.match(storedRevision, /orchestratePreparedRuntimeReplacement/);
|
|
assert.doesNotMatch(
|
|
storedRevision,
|
|
/input\.operation === "REDEPLOY"\s*&&\s*strategy/,
|
|
);
|
|
});
|