feat(docker): add guided install and saved one-command updates

This commit is contained in:
Simo committed 2026-09-09 20:49:05 +02:00
1 parent 8dc187483c
commit 27447ce029
11 files changed
+395 -7

No files matched your search

+4
View File
@@ -36,3 +36,7 @@ db_backup_*.sql
*.log
.codex
.agents
.docker-install
.docker-install.tmp.*
.env.install.*
+5
View File
@@ -45,3 +45,8 @@ coverage/
test-results/
playwright-report/
blob-report/
# Local Docker installation metadata
.docker-install
.docker-install.tmp.*
.env.install.*
+44
View File
@@ -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.
`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:
Gitea packages belong to an account or organization, independently of repository
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
gitlab.epicnabbo.nl/simo/epicnext-cms
+24
View File
@@ -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}"
}
+107
View File
@@ -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"
+6 -3
View File
@@ -69,6 +69,9 @@ if [ "$script_before" != "$(git hash-object scripts/docker-update.sh)" ]; then
exec 9>&-
exec bash "$DIR/scripts/docker-update.sh"
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)"
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
[[ -f .env ]] || die "Create .env before installing or updating."
@@ -82,16 +85,16 @@ if [[ -n "$previous_container" ]]; then
rollback_tag="rollback-$CMS_RELEASE-$$"
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
fi
log "Building release $CMS_RELEASE from $DIR"
log "Preparing release $CMS_RELEASE from $DIR"
# The migrations stage contains matching source and locked dependencies.
# No Node/package manager installation on the host is required.
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
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."
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"
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-migrations" "$migration_image"
else
+148
View File
@@ -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");
});
+37 -4
View File
@@ -30,6 +30,14 @@ function simulate(scenario: string) {
try {
mkdirSync(join(dir, "scripts"));
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(
join(dir, "logs/docker-release-history.log"),
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
@@ -39,6 +47,11 @@ function simulate(scenario: string) {
join(dir, "scripts/docker-update.sh"),
);
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")], {
cwd: dir,
encoding: "utf8",
@@ -49,10 +62,14 @@ function simulate(scenario: string) {
TEST_DIR: dir.replaceAll("\\", "/"),
TEST_SHA: sha,
SCENARIO: scenario,
CMS_PUBLIC_URL: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("registry")
? "registry.test/team/cms"
: "",
CMS_PUBLIC_URL: scenario.startsWith("saved-")
? undefined
: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("saved-")
? undefined
: scenario.startsWith("registry")
? "registry.test/team/cms"
: "",
},
});
if (result.error) throw result.error;
@@ -169,3 +186,19 @@ describe("HTTP release verification", () => {
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");
});
+10
View File
@@ -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