feat(docker): add guided install and saved one-command updates
CI / check (push) Successful in 53s
CI / deploy (push) Successful in 1m9s
CI / publish-container (push) Successful in 46s

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

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}"
}
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
+35 -2
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,8 +62,12 @@ 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")
CMS_PUBLIC_URL: scenario.startsWith("saved-")
? undefined
: "https://example.test",
CMS_IMAGE_REPOSITORY: scenario.startsWith("saved-")
? undefined
: scenario.startsWith("registry")
? "registry.test/team/cms"
: "",
},
@@ -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