feat(docker): add guided install and saved one-command updates
This commit is contained in:
1 parent
8dc187483c
commit
27447ce029
11 files changed
+393
-5
No files matched your search
@@ -36,3 +36,7 @@ db_backup_*.sql
|
|||||||
*.log
|
*.log
|
||||||
.codex
|
.codex
|
||||||
.agents
|
.agents
|
||||||
|
|
||||||
|
.docker-install
|
||||||
|
.docker-install.tmp.*
|
||||||
|
.env.install.*
|
||||||
@@ -45,3 +45,8 @@ coverage/
|
|||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
blob-report/
|
blob-report/
|
||||||
|
|
||||||
|
# Local Docker installation metadata
|
||||||
|
.docker-install
|
||||||
|
.docker-install.tmp.*
|
||||||
|
.env.install.*
|
||||||
@@ -217,6 +217,50 @@ local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `AP
|
|||||||
After changing `.env`, recreate the container; an image rebuild is not required.
|
After changing `.env`, recreate the container; an image rebuild is not required.
|
||||||
`NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image.
|
`NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image.
|
||||||
|
|
||||||
|
### Guided installation from this repository
|
||||||
|
|
||||||
|
On a Linux host with Docker Engine, the Compose plugin, Git and `flock` available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
||||||
|
cd EpicNext-Cms
|
||||||
|
bash cms install
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard asks for your public URL, delivery method, hotel name, existing database
|
||||||
|
connection, Redis and avatar imager. It generates an authentication secret and
|
||||||
|
creates `.env` with private permissions. An existing `.env` is preserved; review
|
||||||
|
its `APP_URL`, `AUTH_URL`, RCON and optional original Laravel `APP_KEY` when moving
|
||||||
|
an existing hotel. This installs the CMS, not the Habbo database, emulator, Redis
|
||||||
|
or reverse proxy. Point HTTPS at port 3002 before running the final public check.
|
||||||
|
The wizard may request sudo to prepare persistent directories for UID/GID 33;
|
||||||
|
it does not recursively change ownership of existing assets.
|
||||||
|
|
||||||
|
Choose **prebuilt** to download the images specified by this repository in
|
||||||
|
`docker-image.txt`, without entering a registry namespace or commit tag. Choose
|
||||||
|
**source** when building your own fork on the installation host. Private packages
|
||||||
|
still require `docker login` on that host. Maintainers must update `docker-image.txt`
|
||||||
|
if the publication registry or namespace changes.
|
||||||
|
|
||||||
|
After installation, update with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash cms update
|
||||||
|
```
|
||||||
|
|
||||||
|
The updater pulls the configured Git upstream, loads `.docker-install`, uses the
|
||||||
|
matching application/migration images and verifies the running release locally
|
||||||
|
and at the saved public URL. If publication for the new commit is still running,
|
||||||
|
it stops before replacing the current container; run the same command after CI
|
||||||
|
succeeds. Existing application rollback remains available on a failed cutover;
|
||||||
|
database migrations are not reversed.
|
||||||
|
|
||||||
|
`bash cms install --configure-only` saves configuration without preparing runtime
|
||||||
|
directories or starting containers. Neither `.env` nor `.docker-install` is
|
||||||
|
committed or sent in Docker build contexts. Existing users of
|
||||||
|
`bash scripts/docker-update.sh` retain the previous behavior when no wizard
|
||||||
|
profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work.
|
||||||
|
|
||||||
To publish from Gitea:
|
To publish from Gitea:
|
||||||
|
|
||||||
Gitea packages belong to an account or organization, independently of repository
|
Gitea packages belong to an account or organization, independently of repository
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
case "${1:-help}" in
|
||||||
|
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
||||||
|
update) shift; [[ $# = 0 ]] || { echo "Usage: bash cms update" >&2; exit 1; }; exec bash "$DIR/scripts/docker-update.sh" ;;
|
||||||
|
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using the saved installation settings' ;;
|
||||||
|
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gitlab.epicnabbo.nl/simo/epicnext-cms
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Read installation metadata as data, never execute it as a shell script.
|
||||||
|
load_docker_config() {
|
||||||
|
local root="$1" key value mode="" public_url="" repository=""
|
||||||
|
[[ -f "$root/.docker-install" ]] || return 0
|
||||||
|
while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
case "$key" in
|
||||||
|
''|'#'*) continue ;;
|
||||||
|
MODE) [[ "$value" = prebuilt || "$value" = source ]] || return 1; mode="$value" ;;
|
||||||
|
PUBLIC_URL) [[ "$value" =~ ^https?://[^[:space:]]+$ && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || return 1; public_url="$value" ;;
|
||||||
|
*) echo "Unknown installation setting: $key" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done < "$root/.docker-install"
|
||||||
|
[[ -n "$mode" && -n "$public_url" ]] || return 1
|
||||||
|
if [[ "$mode" = prebuilt && ! ${CMS_IMAGE_REPOSITORY+x} ]]; then
|
||||||
|
IFS= read -r repository < "$root/docker-image.txt"
|
||||||
|
repository="${repository%$'\r'}"
|
||||||
|
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || return 1
|
||||||
|
export CMS_IMAGE_REPOSITORY="$repository"
|
||||||
|
fi
|
||||||
|
CMS_INSTALL_MODE="$mode"
|
||||||
|
export CMS_PUBLIC_URL="${CMS_PUBLIC_URL-$public_url}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Guided setup for the existing Linux/host-network Compose deployment.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
configure_only=0
|
||||||
|
case "${1:-}" in '') ;; --configure-only) configure_only=1 ;; *) echo "Usage: bash cms install [--configure-only]" >&2; exit 1 ;; esac
|
||||||
|
[[ $# -le 1 ]] || exit 1
|
||||||
|
umask 077
|
||||||
|
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
[[ "$(uname -s)" = Linux ]] || fail "Use a Linux Docker host. This Compose deployment uses host networking."
|
||||||
|
for command in docker git flock od tr mktemp; do command -v "$command" >/dev/null || fail "Install required command: $command"; done
|
||||||
|
docker info >/dev/null 2>&1 || fail "Docker is not reachable. Start Docker and check your account permissions."
|
||||||
|
docker compose version >/dev/null 2>&1 || fail "Install the Docker Compose plugin."
|
||||||
|
exec 9>"$DIR/.deploy.lock"
|
||||||
|
flock -w 30 9 || fail "Another installation or update is running."
|
||||||
|
[[ ! -L .env && ! -L .docker-install ]] || fail "Configuration files must not be symbolic links."
|
||||||
|
[[ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" != true ]] || fail "This host is managed by CI. Do not create a second Compose installation."
|
||||||
|
source "$DIR/scripts/docker-config.sh"
|
||||||
|
load_docker_config "$DIR" || fail "Existing .docker-install is invalid; correct it before continuing."
|
||||||
|
read_value() {
|
||||||
|
local label="$1" default="${2:-}" secret="${3:-0}" value
|
||||||
|
printf '%s' "$label" >&2
|
||||||
|
[[ -z "$default" ]] || printf ' [%s]' "$default" >&2
|
||||||
|
printf ': ' >&2
|
||||||
|
if [[ "$secret" = 1 ]]; then
|
||||||
|
IFS= read -r -s value || fail "Input cancelled."
|
||||||
|
printf '\n' >&2
|
||||||
|
else IFS= read -r value || fail "Input cancelled."; fi
|
||||||
|
REPLY="${value:-$default}"
|
||||||
|
}
|
||||||
|
safe_value() { [[ -n "$1" && "$1" != *"'"* && "$1" != *'$'* && "$1" != *'\'* && ! "$1" =~ [[:cntrl:]] ]]; }
|
||||||
|
http_url() { safe_value "$1" && [[ "$1" =~ ^https?://[^[:space:]]+$ && "$1" != *'@'* && "$1" != *'#'* ]]; }
|
||||||
|
uri_encode() {
|
||||||
|
local LC_ALL=C text="$1" i char
|
||||||
|
for ((i=0;i<${#text};i++)); do
|
||||||
|
char="${text:i:1}"
|
||||||
|
case "$char" in [a-zA-Z0-9.~_-]) printf '%s' "$char" ;; *) printf '%%%02X' "'$char" ;; esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
printf '%s\n' 'EpicNext-Cms installation' 'Requires an existing Habbo database and Redis. Configure HTTPS/reverse proxy to this host on port 3002.'
|
||||||
|
read_value 'Public CMS URL (e.g. https://hotel.example)' "${CMS_PUBLIC_URL:-}"
|
||||||
|
public_url="${REPLY%/}"
|
||||||
|
http_url "$public_url" && [[ "$public_url" != *'?'* ]] || fail "Enter an HTTP(S) URL without credentials, query or fragment."
|
||||||
|
read_value 'Delivery method: prebuilt or source' "${CMS_INSTALL_MODE:-prebuilt}"
|
||||||
|
mode="$REPLY"
|
||||||
|
[[ "$mode" = prebuilt || "$mode" = source ]] || fail "Choose prebuilt or source."
|
||||||
|
if [[ "$mode" = prebuilt ]]; then
|
||||||
|
IFS= read -r repository < docker-image.txt
|
||||||
|
repository="${repository%$'\r'}"
|
||||||
|
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || fail "Invalid docker-image.txt."
|
||||||
|
printf 'Image location is provided by the repository: %s\n' "$repository"
|
||||||
|
fi
|
||||||
|
env_tmp=""; profile_tmp=""
|
||||||
|
cleanup() { [[ -z "$env_tmp" ]] || rm -f -- "$env_tmp"; [[ -z "$profile_tmp" ]] || rm -f -- "$profile_tmp"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
if [[ -e .env ]]; then
|
||||||
|
[[ -f .env ]] || fail ".env must be a regular file."
|
||||||
|
printf '%s\n' 'Existing .env preserved. Check APP_URL/AUTH_URL and database settings there if needed.'
|
||||||
|
else
|
||||||
|
read_value 'Hotel name'; hotel="$REPLY"; safe_value "$hotel" || fail "Invalid hotel name (avoid quotes, dollar signs and backslashes)."
|
||||||
|
read_value 'Database host' '127.0.0.1'; db_host="$REPLY"
|
||||||
|
[[ "$db_host" =~ ^[a-zA-Z0-9.-]+$ ]] || fail "Invalid database hostname."
|
||||||
|
read_value 'Database port' '3306'; db_port="$REPLY"
|
||||||
|
[[ "$db_port" =~ ^[0-9]{1,5}$ ]] && ((10#$db_port>0 && 10#$db_port<=65535)) || fail "Invalid database port."
|
||||||
|
read_value 'Existing database name'; db_name="$REPLY"; [[ -n "$db_name" ]] || fail "Database name is required."
|
||||||
|
read_value 'Database user'; db_user="$REPLY"; [[ -n "$db_user" ]] || fail "Database user is required."
|
||||||
|
read_value 'Database password (hidden)' '' 1; db_password="$REPLY"
|
||||||
|
database_url="mysql://$(uri_encode "$db_user"):$(uri_encode "$db_password")@$db_host:$db_port/$(uri_encode "$db_name")?charset=utf8mb4"
|
||||||
|
unset db_password
|
||||||
|
read_value 'Redis URL (hidden; percent-encode special characters in credentials)' 'redis://127.0.0.1:6379' 1; redis_url="$REPLY"
|
||||||
|
safe_value "$redis_url" && [[ "$redis_url" =~ ^rediss?://[^[:space:]]+$ ]] || fail "Invalid Redis URL."
|
||||||
|
read_value 'Avatar imager URL'; imager_url="$REPLY"; http_url "$imager_url" || fail "Invalid imager URL."
|
||||||
|
auth_secret="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
|
||||||
|
[[ "$auth_secret" =~ ^[0-9a-f]{64}$ ]] || fail "Could not generate the authentication secret."
|
||||||
|
env_tmp="$(mktemp "$DIR/.env.install.XXXXXX")"
|
||||||
|
{
|
||||||
|
printf '%s\n' 'NODE_ENV=production' 'PORT=3002' 'NEXT_TELEMETRY_DISABLED=1' 'AUTH_TRUST_HOST=true' 'CONVERT_PASSWORDS=true'
|
||||||
|
printf "HOTEL_NAME='%s'\nAPP_URL='%s'\nAUTH_URL='%s'\n" "$hotel" "$public_url" "$public_url"
|
||||||
|
printf "DATABASE_URL='%s'\nREDIS_URL='%s'\nAUTH_SECRET='%s'\n" "$database_url" "$redis_url" "$auth_secret"
|
||||||
|
printf "IMAGER_URL='%s'\nIMAGING_UPSTREAM_URL='%s'\nBADGE_URL='/swf/c_images/album1584'\n" "$imager_url" "$imager_url"
|
||||||
|
printf '%s\n' 'RCON_HOST=127.0.0.1' 'RCON_PORT=3003' '# Existing Laravel 2FA: add the original APP_KEY when migrating.'
|
||||||
|
} > "$env_tmp"
|
||||||
|
# Atomic creation fails rather than overwriting a concurrently created .env.
|
||||||
|
ln "$env_tmp" "$DIR/.env" || fail ".env already exists; nothing was overwritten."
|
||||||
|
unset auth_secret database_url redis_url
|
||||||
|
fi
|
||||||
|
profile_tmp="$(mktemp "$DIR/.docker-install.tmp.XXXXXX")"
|
||||||
|
printf 'MODE=%s\nPUBLIC_URL=%s\n' "$mode" "$public_url" > "$profile_tmp"
|
||||||
|
mv -f -- "$profile_tmp" "$DIR/.docker-install"; profile_tmp=""
|
||||||
|
printf '%s\n' 'Configuration saved. Credentials remain in .env; installation settings are excluded from Git.'
|
||||||
|
if [[ "$configure_only" = 1 ]]; then printf '%s\n' 'No container was started. Next: bash cms install'; exit 0; fi
|
||||||
|
as_root() { if [[ "$EUID" = 0 ]]; then "$@"; else command -v sudo >/dev/null || fail "sudo is required to prepare runtime directories."; sudo "$@"; fi; }
|
||||||
|
for path in "$DIR/public/nitro-assets" "$DIR/public/swf" "$DIR/storage" /var/www/Gamedata; do
|
||||||
|
[[ ! -L "$path" ]] || fail "Runtime directory is a symlink: $path. Configure its permissions manually."
|
||||||
|
if [[ ! -d "$path" ]]; then as_root install -d -o 33 -g 33 -m 0755 "$path"; fi
|
||||||
|
# Do not recursively change ownership of existing assets.
|
||||||
|
[[ "$(stat -c '%u:%g' "$path")" = 33:33 ]] || as_root chown 33:33 "$path"
|
||||||
|
done
|
||||||
|
cleanup
|
||||||
|
trap - EXIT
|
||||||
|
exec 9>&-
|
||||||
|
# Avoid a stale value exported while reading previous installation settings.
|
||||||
|
unset CMS_IMAGE_REPOSITORY CMS_PUBLIC_URL
|
||||||
|
exec bash "$DIR/scripts/docker-update.sh"
|
||||||
@@ -69,6 +69,9 @@ if [ "$script_before" != "$(git hash-object scripts/docker-update.sh)" ]; then
|
|||||||
exec 9>&-
|
exec 9>&-
|
||||||
exec bash "$DIR/scripts/docker-update.sh"
|
exec bash "$DIR/scripts/docker-update.sh"
|
||||||
fi
|
fi
|
||||||
|
# Load after pulling so image location changes follow the repository.
|
||||||
|
source "$DIR/scripts/docker-config.sh"
|
||||||
|
load_docker_config "$DIR" || die "Invalid .docker-install: expected MODE=prebuilt or source and PUBLIC_URL=http(s)://your-host."
|
||||||
export CMS_RELEASE="$(git rev-parse HEAD)"
|
export CMS_RELEASE="$(git rev-parse HEAD)"
|
||||||
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
|
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
|
||||||
[[ -f .env ]] || die "Create .env before installing or updating."
|
[[ -f .env ]] || die "Create .env before installing or updating."
|
||||||
@@ -82,16 +85,16 @@ if [[ -n "$previous_container" ]]; then
|
|||||||
rollback_tag="rollback-$CMS_RELEASE-$$"
|
rollback_tag="rollback-$CMS_RELEASE-$$"
|
||||||
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
|
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
|
||||||
fi
|
fi
|
||||||
log "Building release $CMS_RELEASE from $DIR"
|
log "Preparing release $CMS_RELEASE from $DIR"
|
||||||
# The migrations stage contains matching source and locked dependencies.
|
# The migrations stage contains matching source and locked dependencies.
|
||||||
# No Node/package manager installation on the host is required.
|
# No Node/package manager installation on the host is required.
|
||||||
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
|
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
|
||||||
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||||
[[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag."
|
[[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag."
|
||||||
log "Pulling prebuilt application and matching migrations for $CMS_RELEASE"
|
log "Pulling prebuilt application and matching migrations for $CMS_RELEASE"
|
||||||
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1
|
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Application image unavailable. Publication for this commit may still be running; retry bash cms update after CI succeeds. For private packages, log in to the registry first."
|
||||||
remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations"
|
remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations"
|
||||||
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1
|
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1 || die "Matching migrations image unavailable. Current CMS is unchanged; retry after publication succeeds."
|
||||||
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE"
|
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE"
|
||||||
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image"
|
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { delimiter, dirname, join, resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const bash =
|
||||||
|
process.platform === "win32"
|
||||||
|
? ((process.env.PATH ?? "")
|
||||||
|
.split(delimiter)
|
||||||
|
.flatMap((dir) => [
|
||||||
|
join(dir, "bash.exe"),
|
||||||
|
join(dirname(dir), "bin", "bash.exe"),
|
||||||
|
join(dirname(dirname(dir)), "bin", "bash.exe"),
|
||||||
|
])
|
||||||
|
.find((path) => existsSync(path)) ?? "bash")
|
||||||
|
: "bash";
|
||||||
|
function configure(
|
||||||
|
input: string,
|
||||||
|
existing?: string,
|
||||||
|
profile?: string,
|
||||||
|
start = false,
|
||||||
|
) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cms-install-test-"));
|
||||||
|
try {
|
||||||
|
mkdirSync(join(dir, "scripts"));
|
||||||
|
for (const name of ["docker-install.sh", "docker-config.sh"])
|
||||||
|
copyFileSync(resolve(root, "scripts", name), join(dir, "scripts", name));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "scripts/docker-update.sh"),
|
||||||
|
"#!/usr/bin/env bash\nprintf 'Update invoked\\n'\n",
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
resolve(root, "docker-image.txt"),
|
||||||
|
join(dir, "docker-image.txt"),
|
||||||
|
);
|
||||||
|
if (existing !== undefined) writeFileSync(join(dir, ".env"), existing);
|
||||||
|
if (profile !== undefined)
|
||||||
|
writeFileSync(join(dir, ".docker-install"), profile);
|
||||||
|
const result = spawnSync(
|
||||||
|
bash,
|
||||||
|
[
|
||||||
|
join(dir, "scripts/docker-install.sh"),
|
||||||
|
...(start ? [] : ["--configure-only"]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: dir,
|
||||||
|
input,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 15000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BASH_ENV: resolve(root, "src/test/docker-install-harness.sh"),
|
||||||
|
CMS_PUBLIC_URL: undefined,
|
||||||
|
CMS_IMAGE_REPOSITORY: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
output: result.stdout + result.stderr,
|
||||||
|
env: existsSync(join(dir, ".env"))
|
||||||
|
? readFileSync(join(dir, ".env"), "utf8")
|
||||||
|
: "",
|
||||||
|
profile: existsSync(join(dir, ".docker-install"))
|
||||||
|
? readFileSync(join(dir, ".docker-install"), "utf8")
|
||||||
|
: "",
|
||||||
|
injected: existsSync(join(dir, "injected")),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const answers = [
|
||||||
|
"https://hotel.example",
|
||||||
|
"prebuilt",
|
||||||
|
"Test Hotel",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"hotel",
|
||||||
|
"cms",
|
||||||
|
"p@ss'$ word",
|
||||||
|
"",
|
||||||
|
"https://imager.example/imaging",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
describe("guided Docker installation", () => {
|
||||||
|
it("generates a unique secret and encodes database credentials without exposing them", () => {
|
||||||
|
const result = configure(answers);
|
||||||
|
expect(result.status, result.output).toBe(0);
|
||||||
|
expect(result.env).toContain(
|
||||||
|
"mysql://cms:p%40ss%27%24%[email protected]:3306/hotel",
|
||||||
|
);
|
||||||
|
const secret = result.env.match(/AUTH_SECRET='([a-f0-9]{64})'/)?.[1];
|
||||||
|
expect(secret).toHaveLength(64);
|
||||||
|
expect(result.output).not.toContain(secret);
|
||||||
|
expect(result.output).not.toContain("p@ss");
|
||||||
|
expect(result.profile).toBe(
|
||||||
|
"MODE=prebuilt\nPUBLIC_URL=https://hotel.example\n",
|
||||||
|
);
|
||||||
|
expect(result.output).toContain("No container was started");
|
||||||
|
});
|
||||||
|
it("preserves an existing environment byte for byte", () => {
|
||||||
|
const existing = "AUTH_SECRET=keep-me\nDATABASE_URL=existing\n";
|
||||||
|
const result = configure("https://hotel.example\nsource\n", existing);
|
||||||
|
expect(result.status, result.output).toBe(0);
|
||||||
|
expect(result.env).toBe(existing);
|
||||||
|
expect(result.profile).toContain("MODE=source");
|
||||||
|
});
|
||||||
|
it("does not create configuration when input is cancelled", () => {
|
||||||
|
const result = configure("https://hotel.example\nprebuilt\n");
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.env).toBe("");
|
||||||
|
expect(result.profile).toBe("");
|
||||||
|
});
|
||||||
|
it("rejects unsafe dotenv values", () => {
|
||||||
|
const result = configure(answers.replace("Test Hotel", "Hotel '$BAD"));
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.env).toBe("");
|
||||||
|
});
|
||||||
|
it("treats saved settings as data, never shell commands", () => {
|
||||||
|
const result = configure(
|
||||||
|
"",
|
||||||
|
"original",
|
||||||
|
"MODE=$(touch injected)\nPUBLIC_URL=https://hotel.example\n",
|
||||||
|
);
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.env).toBe("original");
|
||||||
|
expect(result.injected).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands a completed installation to the existing updater", () => {
|
||||||
|
const result = configure(answers, undefined, undefined, true);
|
||||||
|
expect(result.status, result.output).toBe(0);
|
||||||
|
expect(result.output).toContain("Update invoked");
|
||||||
|
expect(result.profile).toContain("MODE=prebuilt");
|
||||||
|
});
|
||||||
@@ -30,6 +30,14 @@ function simulate(scenario: string) {
|
|||||||
try {
|
try {
|
||||||
mkdirSync(join(dir, "scripts"));
|
mkdirSync(join(dir, "scripts"));
|
||||||
mkdirSync(join(dir, "logs"));
|
mkdirSync(join(dir, "logs"));
|
||||||
|
copyFileSync(
|
||||||
|
resolve(root, "scripts/docker-config.sh"),
|
||||||
|
join(dir, "scripts/docker-config.sh"),
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
resolve(root, "docker-image.txt"),
|
||||||
|
join(dir, "docker-image.txt"),
|
||||||
|
);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(dir, "logs/docker-release-history.log"),
|
join(dir, "logs/docker-release-history.log"),
|
||||||
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
|
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
|
||||||
@@ -39,6 +47,11 @@ function simulate(scenario: string) {
|
|||||||
join(dir, "scripts/docker-update.sh"),
|
join(dir, "scripts/docker-update.sh"),
|
||||||
);
|
);
|
||||||
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
|
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
|
||||||
|
if (scenario.startsWith("saved-"))
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".docker-install"),
|
||||||
|
`MODE=${scenario === "saved-source" ? "source" : "prebuilt"}\nPUBLIC_URL=https://saved.test\n`,
|
||||||
|
);
|
||||||
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
|
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
@@ -49,8 +62,12 @@ function simulate(scenario: string) {
|
|||||||
TEST_DIR: dir.replaceAll("\\", "/"),
|
TEST_DIR: dir.replaceAll("\\", "/"),
|
||||||
TEST_SHA: sha,
|
TEST_SHA: sha,
|
||||||
SCENARIO: scenario,
|
SCENARIO: scenario,
|
||||||
CMS_PUBLIC_URL: "https://example.test",
|
CMS_PUBLIC_URL: scenario.startsWith("saved-")
|
||||||
CMS_IMAGE_REPOSITORY: scenario.startsWith("registry")
|
? undefined
|
||||||
|
: "https://example.test",
|
||||||
|
CMS_IMAGE_REPOSITORY: scenario.startsWith("saved-")
|
||||||
|
? undefined
|
||||||
|
: scenario.startsWith("registry")
|
||||||
? "registry.test/team/cms"
|
? "registry.test/team/cms"
|
||||||
: "",
|
: "",
|
||||||
},
|
},
|
||||||
@@ -169,3 +186,19 @@ describe("HTTP release verification", () => {
|
|||||||
expect(result.status, result.stderr).toBe(expected);
|
expect(result.status, result.stderr).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates using the saved profile and repository-provided image without arguments", () => {
|
||||||
|
const r = simulate("saved-prebuilt");
|
||||||
|
expect(r.status, r.output).toBe(0);
|
||||||
|
expect(r.calls).toContain(
|
||||||
|
`docker pull gitlab.epicnabbo.nl/simo/epicnext-cms:${sha}`,
|
||||||
|
);
|
||||||
|
expect(r.calls).toContain("https://saved.test/api/health");
|
||||||
|
expect(r.calls).not.toContain("docker compose build");
|
||||||
|
});
|
||||||
|
it("keeps source builds available for a saved source installation", () => {
|
||||||
|
const r = simulate("saved-source");
|
||||||
|
expect(r.status, r.output).toBe(0);
|
||||||
|
expect(r.calls).toContain("docker compose build");
|
||||||
|
expect(r.calls).not.toContain("docker pull");
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# No Docker, permissions or external services are modified by wizard tests.
|
||||||
|
docker() { return 0; }
|
||||||
|
uname() { echo Linux; }
|
||||||
|
flock() { :; }
|
||||||
|
export -f docker uname flock
|
||||||
|
install() { :; }
|
||||||
|
chown() { :; }
|
||||||
|
stat() { echo 33:33; }
|
||||||
|
sudo() { "$@"; }
|
||||||
|
export -f install chown stat sudo
|
||||||
Reference in new issue
Block a user