diff --git a/.dockerignore b/.dockerignore index 0e0ef78b1a..27835586e5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -36,3 +36,7 @@ db_backup_*.sql *.log .codex .agents + +.docker-install +.docker-install.tmp.* +.env.install.* diff --git a/.gitignore b/.gitignore index c1eee4506b..6e7497fc08 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,8 @@ coverage/ test-results/ playwright-report/ blob-report/ + +# Local Docker installation metadata +.docker-install +.docker-install.tmp.* +.env.install.* diff --git a/README.md b/README.md index 6dc431e873..4f79b27c8f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cms b/cms new file mode 100644 index 0000000000..86bb786e15 --- /dev/null +++ b/cms @@ -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 diff --git a/docker-image.txt b/docker-image.txt new file mode 100644 index 0000000000..3e4a28c9e2 --- /dev/null +++ b/docker-image.txt @@ -0,0 +1 @@ +gitlab.epicnabbo.nl/simo/epicnext-cms diff --git a/scripts/docker-config.sh b/scripts/docker-config.sh new file mode 100644 index 0000000000..e0917313e5 --- /dev/null +++ b/scripts/docker-config.sh @@ -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}" +} diff --git a/scripts/docker-install.sh b/scripts/docker-install.sh new file mode 100644 index 0000000000..e595dd307e --- /dev/null +++ b/scripts/docker-install.sh @@ -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" diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh index 645d60ce78..2e13b38ce7 100755 --- a/scripts/docker-update.sh +++ b/scripts/docker-update.sh @@ -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 diff --git a/src/lib/docker-install.test.ts b/src/lib/docker-install.test.ts new file mode 100644 index 0000000000..07ee23ee3c --- /dev/null +++ b/src/lib/docker-install.test.ts @@ -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%20word@127.0.0.1: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"); +}); diff --git a/src/lib/docker-update.test.ts b/src/lib/docker-update.test.ts index c0f716ceb4..880b36eb33 100644 --- a/src/lib/docker-update.test.ts +++ b/src/lib/docker-update.test.ts @@ -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"); +}); diff --git a/src/test/docker-install-harness.sh b/src/test/docker-install-harness.sh new file mode 100644 index 0000000000..a86f7fb0e6 --- /dev/null +++ b/src/test/docker-install-harness.sh @@ -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