diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a9999c0..9139addf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,6 +302,36 @@ jobs: openssl pkeyutl -verify -pubin -inkey throwaway.pub.pem -rawin -in digest.bin -sigfile sig.bin done + server-install: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 26 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: shellcheck test/installer/scenarios.sh + - name: Install, update, roll back, uninstall and install Gophenberg again on this runner + run: bash test/installer/scenarios.sh run + - name: Gather the journals, the update state and the install record + if: failure() || cancelled() + run: bash test/installer/scenarios.sh logs "$RUNNER_TEMP/server-install-logs" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: failure() || cancelled() + with: + name: server-install-logs + path: ${{ runner.temp }}/server-install-logs/ + retention-days: 1 + image-check: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/docs/src/content/docs/self-hosting/commands.md b/docs/src/content/docs/self-hosting/commands.md index 7af2df9a..06c9b03f 100644 --- a/docs/src/content/docs/self-hosting/commands.md +++ b/docs/src/content/docs/self-hosting/commands.md @@ -7,9 +7,10 @@ The `gophenberg` binary serves the site, and it also runs the jobs an operator does beside it, such as creating the first account or applying the schema. Every command reads the same environment variables and `.env` file as the server, listed in -[configuration](/self-hosting/configuration/). The one exception is -[`server:install`](#serverinstall), which runs as root and takes none -of its settings from a `.env` file, see +[configuration](/self-hosting/configuration/). The two exceptions are +[`server:install`](#serverinstall) and +[`server:uninstall`](#serveruninstall). They run as root and take none +of their settings from a `.env` file, see [the server settings](/self-hosting/configuration/#the-server-settings). With Docker Compose, put the command after the service name: @@ -50,6 +51,7 @@ Available commands: account:role set one account's role server server:install install Gophenberg on this Linux server with PostgreSQL, Caddy and a systemd service + server:uninstall remove what server:install set up on this server, keeping the site's data unless -delete-data update update:apply update this server install to a newer Gophenberg, then ask for a restart update:check check the update site for a newer Gophenberg and keep what it found @@ -149,9 +151,9 @@ covers the address, the file and its settings. ## Dry runs and -yes -Five commands only show what they would change until you add `-yes`: -`seed`, `account:grant-role`, `account:role`, `account:disable` and -`account:enable`. This line, for example: +Six commands only show what they would change until you add `-yes`: +`seed`, `account:grant-role`, `account:role`, `account:disable`, +`account:enable` and `server:uninstall`. This line, for example: ```sh gophenberg account:role author@example.com editor -as admin@example.com @@ -518,23 +520,29 @@ account holding it. gophenberg server:install [flags] ``` -Checks a fresh Linux server before Gophenberg is installed on it, with -PostgreSQL, Caddy in front for HTTPS unless you keep a web server of -your own, and a systemd service that runs the site. In this release, -`-dry-run` checks the server and then prints what an install would -do: each step it would take and the full text of every file it would -write. It changes nothing on the server. +Installs Gophenberg on a fresh Linux server. It sets up PostgreSQL, +Caddy in front for HTTPS unless you keep a web server of your own, +and a systemd service that runs the site. With `-dry-run` it only +checks the server and prints what it would do. Run it on the server itself as root, with the `gophenberg` binary of a Linux release archive, see [check a download](/self-hosting/install/#check-a-download). It is not -a command for the container image. +a command for the container image. The archive goes in +`/var/lib/gophenberg-installer`, a folder only root can open: ```sh +sudo mkdir -m 0700 /var/lib/gophenberg-installer +sudo cp gophenberg_%VERSION%_linux_amd64.tar.gz /var/lib/gophenberg-installer/ tar -xzf gophenberg_%VERSION%_linux_amd64.tar.gz gophenberg sudo ./gophenberg server:install -dry-run -domain example.com -admin-email admin@example.com +sudo ./gophenberg server:install -domain example.com -admin-email admin@example.com \ + -archive /var/lib/gophenberg-installer/gophenberg_%VERSION%_linux_amd64.tar.gz ``` +The last line installs. It asks for the first administrator's name +and password, see [the first administrator](#the-first-administrator). + - `-domain host` is the host name the site answers at, such as `example.com`, or `localhost` to try it on the server itself. A first run needs it, unless the environment or @@ -544,58 +552,63 @@ sudo ./gophenberg server:install -dry-run -domain example.com -admin-email admin either end, a letter in the last label, and 253 characters at most. So an address such as `203.0.113.7` is not a domain. A domain that breaks the rule stops the run before anything else is read. -- `-admin-email email` is the email of the first administrator. The - dry run gives it to Caddy for the HTTPS certificate, unless +- `-admin-email email` is the email of the first administrator. Caddy + also gives it to Let's Encrypt for the HTTPS certificate, unless `GOPHENBERG_INSTALL_CERTIFICATE_EMAIL` names another. -- `-admin-name name` and `-admin-password-file file` name the first - administrator and the file holding the password. A dry run reads - neither. +- `-admin-name name` is the first administrator's display name. +- `-admin-password-file file` is a file whose first line is the first + administrator's password. It must be an absolute path, or the run + stops before anything else is read. - `-archive file` is the checked release archive, inside - `/var/lib/gophenberg-installer`. A dry run does not open it, but an - archive outside that folder stops the run before anything else is - read, as a bad domain does. + `/var/lib/gophenberg-installer`. A run needs it while `current` + names no build, so a first run always does. A dry run does not open + it, but an archive outside that folder stops the run before anything + else is read, as a bad domain does. - `-web-server name` picks the web server in front of the site: `caddy`, the default, has the install set Caddy up, and `none` keeps a web server of your own, see [with your own web server](#with-your-own-web-server). The first run keeps it in `/etc/gophenberg/server.env` as `GOPHENBERG_INSTALL_WEB_SERVER`, and a later run without the flag - keeps it. A later run that asks for the other one stops, since moving - a server from one to the other is not built yet. So does a later run - on a server where the install record shows the install set Caddy up, - once that line in `server.env` was changed to `none` by hand: + keeps it. A later run that asks for the other one stops. So does a + run whose line in `server.env` was changed to `none` by hand while + Gophenberg's Caddy site, `/etc/caddy/gophenberg.caddy`, is still + there: ```text gophenberg: GOPHENBERG_INSTALL_WEB_SERVER: this server was set up with caddy, and a later run cannot move it to none, so run sudo gophenberg server:uninstall first, then change the line in /etc/gophenberg/server.env ``` - Any other name stops the run before anything else is read, as a bad - domain does. + [Moving to your own web server](#moving-to-your-own-web-server) + shows the steps. Any other name stops the run before anything else + is read, as a bad domain does. - `-skip-domain-check` skips the check that the domain points at this server, for a server behind a proxy the check cannot cross. - `-dry-run` runs every check, then prints each step and every file, - and changes nothing. It asks no questions. - -Without `-dry-run`, it runs the same checks, then stops with exit -code 1: - -```text -gophenberg: this build of server:install only checks the server, and -dry-run prints the steps it would take, so nothing was changed -``` + and changes nothing. It asks no question and opens no archive. A run goes in this order: -1. It reads [the server settings](/self-hosting/configuration/#the-server-settings) - and stops on a bad value, naming every bad value at once. On a - server an earlier run set up, a run that is not root's stops here - with the root line below, since root alone may read `server.env` - and the install record. -2. It reads the install record, +1. It runs the first two checks listed below, root and the installer + folder, before it opens any file. So a run that is not root's stops + with the root line below. +2. Without `-dry-run`, it stops at once while another `server:install` + or `server:uninstall -yes` is running on the server. +3. It reads [the server settings](/self-hosting/configuration/#the-server-settings) + and stops on a bad value, naming every bad value at once. +4. It reads the install record, `/var/lib/gophenberg-installer/install.json`, when one is there. It stops when the record names a newer installer than this one, so an older binary never writes its files over newer ones. -3. It checks the server, as listed below. -4. With `-dry-run`, it prints the steps. +5. It runs the rest of the checks listed below. +6. With `-dry-run`, it prints the steps and ends. +7. It opens the archive. Its `release.json` must name the version of + this binary and the platform of this server, such as `linux/amd64`. +8. While the first administrator is still to be made, it asks for + what the flags leave out. +9. It takes the steps, see [taking the steps](#taking-the-steps). + +Nothing on the server changes before the steps. It never reads the `.env` file of the folder it runs in, since the site's own user can write such a file. Every program it starts, such @@ -612,6 +625,9 @@ A check that fails stops the run with exit code 1 and a line that says why and ends with `so nothing was changed`. In order: - **Root.** It runs as root, such as with `sudo`. +- **The installer folder.** `/var/lib/gophenberg-installer`, when it + exists, is a real folder, not a link, owned by root and writable by + root alone. - **The system.** `/etc/os-release` names Ubuntu 24.04, Debian 12 or Debian 13. On Debian it also prints that Gophenberg's CI tests Ubuntu 24.04 only, such as @@ -636,6 +652,9 @@ says why and ends with `so nothing was changed`. In order: `/etc/apt/keyrings` or `/usr/local/bin`, or a folder root finds programs in, such as `/usr/bin`. So `GOPHENBERG_INSTALL_ROOT=/usr/local` stops here too. +- **The current link.** A `current` link in the install root names a + build in `versions` that holds its `gophenberg` as a file that may + run. Otherwise the run cannot tell which build runs. - **Disk.** At least `GOPHENBERG_INSTALL_DISK_FLOOR_MB`, 1024 MB unless set, is free under the install root and under `/var/lib`, each measured at its nearest folder that exists. @@ -651,6 +670,12 @@ says why and ends with `so nothing was changed`. In order: `gophenberg.service` and `gophenberg-rollback.service` in `/etc/systemd/system`. `GOPHENBERG_DATABASE_URL` must not be set either, since the install sets up a PostgreSQL of its own. +- **The command on the path.** Nothing is at `/usr/local/bin/gophenberg` + but the command an earlier run wrote. +- **The Caddyfile.** Once an earlier run wrote `/etc/caddy/Caddyfile`, + it still holds the two lines that run wrote: + `import /etc/caddy/gophenberg.caddy` and + `admin unix//run/caddy/admin.sock`. - **The domain.** The domain has an A or AAAA record. An address that never leaves the server, a loopback address such as `127.0.1.1` or an unspecified one such as `0.0.0.0`, stops the run, since Let's @@ -711,7 +736,7 @@ server, so it does not end that way. Once every check passes, a dry run prints each step to standard output, and the notes and warnings of the checks stay on standard error. Under each step comes every file it would write, with its -owner and mode, then its full text indented by four spaces. For +owner and mode, then its text indented by four spaces. For `-domain example.com -admin-email admin@example.com` on a fresh server it begins: @@ -767,13 +792,133 @@ For `localhost`, which Caddy serves with a certificate of its own, the Caddyfile names no email. It names none either when the run knows no certificate email. -The list follows what the server already holds. When the install -record says the Caddy step ended, the list leaves the Caddyfile alone -and reloads Caddy. When it says the start step ended, the list -restarts the site, and when it says the admin step ended, the list -creates no administrator. A `current` link under the install root -keeps the build it names, since updates go through -[`update:apply`](#updateapply). +In the site's `.env`, each line you added prints as its name and +`(kept as it is)`, such as `GOPHENBERG_SMTP_PASSWORD=(kept as it is)`. +So a password you keep there never reaches your terminal. The file +keeps the value. + +On a later run the list follows what the server already holds, see +[the install record and later runs](#the-install-record-and-later-runs). + +#### The first administrator + +A first run creates the first administrator, an account under the +role `admin`. It asks on the terminal for each answer the flags leave +out: + +```text +Email of the first administrator: admin@example.com +Name of the first administrator [admin]: Maria Perez +Password: +Password again: +``` + +- An empty name takes the part of the email before `@`, shown in the + brackets. +- The password is typed twice and never shown. +- An answer the account rules refuse, or two passwords that differ, + prints why and is asked again, such as + `password shorter than 12 characters, so type it again`. +- Ctrl-C at a question ends the run with nothing changed, and the + terminal shows what you type again. + +A password file whose first line ends in a carriage return, as in a +file saved with Windows line endings, stops the run. So does a flag +whose answer the account rules refuse, such as a short password: + +```text +gophenberg: -admin-password-file: password shorter than 12 characters, so nothing was changed +``` + +The questions need a terminal. A run with none, such as one a script +starts, stops and names the flags to give: + +```text +gophenberg: this run has no terminal to ask the first administrator's name and password on, so give -admin-name and -admin-password-file: open /dev/tty: no such device or address, so nothing was changed +``` + +#### Taking the steps + +Without `-dry-run`, the run takes the steps a dry run lists, in +order. Before each one it prints its line, such as +`Step 10: write the gophenberg command on the path`. Each file it +writes is the one a dry run prints, with that owner and mode. + +The step that starts the site waits for its pages, up to +`GOPHENBERG_UPDATE_VERIFY_TIMEOUT`, 1 minute unless +[the server settings](/self-hosting/configuration/#the-server-settings) +set it. A site that never answers stops the run: + +```text +gophenberg: gophenberg.service did not pass its start check within 1m0s, the time GOPHENBERG_UPDATE_VERIFY_TIMEOUT allows, as at the last try it could not ask for /admin/: dial tcp 127.0.0.1:8081: connect: connection refused, so read why with sudo journalctl -u gophenberg +``` + +With Caddy, the HTTPS step waits up to +`GOPHENBERG_INSTALL_HTTPS_WAIT`, 2 minutes unless set, for +`https:///admin/` to answer 200. For `localhost` it trusts +the root certificate Caddy signs `localhost` with. When no answer +comes, the site still runs, so the run only warns on standard error +and ends with exit code 0. The next run checks again: + +```text +warning: https://example.com/admin/ did not answer 200 within 2m0s, the time GOPHENBERG_INSTALL_HTTPS_WAIT allows, as at the last try it could not be reached: dial tcp 203.0.113.7:443: i/o timeout. The site runs, so check that the hosting provider's firewall lets TCP 80, TCP 443 and UDP 443 in, then read sudo journalctl -u caddy, and the next sudo gophenberg server:install checks again +``` + +The last step prints a summary. The email shows only on the run that +created the administrator: + +```text +Gophenberg %VERSION% runs at https://example.com. Sign in at https://example.com/admin/ as admin@example.com. +The hosting provider's firewall must let TCP 80, TCP 443 and UDP 443 in for visitors to reach the site. +Commands to know: + sudo gophenberg update:status shows the build that runs and the update on offer + sudo gophenberg update:apply installs the update on offer + sudo journalctl -u gophenberg shows what the site logged + sudo gophenberg server:uninstall removes Gophenberg and keeps its data +``` + +#### The install record and later runs + +The install record, `/var/lib/gophenberg-installer/install.json`, is +how a run knows what earlier runs did. Root owns it, with mode 0600. +It marks each step started before the step runs, and ended once the +step is done. It also names what the install put on the server, such +as PostgreSQL and Caddy, the domain, and the SHA-256 of the Caddyfile +the first run wrote. + +So a run cut off part way, such as by a power cut, picks up where it +stopped. Run the same line again. Each step checks what is already +done: + +- After a cut in the packages step, it runs `dpkg --configure -a` + first. +- A database role or database that already exists counts as made. +- A build folder left half unpacked is removed and unpacked again. +- After a Caddy restart that failed, it keeps `/etc/caddy/Caddyfile` + as it is, checks its two lines and restarts Caddy. +- An administrator the cut-off run made is found with + `account:list`, so nothing is asked. + +A step an earlier run ended stays ended, even when a later run stops +inside it. + +The first run puts the `gophenberg` command on the path. It runs +`server:install` and `server:uninstall` with the installer's own +copy, `/usr/local/lib/gophenberg/gophenberg`. It runs every other +command as the user `gophenberg` in the install root. So a later run +is: + +```sh +sudo gophenberg server:install +``` + +It needs no flag, since `server.env` keeps the settings. It asks +nothing and creates no administrator. It keeps the build `current` +names, since updates go through [`update:apply`](#updateapply). It +leaves the Caddyfile alone and reloads Caddy. It writes every other +file again from the settings, then restarts the site. So to change a +setting such as `GOPHENBERG_SERVICE_RESTART_DELAY`, change its line in +`server.env` and run it again. #### With your own web server @@ -813,6 +958,14 @@ Step 13: print the summary and end the install record Step 14: print the lines for nginx, Caddy, Traefik and Apache that send example.com to the site at 127.0.0.1:8081 ``` +The summary names the address the site answers on, since your web +server must send the domain there first: + +```text +Gophenberg %VERSION% answers on 127.0.0.1:8081, the GOPHENBERG_ADDR the site listens on. +Once your own web server sends example.com there, sign in at https://example.com/admin/ as admin@example.com. +``` + The last step prints, under a `prints:` line, the lines each of the four web servers needs to send the domain to the site. The dry run prints the same text the install will. Each block passes on the @@ -854,6 +1007,141 @@ address stays `https://`. While the site restarts after an update, your web server shows its own error page, unless it can hold requests until the site answers again, as the Caddy the install sets up does. +### server:uninstall + +```text +gophenberg server:uninstall [flags] +``` + +Removes what [`server:install`](#serverinstall) set up and keeps the +site's data. Without `-yes` it only lists what it would remove and +what it keeps. Run it as root, with the `gophenberg` command the +install put on the path: + +```sh +sudo gophenberg server:uninstall +``` + +On a server set up for `example.com` with Caddy, it prints: + +```text +server:uninstall removes: + gophenberg.service, stopped and disabled + /etc/systemd/system/gophenberg.service + /etc/systemd/system/gophenberg-rollback.service + /etc/caddy/Caddyfile, putting back /etc/caddy/Caddyfile.before-gophenberg, the one the caddy package wrote + /etc/systemd/system/caddy.service.d/gophenberg.conf + /etc/caddy/gophenberg.caddy + /usr/local/share/gophenberg/maintenance + /usr/local/bin/gophenberg + /usr/local/lib/gophenberg/gophenberg + /opt/gophenberg/versions + /opt/gophenberg/downloads + /opt/gophenberg/current +server:uninstall keeps: + the database gophenberg and its role gophenberg + the install root /opt/gophenberg, with the site's .env, media, themes and backups + /etc/gophenberg/server.env + the system user gophenberg + /var/lib/gophenberg-installer/install.json, so a later install picks up what is kept + the packages server:install installed, which sudo apt-get purge postgresql caddy removes + /etc/apt/keyrings/caddy.asc, /etc/apt/sources.list.d/caddy.sources and /etc/apt/preferences.d/gophenberg-caddy, which bring Caddy's security fixes from its own apt repository +gophenberg: dry run, nothing changed, pass -yes to apply +``` + +Add `-yes` to remove what it lists. The run then ends with +`server:uninstall removed what it lists and kept the rest.` + +- It stops the site and removes its two units. +- A Caddyfile still as the install wrote it goes back to the one the + caddy package wrote, and Caddy restarts. A Caddyfile you changed, + such as with a site of your own, stays, and so does Caddy's drop-in. + The line `import /etc/caddy/gophenberg.caddy` comes out when it is + there. Before Caddy's files change, the install record keeps the + SHA-256 of the Caddyfile the install wrote, so a later install never + writes over yours, even when the uninstall stops part way. Caddy then + reloads, or restarts when the install never ended its caddy step, + such as after a first run that stopped at `caddy validate` or a + Caddy restart that failed. +- The builds go, and so do the `previous` link and + `state/update.json` when they are there. +- Anything already gone is skipped, so a second run prints + `server:uninstall finds nothing left to remove.` The command on the + path is gone by then, so run it with the binary of the release + archive, such as `sudo ./gophenberg server:uninstall`. +- A run cut off part way, such as by a lost connection, is finished by + the next one, which restarts or reloads Caddy where the cut-off run + did not. + +It reads the install root, the domain and the web server from +`/etc/gophenberg/server.env` alone, with the defaults of +[the server settings](/self-hosting/configuration/#the-server-settings) +for a missing line. It never takes them from its environment or a +`.env` file. It stops on these lines before it changes anything: + +```text +gophenberg: server:uninstall must run as root, such as with sudo, so nothing was changed +gophenberg: /var/lib/gophenberg-installer, which holds the install record, must be a real folder owned by root and writable by root alone, so nothing was changed +gophenberg: another server:install or server:uninstall is running on this server, so nothing was changed +gophenberg: this server has no install record at /var/lib/gophenberg-installer/install.json, so server:uninstall has nothing to remove +gophenberg: server:uninstall needs /etc/caddy/Caddyfile to take Gophenberg's site out of Caddy, and it is not there, so nothing was changed +gophenberg: /etc/caddy/Caddyfile is a link, and the line "import /etc/caddy/gophenberg.caddy" must come out of the file it leads to before server:uninstall runs again, so nothing was changed +``` + +`-delete-data` also drops the database and its role. It removes the +install root with everything in it, `backups` included, then +`server.env` and the system user `gophenberg`. Nothing brings that +data back, so copy a backup off the server first. It removes an +install root only when the install record shows that `server:install` +made it, and otherwise lists the folder as kept and says why. An +install root the install made that `server.env` no longer names stays +too, listed as kept. The install record then holds only the packages +the install put there and the domain. The +kept Caddy still holds TCP port 80, so a later install +for that domain skips the domain check. When a Caddyfile you changed +stays, the record also keeps the SHA-256 of the Caddyfile the install +wrote, even after a first run that stopped in its caddy step, so a +later install stops until the import line is back, as in +[installing again](#installing-again). A later install takes the +packages as its own, and needs `-domain` and the first administrator +again. + +#### Installing again + +A later `server:install` reuses what was kept. It unpacks the build +again from `-archive`, writes the files and starts the site. It +reuses the database and asks nothing, since the first administrator +exists: + +```sh +sudo ./gophenberg server:install -archive /var/lib/gophenberg-installer/gophenberg_%VERSION%_linux_amd64.tar.gz +``` + +When the uninstall kept a Caddyfile you changed, the run stops until +the import line is back. It never writes over your Caddyfile: + +```text +gophenberg: /etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so nothing was changed +``` + +#### Moving to your own web server + +A server the install set Caddy up on moves to a web server of your +own in three steps: + +1. Run `sudo gophenberg server:uninstall -yes`. +2. Change the line `GOPHENBERG_INSTALL_WEB_SERVER=caddy` in + `/etc/gophenberg/server.env` to `GOPHENBERG_INSTALL_WEB_SERVER=none`. +3. Run `server:install` again, as in + [installing again](#installing-again). + +Caddy stays installed and running, with the Caddyfile the uninstall +left, and still answers on its ports. Before another web server, such +as nginx, takes ports 80 and 443, stop Caddy with +`sudo systemctl disable --now caddy`, or remove it with +`sudo apt-get purge caddy`. The run ends with the lines your web +server needs, see [with your own web server](#with-your-own-web-server). + ### update:check ```text @@ -953,15 +1241,16 @@ update instead. It never opens the database, so it works when the database is what broke. `-after-failure` is the form the rollback unit of the service manager -runs after a failed start. It reads `MONITOR_SERVICE_RESULT` and -`MONITOR_EXIT_STATUS`, which systemd hands that unit, and takes the -update back only when the state says the update switched builds and -systemd says the starts hit the start limit, `start-limit-hit`, or the -new build exited with code 76. It then exits with code 0, so the unit -starts the service again. Otherwise it changes nothing and exits with -code 1. It takes back each update once, so two failing builds never -take turns. An update that raised the minimum version and whose new -build already reached the database stays down: it writes that the +runs. The unit acts only once systemd gave up restarting the site. It +reads `MONITOR_SERVICE_RESULT`, which systemd hands that unit, and +takes the update back only when the state says the update switched +builds and systemd reports any result but `success`. So a start that +failed its check with exit code 76 and a build that keeps crashing +both count. It then exits with code 0, so the unit starts the service +again. Otherwise it changes nothing and exits with code 1. It takes +back each update once, so two failing builds never take turns. An +update that raised the minimum version and whose new build already +reached the database stays down: it writes that the update failed, so `update:status` shows the steps that restore the backup, prints them and exits with code 1. One whose new build never reached the database, such as one that started while PostgreSQL was diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index 40bd2353..71263a17 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -8,9 +8,9 @@ Gophenberg is configured entirely through environment variables. A environment variables win over it. Every command reads the same settings, the `.env` file included, so `migrate` or an account command run beside the server finds the database the server uses. -The one exception is `server:install`, which reads -[the server settings](#the-server-settings) and takes none of its -settings from a `.env` file. +The two exceptions are `server:install` and `server:uninstall`, which +read [the server settings](#the-server-settings) and take none of +their settings from a `.env` file. Every value is trimmed of the spaces around it. A padded value works as if it had none, and a value made only of spaces counts as unset, @@ -95,7 +95,7 @@ so it takes the default. | `GOPHENBERG_UPDATE_BACKUPS_KEEP` | No | `3` | How many database backups the `backups` folder of the install root keeps | | `GOPHENBERG_PG_DUMP_BIN` | No | `pg_dump` | The `pg_dump` an update backs up the database with, a name found on the path or a full path. Its major version must be at least the server's | | `GOPHENBERG_UPDATE_ATTEMPTS` | No | `3` | How many times the build an update switched to may try to start. The next start goes back to the build before it, or stays down when the update raised the minimum version and its new build reached the database | -| `GOPHENBERG_UPDATE_VERIFY_TIMEOUT` | No | `1m` | How long the first start of a new build has to check itself: the server listens, then `/admin/` and `/api/locale` answer 200 and `/` answers below 500 over loopback | +| `GOPHENBERG_UPDATE_VERIFY_TIMEOUT` | No | `1m` | How long the first start of a new build has to check itself: the server listens, then `/admin/` and `/api/locale` answer 200 and `/` answers below 500 over loopback. `server:install` reads its own from [the server settings](#the-server-settings) | | `GOPHENBERG_UPDATE_VERSIONS_KEEP` | No | `2` | How many of the newest builds the `versions` folder keeps once a new build passes its check. The builds the `current` and `previous` links name always stay | | `GOPHENBERG_FEED_TITLE` | No | `Gophenberg` | The RSS channel title | | `GOPHENBERG_FEED_ITEMS` | No | `20` | How many posts the RSS feed carries | @@ -569,7 +569,7 @@ first of these that gives it a value: | `GOPHENBERG_INSTALL_ROOT` | `/opt/gophenberg` | The install root, an absolute path of letters, digits, dots, hyphens, underscores and slashes alone | | `GOPHENBERG_PUBLIC_URL` | | `https://` and the domain the site answers at, with nothing after it. Every run needs it, from `-domain`, the environment or `server.env` | | `GOPHENBERG_ADDR` | `127.0.0.1:8081` | The address the site listens on. It must be a loopback address with a port, since Caddy, or your own web server, runs on the same server and forwards to it. Its port must not be 5432, PostgreSQL's, or with Caddy 80 or 443 | -| `GOPHENBERG_INSTALL_WEB_SERVER` | `caddy` | The web server in front of the site, from the `-web-server` flag. `caddy` has the install set Caddy up, and `none` keeps a web server of your own, see [with your own web server](/self-hosting/commands/#with-your-own-web-server). A later run keeps it. It stops when a flag or the environment asks for the other one, and when the line says `none` on a server the install set Caddy up on | +| `GOPHENBERG_INSTALL_WEB_SERVER` | `caddy` | The web server in front of the site, from the `-web-server` flag. `caddy` has the install set Caddy up, and `none` keeps a web server of your own, see [with your own web server](/self-hosting/commands/#with-your-own-web-server). A later run keeps it. It stops when a flag or the environment asks for the other one, and when the line says `none` while Gophenberg's Caddy site is still there, see [moving to your own web server](/self-hosting/commands/#moving-to-your-own-web-server) | | `GOPHENBERG_INSTALL_CERTIFICATE_EMAIL` | The `-admin-email` flag | The email Caddy gives Let's Encrypt for the HTTPS certificate, one plain address with no braces. Empty leaves it out, and `localhost` never uses it | | `GOPHENBERG_SERVICE_RESTART_DELAY` | `5s` | How long systemd waits before it starts the site again, `RestartSec` of the site's unit, in whole seconds | | `GOPHENBERG_SERVICE_START_LIMIT_INTERVAL` | `300s` | The window systemd counts the site's starts in, `StartLimitIntervalSec` of the site's unit, in whole seconds | @@ -583,6 +583,7 @@ first of these that gives it a value: | `GOPHENBERG_INSTALL_DOMAIN_CHECK_TIMEOUT` | `10s` | How long the domain check waits for each fetch of its token | | `GOPHENBERG_INSTALL_HTTPS_WAIT` | `2m` | How long the HTTPS step waits for `https:///admin/` to answer | | `GOPHENBERG_INSTALL_POLL_INTERVAL` | `1s` | The pause between two tries in each wait of the steps | +| `GOPHENBERG_UPDATE_VERIFY_TIMEOUT` | `1m` | How long the step that starts the site waits for its first start to pass the same check an update's first start does. Updates take theirs from the site's `.env` | With `GOPHENBERG_INSTALL_WEB_SERVER=none`, the run uses neither the certificate email, `GOPHENBERG_MAINTENANCE_RETRY_AFTER`, @@ -602,6 +603,10 @@ gophenberg: GOPHENBERG_ADDR: must be a loopback address with a port, such as 127 in its environment, the run stops, since the install sets up a PostgreSQL of its own. +[`server:uninstall`](/self-hosting/commands/#serveruninstall) reads +only `server.env` and the defaults, so nothing in its environment +changes what it removes. + ### The server.env file `/etc/gophenberg/server.env` holds the settings `server:install` acts @@ -630,17 +635,17 @@ the file keeps what you wrote: - A line you changed keeps your value, and a line missing from the file gets its default. A `GOPHENBERG_INSTALL_WEB_SERVER` line changed - to `none` on a server the install set Caddy up on stops the run, as - the install record shows. + to `none` stops the run while Gophenberg's Caddy site is still there. - A flag or an environment value given on that run replaces its line, except `GOPHENBERG_INSTALL_WEB_SERVER`, where asking for the other - web server stops the run. + web server stops the run. So does asking for another + `GOPHENBERG_INSTALL_ROOT`. - A line you added, such as `GOPHENBERG_INSTALL_DISK_FLOOR_MB=2048`, is read and kept, after the eleven lines above in the order of the names. A comment is not kept. -- The last six settings of the table, from +- The last seven settings of the table, from `GOPHENBERG_INSTALL_MEMORY_FLOOR_MB` to - `GOPHENBERG_INSTALL_POLL_INTERVAL`, are not among the eleven lines. + `GOPHENBERG_UPDATE_VERIFY_TIMEOUT`, are not among the eleven lines. Given only in the environment, one is used for that run but not written into the file. @@ -673,6 +678,8 @@ GOPHENBERG_DATABASE_WAIT=60s - `GOPHENBERG_DATABASE_WAIT` keeps the value the site's `.env` holds, and `60s` while it holds none. Every other line is kept, after these, in the order of the names. A comment is not kept. +- A dry run prints each kept line as its name and `(kept as it is)`. + The file keeps the value. `server:install` takes none of its own settings from this file, since the site's own user can write it, so a `GOPHENBERG_INSTALL_ROOT` line @@ -687,8 +694,9 @@ could not read stops the run, naming the file: gophenberg: /opt/gophenberg/.env: GOPHENBERG_SHUTDOWN_GRACE: must be a duration like 30s, got "soon" ``` -A dry run prints the file to your terminal, so the run also stops on -a file that could reach the terminal or hold the run instead: +A dry run still prints the name of every line to your terminal, so +the run also stops on a file that could reach the terminal or hold +the run instead: - a line whose name or value holds a control character other than a tab, such as the escape character, or a byte that is not UTF-8 diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index ee05438d..d695dff2 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -812,9 +812,10 @@ have fixed what failed, run: /opt/gophenberg/current/gophenberg update:apply -to 1.4.1 -retry ``` -Under systemd, a service that stops at exit code 76 can start a -rollback unit, which runs `update:rollback -after-failure` and then -starts the service again, see +Under systemd, once systemd gave up restarting the site, a rollback +unit runs `update:rollback -after-failure` and then starts the +service again. A start that failed its check with exit code 76 and a +build that keeps crashing both count, see [update:rollback](/self-hosting/commands/#updaterollback). To take back an update that passed its check, run `update:rollback` and restart Gophenberg. It refuses that version from then on too. diff --git a/internal/app/core_updates.go b/internal/app/core_updates.go index 89f767c9..68dc5e9b 100644 --- a/internal/app/core_updates.go +++ b/internal/app/core_updates.go @@ -181,9 +181,7 @@ func rolledBack(settings updater.Settings, env gonsole.Env, afterFailure bool) ( if !afterFailure { return updater.Rollback(settings) } - return updater.RollbackAfterFailure(settings, updater.Failure{ - ServiceResult: env.Getenv("MONITOR_SERVICE_RESULT"), ExitStatus: env.Getenv("MONITOR_EXIT_STATUS"), - }) + return updater.RollbackAfterFailure(settings, updater.Failure{ServiceResult: env.Getenv("MONITOR_SERVICE_RESULT")}) } // updateStatusCommand returns the command that shows the builds of a server install, its last update and backups. diff --git a/internal/app/program_test.go b/internal/app/program_test.go index aa3cc5e0..d2b95ac5 100644 --- a/internal/app/program_test.go +++ b/internal/app/program_test.go @@ -16,8 +16,8 @@ import ( var listedCommands = []string{ "check", "help", "list", "migrate", "seed", "serve", "version", "account:create-admin", "account:disable", "account:enable", "account:grant-role", "account:list", - "account:records", "account:role", "server:install", "update:apply", "update:check", "update:rollback", - "update:status", + "account:records", "account:role", "server:install", "server:uninstall", "update:apply", "update:check", + "update:rollback", "update:status", } func TestProgramListsEveryCommandWhenNoneIsNamed(t *testing.T) { @@ -53,6 +53,8 @@ func TestProgramListsEveryCommandWhenNoneIsNamed(t *testing.T) { " account:role set one account's role", " server", " server:install install Gophenberg on this Linux server with PostgreSQL, Caddy and a systemd service", + " server:uninstall remove what server:install set up on this server, keeping the site's data unless " + + "-delete-data", " update", " update:apply update this server install to a newer Gophenberg, then ask for a restart", " update:check check the update site for a newer Gophenberg and keep what it found", diff --git a/internal/app/server_commands.go b/internal/app/server_commands.go index 72be576d..0bcb26bc 100644 --- a/internal/app/server_commands.go +++ b/internal/app/server_commands.go @@ -27,9 +27,28 @@ func serverCommands(machine installer.Machine) []gonsole.Command { Summary: "install Gophenberg on this Linux server with PostgreSQL, Caddy and a systemd service", Flags: installFlags, Run: runInstall(machine), + }, { + Name: serverNamespace + "uninstall", + Summary: "remove what server:install set up on this server, keeping the site's data unless -delete-data", + Flags: uninstallFlags, + Writes: true, + Run: runUninstall(machine), }} } +// uninstallFlags declares the flags of server:uninstall. +func uninstallFlags(fs *flag.FlagSet) { + fs.Bool("delete-data", false, "also drop the database and its role, and remove the install root, "+ + "/etc/gophenberg/server.env and the system user gophenberg") +} + +// runUninstall returns the run of server:uninstall on machine. +func runUninstall(machine installer.Machine) func(context.Context, gonsole.Call) error { + return func(ctx context.Context, call gonsole.Call) error { + return installer.Uninstall(ctx, machine, call, call.Flags["delete-data"] == "true") + } +} + // installFlags declares the flags of server:install. func installFlags(fs *flag.FlagSet) { fs.String("domain", "", diff --git a/internal/app/server_commands_test.go b/internal/app/server_commands_test.go index 302189bb..0a1047ed 100644 --- a/internal/app/server_commands_test.go +++ b/internal/app/server_commands_test.go @@ -6,13 +6,16 @@ import ( "bytes" "errors" "flag" + "io/fs" "maps" "os" "os/exec" + "os/user" "path/filepath" "slices" "strconv" "strings" + "syscall" "testing" "time" @@ -83,6 +86,110 @@ func TestServerInstallOffersEveryFlagOfTheInstallLine(t *testing.T) { } } +func TestServerUninstallOffersDeleteDataAndYes(t *testing.T) { + t.Parallel() + + got := testkit.Run(t, Program(testkit.Getenv(nil), noPlugins), "", "help", "server:uninstall") + + want := strings.Join([]string{ + "remove what server:install set up on this server, keeping the site's data unless -delete-data", + "", + "Usage:", + " gophenberg server:uninstall [flags]", + "", + "Flags:", + " -delete-data", + " \talso drop the database and its role, and remove the install root, /etc/gophenberg/server.env and the " + + "system user gophenberg", + " -yes", + " \tapply the change, a dry run without it", + "", + }, "\n") + if got.Code != gonsole.ExitDone || got.Stdout != want { + t.Errorf("help server:uninstall = %d, stdout %q, want 0 and\n%s", got.Code, got.Stdout, want) + } + if got.Stderr != "" { + t.Errorf("help server:uninstall noted %q, want nothing", got.Stderr) + } +} + +// rootOwned describes a file as root's, whatever user the test runs as. +type rootOwned struct{ fs.FileInfo } + +// Sys returns the system description of the file with root as its owner. +func (i rootOwned) Sys() any { + stat := *i.FileInfo.Sys().(*syscall.Stat_t) + stat.Uid = 0 + return &stat +} + +// lstatAsRoot describes the file at path as os.Lstat does, with root as its owner. +func lstatAsRoot(path string) (fs.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + return rootOwned{info}, nil +} + +// recordedMachine returns a server root runs on whose install record names the PostgreSQL it installed and the user. +func recordedMachine(t *testing.T) installer.Machine { + t.Helper() + root := t.TempDir() + folder := filepath.Join(root, "var/lib/gophenberg-installer") + if err := os.MkdirAll(folder, 0o700); err != nil { + t.Fatalf("making the installer's folder: %v", err) + } + record := []byte(`{"installer": "0.19.0", "installed": ["postgresql"]}` + "\n") + if err := os.WriteFile(filepath.Join(folder, "install.json"), record, 0o600); err != nil { + t.Fatalf("writing the install record: %v", err) + } + return installer.Machine{ + Root: root, Lstat: lstatAsRoot, Euid: func() int { return 0 }, + LookupUser: func(name string) (*user.User, error) { return &user.User{Username: name}, nil }, + } +} + +func TestServerUninstallListsWhatItKeepsAndEndsWithTheDryRunLineWithoutYes(t *testing.T) { + t.Parallel() + + got := testkit.Run(t, onMachine(nil, recordedMachine(t)), "", "server:uninstall") + + want := "server:uninstall finds nothing left to remove.\nserver:uninstall keeps:\n the system user gophenberg\n" + + " /var/lib/gophenberg-installer/install.json, so a later install picks up what is kept\n" + + " the packages server:install installed, which sudo apt-get purge postgresql removes\n" + if got.Code != gonsole.ExitDone || got.Stdout != want { + t.Errorf("server:uninstall = %d, stdout %q, stderr %q, want 0 and\n%s", got.Code, got.Stdout, got.Stderr, want) + } + if line := "gophenberg: dry run, nothing changed, pass -yes to apply\n"; got.Stderr != line { + t.Errorf("server:uninstall noted %q, want gonsole's dry run line %q", got.Stderr, line) + } +} + +func TestServerUninstallHandsDeleteDataToTheUninstall(t *testing.T) { + t.Parallel() + + got := testkit.Run(t, onMachine(nil, recordedMachine(t)), "", "server:uninstall", "-delete-data") + + want := "server:uninstall removes:\n the system user gophenberg\nserver:uninstall keeps:\n" + if got.Code != gonsole.ExitDone || !strings.HasPrefix(got.Stdout, want) { + t.Errorf("server:uninstall -delete-data = %d, stdout %q, stderr %q, want 0 and the user removed", got.Code, + got.Stdout, got.Stderr) + } +} + +func TestServerUninstallRefusesAPlainUser(t *testing.T) { + t.Parallel() + + machine := recordedMachine(t) + machine.Euid = func() int { return 1000 } + + got := testkit.Run(t, onMachine(nil, machine), "", "server:uninstall", "-yes") + + wantAnswer(t, got, gonsole.ExitFailed, + "gophenberg: server:uninstall must run as root, such as with sudo, so nothing was changed") +} + func TestServerInstallHandsTheInstallerEveryFlagTheLineSets(t *testing.T) { t.Parallel() @@ -124,9 +231,9 @@ func TestServerInstallRefusesAnInstallRootThatIsNotAbsolute(t *testing.T) { t.Parallel() env := map[string]string{"GOPHENBERG_INSTALL_ROOT": "planted/root"} + machine := installer.Machine{Root: t.TempDir(), Lstat: lstatAsRoot, Euid: func() int { return 0 }} - got := testkit.Run(t, onMachine(env, installer.Machine{Root: t.TempDir()}), "", "server:install", "-dry-run", - "-domain", "example.com") + got := testkit.Run(t, onMachine(env, machine), "", "server:install", "-dry-run", "-domain", "example.com") wantAnswer(t, got, gonsole.ExitFailed, `gophenberg: GOPHENBERG_INSTALL_ROOT: must be an absolute path, got "planted/root"`) @@ -230,6 +337,9 @@ func TestOnlyTheServerCommandsLeaveTheDotEnvFileUnread(t *testing.T) { func TestMainBinaryLeavesTheDotEnvFileUnreadForTheServerCommands(t *testing.T) { t.Parallel() + if os.Geteuid() == 0 { + t.Skip("running as root, which takes server:install past its root check onto the real server") + } binary, env := testkit.CoverBinary(t, "GOPHENBERG_", "gophenberg") folder := t.TempDir() @@ -238,14 +348,13 @@ func TestMainBinaryLeavesTheDotEnvFileUnreadForTheServerCommands(t *testing.T) { t.Fatalf("writing the .env file: %v", err) } - install, code := stderrOf(t, binary, append(slices.Clone(env), "GOPHENBERG_INSTALL_MEMORY_FLOOR_MB=0"), folder, - "server:install", "-dry-run", "-domain", "example.com") + install, code := stderrOf(t, binary, env, folder, "server:install", "-dry-run", "-domain", "example.com") status, _ := stderrOf(t, binary, env, folder, "update:status") - want := "gophenberg: GOPHENBERG_INSTALL_MEMORY_FLOOR_MB: must stand above zero, got \"0\"\n" + want := "gophenberg: server:install must run as root, such as with sudo, so nothing was changed\n" if install != want || code != gonsole.ExitFailed { - t.Errorf("server:install = %d, printing %q, want %d and only the memory floor of its environment %q, the "+ - ".env file never read", code, install, gonsole.ExitFailed, want) + t.Errorf("server:install = %d, printing %q, want %d and only the root line %q, nothing of the .env file", + code, install, gonsole.ExitFailed, want) } if want := `GOPHENBERG_INSTALL_ROOT: must be an absolute path, got "planted/root"`; !strings.Contains(status, want) { t.Errorf("update:status printed %q, want the .env file read and %q", status, want) diff --git a/internal/installer/apply.go b/internal/installer/apply.go new file mode 100644 index 00000000..9494cf90 --- /dev/null +++ b/internal/installer/apply.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/gophenberg/internal/version" +) + +// errLeftOpen is what an apply returns once it warned that its step stays open, so the run goes on and ends well. +var errLeftOpen = errors.New("installer: the step stays open for the next run") + +// owner is the OS user and group a file or folder the installer writes belongs to, by their ids. +type owner struct { + uid, gid int +} + +// installing is one run of server:install that changes the server, with the record it found and the one it writes. +type installing struct { + machine Machine + call gonsole.Call + site site + found Record + record Record + archive io.ReadSeeker + admin admin + created bool + ids *owner + system *os.Root + tree *os.Root +} + +// installingOn returns the run that takes the steps of planned on the machine check checked, its system tree open. +func installingOn(checked *machineCheck, planned site, call gonsole.Call) (*installing, error) { + system, err := checked.machine.tree("/") + if err != nil { + return nil, err + } + record := checked.record + record.Steps = make(map[string]string, len(checked.record.Steps)) + maps.Copy(record.Steps, checked.record.Steps) + record.Installed = slices.Clone(checked.record.Installed) + record.Installer, record.Domain = version.Version(), checked.settings.Domain + return &installing{ + machine: checked.machine, call: call, site: planned, found: checked.record, record: record, system: system, + }, nil +} + +// close closes the system tree and, once the run opened it, the install tree. +func (r *installing) close() { + _ = r.system.Close() + if r.tree != nil { + _ = r.tree.Close() + } +} + +// run takes each of steps in turn, printing what it does, and stops at the first that fails. +func (r *installing) run(ctx context.Context, steps []step) error { + for n, planned := range steps { + if _, err := fmt.Fprintf(r.call.Stdout, "Step %d: %s\n", n+1, planned.does); err != nil { + return err + } + if err := r.take(ctx, planned); err != nil { + return err + } + } + return nil +} + +// take marks planned started unless the record found ended it, applies it, then marks it ended, an ended step kept. +func (r *installing) take(ctx context.Context, planned step) error { + if !r.found.ended(planned.id) { + if err := r.mark(planned.id, stepStarted); err != nil { + return err + } + } + err := planned.apply(r, ctx, planned) + if errors.Is(err, errLeftOpen) { + return nil + } + if err != nil { + return err + } + return r.mark(planned.id, stepEnded) +} + +// mark marks the step called id as mark in the record and saves it. +func (r *installing) mark(id, mark string) error { + r.record.Steps[id] = mark + return r.save() +} + +// save writes the record through the system tree as root's alone, in root's own folder. +func (r *installing) save() error { + if err := makeFolder(r.system, inSystemTree(installerFolder), 0o700, nil); err != nil { + return fmt.Errorf("installer: %w", err) + } + body, _ := json.MarshalIndent(r.record, "", "\t") + saved := file{path: recordPath, owner: "root", mode: 0o600, text: string(body) + "\n"} + return writeFile(r.system, inSystemTree(recordPath), saved, nil) +} + +// inSystemTree returns path, named from the top of the server, as the system tree names it. +func inSystemTree(path string) string { + return strings.TrimPrefix(path, "/") +} + +// tree opens the folder at path, named from the top of the server, as a root that no link inside it leads out of. +func (m Machine) tree(path string) (*os.Root, error) { + tree, err := os.OpenRoot(filepath.Join(m.Root, path)) + if err != nil { + return nil, fmt.Errorf("installer: %w", err) + } + return tree, nil +} + +// writeFile writes written at name in tree under a random name with its mode and the owner ids, then renames it in. +func writeFile(tree *os.Root, name string, written file, ids *owner) error { + temp := filepath.Join(filepath.Dir(name), "."+filepath.Base(name)+"."+rand.Text()+".partial") + err := makeParents(tree, name, ids) + if err == nil { + err = writeTemp(tree, temp, written, ids) + } + if err == nil { + err = tree.Rename(temp, name) + } + if err == nil { + err = saveFolder(tree, filepath.Dir(name)) + } + if err != nil { + _ = tree.Remove(temp) + return fmt.Errorf("installer: write %s: %w", written.path, err) + } + return nil +} + +// writeTemp writes written into the new file temp of tree, its mode past the umask and its owner ids, then saves it. +func writeTemp(tree *os.Root, temp string, written file, ids *owner) error { + out, err := tree.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, written.mode) + if err != nil { + return err + } + err = settle(out, written.mode, ids) + if err == nil { + _, err = io.WriteString(out, written.text) + } + if err == nil { + err = out.Sync() + } + return errors.Join(err, out.Close()) +} + +// settle gives the open file or folder mode, whatever the umask took, then the owner ids unless it stays root's. +func settle(opened *os.File, mode fs.FileMode, ids *owner) error { + if err := opened.Chmod(mode); err != nil || ids == nil { + return err + } + return opened.Chown(ids.uid, ids.gid) +} + +// makeParents makes each missing folder above name in tree at 0755 with the owner ids, leaving every other one alone. +func makeParents(tree *os.Root, name string, ids *owner) error { + parent := filepath.Dir(name) + if parent == "." { + return nil + } + if _, err := tree.Lstat(parent); !errors.Is(err, fs.ErrNotExist) { + return err + } + if err := makeParents(tree, parent, ids); err != nil { + return err + } + return makeFolder(tree, parent, 0o755, ids) +} + +// makeFolder makes the folder name in tree, or takes the real folder there, then gives it mode and the owner ids. +func makeFolder(tree *os.Root, name string, mode fs.FileMode, ids *owner) error { + err := tree.Mkdir(name, mode) + if errors.Is(err, fs.ErrExist) { + err = realFolder(tree.Lstat(name)) + } + var folder *os.File + if err == nil { + folder, err = tree.Open(name) + } + if err == nil { + err = errors.Join(settle(folder, mode, ids), folder.Close()) + } + if err != nil { + return fmt.Errorf("make the folder %s: %w", name, err) + } + return nil +} + +// realFolder refuses what info, or the err of describing it, shows to be a link, a file or anything but a folder. +func realFolder(info fs.FileInfo, err error) error { + if err == nil && !info.IsDir() { + return errors.New("it is not a real folder") + } + return err +} + +// saveFolder flushes the entries of the folder name of tree to disk, so a file renamed into it survives a power cut. +func saveFolder(tree *os.Root, name string) error { + folder, err := tree.Open(name) + if err == nil { + err = errors.Join(folder.Sync(), folder.Close()) + } + return err +} diff --git a/internal/installer/apply_internal_test.go b/internal/installer/apply_internal_test.go new file mode 100644 index 00000000..d6dad9ec --- /dev/null +++ b/internal/installer/apply_internal_test.go @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "errors" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/gophenberg/internal/version" +) + +// openTree returns a root on a new folder of the test's own disk, with the path of that folder. +func openTree(t *testing.T) (*os.Root, string) { + t.Helper() + folder := t.TempDir() + tree, err := os.OpenRoot(folder) + if err != nil { + t.Fatalf("opening %s as a root: %v", folder, err) + } + t.Cleanup(func() { _ = tree.Close() }) + return tree, folder +} + +// testIDs returns the ids of the user the test runs as, the only ones a test that is not root may give a file. +func testIDs() *owner { + return &owner{uid: os.Getuid(), gid: os.Getgid()} +} + +// wantEntry fails t unless the entry at path has the type and the mode of mode, belonging to ids or to the test's own. +func wantEntry(t *testing.T, path string, mode fs.FileMode, ids *owner) { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("describing %s: %v", path, err) + } + if got := info.Mode().Type() | info.Mode().Perm(); got != mode { + t.Errorf("%s is %s, want %s", path, got, mode) + } + want := *testIDs() + if ids != nil { + want = *ids + } + if stat := info.Sys().(*syscall.Stat_t); int(stat.Uid) != want.uid || int(stat.Gid) != want.gid { + t.Errorf("%s belongs to %d:%d, want %d:%d", path, stat.Uid, stat.Gid, want.uid, want.gid) + } +} + +// wantText fails t unless the file at path holds text. +func wantText(t *testing.T, path, text string) { + t.Helper() + held, err := os.ReadFile(path) + if err != nil || string(held) != text { + t.Errorf("reading %s = %q, %v, want %q", path, held, err, text) + } +} + +// wantOnly fails t unless the folder at path holds the entries names alone, in the order of their names. +func wantOnly(t *testing.T, path string, names ...string) { + t.Helper() + entries, err := os.ReadDir(path) + if err != nil { + t.Fatalf("listing %s: %v", path, err) + } + var held []string + for _, entry := range entries { + held = append(held, entry.Name()) + } + if !slices.Equal(held, names) { + t.Errorf("%s holds %q, want %q", path, held, names) + } +} + +// skipAsRoot skips t when it runs as root, who may do what the test proves a plain user may not. +func skipAsRoot(t *testing.T) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("root may give a file to any user and write any folder") + } +} + +// serverEnvFile is a server.env a test writes, with a mode a umask of 022 or 077 would narrow. +var serverEnvFile = file{path: "/etc/gophenberg/server.env", owner: "root", mode: 0o664, text: "GOPHENBERG_ADDR=x\n"} + +func TestTheMachineOpensAFolderOfTheServerAsATree(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + + tree, err := server.machine().tree("/opt") + if err != nil { + t.Fatalf("tree(/opt) = %v, want the folder open", err) + } + defer func() { _ = tree.Close() }() + + if tree.Name() != server.at("/opt") { + t.Errorf("tree(/opt) opened %s, want %s", tree.Name(), server.at("/opt")) + } +} + +func TestTheMachineRefusesATreeOnAFolderThatIsNotThere(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + + _, err := server.machine().tree("/srv") + + wantRefused(t, err, "installer: open "+server.at("/srv")+": no such file or directory") +} + +func TestWriteFileWritesTheTextWithItsModeAndMakesTheMissingFoldersAt0755WhateverTheUmask(t *testing.T) { + kept := syscall.Umask(0o077) + defer syscall.Umask(kept) + tree, folder := openTree(t) + + err := writeFile(tree, "etc/gophenberg/server.env", serverEnvFile, nil) + + if err != nil { + t.Fatalf("writeFile() = %v, want the file written", err) + } + wantText(t, filepath.Join(folder, "etc/gophenberg/server.env"), serverEnvFile.text) + wantEntry(t, filepath.Join(folder, "etc/gophenberg/server.env"), 0o664, nil) + wantEntry(t, filepath.Join(folder, "etc"), fs.ModeDir|0o755, nil) + wantEntry(t, filepath.Join(folder, "etc/gophenberg"), fs.ModeDir|0o755, nil) + wantOnly(t, filepath.Join(folder, "etc/gophenberg"), "server.env") +} + +func TestWriteFileLeavesAFolderAboveItAsItFoundIt(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.MkdirAll(filepath.Join(folder, "var/lib/gophenberg-installer"), 0o700); err != nil { + t.Fatalf("making the installer's folder: %v", err) + } + + err := writeFile(tree, "var/lib/gophenberg-installer/install.json", file{mode: 0o600, text: "{}\n"}, testIDs()) + + if err != nil { + t.Fatalf("writeFile() = %v, want the file written", err) + } + wantEntry(t, filepath.Join(folder, "var/lib/gophenberg-installer"), fs.ModeDir|0o700, nil) +} + +func TestWriteFileReplacesTheFileThereAndLeavesNoPartialFile(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.WriteFile(filepath.Join(folder, "server.env"), []byte("GOPHENBERG_ADDR=old\n"), 0o600); err != nil { + t.Fatalf("writing the old file: %v", err) + } + + err := writeFile(tree, "server.env", serverEnvFile, nil) + + if err != nil { + t.Fatalf("writeFile() = %v, want the file written", err) + } + wantText(t, filepath.Join(folder, "server.env"), serverEnvFile.text) + wantOnly(t, folder, "server.env") +} + +func TestWriteFileGivesTheFileAndTheFoldersItMakesToTheIdsItIsHanded(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + + err := writeFile(tree, "state/.env", file{mode: 0o600, text: "GOPHENBERG_ADDR=x\n"}, testIDs()) + + if err != nil { + t.Fatalf("writeFile() = %v, want the file written", err) + } + wantEntry(t, filepath.Join(folder, "state/.env"), 0o600, testIDs()) + wantEntry(t, filepath.Join(folder, "state"), fs.ModeDir|0o755, testIDs()) +} + +func TestWriteFileRefusesIdsItMayNotGiveAndLeavesNoPartialFile(t *testing.T) { + t.Parallel() + + skipAsRoot(t) + tree, folder := openTree(t) + + err := writeFile(tree, "server.env", serverEnvFile, &owner{uid: 0, gid: 0}) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write /etc/gophenberg/server.env: chown ") || + !strings.HasSuffix(got, ": operation not permitted") { + t.Errorf("writeFile() = %v, want the chown refused", err) + } + wantOnly(t, folder) +} + +func TestWriteFileRefusesAFolderItMayNotWriteIn(t *testing.T) { + t.Parallel() + + skipAsRoot(t) + tree, folder := openTree(t) + if err := os.Mkdir(filepath.Join(folder, "etc"), 0o555); err != nil { + t.Fatalf("making a folder nobody may write: %v", err) + } + + err := writeFile(tree, "etc/server.env", serverEnvFile, nil) + + if got := fmt.Sprint(err); !strings.HasSuffix(got, ": permission denied") { + t.Errorf("writeFile() = %v, want the write refused", err) + } +} + +func TestWriteFileRefusesAFolderAboveItThatLeadsOutOfTheTree(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(folder, "etc")); err != nil { + t.Fatalf("linking etc out of the tree: %v", err) + } + + err := writeFile(tree, "etc/server.env", serverEnvFile, nil) + + if got := fmt.Sprint(err); !strings.HasSuffix(got, "path escapes from parent") { + t.Errorf("writeFile() = %v, want the link out of the tree refused", err) + } + wantOnly(t, outside) +} + +func TestWriteFileRefusesAFileWhereAFolderAboveItGoes(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.WriteFile(filepath.Join(folder, "etc"), nil, 0o644); err != nil { + t.Fatalf("writing a file where a folder goes: %v", err) + } + + err := writeFile(tree, "etc/gophenberg/server.env", serverEnvFile, nil) + + if got := fmt.Sprint(err); !strings.HasSuffix(got, ": not a directory") { + t.Errorf("writeFile() = %v, want the file in the way refused", err) + } +} + +func TestWriteFileRefusesAFolderAtItsNameAndLeavesNoPartialFile(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.MkdirAll(filepath.Join(folder, "server.env/kept"), 0o755); err != nil { + t.Fatalf("making a folder where the file goes: %v", err) + } + + err := writeFile(tree, "server.env", serverEnvFile, nil) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write /etc/gophenberg/server.env: renameat ") { + t.Errorf("writeFile() = %v, want the rename refused", err) + } + wantOnly(t, folder, "server.env") +} + +func TestWriteFileRefusesAFolderAboveItThatCannotBeMadeTwoLevelsUp(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.Symlink("nowhere/deeper", filepath.Join(folder, "etc")); err != nil { + t.Fatalf("linking etc to nowhere: %v", err) + } + + err := writeFile(tree, "etc/apt/keyrings/caddy.asc", file{path: caddyKeyPath, mode: 0o644}, nil) + + wantRefused(t, err, "installer: write /etc/apt/keyrings/caddy.asc: make the folder etc/apt: mkdirat etc/apt: no "+ + "such file or directory") +} + +func TestMakeFolderMakesTheFolderWithItsModeWhateverTheUmask(t *testing.T) { + kept := syscall.Umask(0o077) + defer syscall.Umask(kept) + tree, folder := openTree(t) + + err := makeFolder(tree, "media", 0o750, nil) + + if err != nil { + t.Fatalf("makeFolder() = %v, want the folder made", err) + } + wantEntry(t, filepath.Join(folder, "media"), fs.ModeDir|0o750, nil) +} + +func TestMakeFolderTakesTheFolderThereAndGivesItItsModeAndOwnerAgain(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.Mkdir(filepath.Join(folder, "versions"), 0o700); err != nil { + t.Fatalf("making the folder: %v", err) + } + + err := makeFolder(tree, "versions", 0o755, testIDs()) + + if err != nil { + t.Fatalf("makeFolder() = %v, want the folder taken", err) + } + wantEntry(t, filepath.Join(folder, "versions"), fs.ModeDir|0o755, testIDs()) +} + +func TestMakeFolderTakesTheTopOfTheTree(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + + err := makeFolder(tree, ".", 0o750, testIDs()) + + if err != nil { + t.Fatalf("makeFolder(.) = %v, want the top of the tree taken", err) + } + wantEntry(t, folder, fs.ModeDir|0o750, testIDs()) +} + +func TestMakeFolderRefusesALinkWhetherItLeadsInsideTheTreeOrOut(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + outside := t.TempDir() + if err := os.Chmod(outside, 0o700); err != nil { + t.Fatalf("changing the mode of the folder outside: %v", err) + } + if err := os.Mkdir(filepath.Join(folder, "media"), 0o700); err != nil { + t.Fatalf("making the folder inside: %v", err) + } + for name, target := range map[string]string{"themes": "media", "state": outside} { + if err := os.Symlink(target, filepath.Join(folder, name)); err != nil { + t.Fatalf("linking %s to %s: %v", name, target, err) + } + + err := makeFolder(tree, name, 0o755, nil) + + wantRefused(t, err, "make the folder "+name+": it is not a real folder") + } + wantEntry(t, filepath.Join(folder, "media"), fs.ModeDir|0o700, nil) + wantEntry(t, outside, fs.ModeDir|0o700, nil) +} + +func TestMakeFolderRefusesAFileAtItsName(t *testing.T) { + t.Parallel() + + tree, folder := openTree(t) + if err := os.WriteFile(filepath.Join(folder, "media"), nil, 0o644); err != nil { + t.Fatalf("writing a file where the folder goes: %v", err) + } + + err := makeFolder(tree, "media", 0o750, nil) + + wantRefused(t, err, "make the folder media: it is not a real folder") +} + +func TestMakeFolderRefusesAFolderItCannotMake(t *testing.T) { + t.Parallel() + + tree, _ := openTree(t) + + err := makeFolder(tree, "srv/sites", 0o755, nil) + + wantRefused(t, err, "make the folder srv/sites: mkdirat srv/sites: no such file or directory") +} + +// runOn returns the run that changes server as a first run for example.com that found the record found, and its answer. +func runOn(t *testing.T, server *fakeServer, found Record) (*installing, *strings.Builder) { + t.Helper() + check := server.check() + check.settings.Domain, check.record = "example.com", found + answer := &strings.Builder{} + call := gonsole.Call{Stdout: answer, Stderr: &server.notes} + running, err := installingOn(check, firstSite(t, "example.com", 255), call) + if err != nil { + t.Fatalf("installingOn() = %v, want the run ready", err) + } + t.Cleanup(running.close) + return running, answer +} + +// marks returns the marks of the install record on server, failing t when it cannot read it. +func marks(t *testing.T, server *fakeServer) map[string]string { + t.Helper() + record, err := readRecord(server.machine()) + if err != nil { + t.Fatalf("readRecord() = %v, want the record the run saved", err) + } + return record.Steps +} + +// notApplied is the mark a step's apply notes until the apply runs. +const notApplied = "not applied" + +// markedDuring returns a step called id whose apply notes the mark the saved record holds for it, then returns err. +func markedDuring(t *testing.T, server *fakeServer, id string, err error) (step, *string) { + during := notApplied + return step{id: id, does: "take the step " + id, apply: func(*installing, context.Context, step) error { + during = marks(t, server)[id] + return err + }}, &during +} + +func TestTakeMarksTheStepStartedBeforeItsApplyAndEndedAfter(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + planned, during := markedDuring(t, server, "packages", nil) + + err := running.take(t.Context(), planned) + + if err != nil || *during != "started" { + t.Errorf("take() = %v, the record marking the step %q during its apply, want nil and started", err, *during) + } + if got := marks(t, server)["packages"]; got != "ended" { + t.Errorf("the record marks the step %q after take(), want ended", got) + } +} + +func TestTheFirstSaveNamesTheRunningInstallerAndTheDomainInRootsOwnFolder(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{Installer: "0.0.1", Domain: "www.example.com"}) + planned, _ := markedDuring(t, server, "server-env", nil) + + err := running.take(t.Context(), planned) + + record, readErr := readRecord(server.machine()) + if err := errors.Join(err, readErr); err != nil { + t.Fatalf("take() then readRecord() = %v, want the record saved", err) + } + if record.Installer != version.Version() || record.Domain != "example.com" { + t.Errorf("take() saved %+v, want the installer %s and example.com", record, version.Version()) + } + wantEntry(t, server.at(installerFolder), fs.ModeDir|0o700, nil) + wantEntry(t, server.at(recordPath), 0o600, nil) +} + +func TestTakeLeavesTheMarkOfAStepAnEarlierRunEndedWhenItsApplyFails(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{Steps: map[string]string{"server-env": "ended", "caddy": "ended"}}) + if err := running.save(); err != nil { + t.Fatalf("save() = %v, want the record the run found saved", err) + } + planned, during := markedDuring(t, server, "caddy", errors.New("caddy validate failed")) + + err := running.take(t.Context(), planned) + + wantRefused(t, err, "caddy validate failed") + if got := marks(t, server)["caddy"]; *during != "ended" || got != "ended" { + t.Errorf("the record marks the step %q during its apply and %q after, want ended both times", *during, got) + } +} + +func TestTakeStopsBeforeTheApplyWhenItCannotMarkTheStepStarted(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder(recordPath + "/kept") + running, _ := runOn(t, server, Record{}) + planned, during := markedDuring(t, server, "packages", nil) + + err := running.take(t.Context(), planned) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write "+recordPath+": ") || *during != notApplied { + t.Errorf("take() = %v, want it stopped before the apply", err) + } +} + +func TestTakeKeepsAStepStartedWhenItsApplyFails(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + planned, _ := markedDuring(t, server, "packages", errors.New("apt-get failed")) + + err := running.take(t.Context(), planned) + + wantRefused(t, err, "apt-get failed") + if got := marks(t, server)["packages"]; got != "started" { + t.Errorf("the record marks the step %q after a failed apply, want started", got) + } +} + +func TestTakeGoesOnPastAStepItsApplyLeavesOpen(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + planned, _ := markedDuring(t, server, "https", errLeftOpen) + + err := running.take(t.Context(), planned) + + if got := marks(t, server)["https"]; err != nil || got != "started" { + t.Errorf("take() = %v, the record marking the step %q, want nil and the step left started", err, got) + } +} + +func TestRunPrintsAndTakesEveryStepInOrder(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, answer := runOn(t, server, Record{}) + user, userDuring := markedDuring(t, server, "user", nil) + database, databaseDuring := markedDuring(t, server, "database", nil) + + err := running.run(t.Context(), []step{user, database}) + + if err != nil || answer.String() != "Step 1: take the step user\nStep 2: take the step database\n" { + t.Errorf("run() = %v, printing %q, want both steps printed", err, answer.String()) + } + if during := []string{*userDuring, *databaseDuring}; !slices.Equal(during, []string{"started", "started"}) { + t.Errorf("the record marked the steps %q during their applies, want both started", during) + } + if got, want := marks(t, server), map[string]string{"user": "ended", "database": "ended"}; !maps.Equal(got, want) { + t.Errorf("the record marks %v, want %v", got, want) + } +} + +func TestRunStopsAtTheFirstStepThatFails(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, answer := runOn(t, server, Record{}) + user, _ := markedDuring(t, server, "user", errors.New("useradd failed")) + database, databaseDuring := markedDuring(t, server, "database", nil) + + err := running.run(t.Context(), []step{user, database}) + + wantRefused(t, err, "useradd failed") + if *databaseDuring != notApplied || answer.String() != "Step 1: take the step user\n" { + t.Errorf("run() printed %q and took the next step, want it stopped at the first", answer.String()) + } +} + +func TestRunReturnsWhatStopsItsAnswer(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.call.Stdout = refusingWriter{} + user, during := markedDuring(t, server, "user", nil) + + err := running.run(t.Context(), []step{user}) + + wantRefused(t, err, "the terminal is gone") + if *during != notApplied { + t.Error("run() took a step it could not print, want none taken") + } +} + +func TestSaveRefusesAnInstallerFolderThatIsALink(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder("/var/lib/other") + server.link(installerFolder, "/var/lib/other") + running, _ := runOn(t, server, Record{}) + + err := running.save() + + wantRefused(t, err, "installer: make the folder var/lib/gophenberg-installer: it is not a real folder") +} + +func TestSaveRefusesARecordItCannotWrite(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder(recordPath + "/kept") + running, _ := runOn(t, server, Record{}) + + err := running.save() + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write "+recordPath+": renameat ") { + t.Errorf("save() = %v, want the record refused", err) + } +} + +func TestInstallingOnRefusesAServerItCannotOpen(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.root = filepath.Join(server.root, "missing") + + _, err := installingOn(server.check(), site{}, gonsole.Call{}) + + wantRefused(t, err, "installer: open "+server.root+": no such file or directory") +} + +func TestInstallingOnKeepsTheRecordItFoundApartFromTheOneItWrites(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + found := Record{Steps: map[string]string{"server-env": "ended"}, Installed: []string{piecePostgreSQL}} + running, _ := runOn(t, server, found) + + running.record.Steps["packages"] = "started" + running.record.Installed[0] = pieceCaddy + + if _, marked := found.Steps["packages"]; marked || found.Installed[0] != piecePostgreSQL { + t.Errorf("the record found became %+v, want it as it was found", found) + } +} + +func TestCloseClosesTheInstallTreeOnceTheRunOpenedIt(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + tree, err := server.machine().tree("/opt") + if err != nil { + t.Fatalf("opening the install tree: %v", err) + } + running.tree = tree + + running.close() + + if _, err := tree.Stat("."); err == nil { + t.Error("close() left the install tree open") + } +} + +func TestMakeFolderRefusesIdsItMayNotGive(t *testing.T) { + t.Parallel() + + skipAsRoot(t) + tree, _ := openTree(t) + + err := makeFolder(tree, "media", 0o750, &owner{uid: 0, gid: 0}) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "make the folder media: chown ") { + t.Errorf("makeFolder() = %v, want the chown refused", err) + } +} diff --git a/internal/installer/archive.go b/internal/installer/archive.go new file mode 100644 index 00000000..5e09d4da --- /dev/null +++ b/internal/installer/archive.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "runtime" + + "github.com/gopherium/gophenberg/internal/version" +) + +// serverPlatform is the platform of this server as the release.json of its archive names it. +const serverPlatform = "linux/" + runtime.GOARCH + +// maxReleaseJSONBytes is the largest release.json the installer reads, far above what a release writes. +const maxReleaseJSONBytes = 1 << 16 + +// releaseFacts is the part of the release.json of an archive that must match this installer and this server. +type releaseFacts struct { + Version string `json:"version"` + Platform string `json:"platform"` +} + +// openArchive opens the release archive at path and checks its release.json, none while the run needs no archive. +func (c *machineCheck) openArchive(path string) (*os.File, error) { + if path == "" { + return nil, c.archiveNeeded() + } + archive, err := os.Open(filepath.Join(c.machine.Root, path)) + if err != nil { + return nil, fmt.Errorf("installer: %w", err) + } + if err := checkArchive(archive, filepath.Clean(path)); err != nil { + _ = archive.Close() + return nil, err + } + return archive, nil +} + +// archiveNeeded refuses a run without an archive while the current link of the install root names no build. +func (c *machineCheck) archiveNeeded() error { + if c.found.current != "" { + return nil + } + return stop("-archive is required while %s/current names no build, such as the archive install.sh downloads "+ + "into %s", c.settings.Root, installerFolder) +} + +// checkArchive refuses an archive at path whose release.json names another version or platform than this run's. +func checkArchive(archive io.Reader, path string) error { + facts, err := releaseFactsOf(archive) + if err != nil { + return stop("the archive %s %v", path, err) + } + if facts.Version != version.Version() { + return stop("the archive %s holds Gophenberg %s, not this installer's %s, and server:install installs its "+ + "own release only", path, facts.Version, version.Version()) + } + if facts.Platform != serverPlatform { + return stop("the archive %s is built for %s, not for this server's %s", path, facts.Platform, serverPlatform) + } + return nil +} + +// releaseFactsOf reads the release.json of a release archive, saying what the archive is when it holds none. +func releaseFactsOf(archive io.Reader) (releaseFacts, error) { + unzipped, err := gzip.NewReader(archive) + if err != nil { + return releaseFacts{}, fmt.Errorf("is not a gzip file: %w", err) + } + entries := tar.NewReader(unzipped) + for { + header, err := entries.Next() + if errors.Is(err, io.EOF) { + return releaseFacts{}, errors.New("holds no release.json") + } + if err != nil { + return releaseFacts{}, fmt.Errorf("cannot be read: %w", err) + } + if path.Clean(header.Name) == "release.json" { + return decodedFacts(entries) + } + } +} + +// decodedFacts decodes the release.json release holds, reading no more of it than any release writes. +func decodedFacts(release io.Reader) (releaseFacts, error) { + var facts releaseFacts + if err := json.NewDecoder(io.LimitReader(release, maxReleaseJSONBytes)).Decode(&facts); err != nil { + return releaseFacts{}, fmt.Errorf("holds a release.json that cannot be read: %w", err) + } + return facts, nil +} + +// closeArchive closes archive, when the run opened one. +func closeArchive(archive *os.File) { + if archive != nil { + _ = archive.Close() + } +} diff --git a/internal/installer/archive_internal_test.go b/internal/installer/archive_internal_test.go new file mode 100644 index 00000000..3ec454a3 --- /dev/null +++ b/internal/installer/archive_internal_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "runtime" + "testing" + + "github.com/gopherium/gophenberg/internal/version" +) + +// entry is one file of a release archive a test packs: its name and what it holds. +type entry struct { + name, body string +} + +// packed returns a gzip tar archive that holds entries in order, as make release-archives packs a release. +func packed(t *testing.T, entries ...entry) string { + t.Helper() + var archive bytes.Buffer + zipped := gzip.NewWriter(&archive) + files := tar.NewWriter(zipped) + for _, packed := range entries { + header := &tar.Header{Name: packed.name, Mode: 0o644, Size: int64(len(packed.body)), Typeflag: tar.TypeReg} + if err := files.WriteHeader(header); err != nil { + t.Fatalf("packing %s: %v", packed.name, err) + } + if _, err := files.Write([]byte(packed.body)); err != nil { + t.Fatalf("packing %s: %v", packed.name, err) + } + } + if err := files.Close(); err != nil { + t.Fatalf("closing the tar archive: %v", err) + } + if err := zipped.Close(); err != nil { + t.Fatalf("closing the gzip stream: %v", err) + } + return archive.String() +} + +// releaseArchiveOf returns a release archive whose release.json names version and platform, its files in tar order. +func releaseArchiveOf(t *testing.T, version, platform string) string { + t.Helper() + return packed(t, + entry{"LICENSE", "Apache License\n"}, + entry{"NOTICE", "Gophenberg\n"}, + entry{"gophenberg", "#!/bin/sh\n"}, + entry{"release.json", releaseJSONOf(version, platform)}, + ) +} + +// releaseJSONOf returns the release.json of a release of version built for platform. +func releaseJSONOf(version, platform string) string { + return `{"schema": 1, "name": "gophenberg", "version": "` + version + `", "platform": "` + platform + `"}` + "\n" +} + +// archivePath is where the tests put the release archive, from the top of the server. +const archivePath = "/var/lib/gophenberg-installer/release.tar.gz" + +// archiveChecked opens the archive at path on server as a first run on example.com does, returning what it opens. +func archiveChecked(server *fakeServer, path string) (*os.File, error) { + return server.check().openArchive(path) +} + +func TestOpenArchiveOpensTheReleaseArchiveOfThisInstallerForThisServer(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(archivePath, releaseArchiveOf(t, version.Version(), "linux/"+runtime.GOARCH)) + + archive, err := archiveChecked(server, archivePath) + if err != nil { + t.Fatalf("openArchive() = %v, want the archive open", err) + } + defer closeArchive(archive) + + if archive.Name() != server.at(archivePath) { + t.Errorf("openArchive() opened %s, want %s", archive.Name(), server.at(archivePath)) + } +} + +func TestOpenArchiveReadsAReleaseJSONItsArchiveNamesFromTheFolderItIsIn(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(archivePath, packed(t, entry{"./release.json", releaseJSONOf(version.Version(), + "linux/"+runtime.GOARCH)})) + + archive, err := archiveChecked(server, archivePath) + defer closeArchive(archive) + + if err != nil { + t.Errorf("openArchive() = %v, want the archive open", err) + } +} + +func TestOpenArchiveNeedsNoArchiveOnceCurrentNamesABuild(t *testing.T) { + t.Parallel() + + check := newFakeServer(t).check() + check.found.current = earlierBuild + + archive, err := check.openArchive("") + + if archive != nil || err != nil { + t.Errorf("openArchive() = %v, %v, want no archive needed", archive, err) + } +} + +func TestOpenArchiveRefusesAFirstRunWithoutAnArchive(t *testing.T) { + t.Parallel() + + archive, err := archiveChecked(newFakeServer(t), "") + + if archive != nil { + t.Errorf("openArchive() opened %s, want nothing open", archive.Name()) + } + wantRefused(t, err, "-archive is required while /opt/gophenberg/current names no build, such as the archive "+ + "install.sh downloads into /var/lib/gophenberg-installer, so nothing was changed") +} + +func TestOpenArchiveRefusesAnArchiveItCannotOpen(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + + _, err := archiveChecked(server, archivePath) + + wantRefused(t, err, "installer: open "+server.at(archivePath)+": no such file or directory") +} + +func TestOpenArchiveRefusesAnArchiveThatIsNoReleaseOfThisInstallerForThisServer(t *testing.T) { + t.Parallel() + + for _, refused := range []struct { + archive func(t *testing.T) string + want string + }{ + {func(*testing.T) string { return "a release note, which is no gzip stream" }, + "is not a gzip file: gzip: invalid header"}, + {func(t *testing.T) string { return gzipped(t, "not a tar archive") }, "cannot be read: unexpected EOF"}, + {func(t *testing.T) string { return packed(t, entry{"gophenberg", "#!/bin/sh\n"}) }, "holds no release.json"}, + {func(t *testing.T) string { return packed(t, entry{"release.json", "not JSON"}) }, + "holds a release.json that cannot be read: invalid character 'o' in literal null (expecting 'u')"}, + {func(t *testing.T) string { return releaseArchiveOf(t, "0.0.1", "linux/"+runtime.GOARCH) }, + "holds Gophenberg 0.0.1, not this installer's " + version.Version() + ", and server:install installs " + + "its own release only"}, + {func(t *testing.T) string { return releaseArchiveOf(t, version.Version(), "windows/arm64") }, + "is built for windows/arm64, not for this server's linux/" + runtime.GOARCH}, + } { + server := newFakeServer(t) + server.write(archivePath, refused.archive(t)) + + archive, err := archiveChecked(server, archivePath) + + if archive != nil { + t.Errorf("openArchive() opened %s, want nothing open", archive.Name()) + } + wantRefused(t, err, "the archive "+archivePath+" "+refused.want+", so nothing was changed") + } +} + +// gzipped returns body compressed with gzip and nothing else around it. +func gzipped(t *testing.T, body string) string { + t.Helper() + var archive bytes.Buffer + zipped := gzip.NewWriter(&archive) + if _, err := zipped.Write([]byte(body)); err != nil { + t.Fatalf("compressing: %v", err) + } + if err := zipped.Close(); err != nil { + t.Fatalf("closing the gzip stream: %v", err) + } + return archive.String() +} + +func TestCloseArchiveTakesNoArchive(t *testing.T) { + t.Parallel() + + closeArchive(nil) +} diff --git a/internal/installer/build.go b/internal/installer/build.go new file mode 100644 index 00000000..d4ca8246 --- /dev/null +++ b/internal/installer/build.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/gopherium/gophenberg/internal/updater" + "github.com/gopherium/gophenberg/internal/version" +) + +// build unpacks the archive into a fresh build folder and points current at it, unless current names a build already. +func (r *installing) build(_ context.Context, _ step) error { + if r.site.current { + return nil + } + name := version.Version() + dir := updater.Layout{}.Build(name) + _, err := r.archive.Seek(0, io.SeekStart) + if err == nil { + err = r.tree.RemoveAll(dir) + } + if err == nil { + err = updater.UnpackIn(r.tree, r.archive, dir) + } + if err == nil { + err = r.giveAll(dir) + } + if err == nil { + err = updater.PointIn(r.tree, updater.Current, name) + } + if err == nil { + err = r.tree.Lchown(updater.Current, r.ids.uid, r.ids.gid) + } + if err != nil { + return fmt.Errorf("installer: unpack the build %s: %w", name, err) + } + return nil +} + +// giveAll gives the folder dir of the install tree and everything in it to gophenberg, following no link. +func (r *installing) giveAll(dir string) error { + return fs.WalkDir(r.tree.FS(), dir, func(path string, _ fs.DirEntry, err error) error { + if err == nil { + err = r.tree.Lchown(path, r.ids.uid, r.ids.gid) + } + return err + }) +} + +// keepCopy copies the running installer to installerCopy as root's, unless the run runs that copy already. +func (r *installing) keepCopy(_ context.Context, _ step) error { + running, err := r.machine.Executable() + if err != nil { + return fmt.Errorf("installer: find the running installer: %w", err) + } + if r.runsCopy(running) { + return nil + } + binary, err := os.ReadFile(running) + if err != nil { + return fmt.Errorf("installer: read the running installer: %w", err) + } + copied := file{path: installerCopy, owner: "root", mode: 0o755, text: string(binary)} + return writeFile(r.system, inSystemTree(installerCopy), copied, nil) +} + +// runsCopy reports whether the installer at running is the installer's own copy, under its name or another. +func (r *installing) runsCopy(running string) bool { + copied, err := os.Stat(filepath.Join(r.machine.Root, installerCopy)) + if err != nil { + return false + } + ran, err := os.Stat(running) + return err == nil && os.SameFile(ran, copied) +} diff --git a/internal/installer/build_internal_test.go b/internal/installer/build_internal_test.go new file mode 100644 index 00000000..51224232 --- /dev/null +++ b/internal/installer/build_internal_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/gopherium/gophenberg/internal/version" +) + +// builtOn returns the run of a first run for example.com on server whose install root is made, with its own archive. +func builtOn(t *testing.T, server *fakeServer, ids *owner) *installing { + t.Helper() + running, _ := runOn(t, server, Record{}) + running.ids = ids + if err := running.take(t.Context(), plannedStep(t, running, "install-root")); err != nil { + t.Fatalf("take(install-root) = %v, want the install root made", err) + } + running.archive = strings.NewReader(releaseArchiveOf(t, version.Version(), serverPlatform)) + return running +} + +// wantOwnBuild fails t unless server holds this installer's build, as its archive holds it, owned by ids. +func wantOwnBuild(t *testing.T, server *fakeServer, ids *owner) { + t.Helper() + wantOnly(t, server.at(ownBuild), "LICENSE", "NOTICE", "gophenberg", "release.json") + for name, text := range map[string]string{"LICENSE": "Apache License\n", "gophenberg": "#!/bin/sh\n"} { + wantText(t, server.at(ownBuild+"/"+name), text) + } + wantEntry(t, server.at(ownBuild), fs.ModeDir|0o755, ids) + wantEntry(t, server.at(ownBuild+"/gophenberg"), 0o755, ids) + wantEntry(t, server.at(ownBuild+"/release.json"), 0o644, ids) + target, err := os.Readlink(server.at("/opt/gophenberg/current")) + if err != nil || target != "versions/"+version.Version() { + t.Errorf("the current link names %q, %v, want versions/%s", target, err, version.Version()) + } + wantEntry(t, server.at("/opt/gophenberg/current"), fs.ModeSymlink|0o777, ids) +} + +func TestTheBuildStepUnpacksTheArchiveAndPointsCurrentAtItAsGophenbergs(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := builtOn(t, server, otherGroupIDs(t)) + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + if err != nil { + t.Fatalf("take(build) = %v, want the build unpacked", err) + } + wantOwnBuild(t, server, running.ids) +} + +func TestTheBuildStepRemovesTheBuildACutOffRunLeftFirst(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := builtOn(t, server, testIDs()) + server.write(ownBuild+"/gophenberg", "#!/bin/sh\n# cut off while it was written\n") + server.folder(ownBuild + "/LICENSES") + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + if err != nil { + t.Fatalf("take(build) = %v, want the build unpacked again", err) + } + wantOwnBuild(t, server, nil) +} + +func TestTheBuildStepReadsTheArchiveFromItsStartAfterTheCheckReadItsReleaseJSON(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := builtOn(t, server, testIDs()) + if _, err := io.CopyN(io.Discard, running.archive, 64); err != nil { + t.Fatalf("reading into the archive: %v", err) + } + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + if err != nil { + t.Fatalf("take(build) = %v, want the build unpacked from the start of the archive", err) + } + wantOwnBuild(t, server, nil) +} + +func TestTheBuildStepKeepsTheBuildCurrentNames(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.pointCurrent() + running := builtOn(t, server, testIDs()) + running.site.current = true + running.archive = unseekable{Reader: running.archive} + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + target, readErr := os.Readlink(server.at("/opt/gophenberg/current")) + if err := errors.Join(err, readErr); err != nil || target != "versions/"+earlierBuild { + t.Errorf("take(build) = %v, current naming %q, want the build %s kept", err, target, earlierBuild) + } + wantOnly(t, server.at("/opt/gophenberg/versions/"+earlierBuild), "gophenberg") +} + +func TestTheBuildStepStopsOnAnArchiveItCannotUnpackAndLeavesNoBuildFolder(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := builtOn(t, server, testIDs()) + running.archive = strings.NewReader("a release note, which is no gzip stream") + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + wantRefused(t, err, "installer: unpack the build "+version.Version()+": updater: refused the archive, which is "+ + "not gzip: gzip: invalid header") + wantOnly(t, server.at("/opt/gophenberg/versions")) +} + +// unseekable is an archive that cannot go back to its start. +type unseekable struct { + io.Reader +} + +// Seek refuses to move. +func (unseekable) Seek(int64, int) (int64, error) { + return 0, errors.New("the archive cannot go back") +} + +func TestTheBuildStepStopsWhenItCannotGoBackToTheStartOfTheArchive(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := builtOn(t, server, testIDs()) + running.archive = unseekable{Reader: running.archive} + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + wantRefused(t, err, "installer: unpack the build "+version.Version()+": the archive cannot go back") +} + +func TestTheBuildStepStopsWhenItCannotGiveTheBuildToGophenberg(t *testing.T) { + t.Parallel() + + skipAsRoot(t) + server := newFakeServer(t) + running := builtOn(t, server, testIDs()) + running.ids = &owner{uid: 0, gid: 0} + + err := running.take(t.Context(), plannedStep(t, running, "build")) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: unpack the build "+version.Version()+": lchownat ") { + t.Errorf("take(build) = %v, want the chown refused", err) + } + if server.machine().there("/opt/gophenberg/current") { + t.Error("take(build) pointed current at a build it could not give to gophenberg") + } +} + +func TestTheInstallerCopyStepCopiesTheRunningInstallerAsRootsAt0755(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(fakeInstaller, "#!/bin/sh\n# the installer this run runs\n") + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "installer-copy")) + + if err != nil { + t.Fatalf("take(installer-copy) = %v, want the copy written", err) + } + wantText(t, server.at(installerCopy), "#!/bin/sh\n# the installer this run runs\n") + wantEntry(t, server.at(installerCopy), 0o755, nil) + wantEntry(t, server.at(filepath.Dir(installerCopy)), fs.ModeDir|0o755, nil) +} + +func TestTheInstallerCopyStepLeavesTheCopyAloneWhenTheRunRunsIt(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.program(installerCopy) + server.folder("/usr/local/bin") + if err := os.Link(server.at(installerCopy), server.at("/usr/local/bin/other-name")); err != nil { + t.Fatalf("linking the copy under another name: %v", err) + } + before, err := os.Stat(server.at(installerCopy)) + if err != nil { + t.Fatalf("describing the copy: %v", err) + } + for _, running := range []string{installerCopy, "/usr/local/bin/other-name"} { + run, _ := runOn(t, server, Record{}) + run.machine.Executable = func() (string, error) { return server.at(running), nil } + + err := run.take(t.Context(), plannedStep(t, run, "installer-copy")) + + after, statErr := os.Stat(server.at(installerCopy)) + if err := errors.Join(err, statErr); err != nil || !os.SameFile(before, after) { + t.Errorf("take(installer-copy) from %s = %v, want the copy left as it is", running, err) + } + } +} + +func TestTheInstallerCopyStepStopsWhenItCannotFindTheRunningInstaller(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.machine.Executable = func() (string, error) { return "", syscall.ENOENT } + + err := running.take(t.Context(), plannedStep(t, running, "installer-copy")) + + wantRefused(t, err, "installer: find the running installer: no such file or directory") +} + +func TestTheInstallerCopyStepStopsWhenItCannotReadTheRunningInstaller(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.program(installerCopy) + server.remove(fakeInstaller) + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "installer-copy")) + + wantRefused(t, err, "installer: read the running installer: open "+server.at(fakeInstaller)+ + ": no such file or directory") +} diff --git a/internal/installer/checks.go b/internal/installer/checks.go index 9ef993f0..777430e5 100644 --- a/internal/installer/checks.go +++ b/internal/installer/checks.go @@ -7,6 +7,9 @@ import ( "context" "fmt" "io" + "os" + "path/filepath" + "slices" "strings" "github.com/gopherium/framework/gonsole" @@ -15,9 +18,25 @@ import ( // nothingChanged ends every message a machine check stops the run with. const nothingChanged = ", so nothing was changed" +// installCommand is the name of the command that installs Gophenberg on a server. +const installCommand = "server:install" + // varLib is the folder PostgreSQL, Caddy and the install record keep their data under, from the top of the server. const varLib = "/var/lib" +// caddyfilePath is Caddy's own settings file, which the first run writes and no later run writes again. +const caddyfilePath = "/etc/caddy/Caddyfile" + +// caddyfileLines are the lines of the Caddyfile the first run writes that every later run needs, each with why. +var caddyfileLines = []struct{ line, why string }{ + {"import " + caddySitePath, "which has Caddy serve the site"}, + {"admin unix//run/caddy/admin.sock", "which keeps Caddy's admin address off the loopback port any local user can " + + "reach"}, +} + +// commandHeader is how the gophenberg command server:install writes begins, which shows that it wrote the file. +var commandHeader = strings.Join(strings.SplitAfter(render("gophenberg", fileData{}), "\n")[:2], "") + // sandboxProbe runs /bin/true in the sandbox of the site's unit, which only a server that can run the unit passes. var sandboxProbe = Program{Name: "systemd-run", Args: []string{ "--wait", "--collect", "-p", "ProtectSystem=strict", "-p", "PrivateTmp=yes", "-p", "NoNewPrivileges=yes", @@ -29,8 +48,8 @@ var clockProbe = Program{Name: "timedatectl", Args: []string{"show", "-p", "NTPS // machineChecks are the checks of the machine in the order server:install runs them, before anything changes. var machineChecks = []func(context.Context, *machineCheck) error{ - checkRoot, checkSystem, checkSystemd, checkSandbox, checkMemory, checkInstallRoot, checkDisk, checkSitePort, - checkPorts, checkPieces, checkDatabaseURL, checkDomain, checkClock, + checkSystem, checkSystemd, checkSandbox, checkMemory, checkInstallRoot, checkCurrent, checkDisk, checkSitePort, + checkPorts, checkPieces, checkCommand, checkCaddyfile, checkDatabaseURL, checkDomain, checkClock, } // machineCheck is one run of the machine checks, with what they read, where their notes go and what they find. @@ -44,9 +63,10 @@ type machineCheck struct { found facts } -// facts is what the machine checks find that the files server:install writes depend on. +// facts is what the machine checks find that the steps of server:install depend on. type facts struct { systemd int + current string } // run runs every machine check in turn and stops at the first one that fails. @@ -64,17 +84,67 @@ func stop(format string, args ...any) error { return fmt.Errorf(format+nothingChanged, args...) } -// checkRoot stops a run that is not root's, since the steps after the checks change the whole server. -func checkRoot(_ context.Context, c *machineCheck) error { - if c.machine.Euid() != 0 { - return notRoot() +// notRoot returns the stop of a run of command that is not root's. +func notRoot(command string) error { + return stop("%s must run as root, such as with sudo", command) +} + +// checkCommand stops where a gophenberg command server:install did not write sits on the path, which it would replace. +func checkCommand(_ context.Context, c *machineCheck) error { + info, err := c.machine.lstat(commandPath) + if err != nil || info == nil { + return err + } + head := "" + if info.Mode().IsRegular() { + if head, err = c.machine.head(commandPath, len(commandHeader)); err != nil { + return fmt.Errorf("installer: %w", err) + } + } + if head != commandHeader { + return stop("this server already has a %s that server:install did not write, and server:install puts the "+ + "gophenberg command there", commandPath) + } + return nil +} + +// checkCaddyfile stops a run whose Caddyfile lost a line a first run wrote, once the record holds that Caddyfile's sum. +func checkCaddyfile(_ context.Context, c *machineCheck) error { + if !c.settings.withCaddy() || c.record.Caddyfile == "" { + return nil + } + body, err := c.machine.read(caddyfilePath) + if err != nil { + return err + } + held := trimmedLines(string(body)) + for _, needed := range caddyfileLines { + if !slices.Contains(held, needed.line) { + return stop("%s lacks the line %q that an earlier run wrote, %s, and it must go back in before "+ + "server:install runs again", caddyfilePath, needed.line, needed.why) + } } return nil } -// notRoot returns the stop of a run that is not root's. -func notRoot() error { - return stop("server:install must run as root, such as with sudo") +// head returns at most n bytes from the start of the file at path, named from the top of the server. +func (m Machine) head(path string, n int) (string, error) { + file, err := os.Open(filepath.Join(m.Root, path)) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + read, err := io.ReadAll(io.LimitReader(file, int64(n))) + return string(read), err +} + +// trimmedLines returns each line of text without the spaces and tabs around it. +func trimmedLines(text string) []string { + var lines []string + for line := range strings.Lines(text) { + lines = append(lines, strings.TrimSpace(line)) + } + return lines } // checkSandbox stops where systemd cannot set up the site's sandbox, naming what systemd-detect-virt reports. diff --git a/internal/installer/checks_internal_test.go b/internal/installer/checks_internal_test.go index 675face1..9eff94bc 100644 --- a/internal/installer/checks_internal_test.go +++ b/internal/installer/checks_internal_test.go @@ -3,7 +3,9 @@ package installer import ( + "cmp" "context" + "crypto/x509" "errors" "fmt" "io/fs" @@ -11,8 +13,10 @@ import ( "net/http" "net/netip" "os" + "os/user" "path/filepath" "slices" + "strconv" "strings" "syscall" "testing" @@ -70,28 +74,42 @@ func (i ownedInfo) Sys() any { // fakeServer is a server every machine check passes on, which records what the checks ask of it. type fakeServer struct { - t *testing.T - root string - euid int - answers map[string]answer - taken map[string]error - freeMB map[string]uint64 - owners map[string]uint32 - broken map[string]error - described map[string]fs.FileInfo - unmeasured map[string]error - addresses []netip.Addr - unresolved error - own []net.Addr - unlisted error - answerFetch func(request *http.Request) (*http.Response, error) - notes strings.Builder - ran []string - listened []string - listeners []*pipeListener - measured []string - resolved []string - fetched []string + t *testing.T + root string + euid int + answers map[string]answer + taken map[string]error + freeMB map[string]uint64 + owners map[string]uint32 + broken map[string]error + described map[string]fs.FileInfo + unmeasured map[string]error + addresses []netip.Addr + unresolved error + own []net.Addr + unlisted error + answerFetch func(request *http.Request) (*http.Response, error) + answerHTTPS func(request *http.Request) (*http.Response, error) + prompt *fakePrompt + users map[string]*user.User + dpkg map[string]string + effects map[string]func(Program) answer + running map[string]bool + siteDown bool + unloads bool + accounts []listedAccount + ranFrom string + notes strings.Builder + runs []Program + ran []string + listened []string + listeners []*pipeListener + measured []string + resolved []string + fetched []string + fetchedHTTPS []string + httpsRoots []*x509.CertPool + terminals int } // newFakeServer returns a fake server every machine check passes on. @@ -100,23 +118,74 @@ func newFakeServer(t *testing.T) *fakeServer { server := &fakeServer{ t: t, root: t.TempDir(), answers: map[string]answer{ - "systemctl": {printed: "systemd 255 (255.4-1ubuntu8.6)\n+PAM +AUDIT +SELINUX +APPARMOR\n"}, - "systemd-run": {printed: "Running as unit: run-u7.service\nFinished with result: success\n"}, - "timedatectl": {printed: "yes\n"}, + "systemctl": {printed: "systemd 255 (255.4-1ubuntu8.6)\n+PAM +AUDIT +SELINUX +APPARMOR\n"}, + "systemd-run": {printed: "Running as unit: run-u7.service\nFinished with result: success\n"}, + "timedatectl": {printed: "yes\n"}, + "createuser": {}, + "createdb": {}, + "gophenberg check": {printed: "every setting checks out\n"}, }, taken: map[string]error{}, freeMB: map[string]uint64{}, owners: map[string]uint32{}, broken: map[string]error{}, - described: map[string]fs.FileInfo{}, unmeasured: map[string]error{}, - addresses: addresses("::ffff:203.0.113.7"), - own: []net.Addr{cardAddress("127.0.0.1/8"), cardAddress("203.0.113.7/24"), cardAddress("::1/128")}, + described: map[string]fs.FileInfo{}, unmeasured: map[string]error{}, users: map[string]*user.User{}, + dpkg: map[string]string{}, running: map[string]bool{}, addresses: addresses("::ffff:203.0.113.7"), + own: []net.Addr{cardAddress("127.0.0.1/8"), cardAddress("203.0.113.7/24"), cardAddress("::1/128")}, + } + server.effects = map[string]func(Program) answer{ + "apt-get": server.aptGet, "dpkg-query": server.dpkgQuery, "dpkg": server.configurePackages, + "useradd": server.useradd, "systemctl": server.systemctl, "caddy": server.caddyValidate, + "gophenberg account:create-admin": server.createAdmin, "gophenberg account:list": server.listAccounts, } server.write("/etc/os-release", ubuntuRelease) server.write("/proc/meminfo", meminfoOf(2048000)) server.folder("/run/systemd/system", "/opt", "/var/lib") server.program("/usr/bin/systemctl") server.program("/usr/sbin/runuser") + server.program(fakeInstaller) return server } +// fakeInstaller is where the installer a fake server runs sits, named from the top of the server. +const fakeInstaller = "/root/installer/gophenberg" + +// packageCaddyfile is the Caddyfile the caddy package puts on a server, which serves a page of its own on port 80. +const packageCaddyfile = ":80 {\n\troot * /usr/share/caddy\n\tfile_server\n}\n" + +// installPackage puts the package called name on the server as apt-get does, dpkg then showing it installed. +func (s *fakeServer) installPackage(name string) { + s.t.Helper() + switch name { + case "postgresql": + s.folder("/etc/postgresql/16/main", "/usr/lib/postgresql/16/bin", "/var/lib/postgresql/16/main") + case "caddy": + s.write(caddyfilePath, packageCaddyfile) + s.program("/usr/bin/caddy") + s.addUser("caddy", "/var/lib/caddy") + } + s.dpkg[name] = "ii" +} + +// addUser gives the server the OS user called name with home, in the test's uid and siteGroup, so root's ids differ. +func (s *fakeServer) addUser(name, home string) { + s.users[name] = &user.User{ + Username: name, Uid: strconv.Itoa(os.Getuid()), Gid: strconv.Itoa(siteGroup(s.t)), HomeDir: home, + } +} + +// siteGroup returns a group of the test's user other than its own where it has one, else its own. +func siteGroup(t *testing.T) int { + t.Helper() + groups, err := os.Getgroups() + if err != nil { + t.Fatalf("listing the groups of the test's user: %v", err) + } + for _, group := range groups { + if group != os.Getgid() { + return group + } + } + return os.Getgid() +} + // program puts a program anybody may run at path, named from the top of the server. func (s *fakeServer) program(path string) { s.t.Helper() @@ -161,6 +230,18 @@ func (s *fakeServer) link(path, target string) { } } +// earlierBuild is the build an earlier run left the current link naming. +const earlierBuild = "0.19.0" + +// pointCurrent puts the earlier build with its program in the install root and points current at it, as Point does. +func (s *fakeServer) pointCurrent() { + s.t.Helper() + s.program("/opt/gophenberg/versions/" + earlierBuild + "/gophenberg") + if err := os.Symlink("versions/"+earlierBuild, s.at("/opt/gophenberg/current")); err != nil { + s.t.Fatalf("pointing current at %s: %v", earlierBuild, err) + } +} + // remove takes the file or folder at path away, named from the top of the server. func (s *fakeServer) remove(path string) { s.t.Helper() @@ -186,20 +267,119 @@ func (s *fakeServer) serverPath(path string) string { func (s *fakeServer) machine() Machine { return Machine{ Run: s.run, Root: s.root, Lstat: s.lstat, Free: s.free, Listen: s.listen, ListenPacket: s.listenPacket, - Fetch: s.fetch, Resolve: s.resolve, Addresses: s.ownAddresses, Euid: func() int { return s.euid }, + Fetch: s.fetch, Resolve: s.resolve, Addresses: s.ownAddresses, Terminal: s.terminal, + Executable: s.executable, LookupUser: s.lookupUser, FetchHTTPS: s.fetchHTTPS, + Euid: func() int { return s.euid }, + } +} + +// terminal returns the fake terminal a test gave the server, or the error of a run with no terminal at all. +func (s *fakeServer) terminal() (Prompt, error) { + s.terminals++ + if s.prompt == nil { + return nil, &fs.PathError{Op: "open", Path: "/dev/tty", Err: syscall.ENXIO} + } + return s.prompt, nil +} + +// executable returns the installer the fake server runs, on the test's own disk, the one a test ran it from if any. +func (s *fakeServer) executable() (string, error) { + return s.at(cmp.Or(s.ranFrom, fakeInstaller)), nil +} + +// lookupUser returns the OS user called name that a test or a fake program gave the server. +func (s *fakeServer) lookupUser(name string) (*user.User, error) { + if found, held := s.users[name]; held { + return found, nil } + return nil, user.UnknownUserError(name) } -// run answers program as the answers say, failing the test for a program no answer covers. +// fetchHTTPS records the page asked for and the roots that came with it, then answers as the test says, else as Caddy. +func (s *fakeServer) fetchHTTPS(request *http.Request, roots *x509.CertPool) (*http.Response, error) { + s.fetchedHTTPS = append(s.fetchedHTTPS, fmt.Sprintf("%s with roots %t", request.URL, roots != nil)) + s.httpsRoots = append(s.httpsRoots, roots) + if s.answerHTTPS == nil { + return s.caddyPage(request) + } + return s.answerHTTPS(request) +} + +// run records program, then answers it through its effect on the server or as the answers say, else fails the test. func (s *fakeServer) run(_ context.Context, program Program) (string, error) { - s.ran = append(s.ran, strings.Join(append([]string{program.Name}, program.Args...), " ")) - reply, found := s.answers[program.Name] + s.runs = append(s.runs, program) + s.ran = append(s.ran, lineOf(program)) + key := programKey(program) + if effect, found := s.effects[key]; found { + reply := effect(program) + return reply.printed, reply.err + } + reply, found := s.answers[key] if !found { - s.t.Errorf("the checks ran %s, which the fake server does not answer", program.Name) + s.t.Errorf("the run ran %s, which the fake server does not answer", key) } return reply.printed, reply.err } +// programKey names program as the fake server answers it, a command of the site by gophenberg and its first argument. +func programKey(program Program) string { + if filepath.Base(program.Name) != siteUser || len(program.Args) == 0 { + return program.Name + } + return siteUser + " " + program.Args[0] +} + +// lineOf returns the command line of program as the runner starts it, with runuser in front for another OS user. +func lineOf(program Program) string { + line := strings.Join(append([]string{program.Name}, program.Args...), " ") + if program.User == "" { + return line + } + return "runuser -u " + program.User + " -- " + line +} + +// aptGet installs each package an apt-get install line names, as apt-get does, and changes nothing on an update. +func (s *fakeServer) aptGet(program Program) answer { + if !slices.Contains(program.Args, "install") { + return answer{printed: "Reading package lists... Done\n"} + } + for _, name := range []string{piecePostgreSQL, pieceCaddy} { + if slices.Contains(program.Args, name) { + s.installPackage(name) + } + } + return answer{printed: "Setting up postgresql (16+257build1.1) ...\n"} +} + +// dpkgQuery prints the status of each package its line names, failing as dpkg-query does on one it cannot find. +func (s *fakeServer) dpkgQuery(program Program) answer { + var reply answer + for _, name := range program.Args[3:] { + status, found := s.dpkg[name] + if !found { + reply.printed += "dpkg-query: no packages found matching " + name + "\n" + reply.err = errors.New("installer: run dpkg-query: exit status 1") + continue + } + reply.printed += fmt.Sprintf("%-3s\n", status) + } + return reply +} + +// configurePackages sets up every package dpkg left half set up, as dpkg --configure -a does. +func (s *fakeServer) configurePackages(Program) answer { + for name := range s.dpkg { + s.dpkg[name] = "ii" + } + return answer{} +} + +// useradd adds the OS user its line names, at the home folder its line names, as useradd does. +func (s *fakeServer) useradd(program Program) answer { + s.addUser(program.Args[len(program.Args)-1], program.Args[slices.Index(program.Args, "--home-dir")+1]) + return answer{} +} + // lstat describes the file at path on the test's own disk as owned by root, or by the owner a test gave it. func (s *fakeServer) lstat(path string) (fs.FileInfo, error) { server := s.serverPath(path) @@ -263,13 +443,16 @@ func (s *fakeServer) ownAddresses() ([]net.Addr, error) { return s.own, s.unlisted } -// fetch records the page asked for, then answers as the test says or else through the newest TCP listener. +// fetch records the page asked for, then answers as the test says, a token through the newest listener, else a page. func (s *fakeServer) fetch(request *http.Request) (*http.Response, error) { s.fetched = append(s.fetched, request.Host+" "+request.URL.String()) if s.answerFetch != nil { return s.answerFetch(request) } - return s.listeners[len(s.listeners)-1].fetch(request) + if strings.HasPrefix(request.URL.Path, tokenFolder) { + return s.listeners[len(s.listeners)-1].fetch(request) + } + return s.sitePage(request) } // check returns the machine check of this fake server with checkedSettings, no install record and no environment. @@ -316,17 +499,191 @@ func TestTheMachineChecksPassAFreshUbuntuServerAndNoteNothing(t *testing.T) { } } -func TestTheMachineChecksStopARunThatIsNotRoots(t *testing.T) { +func TestInstallStopsARunThatIsNotRootsBeforeItReadsTheRecordOrRunsAnything(t *testing.T) { + t.Parallel() + + for _, dryRun := range []bool{false, true} { + server := newFakeServer(t) + server.euid = 1000 + server.write(recordPath, "{") + + _, _, err := runInstall(server, Request{Domain: "example.com", DryRun: dryRun}) + + wantRefused(t, err, "server:install must run as root, such as with sudo, so nothing was changed") + if len(server.ran) != 0 { + t.Errorf("the run ran %q as a plain user, want nothing run", server.ran) + } + } +} + +func TestNotRootNamesTheCommandThatMustRunAsRoot(t *testing.T) { + t.Parallel() + + wantRefused(t, notRoot("server:uninstall"), "server:uninstall must run as root, such as with sudo, so nothing "+ + "was changed") +} + +// otherCommand is the stop of a run that finds a gophenberg command on the path server:install did not write. +const otherCommand = "this server already has a /usr/local/bin/gophenberg that server:install did not write, and " + + "server:install puts the gophenberg command there, so nothing was changed" + +func TestTheMachineChecksPassAGophenbergCommandServerInstallWrote(t *testing.T) { t.Parallel() server := newFakeServer(t) - server.euid = 1000 + server.write(commandPath, wantedCommand) err := server.check().run(t.Context()) - wantRefused(t, err, "server:install must run as root, such as with sudo, so nothing was changed") - if len(server.ran) != 0 { - t.Errorf("the checks ran %q as a plain user, want nothing run", server.ran) + server.wantPassed(err, "") +} + +func TestTheMachineChecksStopAtAGophenbergCommandServerInstallDidNotWrite(t *testing.T) { + t.Parallel() + + for what, put := range map[string]func(*fakeServer){ + "another script": func(s *fakeServer) { s.write(commandPath, "#!/bin/sh\nexec /opt/other/gophenberg \"$@\"\n") }, + "a shorter file": func(s *fakeServer) { s.write(commandPath, "#!/bin/sh\n") }, + "a folder": func(s *fakeServer) { s.folder(commandPath) }, + "a link to the command server:install writes": func(s *fakeServer) { + s.write("/usr/local/share/gophenberg/command", wantedCommand) + s.link(commandPath, "/usr/local/share/gophenberg/command") + }, + } { + server := newFakeServer(t) + put(server) + + err := server.check().run(t.Context()) + + if err == nil || err.Error() != otherCommand { + t.Errorf("the checks of %s on the path = %v, want %q", what, err, otherCommand) + } + } +} + +func TestTheMachineChecksRefuseAGophenbergCommandTheyCannotReadOrDescribe(t *testing.T) { + t.Parallel() + + if os.Geteuid() == 0 { + t.Skip("root reads a file of mode 0000") + } + locked := newFakeServer(t) + locked.write(commandPath, wantedCommand) + locked.chmod(commandPath, 0) + broken := newFakeServer(t) + broken.broken[commandPath] = syscall.EIO + + for server, want := range map[*fakeServer]string{ + locked: "installer: open " + locked.at(commandPath) + ": permission denied", + broken: "installer: input/output error", + } { + err := server.check().run(t.Context()) + + wantRefused(t, err, want) + } +} + +// laterCheck returns the machine check of server for a later run, whose record owns Caddy and the Caddyfile it wrote. +func laterCheck(server *fakeServer) *machineCheck { + check := server.check() + check.record.Installed = []string{pieceCaddy} + check.record.Caddyfile = caddyfileSum(wantedCaddyfile) + return check +} + +func TestTheMachineChecksStopAtALostCaddyfileLineOnceTheRecordHoldsItsSumWhateverTheCaddyMark(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(caddyfilePath, strings.Replace(wantedCaddyfile, "import "+caddySitePath+"\n", "", 1)) + check := laterCheck(server) + check.record.Steps = map[string]string{stepCaddy: stepStarted} + + err := check.run(t.Context()) + + wantRefused(t, err, `/etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier `+ + "run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so "+ + "nothing was changed") +} + +func TestTheMachineChecksPassALaterRunWhoseCaddyfileHoldsTheLinesTheFirstRunWrote(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(caddyfilePath, wantedCaddyfile+"\nexample.org {\n\trespond \"Maria Perez's own site\"\n}\n") + + err := laterCheck(server).run(t.Context()) + + server.wantPassed(err, "") +} + +func TestTheMachineChecksStopALaterRunWhoseCaddyfileLostALineTheFirstRunWrote(t *testing.T) { + t.Parallel() + + for _, lost := range caddyfileLines { + server := newFakeServer(t) + server.write(caddyfilePath, strings.Replace(wantedCaddyfile, lost.line+"\n", "", 1)) + + err := laterCheck(server).run(t.Context()) + + wantRefused(t, err, `/etc/caddy/Caddyfile lacks the line "`+lost.line+`" that an earlier run wrote, `+lost.why+ + ", and it must go back in before server:install runs again, so nothing was changed") + } +} + +func TestTheMachineChecksStopALaterRunWhoseCaddyfileIsGone(t *testing.T) { + t.Parallel() + + err := laterCheck(newFakeServer(t)).run(t.Context()) + + wantRefused(t, err, `/etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier `+ + "run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so "+ + "nothing was changed") +} + +func TestTheMachineChecksRefuseACaddyfileTheyCannotRead(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder(caddyfilePath) + + err := laterCheck(server).run(t.Context()) + + wantRefused(t, err, "installer: read "+server.at(caddyfilePath)+": is a directory") +} + +func TestTheMachineChecksLeaveTheCaddyfileAloneOnAFirstRunAndWithoutCaddy(t *testing.T) { + t.Parallel() + + own := newFakeServer(t).ownWebServerCheck() + own.record.Caddyfile = caddyfileSum(wantedCaddyfile) + unwritten := newFakeServer(t) + unwritten.write(caddyfilePath, packageCaddyfile) + cutOff := unwritten.check() + cutOff.record.Steps, cutOff.record.Installed = map[string]string{stepCaddy: stepStarted}, []string{pieceCaddy} + + for _, check := range []*machineCheck{newFakeServer(t).check(), own, cutOff} { + err := check.run(t.Context()) + + if err != nil { + t.Errorf("the checks without a Caddyfile to check = %v, want them passed", err) + } + } +} + +func TestTheLinesALaterRunNeedsAreLinesOfTheCaddyfileTheFirstRunWrites(t *testing.T) { + t.Parallel() + + for _, domain := range []string{"example.com", "localhost"} { + files := firstSite(t, domain, 255).caddyFiles() + caddyfile := files[len(files)-1] + + for _, needed := range caddyfileLines { + if caddyfile.path != caddyfilePath || !slices.Contains(trimmedLines(caddyfile.text), needed.line) { + t.Errorf("the first run writes %s for %s as\n%s\nwant it to hold the line %q", caddyfile.path, domain, + caddyfile.text, needed.line) + } + } } } diff --git a/internal/installer/files.go b/internal/installer/files.go index c28a2616..76221e6c 100644 --- a/internal/installer/files.go +++ b/internal/installer/files.go @@ -127,16 +127,16 @@ func (s site) unitFiles() []file { "caddy-gophenberg.conf")) } -// caddyFiles returns the maintenance page, Gophenberg's Caddy site and, until the Caddy step ended, the Caddyfile. +// caddyFiles returns the maintenance page, Gophenberg's Caddy site and, until the record holds its sum, the Caddyfile. func (s site) caddyFiles() []file { files := []file{ s.rootFile(maintenanceFolder+"/index.html", 0o644, "maintenance.html"), s.rootFile(caddySitePath, 0o644, "gophenberg.caddy"), } - if s.record.ended(stepCaddy) { + if s.record.Caddyfile != "" { return files } - return append(files, s.rootFile("/etc/caddy/Caddyfile", 0o644, "Caddyfile")) + return append(files, s.rootFile(caddyfilePath, 0o644, "Caddyfile")) } // commandFile returns the gophenberg command on the path. diff --git a/internal/installer/files_internal_test.go b/internal/installer/files_internal_test.go index 2471f0f6..e0442b45 100644 --- a/internal/installer/files_internal_test.go +++ b/internal/installer/files_internal_test.go @@ -60,6 +60,7 @@ Type=oneshot User=gophenberg Group=gophenberg WorkingDirectory=/opt/gophenberg +ExecCondition=+/usr/bin/systemctl is-failed --quiet gophenberg.service ExecStart=/opt/gophenberg/previous/gophenberg update:rollback -after-failure ExecStartPost=+/usr/bin/systemctl reset-failed gophenberg.service ExecStartPost=+/usr/bin/systemctl start gophenberg.service @@ -400,12 +401,48 @@ func TestTheCaddyFilesOfALaterRunLeaveTheCaddyfileAlone(t *testing.T) { running := firstSite(t, "example.com", 255) running.record.Steps = map[string]string{stepCaddy: stepEnded} + running.record.Caddyfile = caddyfileSum(wantedCaddyfile) got := running.caddyFiles() wantFiles(t, got, caddyFilesOf(wantedCaddySite, wantedCaddyfile)[:2]) } +func TestTheCaddyFilesLeaveTheCaddyfileAloneOnceTheRecordHoldsItsSumWhateverTheCaddyMark(t *testing.T) { + t.Parallel() + + running := firstSite(t, "example.com", 255) + running.record.Steps = map[string]string{stepCaddy: stepStarted} + running.record.Caddyfile = caddyfileSum(wantedCaddyfile) + + got := running.caddyFiles() + + wantFiles(t, got, caddyFilesOf(wantedCaddySite, wantedCaddyfile)[:2]) +} + +func TestTheCaddyStepRestartsCaddyUntilTheRecordEndsItAndKeepsTheCaddyfileOnceTheRecordHoldsItsSum(t *testing.T) { + t.Parallel() + + restart := "write Caddy's files, keep the package's Caddyfile as /etc/caddy/Caddyfile.before-gophenberg, check " + + "them with caddy validate and restart Caddy" + keptRestart := "write Caddy's files, check them with caddy validate and restart Caddy" + reload := "write Caddy's files, check them with caddy validate and reload Caddy" + for _, earlier := range []struct { + mark, sum, want string + }{ + {"", "", restart}, {stepStarted, "", restart}, {stepStarted, abcSum, keptRestart}, {stepEnded, abcSum, reload}, + } { + running := firstSite(t, "example.com", 255) + running.record.Steps = map[string]string{stepCaddy: earlier.mark} + running.record.Caddyfile = earlier.sum + + if got := running.caddyStep().does; got != earlier.want { + t.Errorf("the Caddy step of a run whose record marks it %q with the checksum %q does %q, want %q", + earlier.mark, earlier.sum, got, earlier.want) + } + } +} + func TestTheCaddyFilesOfARunCutOffBeforeItsCaddyStepEndedWriteTheCaddyfile(t *testing.T) { t.Parallel() diff --git a/internal/installer/fresh_checks.go b/internal/installer/fresh_checks.go index 59b63bb5..2f0b8cbc 100644 --- a/internal/installer/fresh_checks.go +++ b/internal/installer/fresh_checks.go @@ -13,6 +13,8 @@ import ( "slices" "strings" "syscall" + + "github.com/gopherium/gophenberg/internal/updater" ) // port is one port a check listens on briefly, what it is for, and the piece of the install record that holds it. @@ -208,12 +210,64 @@ func (c *machineCheck) checkParent(parent string) error { return nil } +// checkInstallerFolder stops when the folder of the install record is a link, a file or others may write it. +func checkInstallerFolder(machine Machine) error { + info, err := machine.lstat(installerFolder) + if err != nil || info == nil { + return err + } + if info.IsDir() && rootAloneWrites(info) { + return nil + } + return stop("%s, which holds the install record, must be a real folder owned by root and writable by root alone", + installerFolder) +} + +// rootAloneWrites reports whether root owns the file info describes and no other user may write it. +func rootAloneWrites(info fs.FileInfo) bool { + return ownedByRoot(info) && info.Mode().Perm()&0o022 == 0 +} + // ownedByRoot reports whether root owns the file info describes. func ownedByRoot(info fs.FileInfo) bool { stat, described := info.Sys().(*syscall.Stat_t) return described && stat.Uid == 0 } +// checkCurrent finds the build the current link of the install root names, stopping at a link that names none. +func checkCurrent(_ context.Context, c *machineCheck) error { + layout := updater.Layout{Root: filepath.Join(c.machine.Root, c.settings.Root)} + current, err := layout.Named(updater.Current) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return stop("cannot tell which build %s/current names: %v", c.settings.Root, err) + } + if err := c.checkProgram(current); err != nil { + return err + } + c.found.current = current + return nil +} + +// checkProgram stops when the build called current has no program anyone may run, as after a delete by hand. +func (c *machineCheck) checkProgram(current string) error { + program := updater.Layout{Root: c.settings.Root}.Binary(current) + info, err := c.machine.lstat(program) + switch { + case err != nil: + return err + case info == nil: + return stop("cannot tell which build %s/current names: %s is not there", c.settings.Root, program) + case !info.Mode().IsRegular(): + return stop("cannot tell which build %s/current names: %s is not a file", c.settings.Root, program) + case info.Mode().Perm()&0o111 == 0: + return stop("cannot tell which build %s/current names: %s is not executable", c.settings.Root, program) + } + return nil +} + // checkRootFolder stops when the install root is a link, a file or a folder holding files the record does not own. func (c *machineCheck) checkRootFolder() error { root := c.settings.Root diff --git a/internal/installer/fresh_checks_internal_test.go b/internal/installer/fresh_checks_internal_test.go index 8cb2999d..56d0fa82 100644 --- a/internal/installer/fresh_checks_internal_test.go +++ b/internal/installer/fresh_checks_internal_test.go @@ -301,6 +301,65 @@ func TestTheMachineChecksStopOnAParentOfTheInstallRootThatIsNotARealFolder(t *te } } +// installerFolderOpen is the stop of a run whose installer folder another user could change. +const installerFolderOpen = "/var/lib/gophenberg-installer, which holds the install record, must be a real folder " + + "owned by root and writable by root alone, so nothing was changed" + +func TestInstallStopsAtAnInstallerFolderThatIsALinkAFileOrThatAnotherUserOwnsOrMayWrite(t *testing.T) { + t.Parallel() + + link := newFakeServer(t) + link.folder("/var/lib/other") + link.link(installerFolder, "/var/lib/other") + file := newFakeServer(t) + file.write(installerFolder, "") + owned := newFakeServer(t) + owned.folder(installerFolder) + owned.owners[installerFolder] = 1000 + group := newFakeServer(t) + group.folder(installerFolder) + group.chmod(installerFolder, 0o720) + others := newFakeServer(t) + others.folder(installerFolder) + others.chmod(installerFolder, 0o702) + + for _, server := range []*fakeServer{link, file, owned, group, others} { + _, _, err := runInstall(server, Request{Domain: "example.com", DryRun: true}) + + wantRefused(t, err, installerFolderOpen) + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want the installer folder checked right after root", server.ran) + } + } +} + +func TestInstallStopsAtAnInstallerFolderAnotherUserOwnsBeforeItReadsTheRecord(t *testing.T) { + t.Parallel() + + for _, dryRun := range []bool{false, true} { + server := newFakeServer(t) + server.write(recordPath, "{") + server.owners[installerFolder] = 1000 + before := server.snapshot() + + _, _, err := runInstall(server, Request{Domain: "example.com", DryRun: dryRun}) + + wantRefused(t, err, installerFolderOpen) + wantUnchanged(t, server, before) + } +} + +func TestInstallRefusesAnInstallerFolderItCannotDescribe(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.broken[installerFolder] = syscall.EIO + + _, _, err := runInstall(server, Request{Domain: "example.com", DryRun: true}) + + wantRefused(t, err, "installer: input/output error") +} + func TestTheMachineChecksTakeAnInstallRootWhoseParentsAreStillMissing(t *testing.T) { t.Parallel() @@ -359,6 +418,109 @@ func TestTheMachineChecksStopOnAnInstallRootThatHoldsFilesTheyDidNotPutThere(t * } } +func TestTheMachineChecksFindNoCurrentBuildOnAFreshServer(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + check := server.check() + + err := check.run(t.Context()) + + server.wantPassed(err, "") + if check.found.current != "" { + t.Errorf("the checks found the current build %q on a fresh server, want none", check.found.current) + } +} + +// ownedRootCheck returns the machine check of server for a later run whose record owns the install root. +func ownedRootCheck(server *fakeServer) *machineCheck { + check := server.check() + check.record.Installed = []string{"/opt/gophenberg"} + return check +} + +func TestTheMachineChecksReadTheBuildTheCurrentLinkNames(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.pointCurrent() + check := ownedRootCheck(server) + + err := check.run(t.Context()) + + server.wantPassed(err, "") + if check.found.current != earlierBuild { + t.Errorf("the checks found the current build %q, want %s", check.found.current, earlierBuild) + } +} + +func TestTheMachineChecksStopAtACurrentLinkThatNamesNoBuild(t *testing.T) { + t.Parallel() + + full := newFakeServer(t) + full.folder("/opt/gophenberg/versions/0.19.0") + if err := os.Symlink(full.at("/opt/gophenberg/versions/0.19.0"), full.at("/opt/gophenberg/current")); err != nil { + t.Fatalf("linking current by its full path: %v", err) + } + folder := newFakeServer(t) + folder.folder("/opt/gophenberg/current") + + for server, why := range map[*fakeServer]string{ + full: "updater: the current link names " + full.at("/opt/gophenberg/versions/0.19.0") + ", not a build in " + + "versions", + folder: "updater: read the current link: readlink " + folder.at("/opt/gophenberg/current") + ": invalid " + + "argument", + } { + err := ownedRootCheck(server).run(t.Context()) + + wantRefused(t, err, "cannot tell which build /opt/gophenberg/current names: "+why+", so nothing was changed") + } +} + +// currentProgram is the program of the build an earlier run left current, from the top of the server. +const currentProgram = "/opt/gophenberg/versions/" + earlierBuild + "/gophenberg" + +func TestTheMachineChecksStopAtACurrentLinkWhoseBuildHasNoProgram(t *testing.T) { + t.Parallel() + + for what, lost := range map[string]struct { + take func(*fakeServer) + why string + }{ + "the build folder deleted": {func(s *fakeServer) { s.remove(filepath.Dir(currentProgram)) }, "is not there"}, + "the program deleted": {func(s *fakeServer) { s.remove(currentProgram) }, "is not there"}, + "a folder in the program's place": {func(s *fakeServer) { + s.remove(currentProgram) + s.folder(currentProgram) + }, "is not a file"}, + "the program's execute bits taken": {func(s *fakeServer) { s.chmod(currentProgram, 0o644) }, "is not executable"}, + } { + server := newFakeServer(t) + server.pointCurrent() + lost.take(server) + + err := ownedRootCheck(server).run(t.Context()) + + want := "cannot tell which build /opt/gophenberg/current names: " + currentProgram + " " + lost.why + + ", so nothing was changed" + if err == nil || err.Error() != want { + t.Errorf("the checks with %s = %v, want %q", what, err, want) + } + } +} + +func TestTheMachineChecksRefuseTheProgramOfTheCurrentBuildTheyCannotDescribe(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.pointCurrent() + server.broken[currentProgram] = syscall.EIO + + err := ownedRootCheck(server).run(t.Context()) + + wantRefused(t, err, "installer: input/output error") +} + func TestTheMachineChecksStopOnAnInstallRootThatIsOrHoldsAFolderRootTrusts(t *testing.T) { t.Parallel() diff --git a/internal/installer/install.go b/internal/installer/install.go index 1e8fe330..8da5f470 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -4,8 +4,11 @@ package installer import ( "context" + "encoding/json" "errors" - "io/fs" + "fmt" + "slices" + "strings" "github.com/gopherium/framework/gonsole" @@ -24,42 +27,148 @@ type Request struct { DryRun bool } -// Install checks machine for server:install as request asks and, with -dry-run, prints every step it would take. +// Install checks machine for server:install as request asks, then prints the steps of a dry run or takes them. func Install(ctx context.Context, machine Machine, call gonsole.Call, request Request) error { + unlock, err := lockedFor(machine, installCommand, !request.DryRun) + if err != nil { + return err + } + defer unlock() + check, err := checkFor(ctx, machine, call, request) + if err != nil { + return err + } + if !request.DryRun { + return install(ctx, call, request, check) + } + planned, err := siteOn(machine, check) + if err != nil { + return err + } + return printSteps(call.Stdout, planned.steps()) +} + +// checkFor reads the settings and the install record of the run request asks for, then checks machine against them. +func checkFor(ctx context.Context, machine Machine, call gonsole.Call, request Request) (*machineCheck, error) { settings, err := settingsOn(machine, call.Env, request) if err != nil { - return rootOr(machine, err) + return nil, err } record, err := readRecord(machine) if err != nil { - return rootOr(machine, err) + return nil, err } if err := record.allows(version.Version()); err != nil { - return err + return nil, err } - if err := record.allowsWebServer(settings, call.Env); err != nil { - return err + if err := allowsWebServer(machine, settings, call.Env); err != nil { + return nil, err } - check := machineCheck{ + check := &machineCheck{ machine: machine, settings: settings, request: request, record: record, env: call.Env, notes: call.Stderr, } - if err := check.run(ctx); err != nil { + return check, check.run(ctx) +} + +// install opens the release archive and settles the first administrator, then takes every step on the server. +func install(ctx context.Context, call gonsole.Call, request Request, check *machineCheck) error { + archive, err := check.openArchive(request.Archive) + if err != nil { return err } - if !request.DryRun { - return stop("this build of server:install only checks the server, and -dry-run prints the steps it would take") + defer closeArchive(archive) + answered, err := check.firstAdmin(ctx, call, request) + if err != nil { + return err } - planned, err := siteOn(machine, &check) + planned, err := siteOn(check.machine, check) if err != nil { return err } - return printSteps(call.Stdout, planned.steps()) + running, err := installingOn(check, planned, call) + if err != nil { + return err + } + defer running.close() + running.admin = answered + if archive != nil { + running.archive = archive + } + return running.run(ctx, planned.steps()) +} + +// firstAdmin returns the first administrator the run creates, asked for while the admin step is open, none after. +func (c *machineCheck) firstAdmin(ctx context.Context, call gonsole.Call, request Request) (admin, error) { + if err := c.settleAdmin(ctx); err != nil || c.record.ended(stepAdmin) { + return admin{}, err + } + answered, err := askAdmin(ctx, c.machine, request, c.typedEmailRule()) + if err != nil { + return admin{}, err + } + request.AdminEmail = answered.email + c.settings, err = settingsOn(c.machine, call.Env, request) + return answered, err +} + +// settleAdmin ends the admin step a cut-off run started once account:list shows that run made an account under admin. +func (c *machineCheck) settleAdmin(ctx context.Context) error { + if !c.record.started(stepAdmin) { + return nil + } + listed, err := listedAccounts(ctx, c.machine, c.settings.Root) + if err != nil { + return stop("cannot tell whether the run cut off in the admin step made the first administrator: %v", err) + } + if slices.ContainsFunc(listed, func(held account) bool { return held.Role == adminRole }) { + c.record.Steps[stepAdmin] = stepEnded + } + return nil } -// rootOr returns the stop of a run that is not root's for err on a file root alone may read, else err itself. -func rootOr(machine Machine, err error) error { - if errors.Is(err, fs.ErrPermission) && machine.Euid() != 0 { - return notRoot() +// account is one account account:list -json lists, by its email and its role. +type account struct { + Email string `json:"email"` + Role string `json:"role"` +} + +// listedAccounts returns the accounts account:list -json lists, run on machine as gophenberg in root. +func listedAccounts(ctx context.Context, machine Machine, root string) ([]account, error) { + printed, err := machine.Run(ctx, siteCommand(root, "account:list", "-json")) + if err != nil { + return nil, withPrinted(err, printed) + } + return accountsIn(printed) +} + +// accountsIn returns the accounts account:list -json printed, past any line the site logged before them. +func accountsIn(printed string) ([]account, error) { + start := strings.Index("\n"+printed, "\n{\n") + if start < 0 { + return nil, errors.New("account:list -json printed no accounts") + } + var listing struct { + Accounts []account `json:"accounts"` + } + if err := json.NewDecoder(strings.NewReader(printed[start:])).Decode(&listing); err != nil { + return nil, fmt.Errorf("account:list -json printed accounts that cannot be read: %w", err) + } + return listing.Accounts, nil +} + +// typedEmailRule returns the rule a typed email keeps, the certificate email's too while no setting gives one. +func (c *machineCheck) typedEmailRule() func(string) (string, error) { + if c.settings.CertificateEmail != "" { + return checkEmail + } + return func(typed string) (string, error) { + email, err := checkEmail(typed) + if err != nil { + return "", err + } + if _, err := certificateEmail(email); err != nil { + return "", fmt.Errorf("the email also becomes %s, which %w", c.env.Key("INSTALL_CERTIFICATE_EMAIL"), err) + } + return email, nil } - return err } diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go index cf40a9a5..faa17869 100644 --- a/internal/installer/install_test.go +++ b/internal/installer/install_test.go @@ -3,8 +3,10 @@ package installer_test import ( + "io/fs" "os" "path/filepath" + "syscall" "testing" "github.com/gopherium/framework/gonsole" @@ -27,10 +29,34 @@ func wantError(t *testing.T, err error, want string) { } } -func TestInstallRefusesADomainThatIsNotAHostNameBeforeItReachesTheMachine(t *testing.T) { +// rootOwned describes a file as root's, whatever user the test runs as. +type rootOwned struct{ fs.FileInfo } + +// Sys returns the system description of the file with root as its owner. +func (i rootOwned) Sys() any { + stat := *i.FileInfo.Sys().(*syscall.Stat_t) + stat.Uid = 0 + return &stat +} + +// lstatAsRoot describes the file at path as os.Lstat does, with root as its owner. +func lstatAsRoot(path string) (fs.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + return rootOwned{info}, nil +} + +// rootRunOn returns the machine whose top is the folder root, run by root and every file in it root's. +func rootRunOn(root string) installer.Machine { + return installer.Machine{Root: root, Lstat: lstatAsRoot, Euid: func() int { return 0 }} +} + +func TestInstallRefusesADomainThatIsNotAHostNameBeforeItReadsAFile(t *testing.T) { t.Parallel() - err := installer.Install(t.Context(), installer.Machine{}, callOf(nil), installer.Request{ + err := installer.Install(t.Context(), rootRunOn(t.TempDir()), callOf(nil), installer.Request{ Domain: "not a host name", DryRun: true, }) @@ -40,12 +66,22 @@ func TestInstallRefusesADomainThatIsNotAHostNameBeforeItReachesTheMachine(t *tes `got "not a host name"`) } +func TestInstallRefusesAPasswordFileThatIsNotAnAbsolutePathBeforeItReadsAFile(t *testing.T) { + t.Parallel() + + err := installer.Install(t.Context(), rootRunOn(t.TempDir()), callOf(nil), installer.Request{ + Domain: "example.com", AdminPasswordFile: "admin-password", + }) + + wantError(t, err, `-admin-password-file: must be an absolute path, got "admin-password"`) +} + func TestInstallRefusesAnInstallRootThatIsNotAbsoluteBeforeItChecksTheMachine(t *testing.T) { t.Parallel() call := callOf(map[string]string{"GOPHENBERG_INSTALL_ROOT": "planted/root"}) - err := installer.Install(t.Context(), installer.Machine{Root: t.TempDir()}, call, installer.Request{ + err := installer.Install(t.Context(), rootRunOn(t.TempDir()), call, installer.Request{ Domain: "example.com", DryRun: true, }) @@ -63,7 +99,7 @@ func recorded(t *testing.T, body string) installer.Machine { if err := os.WriteFile(record, []byte(body), 0o600); err != nil { t.Fatalf("writing the install record: %v", err) } - return installer.Machine{Root: root} + return rootRunOn(root) } func TestInstallRefusesAnInstallerOlderThanTheOneTheRecordNames(t *testing.T) { @@ -134,10 +170,10 @@ func TestInstallRefusesAFileItMayNotReadAsRootWithTheReason(t *testing.T) { } } -func TestInstallChecksTheMachineOnceTheSettingsAndTheRecordAllowTheRun(t *testing.T) { +func TestInstallStopsARunThatIsNotRootsBeforeItReadsTheRecord(t *testing.T) { t.Parallel() - machine := recorded(t, `{"installer": "0.0.1"}`) + machine := recorded(t, `{"installer": "1000.0.0"`) machine.Euid = func() int { return 1000 } err := installer.Install(t.Context(), machine, callOf(nil), installer.Request{Domain: "example.com", DryRun: true}) diff --git a/internal/installer/lock.go b/internal/installer/lock.go new file mode 100644 index 00000000..26d5a18c --- /dev/null +++ b/internal/installer/lock.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// lockedFor checks root and the installer folder for a run of command, then locks the folder when the run applies. +func lockedFor(machine Machine, command string, applies bool) (func(), error) { + if machine.Euid() != 0 { + return nil, notRoot(command) + } + if err := checkInstallerFolder(machine); err != nil { + return nil, err + } + if !applies { + return func() {}, nil + } + return lockInstallerFolder(machine) +} + +// lockInstallerFolder locks the installer folder for this run alone, none while it is missing, and returns the unlock. +func lockInstallerFolder(machine Machine) (func(), error) { + folder, err := absentAsNil(os.OpenFile(filepath.Join(machine.Root, installerFolder), os.O_RDONLY|syscall.O_NONBLOCK, + 0)) + if err != nil { + return nil, err + } + if folder == nil { + return func() {}, nil + } + return heldAlone(folder, syscall.Flock) +} + +// heldAlone locks the open folder unless another run holds it, and returns what lets go of the lock. +func heldAlone(folder *os.File, flock func(fd, how int) error) (func(), error) { + err := flock(int(folder.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return func() { _ = folder.Close() }, nil + } + _ = folder.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, stop("another server:install or server:uninstall is running on this server") + } + return nil, fmt.Errorf("installer: lock %s: %w", installerFolder, err) +} diff --git a/internal/installer/lock_internal_test.go b/internal/installer/lock_internal_test.go new file mode 100644 index 00000000..4e0bae46 --- /dev/null +++ b/internal/installer/lock_internal_test.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + "time" +) + +// lockedByAnother reports whether a run other than the one going on finds the installer folder of server locked. +func lockedByAnother(t *testing.T, server *fakeServer) bool { + t.Helper() + folder, err := os.Open(server.at(installerFolder)) + if err != nil { + t.Fatalf("opening the installer folder: %v", err) + } + defer func() { _ = folder.Close() }() + return errors.Is(syscall.Flock(int(folder.Fd()), syscall.LOCK_EX|syscall.LOCK_NB), syscall.EWOULDBLOCK) +} + +// probeLock has each run of the program called name on server note whether the installer folder is locked. +func probeLock(t *testing.T, server *fakeServer, name string) *[]bool { + t.Helper() + var seen []bool + answering := server.effects[name] + server.effects[name] = func(program Program) answer { + seen = append(seen, lockedByAnother(t, server)) + return answering(program) + } + return &seen +} + +// wantLockedThenLetGo fails t unless each probe in seen found the installer folder locked and server now finds it free. +func wantLockedThenLetGo(t *testing.T, server *fakeServer, seen []bool) { + t.Helper() + if len(seen) == 0 || slices.Contains(seen, false) { + t.Errorf("the run found the installer folder locked %v while systemctl ran, want it locked each time", seen) + } + if lockedByAnother(t, server) { + t.Error("the installer folder stays locked once the run ended, want it let go") + } +} + +func TestInstallHoldsTheInstallerFolderLockedWhileItRunsAndLetsItGoOnceItEnds(t *testing.T) { + t.Parallel() + + scenario := &installScenario{t: t, server: newFakeServer(t)} + scenario.installEarlier() + seen := probeLock(t, scenario.server, "systemctl") + + _, _, err := runInstall(scenario.server, Request{}) + + if err != nil { + t.Fatalf("Install() = %v, want the later run through", err) + } + wantLockedThenLetGo(t, scenario.server, *seen) +} + +func TestUninstallWithApplyHoldsTheInstallerFolderLockedWhileItRunsAndLetsItGoOnceItEnds(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + seen := probeLock(t, server, "systemctl") + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the run through", err) + } + wantLockedThenLetGo(t, server, *seen) +} + +func TestHeldAloneRefusesAFolderTheFileSystemWillNotLockAndLetsItGo(t *testing.T) { + t.Parallel() + + folder, err := os.Open(t.TempDir()) + if err != nil { + t.Fatalf("opening the installer folder: %v", err) + } + + unlock, err := heldAlone(folder, func(int, int) error { return syscall.ENOLCK }) + + wantRefused(t, err, "installer: lock /var/lib/gophenberg-installer: no locks available") + if unlock != nil || !errors.Is(err, syscall.ENOLCK) { + t.Errorf("heldAlone() = %v, want the lock the file system refused, with nothing to let go", err) + } + if err := folder.Close(); !errors.Is(err, os.ErrClosed) { + t.Errorf("closing the folder again = %v, want it closed already when the lock failed", err) + } +} + +// pipeAt puts a named pipe at the installer folder of server. +func pipeAt(t *testing.T, server *fakeServer) { + t.Helper() + server.folder(filepath.Dir(installerFolder)) + if err := syscall.Mkfifo(server.at(installerFolder), 0o700); err != nil { + t.Fatalf("making a pipe at the installer folder: %v", err) + } +} + +// plainFileAt puts a plain file at the installer folder of server. +func plainFileAt(_ *testing.T, server *fakeServer) { + server.write(installerFolder, "") +} + +func TestInstallStopsAtAPipeOrAPlainFileAtTheInstallerFolderWithoutWaitingForAWriter(t *testing.T) { + t.Parallel() + + for what, put := range map[string]func(*testing.T, *fakeServer){"a pipe": pipeAt, "a plain file": plainFileAt} { + for _, dryRun := range []bool{false, true} { + server := newFakeServer(t) + put(t, server) + ended := make(chan error, 1) + go func() { + _, _, err := runInstall(server, Request{Domain: "example.com", DryRun: dryRun}) + ended <- err + }() + + select { + case err := <-ended: + wantRefused(t, err, installerFolderOpen) + case <-time.After(5 * time.Second): + t.Fatalf("Install() still waits on %s at the installer folder after 5s, want it to return at once", what) + } + if len(server.ran) != 0 { + t.Errorf("the run at %s ran %q, want nothing run", what, server.ran) + } + } + } +} + +func TestInstallRefusesAnInstallerFolderItCannotOpenToLock(t *testing.T) { + t.Parallel() + skipAsRoot(t) + + for euid, want := range map[int]string{ + 0: "installer: open %s: permission denied", + 1000: "server:install must run as root, such as with sudo, so nothing was changed", + } { + server := newFakeServer(t) + server.euid = euid + server.folder(installerFolder) + server.chmod(installerFolder, 0o300) + t.Cleanup(func() { server.chmod(installerFolder, 0o700) }) + + _, _, err := runInstall(server, Request{}) + + wantRefused(t, err, strings.ReplaceAll(want, "%s", server.at(installerFolder))) + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run", server.ran) + } + } +} diff --git a/internal/installer/machine.go b/internal/installer/machine.go index 133f0df7..9d48b9a6 100644 --- a/internal/installer/machine.go +++ b/internal/installer/machine.go @@ -5,6 +5,8 @@ package installer import ( "context" + "crypto/tls" + "crypto/x509" "errors" "fmt" "io/fs" @@ -12,6 +14,7 @@ import ( "net/http" "net/netip" "os" + "os/user" "path/filepath" "syscall" ) @@ -35,9 +38,12 @@ type Machine struct { Listen func(network, address string) (net.Listener, error) ListenPacket func(network, address string) (net.PacketConn, error) Fetch func(request *http.Request) (*http.Response, error) + FetchHTTPS func(request *http.Request, roots *x509.CertPool) (*http.Response, error) Resolve func(ctx context.Context, network, host string) ([]netip.Addr, error) Addresses func() ([]net.Addr, error) - Terminal func() (*os.File, error) + Terminal func() (Prompt, error) + Executable func() (string, error) + LookupUser func(name string) (*user.User, error) Euid func() int } @@ -46,11 +52,20 @@ func RealMachine() Machine { fetch := &http.Client{Transport: &http.Transport{}, CheckRedirect: answerAsIs} return Machine{ Run: run, Root: "/", Lstat: os.Lstat, Free: free, Listen: net.Listen, ListenPacket: net.ListenPacket, - Fetch: fetch.Do, Resolve: net.DefaultResolver.LookupNetIP, Addresses: net.InterfaceAddrs, Terminal: terminal, + Fetch: fetch.Do, FetchHTTPS: fetchHTTPS, Resolve: net.DefaultResolver.LookupNetIP, + Addresses: net.InterfaceAddrs, Terminal: openTerminal, Executable: os.Executable, LookupUser: user.Lookup, Euid: os.Geteuid, } } +// fetchHTTPS fetches request over TLS 1.2 or newer trusting roots, the system's when nil, answering a redirect as is. +func fetchHTTPS(request *http.Request, roots *x509.CertPool) (*http.Response, error) { + client := &http.Client{CheckRedirect: answerAsIs, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true, + }} + return client.Do(request) +} + // answerAsIs makes a fetch answer a redirect as it is, so a page comes from the address asked for and no other. func answerAsIs(*http.Request, []*http.Request) error { return http.ErrUseLastResponse @@ -119,7 +134,7 @@ func free(path string) (uint64, error) { return stat.Bavail * uint64(stat.Bsize), nil } -// terminal opens the terminal of the session that started the installer, still there when standard input is a pipe. -func terminal() (*os.File, error) { - return os.OpenFile("/dev/tty", os.O_RDWR, 0) +// openTerminal opens the terminal of the session that started the installer, still there when standard input is a pipe. +func openTerminal() (Prompt, error) { + return promptOn(os.OpenFile("/dev/tty", os.O_RDWR, 0)) } diff --git a/internal/installer/machine_test.go b/internal/installer/machine_test.go index 54cf5321..b5524d0d 100644 --- a/internal/installer/machine_test.go +++ b/internal/installer/machine_test.go @@ -3,14 +3,17 @@ package installer_test import ( + "crypto/x509" "errors" "io" "io/fs" + "log" "net" "net/http" "net/http/httptest" "net/netip" "os" + "os/user" "path/filepath" "slices" "sync/atomic" @@ -207,11 +210,118 @@ func TestTheRealMachineOpensTheTerminalOfTheSessionForQuestions(t *testing.T) { var missing *fs.PathError switch { case err == nil: - defer func() { _ = terminal.Close() }() - if terminal.Name() != "/dev/tty" { - t.Errorf("the terminal is %s, want /dev/tty", terminal.Name()) + if err := terminal.Close(); err != nil { + t.Errorf("closing the terminal: %v", err) } + case terminal != nil: + t.Errorf("opening the terminal outside a session gave %v beside %v, want no terminal", terminal, err) case !errors.As(err, &missing) || missing.Path != "/dev/tty": t.Errorf("opening the terminal outside a session = %v, want /dev/tty named", err) } } + +func TestTheRealMachineNamesTheInstallerItRuns(t *testing.T) { + t.Parallel() + + want, err := os.Executable() + if err != nil { + t.Fatalf("naming the test binary: %v", err) + } + + got, err := installer.RealMachine().Executable() + + if err != nil || got != want { + t.Errorf("the running installer = %q, %v, want %q", got, err, want) + } +} + +func TestTheRealMachineLooksUpRoot(t *testing.T) { + t.Parallel() + + root, err := installer.RealMachine().LookupUser("root") + + if err != nil || [2]string{root.Uid, root.Gid} != [2]string{"0", "0"} { + t.Errorf("looking up root = %+v, %v, want the user and group id 0", root, err) + } +} + +func TestTheRealMachineCannotLookUpAUserThatIsNotThere(t *testing.T) { + t.Parallel() + + _, err := installer.RealMachine().LookupUser("gophenberg-missing") + + if !errors.As(err, new(user.UnknownUserError)) { + t.Errorf("looking up a user that is not there = %v, want the user unknown", err) + } +} + +// rootsOf returns a pool that trusts the root certificate server signs its answers with. +func rootsOf(server *httptest.Server) *x509.CertPool { + roots := x509.NewCertPool() + roots.AddCert(server.Certificate()) + return roots +} + +// fetchedHTTPS asks server for path through the real machine trusting roots, returning the answer it read. +func fetchedHTTPS(t *testing.T, server *httptest.Server, path string, roots *x509.CertPool) (*http.Response, error) { + t.Helper() + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL+path, nil) + if err != nil { + t.Fatalf("making the request: %v", err) + } + return installer.RealMachine().FetchHTTPS(request, roots) +} + +func TestTheRealMachineFetchesHTTPSTrustingTheRootsItIsHanded(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "signed in") + })) + defer server.Close() + + response, err := fetchedHTTPS(t, server, "/admin/", rootsOf(server)) + if err != nil { + t.Fatalf("fetching the page: %v", err) + } + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + + if err != nil || [2]any{response.StatusCode, string(body)} != [2]any{http.StatusOK, "signed in"} { + t.Errorf("the HTTPS fetch answered %s with %q and %v, want 200 and the page", response.Status, body, err) + } +} + +func TestTheRealMachineRefusesAnHTTPSAnswerSignedByARootItWasNotHanded(t *testing.T) { + t.Parallel() + + server := httptest.NewUnstartedServer(http.NotFoundHandler()) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + defer server.Close() + + response, err := fetchedHTTPS(t, server, "/admin/", nil) + + var unknown x509.UnknownAuthorityError + if response != nil || !errors.As(err, &unknown) { + t.Errorf("fetching under the system's roots = %v, %v, want the signer unknown", response, err) + } +} + +func TestTheRealMachineFetchesAnHTTPSRedirectAsItIs(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.RedirectHandler("/elsewhere", http.StatusFound)) + defer server.Close() + + response, err := fetchedHTTPS(t, server, "/admin/", rootsOf(server)) + if err != nil { + t.Fatalf("fetching the page: %v", err) + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode != http.StatusFound || response.Header.Get("Location") != "/elsewhere" { + t.Errorf("the HTTPS fetch answered %s to %q, want the redirect itself", response.Status, + response.Header.Get("Location")) + } +} diff --git a/internal/installer/prompt_linux.go b/internal/installer/prompt_linux.go new file mode 100644 index 00000000..a28b2898 --- /dev/null +++ b/internal/installer/prompt_linux.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "syscall" + "unsafe" +) + +// unseen asks question on tty as ask does with its echo off, so the answer is never shown, then puts the echo back. +func unseen(ctx context.Context, tty *os.File, question string) (string, error) { + conn, err := tty.SyscallConn() + if err != nil { + return "", fmt.Errorf("installer: %w", err) + } + shown, err := hideTyping(conn) + if err != nil { + return "", fmt.Errorf("installer: turn the echo of %s off: %w", tty.Name(), err) + } + defer func() { _ = termios(conn, syscall.TCSETS, &shown) }() + answer, err := ask(ctx, tty, question) + _, newline := io.WriteString(tty, "\n") + if err := errors.Join(err, newline); err != nil { + return "", err + } + return answer, nil +} + +// hideTyping turns the echo off at once on the terminal conn reaches, keeping what was typed, and returns how it was. +func hideTyping(conn syscall.RawConn) (syscall.Termios, error) { + var shown syscall.Termios + if err := termios(conn, syscall.TCGETS, &shown); err != nil { + return shown, err + } + hidden := shown + hidden.Lflag &^= syscall.ECHO + return shown, termios(conn, syscall.TCSETS, &hidden) +} + +// termios runs request on the terminal conn reaches, reading its settings into settings or setting them from it. +func termios(conn syscall.RawConn, request uintptr, settings *syscall.Termios) error { + var failed error + err := conn.Control(func(fd uintptr) { + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, request, uintptr(unsafe.Pointer(settings))) + if errno != 0 { + failed = errno + } + }) + return errors.Join(err, failed) +} diff --git a/internal/installer/prompt_linux_internal_test.go b/internal/installer/prompt_linux_internal_test.go new file mode 100644 index 00000000..2c6b128a --- /dev/null +++ b/internal/installer/prompt_linux_internal_test.go @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package installer + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + "unsafe" +) + +// ptyWait is how long a test waits on the controlling side of a pseudo terminal before it gives up. +const ptyWait = 5 * time.Second + +// ptyPair opens a pseudo terminal and returns the side that types and shows, and the terminal a prompt asks on. +func ptyPair(t *testing.T) (control, terminal *os.File) { + t.Helper() + control, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("opening a pseudo terminal: %v", err) + } + t.Cleanup(func() { _ = control.Close() }) + var unlocked, number uint32 + if err := ptyRequest(control, syscall.TIOCSPTLCK, &unlocked); err != nil { + t.Fatalf("unlocking the pseudo terminal: %v", err) + } + if err := ptyRequest(control, syscall.TIOCGPTN, &number); err != nil { + t.Fatalf("numbering the pseudo terminal: %v", err) + } + terminal, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", number), os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatalf("opening the terminal of the pseudo terminal: %v", err) + } + t.Cleanup(func() { _ = terminal.Close() }) + return control, terminal +} + +// ptyRequest runs the terminal request on file with value, through the file's own descriptor. +func ptyRequest(file *os.File, request uintptr, value *uint32) error { + conn, err := file.SyscallConn() + if err != nil { + return err + } + var failed error + err = conn.Control(func(fd uintptr) { + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, request, uintptr(unsafe.Pointer(value))); errno != 0 { + failed = errno + } + }) + return errors.Join(err, failed) +} + +// screenUntil reads what control shows until it ends with want, failing t once ptyWait passes. +func screenUntil(t *testing.T, control *os.File, want string) string { + t.Helper() + if err := control.SetReadDeadline(time.Now().Add(ptyWait)); err != nil { + t.Fatalf("setting the read deadline: %v", err) + } + var shown strings.Builder + chunk := make([]byte, 256) + for !strings.HasSuffix(shown.String(), want) { + n, err := control.Read(chunk) + shown.Write(chunk[:n]) + if err != nil { + t.Fatalf("the terminal showed %q, then %v, want it to end with %q", shown.String(), err, want) + } + } + return shown.String() +} + +// typeOn types text on the controlling side of a pseudo terminal, as a person at its keyboard does. +func typeOn(t *testing.T, control *os.File, text string) { + t.Helper() + if _, err := io.WriteString(control, text); err != nil { + t.Fatalf("typing %q: %v", text, err) + } +} + +// echoOf reports whether terminal shows what is typed on it. +func echoOf(t *testing.T, terminal *os.File) bool { + t.Helper() + conn, err := terminal.SyscallConn() + if err != nil { + t.Fatalf("reaching the terminal: %v", err) + } + var settings syscall.Termios + if err := termios(conn, syscall.TCGETS, &settings); err != nil { + t.Fatalf("reading the terminal settings: %v", err) + } + return settings.Lflag&syscall.ECHO != 0 +} + +// reply is what one question on a terminal returned. +type reply struct { + answer string + err error +} + +// askedAside asks question on terminal in a goroutine, unseen when hidden, and returns where the reply arrives. +func askedAside(ctx context.Context, terminal *os.File, question string, hidden bool) <-chan reply { + replies := make(chan reply, 1) + prompt, _ := promptOn(terminal, nil) + go func() { + ask := prompt.Ask + if hidden { + ask = prompt.AskUnseen + } + answer, err := ask(ctx, question) + replies <- reply{answer: answer, err: err} + }() + return replies +} + +// replyOf returns the reply that arrives on replies, failing t once ptyWait passes. +func replyOf(t *testing.T, replies <-chan reply) reply { + t.Helper() + select { + case got := <-replies: + return got + case <-time.After(ptyWait): + t.Fatalf("the question got no reply within %s", ptyWait) + return reply{} + } +} + +func TestAskShowsTheQuestionAndReturnsTheLineTypedAfterIt(t *testing.T) { + t.Parallel() + + control, terminal := ptyPair(t) + replies := askedAside(t.Context(), terminal, "Email of the first administrator: ", false) + screenUntil(t, control, "Email of the first administrator: ") + + typeOn(t, control, "admin@example.com\n") + + if got := replyOf(t, replies); got.err != nil || got.answer != "admin@example.com" { + t.Errorf("Ask() = %q, %v, want the line typed", got.answer, got.err) + } + screenUntil(t, control, "admin@example.com\r\n") +} + +func TestAskUnseenShowsNothingTypedAndPutsTheEchoBack(t *testing.T) { + t.Parallel() + + control, terminal := ptyPair(t) + replies := askedAside(t.Context(), terminal, "Password: ", true) + screenUntil(t, control, "Password: ") + + typeOn(t, control, "correct horse battery staple\n") + + if got := replyOf(t, replies); got.err != nil || got.answer != "correct horse battery staple" { + t.Errorf("AskUnseen() = %q, %v, want the line typed", got.answer, got.err) + } + if shown := screenUntil(t, control, "\r\n"); shown != "\r\n" { + t.Errorf("the terminal showed %q after the question, want the newline alone", shown) + } + if !echoOf(t, terminal) { + t.Error("the terminal hides what is typed after AskUnseen, want the echo back") + } +} + +func TestAskEndsWhenTheTerminalGivesNoMoreInput(t *testing.T) { + t.Parallel() + + for _, hidden := range []bool{false, true} { + control, terminal := ptyPair(t) + replies := askedAside(t.Context(), terminal, "Name of the first administrator [admin]: ", hidden) + screenUntil(t, control, "[admin]: ") + + typeOn(t, control, "\x04") + + if got := replyOf(t, replies); !errors.Is(got.err, errEndOfInput) { + t.Errorf("a question asked unseen %t after Ctrl-D = %q, %v, want the end of input", hidden, got.answer, + got.err) + } + if !echoOf(t, terminal) { + t.Errorf("the terminal hides what is typed after a question asked unseen %t, want the echo back", hidden) + } + } +} + +func TestAskEndsAtOnceWhenItsContextEndsWhileItWaits(t *testing.T) { + t.Parallel() + + for _, hidden := range []bool{false, true} { + control, terminal := ptyPair(t) + ctx, cancel := context.WithCancel(t.Context()) + replies := askedAside(ctx, terminal, "Password: ", hidden) + screenUntil(t, control, "Password: ") + + cancel() + + if got := replyOf(t, replies); !errors.Is(got.err, context.Canceled) { + t.Errorf("a question asked unseen %t once its context ended = %q, %v, want it canceled", hidden, + got.answer, got.err) + } + if !echoOf(t, terminal) { + t.Errorf("the terminal hides what is typed after a question asked unseen %t, want the echo back", hidden) + } + } +} + +func TestAskUnseenRefusesAFileThatIsNoTerminal(t *testing.T) { + t.Parallel() + + file, err := os.Create(filepath.Join(t.TempDir(), "answers")) + if err != nil { + t.Fatalf("making a file: %v", err) + } + defer func() { _ = file.Close() }() + + _, err = ttyPrompt{file: file}.AskUnseen(t.Context(), "Password: ") + + if !errors.Is(err, syscall.ENOTTY) { + t.Errorf("AskUnseen() on a plain file = %v, want it refused as no terminal", err) + } +} + +func TestAskUnseenRefusesATerminalThatIsClosed(t *testing.T) { + t.Parallel() + + _, terminal := ptyPair(t) + if err := terminal.Close(); err != nil { + t.Fatalf("closing the terminal: %v", err) + } + + _, err := ttyPrompt{file: terminal}.AskUnseen(t.Context(), "Password: ") + + wantRefused(t, err, "installer: turn the echo of "+terminal.Name()+" off: use of closed file") +} + +func TestAskUnseenRefusesAPromptWithNoTerminal(t *testing.T) { + t.Parallel() + + _, err := ttyPrompt{}.AskUnseen(t.Context(), "Password: ") + + if !errors.Is(err, os.ErrInvalid) { + t.Errorf("AskUnseen() with no terminal = %v, want it refused as invalid", err) + } +} + +func TestAskRefusesATerminalItCannotShowTheQuestionOn(t *testing.T) { + t.Parallel() + + _, terminal := ptyPair(t) + if err := terminal.Close(); err != nil { + t.Fatalf("closing the terminal: %v", err) + } + + _, err := ttyPrompt{file: terminal}.Ask(t.Context(), "Email of the first administrator: ") + + if !errors.Is(err, os.ErrClosed) { + t.Errorf("Ask() on a closed terminal = %v, want it refused as closed", err) + } +} + +func TestTellShowsTheLineAndANewlineOnTheTerminal(t *testing.T) { + t.Parallel() + + control, terminal := ptyPair(t) + + err := ttyPrompt{file: terminal}.Tell(t.Context(), "invalid email, so type it again") + + if err != nil { + t.Errorf("Tell() = %v, want the line shown", err) + } + if shown := screenUntil(t, control, "\r\n"); shown != "invalid email, so type it again\r\n" { + t.Errorf("the terminal showed %q, want the line and its newline", shown) + } +} + +func TestTellEndsAtOnceWhenItsContextEndsWhileTheTerminalTakesNothing(t *testing.T) { + t.Parallel() + + _, terminal := ptyPair(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := ttyPrompt{file: terminal}.Tell(ctx, strings.Repeat("a", 1<<20)) + + if !errors.Is(err, context.Canceled) { + t.Errorf("Tell() on a terminal nobody reads once its context ended = %v, want it canceled", err) + } +} + +func TestTellRefusesATerminalThatIsClosed(t *testing.T) { + t.Parallel() + + _, terminal := ptyPair(t) + if err := terminal.Close(); err != nil { + t.Fatalf("closing the terminal: %v", err) + } + + err := ttyPrompt{file: terminal}.Tell(t.Context(), "the two passwords differ, so type them again") + + wantRefused(t, err, "installer: write "+terminal.Name()+": file already closed") +} + +func TestClosingThePromptClosesItsTerminal(t *testing.T) { + t.Parallel() + + _, terminal := ptyPair(t) + prompt, err := promptOn(terminal, nil) + if err != nil { + t.Fatalf("promptOn() = %v, want the prompt on the terminal", err) + } + + if err := prompt.Close(); err != nil { + t.Errorf("Close() = %v, want the terminal closed", err) + } + if err := terminal.Close(); !errors.Is(err, os.ErrClosed) { + t.Errorf("closing the terminal again = %v, want it already closed", err) + } +} diff --git a/internal/installer/prompt_other.go b/internal/installer/prompt_other.go new file mode 100644 index 00000000..45dc165b --- /dev/null +++ b/internal/installer/prompt_other.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +package installer + +import ( + "context" + "errors" + "os" +) + +// unseen refuses to ask, since typing an answer unseen needs the terminal requests of Linux. +func unseen(context.Context, *os.File, string) (string, error) { + return "", errors.New("installer: typing a password unseen needs Linux") +} diff --git a/internal/installer/questions.go b/internal/installer/questions.go new file mode 100644 index 00000000..345a1a78 --- /dev/null +++ b/internal/installer/questions.go @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/gopherium/gouncer" +) + +// maxAnswerBytes is the longest answer a question reads, the longest password the account rules allow in UTF-8. +const maxAnswerBytes = 4096 + +// errEndOfInput is how a question ends when the terminal gives no more input, as after Ctrl-D. +var errEndOfInput = errors.New("the terminal gave no more input") + +// pastDeadline is a read deadline long gone, which makes a read that waits on the terminal return at once. +var pastDeadline = time.Unix(1, 0) + +// adminAnswers are the answers about the first administrator in the order they are asked, each with its flag. +var adminAnswers = []struct{ what, flag string }{ + {"email", "-admin-email"}, {"name", "-admin-name"}, {"password", "-admin-password-file"}, +} + +// Prompt is the terminal server:install asks its questions on and tells why it asks again, a password typed unseen. +type Prompt interface { + Ask(ctx context.Context, question string) (string, error) + AskUnseen(ctx context.Context, question string) (string, error) + Tell(ctx context.Context, line string) error + Close() error +} + +// ttyPrompt is the prompt on the terminal file of the session that started the installer. +type ttyPrompt struct { + file *os.File +} + +// promptOn returns the prompt on the terminal file, or err when the terminal did not open. +func promptOn(file *os.File, err error) (Prompt, error) { + if err != nil { + return nil, err + } + return ttyPrompt{file: file}, nil +} + +// Ask shows question on the terminal and returns the line typed after it, ending at once when ctx ends. +func (p ttyPrompt) Ask(ctx context.Context, question string) (string, error) { + return ask(ctx, p.file, question) +} + +// AskUnseen asks question as Ask does with the echo of the terminal off, so the answer typed is never shown. +func (p ttyPrompt) AskUnseen(ctx context.Context, question string) (string, error) { + return unseen(ctx, p.file, question) +} + +// Tell shows line on the terminal, ending at once when ctx ends while the terminal takes nothing. +func (p ttyPrompt) Tell(ctx context.Context, line string) error { + stop := context.AfterFunc(ctx, func() { _ = p.file.SetWriteDeadline(pastDeadline) }) + defer stop() + if _, err := io.WriteString(p.file, line+"\n"); err != nil { + return cmp.Or(ctx.Err(), fmt.Errorf("installer: %w", err)) + } + return nil +} + +// Close closes the terminal. +func (p ttyPrompt) Close() error { + return p.file.Close() +} + +// ask shows question on tty and reads the line typed after it, a deadline gone by ending the read once ctx ends. +func ask(ctx context.Context, tty *os.File, question string) (string, error) { + stop := context.AfterFunc(ctx, func() { _ = tty.SetReadDeadline(pastDeadline) }) + defer stop() + if _, err := io.WriteString(tty, question); err != nil { + return "", fmt.Errorf("installer: %w", err) + } + return readLine(ctx, tty) +} + +// readLine reads one line from typed a byte at a time, so nothing typed after it is taken, and returns it bare. +func readLine(ctx context.Context, typed io.Reader) (string, error) { + line := make([]byte, 0, 64) + one := make([]byte, 1) + for len(line) <= maxAnswerBytes { + n, err := typed.Read(one) + switch { + case n == 1 && one[0] == '\n': + return string(line), nil + case n == 1: + line = append(line, one[0]) + case err != nil: + return "", readFailed(ctx, err) + } + } + return "", fmt.Errorf("the answer is longer than %d bytes", maxAnswerBytes) +} + +// readFailed returns why a read on the terminal stopped: the error of ctx once it ended, the end of input, or err. +func readFailed(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, io.EOF) { + return errEndOfInput + } + return fmt.Errorf("installer: %w", err) +} + +// admin is the first administrator a first run makes, its email and name as the account code keeps them. +type admin struct { + email, name, password string +} + +// askAdmin returns the first administrator the flags of request give, asking for the rest, an email under emailRule. +func askAdmin( + ctx context.Context, machine Machine, request Request, emailRule func(string) (string, error), +) (admin, error) { + given, err := flaggedAdmin(machine, request) + if err != nil { + return admin{}, err + } + whats, flags := given.missing() + if len(whats) == 0 { + return given, nil + } + prompt, err := machine.Terminal() + if err != nil { + return admin{}, stop("this run has no terminal to ask the first administrator's %s on, so give %s: %v", + joined(whats), joined(flags), err) + } + defer func() { _ = prompt.Close() }() + return asking{prompt: prompt, emailRule: emailRule}.complete(ctx, given) +} + +// flaggedAdmin returns the answers the flags of request give, each checked once what it is checked beside is known. +func flaggedAdmin(machine Machine, request Request) (admin, error) { + email, err := flagged("-admin-email", request.AdminEmail, checkEmail) + if err != nil { + return admin{}, err + } + password, err := machine.passwordFile(request.AdminPasswordFile) + if err != nil { + return admin{}, err + } + given := admin{email: email, name: request.AdminName, password: password} + if email != "" { + given.name, err = given.flaggedName() + } + return given, err +} + +// flagged returns the answer value its flag gives as check reads it, refusing one the account rules refuse. +func flagged(flag, value string, check func(string) (string, error)) (string, error) { + if value == "" { + return "", nil + } + checked, err := check(value) + if err != nil { + return "", stop("%s: %v, got %q", flag, err, value) + } + return checked, nil +} + +// flaggedName returns the name the answers hold from -admin-name as the account rules read it beside their email. +func (a admin) flaggedName() (string, error) { + return flagged("-admin-name", a.name, func(name string) (string, error) { + return admin{email: a.email, name: name}.checkedName() + }) +} + +// passwordFile returns the password on the first line of the file at path, none when no path is given. +func (m Machine) passwordFile(path string) (string, error) { + if path == "" { + return "", nil + } + head, err := m.head(path, maxAnswerBytes+1) + if err != nil { + return "", fmt.Errorf("-admin-password-file: %w", err) + } + line, _, ended := strings.Cut(head, "\n") + switch { + case !ended && len(head) > maxAnswerBytes: + return "", stop("-admin-password-file: the first line of %s is longer than %d bytes", path, maxAnswerBytes) + case strings.HasSuffix(line, "\r"): + return "", stop("-admin-password-file: the first line of %s ends in a carriage return, as a file saved with "+ + "Windows line endings does, and a password never holds one", path) + } + if err := checkPassword(line); err != nil { + return "", stop("-admin-password-file: %v", err) + } + return line, nil +} + +// missing returns what the answers leave out, each as people name it and by the flag that gives it. +func (a admin) missing() (whats, flags []string) { + for n, value := range []string{a.email, a.name, a.password} { + if value == "" { + whats, flags = append(whats, adminAnswers[n].what), append(flags, adminAnswers[n].flag) + } + } + return whats, flags +} + +// asking is one round of questions about the first administrator on a terminal, with the rule a typed email keeps. +type asking struct { + prompt Prompt + emailRule func(string) (string, error) +} + +// complete asks for each answer given leaves out, in order, and returns the first administrator the answers make. +func (a asking) complete(ctx context.Context, given admin) (admin, error) { + email, err := orAsked(given.email, func() (string, error) { return a.email(ctx) }) + if err != nil { + return admin{}, err + } + name, err := a.name(ctx, admin{email: email, name: given.name}) + if err != nil { + return admin{}, err + } + password, err := orAsked(given.password, func() (string, error) { return a.password(ctx) }) + if err != nil { + return admin{}, err + } + return admin{email: email, name: name, password: password}, nil +} + +// orAsked returns the answer given, else the one ask returns. +func orAsked(given string, ask func() (string, error)) (string, error) { + if given != "" { + return given, nil + } + return ask() +} + +// email asks for the first administrator's email until the rule of the round takes one. +func (a asking) email(ctx context.Context) (string, error) { + return a.until(ctx, "email", func() (string, error) { + return a.prompt.Ask(ctx, "Email of the first administrator: ") + }, a.emailRule) +} + +// name returns the flagged name of named, else asks until the account rules take one, an empty answer the default. +func (a asking) name(ctx context.Context, named admin) (string, error) { + if named.name != "" { + return named.flaggedName() + } + fallback, _, _ := strings.Cut(named.email, "@") + typed := func() (string, error) { return a.prompt.Ask(ctx, "Name of the first administrator ["+fallback+"]: ") } + return a.until(ctx, "name", typed, func(answer string) (string, error) { + return admin{email: named.email, name: cmp.Or(strings.TrimSpace(answer), fallback)}.checkedName() + }) +} + +// password asks for the first administrator's password unseen until the account rules take it and it comes twice. +func (a asking) password(ctx context.Context) (string, error) { + for { + password, err := a.until(ctx, "password", func() (string, error) { return a.prompt.AskUnseen(ctx, "Password: ") }, + func(typed string) (string, error) { return typed, checkPassword(typed) }) + if err != nil { + return "", err + } + again, err := a.prompt.AskUnseen(ctx, "Password again: ") + if err != nil { + return "", askEnded("password", err) + } + if again == password { + return password, nil + } + if err := a.prompt.Tell(ctx, "the two passwords differ, so type them again"); err != nil { + return "", err + } + } +} + +// until asks for what with ask until check takes the answer, telling within ctx why it refused each one. +func (a asking) until(ctx context.Context, what string, ask func() (string, error), + check func(string) (string, error)) (string, error) { + for { + typed, err := ask() + if err != nil { + return "", askEnded(what, err) + } + checked, err := check(typed) + if err == nil { + return checked, nil + } + if err := a.prompt.Tell(ctx, err.Error()+", so type it again"); err != nil { + return "", err + } + } +} + +// askEnded returns the stop of a run whose question for what ended with err. +func askEnded(what string, err error) error { + return stop("asking for the first administrator's %s ended: %w", what, err) +} + +// checkEmail returns email as the account code keeps it, the email standing in for the name it always passes as. +func checkEmail(email string) (string, error) { + user, err := gouncer.NewInvitedUser(email, email) + return user.Email, brokenRule(err) +} + +// checkedName returns the name of the answers as the account code keeps it beside their email, which it already took. +func (a admin) checkedName() (string, error) { + user, err := gouncer.NewInvitedUser(a.email, a.name) + return user.Name, brokenRule(err) +} + +// checkPassword refuses a password the account rules do not take. +func checkPassword(password string) error { + var user gouncer.User + return brokenRule(user.SetPassword(password)) +} + +// brokenRule returns the rule of the account code err names, read without the name of the package behind it. +func brokenRule(err error) error { + if err == nil { + return nil + } + return errors.New(strings.TrimPrefix(err.Error(), "gouncer: ")) +} + +// joined returns items as a person lists them: one alone, else with commas and an and before the last. +func joined(items []string) string { + last := len(items) - 1 + if last < 1 { + return strings.Join(items, "") + } + return strings.Join(items[:last], ", ") + " and " + items[last] +} diff --git a/internal/installer/questions_internal_test.go b/internal/installer/questions_internal_test.go new file mode 100644 index 00000000..9c20fe14 --- /dev/null +++ b/internal/installer/questions_internal_test.go @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "cmp" + "context" + "errors" + "io" + "slices" + "strings" + "testing" + "testing/iotest" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/gophenberg/internal/version" +) + +// goodPassword is a password the account rules take. +const goodPassword = "correct horse battery staple" + +// passwordFile is where the tests keep the first administrator's password file, from the top of the server. +const passwordFile = "/root/admin-password" + +// emailQuestion is the question that asks for the first administrator's email. +const emailQuestion = "Email of the first administrator: " + +// allAsked is every question a run asks while the flags give no answer, the name's default that of admin@example.com. +var allAsked = []asked{ + {question: emailQuestion}, {question: "Name of the first administrator [admin]: "}, + {question: "Password: ", unseen: true}, {question: "Password again: ", unseen: true}, +} + +// askedOn asks for the first administrator on server as request asks, returning the answers and the error. +func askedOn(t *testing.T, server *fakeServer, request Request) (admin, error) { + t.Helper() + return askAdmin(t.Context(), server.machine(), request, checkEmail) +} + +// toldOn is the line a fake terminal shows when told why it asks again. +func toldOn(line string) asked { + return asked{told: line} +} + +// answering gives server a terminal that answers each of answers in turn, and returns it. +func answering(server *fakeServer, answers ...string) *fakePrompt { + server.prompt = &fakePrompt{answers: answers} + return server.prompt +} + +// wantAsked fails t unless prompt was asked want, in order, and closed afterwards. +func wantAsked(t *testing.T, prompt *fakePrompt, want []asked) { + t.Helper() + if !slices.Equal(prompt.asked, want) || !prompt.closed { + t.Errorf("the terminal was asked %+v and closed %t, want %+v and closed", prompt.asked, prompt.closed, want) + } +} + +// wantNoTerminal fails t when the run opened the terminal of server. +func wantNoTerminal(t *testing.T, server *fakeServer) { + t.Helper() + if server.terminals != 0 { + t.Errorf("the run opened the terminal %d times, want it never opened", server.terminals) + } +} + +func TestReadLineReadsNothingAfterTheEndOfTheLine(t *testing.T) { + t.Parallel() + + typed := strings.NewReader("admin@example.com\nMaria Perez\n") + + line, err := readLine(t.Context(), typed) + + if err != nil || [2]any{line, typed.Len()} != [2]any{"admin@example.com", len("Maria Perez\n")} { + t.Errorf("readLine() = %q, %v, leaving %d bytes, want the first line and the second left", line, err, + typed.Len()) + } +} + +func TestReadLineTakesALineRightAtTheLongestAnswer(t *testing.T) { + t.Parallel() + + longest := strings.Repeat("a", maxAnswerBytes) + + line, err := readLine(t.Context(), strings.NewReader(longest+"\n")) + + if err != nil || line != longest { + t.Errorf("readLine() = %d bytes, %v, want the %d bytes typed", len(line), err, maxAnswerBytes) + } +} + +func TestReadLineRefusesALineLongerThanTheLongestAnswer(t *testing.T) { + t.Parallel() + + _, err := readLine(t.Context(), strings.NewReader(strings.Repeat("a", maxAnswerBytes+1)+"\n")) + + wantRefused(t, err, "the answer is longer than 4096 bytes") +} + +func TestReadLineEndsWhenTheInputEnds(t *testing.T) { + t.Parallel() + + _, err := readLine(t.Context(), strings.NewReader("admin@exam")) + + if !errors.Is(err, errEndOfInput) { + t.Errorf("readLine() = %v, want the end of input", err) + } +} + +func TestReadLineReturnsWhatStopsTheRead(t *testing.T) { + t.Parallel() + + _, err := readLine(t.Context(), iotest.ErrReader(errors.New("the terminal is gone"))) + + wantRefused(t, err, "installer: the terminal is gone") +} + +func TestReadLineReturnsTheErrorOfAnEndedContextOverTheReadsOwn(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := readLine(ctx, iotest.ErrReader(io.ErrUnexpectedEOF)) + + if !errors.Is(err, context.Canceled) { + t.Errorf("readLine() = %v, want the context's own error", err) + } +} + +func TestPromptOnReturnsWhyTheTerminalDidNotOpen(t *testing.T) { + t.Parallel() + + prompt, err := promptOn(nil, errors.New("open /dev/tty: no such device or address")) + + if prompt != nil { + t.Errorf("promptOn() = %v, want no prompt", prompt) + } + wantRefused(t, err, "open /dev/tty: no such device or address") +} + +func TestAskAdminTakesEveryAnswerFromTheFlagsAndOpensNoTerminal(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(passwordFile, goodPassword+"\nthe second line\n") + + answered, err := askedOn(t, server, Request{ + AdminEmail: " Admin@Example.com ", AdminName: " Maria Perez ", AdminPasswordFile: passwordFile, + }) + + want := admin{email: "admin@example.com", name: "Maria Perez", password: goodPassword} + if err != nil || answered != want { + t.Errorf("askAdmin() = %+v, %v, want %+v", answered, err, want) + } + wantNoTerminal(t, server) +} + +func TestAskAdminRefusesAFlaggedEmailOrNameTheAccountRulesRefuseBeforeItOpensTheTerminal(t *testing.T) { + t.Parallel() + + tooLong := strings.Repeat("a", 257) + for _, refused := range []struct { + request Request + want string + }{ + {Request{AdminEmail: "maria"}, `-admin-email: invalid email, got "maria"`}, + {Request{AdminEmail: "admin@example.com", AdminName: " "}, `-admin-name: empty name, got " "`}, + {Request{AdminEmail: "admin@example.com", AdminName: tooLong}, + `-admin-name: name longer than 256 characters, got "` + tooLong + `"`}, + } { + server := newFakeServer(t) + answering(server, goodPassword, goodPassword) + + _, err := askedOn(t, server, refused.request) + + wantRefused(t, err, refused.want+", so nothing was changed") + wantNoTerminal(t, server) + } +} + +func TestAskAdminChecksAFlaggedNameOnceTheEmailIsTyped(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, "admin@example.com", goodPassword, goodPassword) + + _, err := askedOn(t, server, Request{AdminName: " "}) + + wantRefused(t, err, `-admin-name: empty name, got " ", so nothing was changed`) + wantAsked(t, prompt, allAsked[:1]) +} + +func TestAskAdminRefusesAPasswordFileTheAccountRulesRefuse(t *testing.T) { + t.Parallel() + + for body, want := range map[string]string{ + goodPassword + "\r\n": "-admin-password-file: the first line of /root/admin-password ends in a carriage " + + "return, as a file saved with Windows line endings does, and a password never holds one", + "too short\n": "-admin-password-file: password shorter than 12 characters", + "": "-admin-password-file: password shorter than 12 characters", + strings.Repeat("a", 1025): "-admin-password-file: password longer than 1024 characters", + strings.Repeat("a", maxAnswerBytes+1): "-admin-password-file: the first line of /root/admin-password is " + + "longer than 4096 bytes", + } { + server := newFakeServer(t) + server.write(passwordFile, body) + + _, err := askedOn(t, server, Request{ + AdminEmail: "admin@example.com", AdminName: "Maria Perez", AdminPasswordFile: passwordFile, + }) + + wantRefused(t, err, want+", so nothing was changed") + wantNoTerminal(t, server) + } +} + +func TestAskAdminTakesAPasswordFileWhoseFirstLineIsTheLongestAnswer(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + longest := strings.Repeat("\U0001D11E", maxAnswerBytes/4) + server.write(passwordFile, longest) + + answered, err := askedOn(t, server, Request{ + AdminEmail: "admin@example.com", AdminName: "Maria Perez", AdminPasswordFile: passwordFile, + }) + + if err != nil || answered.password != longest { + t.Errorf("askAdmin() = %d bytes, %v, want the %d bytes of the file", len(answered.password), err, + maxAnswerBytes) + } +} + +func TestAskAdminRefusesAPasswordFileItCannotRead(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder("/root/folder") + + for path, want := range map[string]string{ + "/root/missing": "open " + server.at("/root/missing") + ": no such file or directory", + "/root/folder": "read " + server.at("/root/folder") + ": is a directory", + } { + _, err := askedOn(t, server, Request{AdminEmail: "admin@example.com", AdminPasswordFile: path}) + + wantRefused(t, err, "-admin-password-file: "+want) + } +} + +func TestAskAdminRefusesARunWithNoTerminalNamingTheFlagsForWhatTheyLeaveOut(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write(passwordFile, goodPassword+"\n") + for _, missing := range []struct { + request Request + what, flags string + }{ + {Request{}, "email, name and password", "-admin-email, -admin-name and -admin-password-file"}, + {Request{AdminEmail: "admin@example.com"}, "name and password", "-admin-name and -admin-password-file"}, + {Request{AdminEmail: "admin@example.com", AdminName: "Maria Perez"}, "password", "-admin-password-file"}, + {Request{AdminName: "Maria Perez", AdminPasswordFile: passwordFile}, "email", "-admin-email"}, + } { + _, err := askedOn(t, server, missing.request) + + wantRefused(t, err, "this run has no terminal to ask the first administrator's "+missing.what+" on, so give "+ + missing.flags+": open /dev/tty: no such device or address, so nothing was changed") + } +} + +func TestAskAdminAsksForWhatTheFlagsLeaveOutInOrder(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, "Admin@Example.com", " Maria Perez ", goodPassword, goodPassword) + + answered, err := askedOn(t, server, Request{}) + + want := admin{email: "admin@example.com", name: "Maria Perez", password: goodPassword} + if err != nil || answered != want { + t.Errorf("askAdmin() = %+v, %v, want %+v", answered, err, want) + } + wantAsked(t, prompt, allAsked) +} + +func TestAskAdminTakesThePartOfTheEmailBeforeTheAtForAnEmptyName(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, "maria@example.com", " ", goodPassword, goodPassword) + + answered, err := askedOn(t, server, Request{}) + + if err != nil || answered.name != "maria" { + t.Errorf("askAdmin() = %+v, %v, want the name maria", answered, err) + } + if want := "Name of the first administrator [maria]: "; prompt.asked[1].question != want { + t.Errorf("the terminal asked %q for the name, want %q", prompt.asked[1].question, want) + } +} + +func TestAskAdminAsksOnlyForThePasswordTheFlagsLeaveOut(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, goodPassword, goodPassword) + + answered, err := askedOn(t, server, Request{AdminEmail: "admin@example.com", AdminName: "Maria Perez"}) + + if err != nil || answered.password != goodPassword { + t.Errorf("askAdmin() = %+v, %v, want the password typed", answered, err) + } + wantAsked(t, prompt, allAsked[2:]) +} + +func TestAskAdminAsksAgainForEachTypedAnswerTheAccountRulesRefuse(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, "maria", "admin@example.com", strings.Repeat("a", 257), "Maria Perez", "too short", + goodPassword, goodPassword) + + answered, err := askedOn(t, server, Request{}) + + want := admin{email: "admin@example.com", name: "Maria Perez", password: goodPassword} + if err != nil || answered != want { + t.Errorf("askAdmin() = %+v, %v, want %+v", answered, err, want) + } + wantAsked(t, prompt, []asked{ + allAsked[0], toldOn("invalid email, so type it again"), allAsked[0], allAsked[1], + toldOn("name longer than 256 characters, so type it again"), allAsked[1], allAsked[2], + toldOn("password shorter than 12 characters, so type it again"), allAsked[2], allAsked[3], + }) +} + +func TestAskAdminAsksForBothPasswordsAgainWhenTheyDiffer(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, goodPassword, "correct horse battery stable", goodPassword, goodPassword) + + answered, err := askedOn(t, server, Request{AdminEmail: "admin@example.com", AdminName: "Maria Perez"}) + + if err != nil || answered.password != goodPassword { + t.Errorf("askAdmin() = %+v, %v, want the password typed twice alike", answered, err) + } + differ := toldOn("the two passwords differ, so type them again") + wantAsked(t, prompt, slices.Concat(allAsked[2:], []asked{differ}, allAsked[2:])) +} + +func TestAskAdminStopsWhenTheTerminalEndsAtAnyQuestion(t *testing.T) { + t.Parallel() + + typed := []string{"admin@example.com", "Maria Perez", goodPassword} + for given, what := range []string{"email", "name", "password", "password"} { + server := newFakeServer(t) + prompt := answering(server, typed[:given]...) + + _, err := askedOn(t, server, Request{}) + + wantRefused(t, err, "asking for the first administrator's "+what+" ended: the terminal gave no more input, "+ + "so nothing was changed") + wantAsked(t, prompt, allAsked[:given+1]) + } +} + +func TestAskAdminStopsWithTheErrorOfItsEndedContext(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + answering(server, "admin@example.com") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := askAdmin(ctx, server.machine(), Request{}, checkEmail) + + wantRefused(t, err, "asking for the first administrator's email ended: context canceled, so nothing was changed") + if !errors.Is(err, context.Canceled) { + t.Errorf("askAdmin() = %v, want it to hold the context's error", err) + } +} + +// wantInstalled fails t unless the run ended with err nil, every step taken. +func wantInstalled(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("install() = %v, want every step taken", err) + } +} + +// certificateEmailTold is what the terminal is told of a typed email that cannot become the certificate email. +const certificateEmailTold = "the email also becomes GOPHENBERG_INSTALL_CERTIFICATE_EMAIL, which must be one plain " + + `email address such as admin@example.com, got "maria{perez}@example.com", so type it again` + +// installedOn runs server:install for example.com from the archive on server under env, returning the check it took. +func installedOn(t *testing.T, server *fakeServer, env map[string]string) (*machineCheck, error) { + t.Helper() + server.write(archivePath, releaseArchiveOf(t, version.Version(), serverPlatform)) + request := Request{Domain: "example.com", Archive: archivePath} + call := gonsole.Call{Env: envOf(env), Stdout: io.Discard, Stderr: io.Discard} + check, err := checkFor(t.Context(), server.machine(), call, request) + if err != nil { + t.Fatalf("checkFor() = %v, want every check passed", err) + } + return check, install(t.Context(), call, request, check) +} + +func TestInstallReadsTheSettingsAgainWithTheEmailTyped(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + answering(server, "Admin@Example.com", "Maria Perez", goodPassword, goodPassword) + + check, err := installedOn(t, server, nil) + + wantInstalled(t, err) + if check.settings.CertificateEmail != "admin@example.com" { + t.Errorf("the certificate email after the questions = %q, want the email typed", check.settings.CertificateEmail) + } +} + +func TestInstallAsksAgainForATypedEmailThatCannotBecomeTheCertificateEmail(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, "maria{perez}@example.com", "admin@example.com", "Maria Perez", goodPassword, + goodPassword) + + check, err := installedOn(t, server, nil) + + wantInstalled(t, err) + wantAsked(t, prompt, slices.Concat(allAsked[:1], []asked{toldOn(certificateEmailTold)}, allAsked)) + if check.settings.CertificateEmail != "admin@example.com" { + t.Errorf("the certificate email after the questions = %q, want the email typed again", + check.settings.CertificateEmail) + } +} + +func TestInstallTakesATypedEmailTheAccountRulesTakeWhileASettingGivesTheCertificateEmail(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + prompt := answering(server, "maria{perez}@example.com", "Maria Perez", goodPassword, goodPassword) + + check, err := installedOn(t, server, map[string]string{"GOPHENBERG_INSTALL_CERTIFICATE_EMAIL": "admin@example.com"}) + + wantInstalled(t, err) + if told := slices.IndexFunc(prompt.asked, func(shown asked) bool { return shown.told != "" }); told >= 0 { + t.Errorf("the terminal was told %q, want the email taken as typed", prompt.asked[told].told) + } + if check.settings.CertificateEmail != "admin@example.com" { + t.Errorf("the certificate email after the questions = %q, want the one the setting gives", + check.settings.CertificateEmail) + } +} + +func TestInstallRefusesAServerEnvChangedWhileTheQuestionsWereOpen(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + answering(server, "admin@example.com", "Maria Perez", goodPassword, goodPassword) + server.write(archivePath, releaseArchiveOf(t, version.Version(), serverPlatform)) + request := Request{Domain: "example.com", Archive: archivePath} + call := gonsole.Call{Env: envOf(nil), Stdout: io.Discard, Stderr: io.Discard} + check, err := checkFor(t.Context(), server.machine(), call, request) + if err != nil { + t.Fatalf("checkFor() = %v, want every check passed", err) + } + server.write(serverEnvPath, "GOPHENBERG_ADDR=0.0.0.0:8081\n") + + err = install(t.Context(), call, request, check) + + wantRefused(t, err, `GOPHENBERG_ADDR: must be a loopback address with a port, such as 127.0.0.1:8081, got `+ + `"0.0.0.0:8081"`) +} + +func TestAskAdminReturnsWhatStopsItTellingWhyItAsksAgain(t *testing.T) { + t.Parallel() + + for _, answers := range [][]string{ + {"maria"}, + {"admin@example.com", "Maria Perez", "too short"}, + {"admin@example.com", "Maria Perez", goodPassword, "correct horse battery stable"}, + } { + server := newFakeServer(t) + answering(server, answers...).untold = errors.New("the terminal is gone") + + _, err := askedOn(t, server, Request{}) + + wantRefused(t, err, "the terminal is gone") + } +} + +// asked is one question a fake terminal was asked and whether its answer was typed unseen, or a line it was told. +type asked struct { + question string + unseen bool + told string +} + +// fakePrompt is a terminal that answers from a list, in order, and records each question and line it shows. +type fakePrompt struct { + answers []string + asked []asked + untold error + closed bool +} + +// Tell records line among the questions, then returns the error a test gave the terminal, if any. +func (p *fakePrompt) Tell(_ context.Context, line string) error { + p.asked = append(p.asked, asked{told: line}) + return p.untold +} + +// Ask records question and returns the next answer, or the end of input once every answer is given. +func (p *fakePrompt) Ask(ctx context.Context, question string) (string, error) { + return p.answer(ctx, question, false) +} + +// AskUnseen records question as asked unseen and returns the next answer, as Ask does. +func (p *fakePrompt) AskUnseen(ctx context.Context, question string) (string, error) { + return p.answer(ctx, question, true) +} + +// Close records that the run is done with the terminal. +func (p *fakePrompt) Close() error { + p.closed = true + return nil +} + +// answer records question and returns the next answer, the error of ctx once it ended, or the end of input. +func (p *fakePrompt) answer(ctx context.Context, question string, unseen bool) (string, error) { + p.asked = append(p.asked, asked{question: question, unseen: unseen}) + if err := ctx.Err(); err != nil { + return "", err + } + if len(p.answers) == 0 { + return "", errEndOfInput + } + answer := p.answers[0] + p.answers = p.answers[1:] + return answer, nil +} + +// shown returns every question and line the terminal showed, one a line, each question asked unseen marked so. +func (p *fakePrompt) shown() string { + lines := make([]string, 0, len(p.asked)) + for _, question := range p.asked { + line := cmp.Or(question.told, strings.TrimSpace(question.question)) + if question.unseen { + line += " (unseen)" + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/installer/record.go b/internal/installer/record.go index a9165dd5..86b2ac56 100644 --- a/internal/installer/record.go +++ b/internal/installer/record.go @@ -3,6 +3,8 @@ package installer import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "slices" @@ -27,12 +29,22 @@ const pieceCaddy = "caddy" // pieceService is how the install record names the gophenberg.service unit server:install wrote. const pieceService = "gophenberg.service" -// Record is the install record of the newest installer, its steps, the pieces it installed and the domain it proved. +// stepStarted is how the install record marks a step a run started and has not ended. +const stepStarted = "started" + +// Record is the install record of the newest installer: its steps, the pieces it installed, its domain and Caddyfile. type Record struct { Installer string `json:"installer"` Steps map[string]string `json:"steps,omitempty"` Installed []string `json:"installed,omitempty"` Domain string `json:"domain,omitempty"` + Caddyfile string `json:"caddyfile,omitempty"` +} + +// caddyfileSum returns the SHA-256 of the Caddyfile text in hex, as the record keeps the one the first run wrote. +func caddyfileSum(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) } // readRecord returns the install record on machine, empty before a first run, refusing one that is not a record. @@ -57,17 +69,29 @@ func (r Record) ended(id string) bool { return r.Steps[id] == stepEnded } +// started reports whether the record says a run started the step it keeps under id and did not end it. +func (r Record) started(id string) bool { + return r.Steps[id] == stepStarted +} + +// own adds piece to what the record says server:install installed, once. +func (r *Record) own(piece string) { + if !r.owns(piece) { + r.Installed = append(r.Installed, piece) + } +} + // owns reports whether the record says server:install installed piece itself, the install root named by its path. func (r Record) owns(piece string) bool { return slices.Contains(r.Installed, piece) } -// allowsWebServer refuses a run without Caddy on a server where the record says server:install installed Caddy. -func (r Record) allowsWebServer(settings Settings, env gonsole.Env) error { - if settings.withCaddy() || !r.owns(pieceCaddy) { +// allowsWebServer refuses a run without Caddy while Gophenberg's Caddy site is on machine, whatever packages it owns. +func allowsWebServer(machine Machine, settings Settings, env gonsole.Env) error { + if settings.withCaddy() || !machine.there(caddySitePath) { return nil } - return webServerMoved(env, webServerCaddy, settings.WebServer) + return settingMoved(env, "INSTALL_WEB_SERVER", webServerCaddy, settings.WebServer) } // allows refuses an installer older than the one the record names, which would write its older files over newer ones. diff --git a/internal/installer/record_internal_test.go b/internal/installer/record_internal_test.go index a379733e..6cf9f153 100644 --- a/internal/installer/record_internal_test.go +++ b/internal/installer/record_internal_test.go @@ -38,6 +38,41 @@ func TestReadRecordReadsWhatAnEarlierRunWroteAndSkipsWhatItDoesNotKnow(t *testin } } +func TestReadRecordReadsTheChecksumOfTheCaddyfileTheFirstRunWrote(t *testing.T) { + t.Parallel() + + machine := serverWith(t, map[string]string{recordPath: `{"installer": "0.20.0", "caddyfile": "` + abcSum + `"}`}) + + record, err := readRecord(machine) + + if err != nil || record.Caddyfile != abcSum { + t.Errorf("readRecord() = %+v, %v, want the Caddyfile's checksum %s", record, err, abcSum) + } +} + +// abcSum is the SHA-256 of abc in hex, the first example of FIPS 180-2. +const abcSum = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + +func TestCaddyfileSumIsTheSHA256OfTheTextInHex(t *testing.T) { + t.Parallel() + + if got := caddyfileSum("abc"); got != abcSum { + t.Errorf("caddyfileSum(abc) = %s, want %s", got, abcSum) + } +} + +func TestStartedReportsAStepARunStartedAndDidNotEnd(t *testing.T) { + t.Parallel() + + record := Record{Steps: map[string]string{"packages": "started", "user": "ended"}} + + for id, want := range map[string]bool{"packages": true, "user": false, "database": false} { + if got := record.started(id); got != want { + t.Errorf("started(%s) = %t, want %t", id, got, want) + } + } +} + func TestReadRecordRefusesARecordItCannotRead(t *testing.T) { t.Parallel() @@ -93,26 +128,29 @@ func TestAllowsTakesAnInstallerAsNewAsTheOneTheRecordNamesOrNewer(t *testing.T) } } -func TestAllowsWebServerRefusesARunWithoutCaddyOnAServerWhoseRecordSaysCaddyWasInstalled(t *testing.T) { +func TestAllowsWebServerRefusesARunWithoutCaddyWhileGophenbergsCaddySiteIsThere(t *testing.T) { t.Parallel() - err := Record{Installed: []string{"postgresql", "caddy"}}.allowsWebServer(Settings{WebServer: "none"}, envOf(nil)) + machine := serverWith(t, map[string]string{caddySitePath: wantedCaddySite}) + machine.Lstat = os.Lstat + + err := allowsWebServer(machine, Settings{WebServer: "none"}, envOf(nil)) wantRefused(t, err, fmt.Sprintf(webServerMove, "caddy", "none")) } -func TestAllowsWebServerTakesARunThatKeepsTheWebServerTheRecordShows(t *testing.T) { +func TestAllowsWebServerTakesARunWithCaddyOrOneWithoutGophenbergsCaddySiteWhateverTheRecordOwns(t *testing.T) { t.Parallel() - for _, run := range []struct { - installed []string - webServer string - }{{[]string{"caddy"}, "caddy"}, {[]string{"postgresql"}, "none"}, {nil, "caddy"}, {nil, "none"}} { - err := Record{Installed: run.installed}.allowsWebServer(Settings{WebServer: run.webServer}, envOf(nil)) + sited := serverWith(t, map[string]string{caddySitePath: wantedCaddySite}) + bare := serverWith(t, map[string]string{caddyfilePath: packageCaddyfile}) + for machine, webServer := range map[*Machine]string{&sited: "caddy", &bare: "none"} { + machine.Lstat = os.Lstat + + err := allowsWebServer(*machine, Settings{WebServer: webServer}, envOf(nil)) if err != nil { - t.Errorf("allowsWebServer(%s) over a record that installed %q = %v, want nil", run.webServer, - run.installed, err) + t.Errorf("allowsWebServer(%s) = %v, want nil", webServer, err) } } } diff --git a/internal/installer/server_install_apply_steps_internal_test.go b/internal/installer/server_install_apply_steps_internal_test.go new file mode 100644 index 00000000..000fb58c --- /dev/null +++ b/internal/installer/server_install_apply_steps_internal_test.go @@ -0,0 +1,546 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io/fs" + "maps" + "math/big" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/cucumber/godog" +) + +// mistypedSite is a site a person adds to the Caddyfile, its directive respond mistyped as respnd. +const mistypedSite = "\nexample.org {\n\trespnd \"Maria Perez's own site\"\n}\n" + +// initializeServerInstallApplies binds the steps that set up and check a run that changes the server. +func initializeServerInstallApplies(sc *godog.ScenarioContext, s *installScenario) { + sc.Given(`^an earlier run for example.com was cut off after account:create-admin made the first administrator `+ + `(\S+)$`, s.cutOffAfterCreateAdmin) + sc.Given(`^the server settings set (GOPHENBERG_[A-Z_]+) to (\S+)$`, s.setServerLine) + sc.Given(`^the hosting provider's firewall keeps TCP port 443 closed$`, s.closePort443) + sc.When(`^the hosting provider's firewall lets TCP port 443 in$`, s.openPort443) + sc.Given(`^the site never answers on 127.0.0.1:8081$`, s.takeSiteDown) + sc.Given(`^the operator runs the installer's own copy$`, s.runOwnCopy) + sc.Given(`^the person added a site to /etc/caddy/Caddyfile whose directive respnd caddy validate does not know$`, + s.addMistypedSite) + sc.When(`^the person fixes the typo in /etc/caddy/Caddyfile$`, s.fixTypo) + sc.Given(`^Caddy fails to restart on the server$`, s.failCaddyRestart) + sc.When(`^Caddy restarts again on the server$`, s.letCaddyRestart) + sc.When(`^the person adds the site example.org to /etc/caddy/Caddyfile$`, s.addPersonSite) + sc.Given(`^an account a person made by hand holds (\S+) under the role (\S+)$`, s.addHandMadeAccount) + sc.Then(`^the run stops with "(.*)", followed by what the program printed$`, s.stopsWithPrinted) + sc.Then(`^the run ran these programs, the checks' own left out:$`, s.ranPrograms) + sc.Then(`^the run ran "([^"]*)"$`, s.ranLine) + sc.Then(`^the run never ran "([^"]*)"$`, s.neverRan) + sc.Then(`^the run ran "([^"]*)" in (/\S+)$`, s.ranIn) + sc.Then(`^the run ran "([^"]*)" in (/\S+), the line "([^"]*)" on its standard input$`, s.ranInWithInput) + sc.Then(`^the run ran "([^"]*)" with (\S+) in its environment$`, s.ranWithSetting) + sc.Then(`^the run created no administrator$`, s.createdNoAdministrator) + sc.Then(`^the run checked (https://\S+) trusting the system's root certificates$`, s.checkedWithSystemRoots) + sc.Then(`^the run checked (https://\S+) trusting Caddy's own root certificate$`, s.checkedWithCaddyRoot) + sc.Then(`^the run checked no page over HTTPS$`, s.checkedNoHTTPS) + sc.Then(`^the run ends with:$`, s.endsWith) + sc.Then(`^the run prints:$`, s.printsLines) + sc.Then(`^the run ends with the lines for the person's web server that the dry run printed$`, s.endsWithWebServer) + initializeServerInstallHeld(sc, s) +} + +// initializeServerInstallHeld binds the steps that check what a run that changes the server leaves on it. +func initializeServerInstallHeld(sc *godog.ScenarioContext, s *installScenario) { + sc.Then(`^the server holds /etc/caddy/Caddyfile.before-gophenberg as the caddy package wrote it$`, + s.keptPackageCaddyfile) + sc.Then(`^the server holds /etc/caddy/Caddyfile with the certificate email (\S+)$`, s.caddyfileEmails) + sc.Then(`^the install record holds the SHA-256 of the Caddyfile the run wrote$`, s.recordsCaddyfileSum) + sc.Then(`^the install record marks every step it took ended$`, s.recordEndsEveryStep) + sc.Then(`^the server holds nothing at (/\S+)$`, s.holdsNothingAt) + sc.Then(`^the server holds "([^"]*)", owned by (root|gophenberg) with mode (0[0-7]{3}):$`, s.holdsText) + sc.Then(`^the site's unit on the server holds the line "([^"]*)"$`, s.writtenUnitHolds) + sc.Then(`^the current link still names the earlier build (\S+)$`, s.stillNames) + sc.Then(`^/etc/caddy/Caddyfile is as the earlier run left it$`, s.caddyfileLeftAlone) + sc.Then(`^/etc/caddy/Caddyfile still holds the person's site$`, s.caddyfileKeepsSite) + sc.Then(`^the installer's own copy is the file the run ran, left as it was$`, s.copyLeftAlone) +} + +// cutOffAfterCreateAdmin leaves the server as a later step of a first run left it, cut off once email was an admin. +func (s *installScenario) cutOffAfterCreateAdmin(email string) error { + s.installEarlier() + record, err := readRecord(s.server.machine()) + if err != nil { + return err + } + record.Steps[stepAdmin] = stepStarted + delete(record.Steps, "https") + delete(record.Steps, "summary") + s.server.saveRecord(record) + s.server.accounts = append(s.server.accounts, listedAccount{Email: email, Name: "Maria", Role: "admin"}) + return nil +} + +// setServerLine sets the line key of server.env to value, as a hand edit does. +func (s *installScenario) setServerLine(key, value string) error { + held, err := os.ReadFile(s.server.at(serverEnvPath)) + if err != nil { + return err + } + var lines []string + for line := range strings.Lines(string(held)) { + if strings.HasPrefix(line, key+"=") { + line = key + "=" + value + "\n" + } + lines = append(lines, line) + } + s.server.write(serverEnvPath, strings.Join(lines, "")) + return nil +} + +// closePort443 has every HTTPS fetch time out, as a firewall that drops TCP port 443 makes it. +func (s *installScenario) closePort443() { + s.server.answerHTTPS = func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp 203.0.113.7:443: i/o timeout") + } +} + +// openPort443 lets every HTTPS fetch through to Caddy again. +func (s *installScenario) openPort443() { + s.server.answerHTTPS = nil +} + +// takeSiteDown has the site refuse every connection, as a site that never starts listening does. +func (s *installScenario) takeSiteDown() { + s.server.siteDown = true +} + +// runOwnCopy has the run run the installer's own copy, noting the file it is. +func (s *installScenario) runOwnCopy() error { + running, err := os.ReadFile(s.server.at(fakeInstaller)) + if err != nil { + return err + } + s.server.write(installerCopy, string(running)) + s.server.ranFrom = installerCopy + s.copied, err = os.Stat(s.server.at(installerCopy)) + return err +} + +// addMistypedSite adds a site to the Caddyfile whose directive caddy validate does not know. +func (s *installScenario) addMistypedSite() error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err != nil { + return err + } + s.server.write(caddyfilePath, string(held)+mistypedSite) + return nil +} + +// fixTypo puts the directive respond in place of the mistyped respnd in the Caddyfile. +func (s *installScenario) fixTypo() error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err != nil { + return err + } + s.server.write(caddyfilePath, strings.Replace(string(held), "respnd", "respond", 1)) + return nil +} + +// failCaddyRestart has every restart of caddy.service fail, as a Caddy that cannot start does. +func (s *installScenario) failCaddyRestart() { + s.server.effects["systemctl"] = func(program Program) answer { + if lineOf(program) == "systemctl restart caddy.service" { + return failed("systemctl", "Job for caddy.service failed.\n") + } + return s.server.systemctl(program) + } +} + +// addHandMadeAccount gives the site's database an account that holds email under role, as one made by hand does. +func (s *installScenario) addHandMadeAccount(email, role string) { + s.server.accounts = append(s.server.accounts, listedAccount{Email: email, Name: "Maria Perez", Role: role}) +} + +// letCaddyRestart has caddy.service restart again. +func (s *installScenario) letCaddyRestart() { + s.server.effects["systemctl"] = s.server.systemctl +} + +// stopsWithPrinted fails unless the run stopped with want followed by what the program that failed printed. +func (s *installScenario) stopsWithPrinted(want string) error { + if s.err == nil || !strings.HasPrefix(s.err.Error(), want+", after it printed:\n") { + return fmt.Errorf("the run ended with %v, want %q and what the program printed", s.err, want) + } + return nil +} + +// ranPrograms fails unless the run ran the program lines want lists, in order, besides the checks' own. +func (s *installScenario) ranPrograms(want *godog.DocString) error { + taken := slices.DeleteFunc(slices.Clone(s.server.ran), func(ran string) bool { return slices.Contains(probes, ran) }) + if got := strings.Join(taken, "\n"); got != want.Content { + return fmt.Errorf("the run ran\n%s\nwant\n%s", got, want.Content) + } + return nil +} + +// ranProgram returns the program the run ran as line, failing when it ran none. +func (s *installScenario) ranProgram(line string) (Program, error) { + at := slices.IndexFunc(s.server.runs, func(program Program) bool { return lineOf(program) == line }) + if at < 0 { + return Program{}, fmt.Errorf("the run ran\n%s\nwith no %q", strings.Join(s.server.ran, "\n"), line) + } + return s.server.runs[at], nil +} + +// ranLine fails unless the run ran the program line. +func (s *installScenario) ranLine(line string) error { + _, err := s.ranProgram(line) + return err +} + +// neverRan fails when the run ran the program line. +func (s *installScenario) neverRan(line string) error { + if slices.Contains(s.server.ran, line) { + return fmt.Errorf("the run ran\n%s\nwant no %q", strings.Join(s.server.ran, "\n"), line) + } + return nil +} + +// ranIn fails unless the run ran the program line in the folder dir. +func (s *installScenario) ranIn(line, dir string) error { + program, err := s.ranProgram(line) + if err == nil && program.Dir != dir { + err = fmt.Errorf("the run ran %q in %q, want %s", line, program.Dir, dir) + } + return err +} + +// ranInWithInput fails unless the run ran the program line in dir with input and its newline on its standard input. +func (s *installScenario) ranInWithInput(line, dir, input string) error { + program, err := s.ranProgram(line) + if err != nil { + return err + } + if got := [2]string{program.Dir, program.Stdin}; got != [2]string{dir, input + "\n"} { + return fmt.Errorf("the run ran %q in %q with %q on its standard input, want %s and %q", line, got[0], got[1], + dir, input+"\n") + } + return nil +} + +// ranWithSetting fails unless the run ran the program line with setting among its extra settings. +func (s *installScenario) ranWithSetting(line, setting string) error { + program, err := s.ranProgram(line) + if err == nil && !slices.Contains(program.Env, setting) { + err = fmt.Errorf("the run ran %q with the settings %q, want %s among them", line, program.Env, setting) + } + return err +} + +// createdNoAdministrator fails when the run ran account:create-admin at all. +func (s *installScenario) createdNoAdministrator() error { + if s.server.createdAdmin() { + return fmt.Errorf("the run ran\n%s\nwant no account:create-admin", strings.Join(s.server.ran, "\n")) + } + return nil +} + +// createdAdmin reports whether a run on the server ran the site's account:create-admin. +func (s *fakeServer) createdAdmin() bool { + return slices.ContainsFunc(s.runs, func(program Program) bool { + return programKey(program) == "gophenberg account:create-admin" + }) +} + +// checkedWithSystemRoots fails unless every HTTPS fetch of the run asked for address trusting the system's roots. +func (s *installScenario) checkedWithSystemRoots(address string) error { + return s.checkedHTTPS(address, func(roots *x509.CertPool) bool { return roots == nil }) +} + +// checkedWithCaddyRoot fails unless every HTTPS fetch of the run asked for address trusting Caddy's local root alone. +func (s *installScenario) checkedWithCaddyRoot(address string) error { + return s.checkedHTTPS(address, func(roots *x509.CertPool) bool { return roots.Equal(fakeCaddyRoots()) }) +} + +// checkedHTTPS fails unless the run fetched address over HTTPS, each time with roots trusted takes. +func (s *installScenario) checkedHTTPS(address string, trusted func(*x509.CertPool) bool) error { + fetched := s.server.fetchedHTTPS + if len(fetched) == 0 { + return fmt.Errorf("the run fetched no page over HTTPS, want %s", address) + } + for n, page := range fetched { + if !strings.HasPrefix(page, address+" ") || !trusted(s.server.httpsRoots[n]) { + return fmt.Errorf("the run fetched %q over HTTPS, want %s alone with the roots the scenario names", fetched, + address) + } + } + return nil +} + +// checkedNoHTTPS fails when the run fetched any page over HTTPS. +func (s *installScenario) checkedNoHTTPS() error { + if len(s.server.fetchedHTTPS) != 0 { + return fmt.Errorf("the run fetched %q over HTTPS, want no page", s.server.fetchedHTTPS) + } + return nil +} + +// endsWith fails unless what the run printed ends with want and its newline. +func (s *installScenario) endsWith(want *godog.DocString) error { + if !strings.HasSuffix(s.stdout, "\n"+want.Content+"\n") { + return fmt.Errorf("the run printed\n%s\nwant it to end with\n%s", s.stdout, want.Content) + } + return nil +} + +// printsLines fails unless what the run printed holds the lines of want, whole and in order. +func (s *installScenario) printsLines(want *godog.DocString) error { + if !strings.Contains("\n"+s.stdout, "\n"+want.Content+"\n") { + return fmt.Errorf("the run printed\n%s\nwant it to hold\n%s", s.stdout, want.Content) + } + return nil +} + +// endsWithWebServer fails unless what the run printed ends with the text the dry run prints for the web server. +func (s *installScenario) endsWithWebServer() error { + text, found := printedText(s.planned) + if !found || !strings.HasSuffix(s.stdout, "\n\n"+text) { + return fmt.Errorf("the run printed\n%s\nwant it to end with the text the dry run printed\n%s", s.stdout, text) + } + return nil +} + +// keptPackageCaddyfile fails unless the server keeps the Caddyfile of the caddy package as Caddyfile.before-gophenberg. +func (s *installScenario) keptPackageCaddyfile() error { + return s.holdsFile(keptCaddyfilePath, "root", "0644", packageCaddyfile) +} + +// caddyfileEmails fails unless the Caddyfile on the server names email as the email of Caddy's certificate account. +func (s *installScenario) caddyfileEmails(email string) error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err == nil && !slices.Contains(trimmedLines(string(held)), "email "+email) { + err = fmt.Errorf("the server holds the Caddyfile\n%s\nwant the line email %s", held, email) + } + return err +} + +// recordsCaddyfileSum fails unless the install record holds the SHA-256 of the Caddyfile on the server. +func (s *installScenario) recordsCaddyfileSum() error { + record, err := readRecord(s.server.machine()) + if err != nil { + return err + } + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err == nil && record.Caddyfile != caddyfileSum(string(held)) { + err = fmt.Errorf("the install record holds the Caddyfile sum %q, want that of\n%s", record.Caddyfile, held) + } + return err +} + +// recordEndsEveryStep fails unless the install record marks each step of a first run ended, and no other. +func (s *installScenario) recordEndsEveryStep() error { + record, err := readRecord(s.server.machine()) + if err != nil { + return err + } + want := map[string]string{} + for _, id := range stepIDs { + want[id] = stepEnded + } + if !maps.Equal(record.Steps, want) { + return fmt.Errorf("the install record marks %v, want %v", record.Steps, want) + } + return nil +} + +// holdsNothingAt fails when the server holds anything at path. +func (s *installScenario) holdsNothingAt(path string) error { + if _, err := os.Lstat(s.server.at(path)); !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("the server holds %s, want nothing there: %v", path, err) + } + return nil +} + +// holdsText fails unless the server holds the file at path, owned by owner with mode, holding want line for line. +func (s *installScenario) holdsText(path, owner, mode string, want *godog.DocString) error { + return s.holdsFile(path, owner, mode, want.Content+"\n") +} + +// writtenUnitHolds fails unless the site's unit on the server holds the line. +func (s *installScenario) writtenUnitHolds(line string) error { + held, err := os.ReadFile(s.server.at("/etc/systemd/system/gophenberg.service")) + if err == nil && !slices.Contains(strings.Split(string(held), "\n"), line) { + err = fmt.Errorf("the server holds the site's unit\n%s\nwant the line %q", held, line) + } + return err +} + +// stillNames fails unless the current link names the build called build, as Point writes it. +func (s *installScenario) stillNames(build string) error { + target, err := os.Readlink(s.server.at("/opt/gophenberg/current")) + if err == nil && target != filepath.Join("versions", build) { + err = fmt.Errorf("the current link names %s, want versions/%s", target, build) + } + return err +} + +// caddyfileLeftAlone fails unless the Caddyfile on the server is the one the earlier run wrote. +func (s *installScenario) caddyfileLeftAlone() error { + return s.holdsFile(caddyfilePath, "root", "0644", wantedCaddyfile) +} + +// caddyfileKeepsSite fails unless the Caddyfile on the server still holds the site the person added to it. +func (s *installScenario) caddyfileKeepsSite() error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err == nil && !strings.Contains(string(held), "example.org {\n") { + err = fmt.Errorf("the server holds the Caddyfile\n%s\nwant the person's site example.org in it", held) + } + return err +} + +// copyLeftAlone fails unless the installer's own copy is still the file the run ran, never written again. +func (s *installScenario) copyLeftAlone() error { + held, err := os.Stat(s.server.at(installerCopy)) + if err != nil { + return err + } + if !os.SameFile(held, s.copied) || !held.ModTime().Equal(s.copied.ModTime()) { + return fmt.Errorf("the run wrote the installer's own copy %s again, want it left alone", installerCopy) + } + return nil +} + +// listedAccount is one account as account:list -json lists it. +type listedAccount struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Disabled bool `json:"disabled"` +} + +// fakeCaddyRoot is the local root certificate the fake Caddy makes once it starts, in PEM. +var fakeCaddyRoot = sync.OnceValue(func() string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Caddy Local Authority - ECC Root"}, + NotBefore: time.Unix(0, 0), NotAfter: time.Unix(1<<32, 0), IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) + if err != nil { + panic(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +}) + +// fakeCaddyRoots returns the roots that trust the fake Caddy's local root certificate alone. +func fakeCaddyRoots() *x509.CertPool { + roots := x509.NewCertPool() + roots.AppendCertsFromPEM([]byte(fakeCaddyRoot())) + return roots +} + +// failed is the answer of a program called name that printed printed and exited 1, as the runner reports it. +func failed(name, printed string) answer { + return answer{printed: printed, err: errors.New("installer: run " + name + ": exit status 1")} +} + +// systemctl answers systemctl as systemd does: its version, a unit started or restarted, a reload of a running unit. +func (s *fakeServer) systemctl(program Program) answer { + verb, unit := program.Args[0], program.Args[len(program.Args)-1] + switch verb { + case "--version": + return s.answers["systemctl"] + case "start", "restart": + s.startUnit(unit) + case "reload": + if !s.running[unit] { + return failed("systemctl", unit+" is not active, cannot reload.\n") + } + } + return answer{} +} + +// startUnit runs unit, Caddy making its local root certificate as it starts, as it does for localhost. +func (s *fakeServer) startUnit(unit string) { + s.running[unit] = true + if caddy, found := s.users[pieceCaddy]; found && unit == caddyUnit { + s.write(filepath.Join(caddy.HomeDir, caddyLocalRoot), fakeCaddyRoot()) + } +} + +// caddyValidate answers caddy validate, refusing a Caddyfile that holds the mistyped directive respnd. +func (s *fakeServer) caddyValidate(Program) answer { + held, _ := os.ReadFile(s.at(caddyfilePath)) + if strings.Contains(string(held), "respnd") { + return failed("caddy", "Error: adapting config using caddyfile: /etc/caddy/Caddyfile:10: unrecognized "+ + "directive: respnd\n") + } + return answer{printed: "Valid configuration\n"} +} + +// createAdmin creates the account account:create-admin names, refusing an email an account already holds. +func (s *fakeServer) createAdmin(program Program) answer { + email := flagValue(program.Args, "-email") + if slices.ContainsFunc(s.accounts, func(held listedAccount) bool { return held.Email == email }) { + return failed(program.Name, "Password: gouncer: email already taken\n") + } + s.accounts = append(s.accounts, listedAccount{ + Email: email, Name: flagValue(program.Args, "-name"), Role: flagValue(program.Args, "-role"), + }) + return answer{printed: "Password: created user " + email + "\n"} +} + +// flagValue returns the value that follows flag in args. +func flagValue(args []string, flag string) string { + return args[slices.Index(args, flag)+1] +} + +// listAccounts lists every account as account:list -json does, indented as gonsole prints it. +func (s *fakeServer) listAccounts(Program) answer { + listed, _ := json.MarshalIndent(map[string][]listedAccount{"accounts": slices.Clone(s.accounts)}, "", " ") + return answer{printed: string(listed) + "\n"} +} + +// sitePage answers a page of the site with 200 while gophenberg.service runs, refusing the connection otherwise. +func (s *fakeServer) sitePage(request *http.Request) (*http.Response, error) { + if !s.running[pieceService] || s.siteDown { + return nil, errors.New("dial tcp " + request.URL.Host + ": connect: connection refused") + } + return answered(http.StatusOK, "")(request) +} + +// caddyPage answers an HTTPS page with 200 while caddy.service runs, refusing the connection otherwise. +func (s *fakeServer) caddyPage(request *http.Request) (*http.Response, error) { + if !s.running[caddyUnit] { + return nil, errors.New("dial tcp " + request.URL.Host + ":443: connect: connection refused") + } + return answered(http.StatusOK, "")(request) +} + +// saveRecord puts record on the server as its install record. +func (s *fakeServer) saveRecord(record Record) { + s.t.Helper() + body, err := json.Marshal(record) + if err != nil { + s.t.Fatalf("encoding the install record: %v", err) + } + s.write(recordPath, string(body)) +} diff --git a/internal/installer/server_install_steps_internal_test.go b/internal/installer/server_install_steps_internal_test.go index 0c466002..8ff9b7e9 100644 --- a/internal/installer/server_install_steps_internal_test.go +++ b/internal/installer/server_install_steps_internal_test.go @@ -3,20 +3,29 @@ package installer import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" "flag" "fmt" + "io" "io/fs" "maps" "net" "net/http" "os" "path/filepath" + "runtime" "slices" "strconv" "strings" + "syscall" "testing" + "testing/synctest" "github.com/cucumber/godog" + "github.com/joho/godotenv" "github.com/gopherium/gophenberg/internal/version" ) @@ -24,14 +33,17 @@ import ( // serverInstallFeature is the feature server:install answers to on a fake server. const serverInstallFeature = "../../test/features/features/server-install.feature" -// earlierRecord is the install record of an earlier run for example.com that ended every step. -const earlierRecord = `{"installer": "0.0.1", "domain": "example.com", +// earlierRecord is the record of an earlier run for example.com that ended every step, its Caddyfile's sum left open. +const earlierRecord = `{"installer": "0.0.1", "domain": "example.com", "caddyfile": "%s", "installed": ["postgresql", "caddy", "gophenberg.service", "/opt/gophenberg"], "steps": {"server-env": "ended", "packages": "ended", "user": "ended", "install-root": "ended", "database": "ended", "build": "ended", "installer-copy": "ended", "site-env": "ended", "units": "ended", "command": "ended", "caddy": "ended", "start": "ended", "admin": "ended", "https": "ended", "summary": "ended"}} ` +// thisPlatform is the platform of this server as a release.json names it. +const thisPlatform = "linux/" + runtime.GOARCH + // probes are the programs the checks run, none of which changes a server. var probes = []string{"systemctl --version", sandboxLine, "systemd-detect-virt", clockLine} @@ -40,22 +52,29 @@ type printedFile struct { header, text string } +// hiddenValue ends each line of the site's .env that a dry run prints with the person's value hidden. +const hiddenValue = "=(kept as it is)" + // installScenario is what one server:install scenario keeps between its steps. type installScenario struct { t *testing.T server *fakeServer env map[string]string + planted map[string]string before map[string]string stdout string stderr string err error checked map[string]bool + planned string + archive string + copied fs.FileInfo } // initializeServerInstall returns the binding of the server:install steps, t holding every fake server. func initializeServerInstall(t *testing.T) func(*godog.ScenarioContext) { return func(sc *godog.ScenarioContext) { - s := &installScenario{t: t, env: map[string]string{}, checked: map[string]bool{}} + s := &installScenario{t: t, env: map[string]string{}, planted: map[string]string{}, checked: map[string]bool{}} sc.Given(`^a fresh Ubuntu 24.04 server run by systemd 255$`, s.freshServer) sc.Given(`^the operator is not root$`, s.notRoot) sc.Given(`^the server runs Ubuntu 22.04$`, s.runUbuntu2204) @@ -69,13 +88,15 @@ func initializeServerInstall(t *testing.T) func(*godog.ScenarioContext) { sc.Given(`^example.com points at 198.51.100.7, where another server answers every page with 404$`, s.pointElsewhere) sc.Given(`^example.com resolves to 127.0.1.1, as a line in /etc/hosts makes it$`, s.pointAtHostsLine) - sc.Given(`^www.example.com points at 198.51.100.7, a public address a NAT in front of the server forwards to it$`, - s.pointThroughNAT) + sc.Given(`^(?:www\.)?example.com points at 198.51.100.7, a public address a NAT in front of the server forwards`+ + ` to it$`, s.pointThroughNAT) sc.Given(`^PostgreSQL is installed on the server$`, s.installPostgreSQL) sc.Given(`^Caddy is installed on the server$`, s.installCaddy) - sc.Given(`^another user can write /opt$`, s.openOpt) + sc.Given(`^another user can write (/opt|/var/lib/gophenberg-installer)$`, s.openFolder) + sc.Given(`^a plain file sits at /var/lib/gophenberg-installer$`, s.fileAtInstallerFolder) sc.Given(`^the install record names the installer "([^"]*)"$`, s.recordInstaller) sc.Given(`^an earlier run installed Gophenberg for example.com on the server$`, s.installEarlier) + sc.Given(`^another server:install is running on the server$`, s.runAnother) sc.Given(`^only root may read the server settings and the install record$`, s.lockRootFiles) sc.Given(`^the web server line of the server settings was changed by hand to none$`, s.keepOwnWebServer) sc.Given(`^the site's .env holds the line "([^"]*)"$`, s.addSiteEnvLine) @@ -83,19 +104,70 @@ func initializeServerInstall(t *testing.T) func(*godog.ScenarioContext) { s.addEscapedSiteEnvLine) sc.Given(`^unattended-upgrades is installed on the server$`, s.installUnattended) sc.Given(`^the environment sets (GOPHENBERG_[A-Z_]+) to (\S+)$`, s.setEnv) + initializeServerInstallAnswers(sc, s) + initializeServerInstallCutOffs(sc, s) sc.When(`^the operator runs "([^"]*)"$`, s.runLine) initializeServerInstallOutcomes(sc, s) + initializeServerInstallChanges(sc, s) + initializeServerInstallApplies(sc, s) + initializeServerUninstall(sc, s) } } +// initializeServerInstallCutOffs binds the steps that leave the server as a first run cut off part way left it. +func initializeServerInstallCutOffs(sc *godog.ScenarioContext, s *installScenario) { + sc.Given(`^a first run for example.com was cut off while apt-get installed PostgreSQL$`, s.cutOffInPackages) + sc.Given(`^a first run for example.com was cut off after createuser made the database role gophenberg$`, + s.cutOffAfterCreateuser) + sc.Given(`^a first run for example.com was cut off after it unpacked part of the build of this installer$`, + s.cutOffInBuild) +} + +// initializeServerInstallChanges binds the steps that check what a server:install run changed on the server. +func initializeServerInstallChanges(sc *godog.ScenarioContext, s *installScenario) { + sc.Then(`^the server holds every file the dry run printed, with its owner and mode$`, s.holdsPrintedFiles) + sc.Then(`^the server holds these folders:$`, s.holdsFolders) + sc.Then(`^the server holds the install record, owned by (root|gophenberg) with mode (0[0-7]{3})$`, s.holdsRecord) + sc.Then(`^the server holds the installer's own copy, owned by (root|gophenberg) with mode (0[0-7]{3})$`, + s.holdsInstallerCopy) + sc.Then(`^the build folder of this installer holds only the files of its archive, owned by gophenberg$`, + s.holdsOwnBuild) + sc.Then(`^the current link names the build of this installer, owned by gophenberg$`, s.pointsAtOwnBuild) + sc.Then(`^the run ran "([^"]*)" (before|after) "([^"]*)"$`, s.ranInOrder) + sc.Then(`^the install record marks the step (\S+) (started|ended)$`, s.recordMarks) +} + +// initializeServerInstallAnswers binds the steps that give a run its archive, its answers and the files it checks. +func initializeServerInstallAnswers(sc *godog.ScenarioContext, s *installScenario) { + sc.Given(`^the operator's terminal answers:$`, s.answerOnTerminal) + sc.Given(`^the release archive of this installer is at (/\S+)$`, s.putOwnArchive) + sc.Given(`^a release archive of Gophenberg ([0-9.]+) is at (/\S+)$`, s.putArchiveOfVersion) + sc.Given(`^a release archive of this installer built for (\S+) is at (/\S+)$`, s.putArchiveForPlatform) + sc.Given(`^the file (/\S+) holds the line "([^"]*)"$`, s.writeLine) + sc.Given(`^the file (/\S+) holds the line "([^"]*)" ending in a carriage return$`, s.writeLineWithReturn) + sc.Given(`^another package put a gophenberg command at /usr/local/bin/gophenberg$`, s.putOtherCommand) + sc.Given(`^the line "([^"]*)" was taken out of /etc/caddy/Caddyfile$`, s.takeOutCaddyfileLine) + sc.Given(`^the current link of the install root names /opt/gophenberg/versions/0.19.0 by its full path$`, + s.pointCurrentByFullPath) + sc.Given(`^the current link of the install root names versions/0.19.0, a build folder deleted by hand$`, + s.deleteCurrentBuild) + sc.Given(`^the current link of the install root names versions/0.19.0, whose program nobody may run$`, + s.stopCurrentProgram) +} + // initializeServerInstallOutcomes binds the steps that check what a server:install run did to s. func initializeServerInstallOutcomes(sc *godog.ScenarioContext, s *installScenario) { sc.Then(`^the run stops with "(.*)"$`, s.stopsWith) sc.Then(`^the run stops because this installer is older than the ([0-9.]+) installer$`, s.stopsAsOlder) + sc.Then(`^the run stops because the archive (\S+) holds Gophenberg ([0-9.]+)$`, s.stopsAsOtherVersion) + sc.Then(`^the run stops because the archive (\S+) is built for (\S+)$`, s.stopsAsOtherPlatform) + sc.Then(`^the run asked nothing on the terminal$`, s.askedNothing) + sc.Then(`^the terminal showed:$`, s.terminalShowed) sc.Then(`^the run succeeds$`, s.succeeds) sc.Then(`^the run notes "([^"]*)"$`, s.notes) sc.Then(`^the run notes nothing$`, s.notesNothing) sc.Then(`^the run prints nothing$`, s.printsNothing) + sc.Then(`^nothing the run printed holds "([^"]*)"$`, s.printedNothingOf) sc.Then(`^nothing on the server changed$`, s.changedNothing) sc.Then(`^the checks listened on no port$`, s.listenedNowhere) sc.Then(`^the checks fetched no page$`, s.fetchedNothing) @@ -190,9 +262,15 @@ func (s *installScenario) installCaddy() { s.server.program("/usr/bin/caddy") } -// openOpt lets every user write /opt. -func (s *installScenario) openOpt() { - s.server.chmod("/opt", 0o777) +// openFolder makes the folder at path where it is missing and lets every user write it. +func (s *installScenario) openFolder(path string) { + s.server.folder(path) + s.server.chmod(path, 0o777) +} + +// fileAtInstallerFolder puts a plain file where the installer folder belongs. +func (s *installScenario) fileAtInstallerFolder() { + s.server.write(installerFolder, "") } // recordInstaller has the install record name installer as the newest installer that ran on the server. @@ -204,17 +282,34 @@ func (s *installScenario) recordInstaller(installer string) { func (s *installScenario) installEarlier() { server := s.server server.write(serverEnvPath, firstServerEnv) - server.write(recordPath, earlierRecord) - server.folder("/etc/postgresql/16/main", "/etc/caddy", "/opt/gophenberg/versions/0.19.0") + server.write(recordPath, fmt.Sprintf(earlierRecord, caddyfileSum(wantedCaddyfile))) + server.installPackage(piecePostgreSQL) + server.installPackage(pieceCaddy) + server.addUser(siteUser, "/opt/gophenberg") + server.running["caddy.service"], server.running["gophenberg.service"] = true, true + server.write(keptCaddyfilePath, packageCaddyfile) + server.write("/etc/caddy/Caddyfile", wantedCaddyfile) + server.write(caddySitePath, wantedCaddySite) server.write("/etc/systemd/system/gophenberg.service", wantedSiteUnit) + server.write(commandPath, wantedCommand) server.write(siteEnvAt, wantedSiteEnv) - server.link("/opt/gophenberg/current", "/opt/gophenberg/versions/0.19.0") + server.pointCurrent() for _, held := range portListens { network, address, _ := strings.Cut(held, " ") server.taken[held] = inUse(network, address) } } +// runAnother holds the lock of the installer folder as another run does, until the test ends. +func (s *installScenario) runAnother() error { + folder, err := os.Open(s.server.at(installerFolder)) + if err != nil { + return err + } + s.t.Cleanup(func() { _ = folder.Close() }) + return syscall.Flock(int(folder.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + // lockRootFiles takes away every right on server.env and the install record, as a plain user meets them. func (s *installScenario) lockRootFiles() { s.server.chmod(serverEnvPath, 0) @@ -231,12 +326,17 @@ func (s *installScenario) keepOwnWebServer() error { return nil } -// addSiteEnvLine adds line to the site's .env. +// addSiteEnvLine adds line to the site's .env, keeping the value it sets by its key. func (s *installScenario) addSiteEnvLine(line string) error { held, err := os.ReadFile(s.server.at(siteEnvAt)) if err != nil { return err } + set, err := godotenv.Unmarshal(line) + if err != nil { + return err + } + maps.Copy(s.planted, set) s.server.write(siteEnvAt, string(held)+line+"\n") return nil } @@ -256,17 +356,98 @@ func (s *installScenario) setEnv(key, value string) { s.env[key] = value } -// runLine runs the server:install line on the scenario's server, noting what was on the server before. +// answerOnTerminal gives the run a terminal that answers each line of answers in turn, an empty line by Enter alone. +func (s *installScenario) answerOnTerminal(answers *godog.DocString) { + s.server.prompt = &fakePrompt{answers: strings.Split(answers.Content, "\n")} +} + +// putOwnArchive puts the release archive of this installer for this server at path. +func (s *installScenario) putOwnArchive(path string) { + s.server.write(path, releaseArchiveOf(s.t, version.Version(), thisPlatform)) + s.archive = path +} + +// putArchiveOfVersion puts a release archive of Gophenberg release for this server at path. +func (s *installScenario) putArchiveOfVersion(release, path string) { + s.server.write(path, releaseArchiveOf(s.t, release, thisPlatform)) +} + +// putArchiveForPlatform puts a release archive of this installer built for platform at path. +func (s *installScenario) putArchiveForPlatform(platform, path string) { + s.server.write(path, releaseArchiveOf(s.t, version.Version(), platform)) +} + +// writeLine puts a file at path that holds line and its newline. +func (s *installScenario) writeLine(path, line string) { + s.server.write(path, line+"\n") +} + +// writeLineWithReturn puts a file at path that holds line ended by a carriage return and a newline. +func (s *installScenario) writeLineWithReturn(path, line string) { + s.server.write(path, line+"\r\n") +} + +// putOtherCommand puts a gophenberg command on the path that another package wrote. +func (s *installScenario) putOtherCommand() { + s.server.write(commandPath, "#!/bin/sh\nexec /opt/other/bin/gophenberg \"$@\"\n") + s.server.chmod(commandPath, 0o755) +} + +// takeOutCaddyfileLine takes line out of the Caddyfile an earlier run wrote, as a hand edit does. +func (s *installScenario) takeOutCaddyfileLine(line string) error { + held, err := os.ReadFile(s.server.at("/etc/caddy/Caddyfile")) + if err != nil { + return err + } + kept := strings.Replace(string(held), line+"\n", "", 1) + if kept == string(held) { + return fmt.Errorf("the Caddyfile\n%s\nholds no line %q", held, line) + } + s.server.write("/etc/caddy/Caddyfile", kept) + return nil +} + +// pointCurrentByFullPath points the current link at its build by the full path, which updater.Point never writes. +func (s *installScenario) pointCurrentByFullPath() error { + s.server.remove("/opt/gophenberg/current") + return os.Symlink("/opt/gophenberg/versions/0.19.0", s.server.at("/opt/gophenberg/current")) +} + +// deleteCurrentBuild deletes the build folder the current link names and leaves the link, as a hand deletion does. +func (s *installScenario) deleteCurrentBuild() { + s.server.remove("/opt/gophenberg/versions/0.19.0") +} + +// stopCurrentProgram takes every execute bit off the program of the build the current link names, as a chmod does. +func (s *installScenario) stopCurrentProgram() { + s.server.chmod(currentProgram, 0o644) +} + +// runLine runs a server:uninstall line, or a server:install line in fake time, so that no wait takes real time. func (s *installScenario) runLine(line string) error { + if strings.HasPrefix(line, uninstallCommand) { + return s.runUninstall(line) + } request, err := requestOf(line) if err != nil { return err } s.before = s.server.snapshot() - s.stdout, s.stderr, s.err = dryRunUnder(s.server, s.env, request) + s.server.forgetRun() + synctest.Test(s.t, func(t *testing.T) { + s.stdout, s.stderr, s.err = installUnder(t.Context(), s.server, s.env, request) + }) + if request.DryRun { + s.planned = s.stdout + } return nil } +// forgetRun forgets the programs, the HTTPS pages and the terminals an earlier run of the scenario asked for. +func (s *fakeServer) forgetRun() { + s.runs, s.ran, s.fetchedHTTPS, s.httpsRoots, s.terminals = nil, nil, nil, nil, 0 +} + // requestOf returns what the server:install line asks for. func requestOf(line string) (Request, error) { fields := strings.Fields(line) @@ -278,6 +459,8 @@ func requestOf(line string) (Request, error) { flags.StringVar(&request.Domain, "domain", "", "") flags.StringVar(&request.Archive, "archive", "", "") flags.StringVar(&request.AdminEmail, "admin-email", "", "") + flags.StringVar(&request.AdminName, "admin-name", "", "") + flags.StringVar(&request.AdminPasswordFile, "admin-password-file", "", "") flags.StringVar(&request.WebServer, "web-server", "", "") flags.BoolVar(&request.SkipDomainCheck, "skip-domain-check", false, "") flags.BoolVar(&request.DryRun, "dry-run", false, "") @@ -331,6 +514,40 @@ func (s *installScenario) stopsAsOlder(recorded string) error { " installer, so run the install.sh line again") } +// stopsAsOtherVersion fails unless the run stopped because the archive at path holds Gophenberg release. +func (s *installScenario) stopsAsOtherVersion(path, release string) error { + return s.stopsWith("the archive " + path + " holds Gophenberg " + release + ", not this installer's " + + version.Version() + ", and server:install installs its own release only, so nothing was changed") +} + +// stopsAsOtherPlatform fails unless the run stopped because the archive at path is built for platform. +func (s *installScenario) stopsAsOtherPlatform(path, platform string) error { + return s.stopsWith("the archive " + path + " is built for " + platform + ", not for this server's " + + thisPlatform + ", so nothing was changed") +} + +// askedNothing fails when the run opened the terminal at all. +func (s *installScenario) askedNothing() error { + if s.server.terminals != 0 { + return fmt.Errorf("the run opened the terminal %d times, want it never opened", s.server.terminals) + } + return nil +} + +// terminalShowed fails unless the terminal showed the questions and lines want lists, one a line, in order. +func (s *installScenario) terminalShowed(want *godog.DocString) error { + if s.server.prompt == nil { + return errors.New("the scenario gave the run no terminal") + } + if got := s.server.prompt.shown(); got != want.Content { + return fmt.Errorf("the terminal showed\n%s\nwant\n%s", got, want.Content) + } + if !s.server.prompt.closed { + return errors.New("the run left the terminal open") + } + return nil +} + // succeeds fails unless the run passed every check. func (s *installScenario) succeeds() error { if s.err != nil { @@ -363,6 +580,14 @@ func (s *installScenario) printsNothing() error { return nil } +// printedNothingOf fails when the run printed text on its answer stream, its error stream or in its error. +func (s *installScenario) printedNothingOf(text string) error { + if printed := s.stdout + s.stderr + fmt.Sprint(s.err); strings.Contains(printed, text) { + return fmt.Errorf("the run printed\n%s\nwant nothing that holds %q", printed, text) + } + return nil +} + // changedNothing fails when a file, folder or link on the server changed, or a program ran that is not a check's. func (s *installScenario) changedNothing() error { if after := s.server.snapshot(); !maps.Equal(after, s.before) { @@ -425,19 +650,25 @@ func (s *installScenario) writesOnly(want *godog.DocString) error { // printsForWebServer fails unless the text a step of the dry run prints is want, line for line. func (s *installScenario) printsForWebServer(want *godog.DocString) error { - _, after, found := strings.Cut(s.stdout, "\n prints:\n") + got, found := printedText(s.stdout) if !found { return fmt.Errorf("the dry run printed\n%s\nwith no step that prints a text", s.stdout) } + if got != want.Content+"\n" { + return fmt.Errorf("the dry run prints\n%s\nwant\n%s", got, want.Content) + } + return nil +} + +// printedText returns the text the step of a dry run that prints a text prints, as its stdout shows it indented. +func printedText(stdout string) (string, bool) { + _, after, found := strings.Cut(stdout, "\n prints:\n") block, _, _ := strings.Cut(after, "\nStep ") var printed strings.Builder for line := range strings.Lines(block) { printed.WriteString(strings.TrimPrefix(line, " ")) } - if got := printed.String(); got != want.Content+"\n" { - return fmt.Errorf("the dry run prints\n%s\nwant\n%s", got, want.Content) - } - return nil + return printed.String(), found } // listsSteps fails unless the steps the dry run lists are want, line for line. @@ -525,7 +756,7 @@ func (s *installScenario) writesNoOtherFile() error { func (s *installScenario) writesAsForExample() error { example := newFakeServer(s.t) example.program(unattendedUpgrade) - stdout, _, err := dryRun(example, Request{Domain: "example.com", AdminEmail: "admin@example.com", DryRun: true}) + stdout, _, err := runInstall(example, Request{Domain: "example.com", AdminEmail: "admin@example.com", DryRun: true}) if err != nil { return fmt.Errorf("the dry run for example.com stopped with %w", err) } @@ -555,9 +786,286 @@ func (s *installScenario) unitHolds(holds, line string) error { return nil } -func TestServerInstall(t *testing.T) { - t.Parallel() +// ownBuild is the build folder of this installer in the install root, named from the top of the server. +var ownBuild = "/opt/gophenberg/versions/" + version.Version() + +// cutOffIn leaves the server as a first run for example.com left it, cut off in the step id, owning installed. +func (s *installScenario) cutOffIn(id string, installed ...string) error { + s.server.write(serverEnvPath, firstServerEnv) + record := Record{Installer: version.Version(), Domain: "example.com", Steps: map[string]string{}, Installed: installed} + for _, done := range stepIDs[:slices.Index(stepIDs, id)] { + record.Steps[done] = stepEnded + record.Installed = append(record.Installed, s.leftBy(done)...) + } + record.Steps[id] = stepStarted + body, err := json.Marshal(record) + s.server.write(recordPath, string(body)) + return err +} + +// leftBy puts on the server what the step called id leaves once it ended, returning the pieces the record then owns. +func (s *installScenario) leftBy(id string) []string { + switch id { + case "packages": + s.server.installPackage(piecePostgreSQL) + s.server.installPackage(pieceCaddy) + return []string{piecePostgreSQL, pieceCaddy} + case "user": + s.server.addUser("gophenberg", "/opt/gophenberg") + case "install-root": + s.server.folder("/opt/gophenberg/versions", "/opt/gophenberg/downloads", "/opt/gophenberg/state", + "/opt/gophenberg/backups", "/opt/gophenberg/media", "/opt/gophenberg/themes") + return []string{"/opt/gophenberg"} + } + return nil +} + +// cutOffInPackages leaves the server as a first run left it when it was cut off while dpkg set up PostgreSQL. +func (s *installScenario) cutOffInPackages() error { + s.server.folder("/etc/postgresql/16/main") + s.server.dpkg[piecePostgreSQL] = "iF" + return s.cutOffIn("packages", piecePostgreSQL, pieceCaddy) +} + +// cutOffAfterCreateuser leaves the server as a first run left it when it was cut off right after createuser. +func (s *installScenario) cutOffAfterCreateuser() error { + s.server.answers["createuser"] = answer{ + printed: "createuser: error: creation of new role failed: ERROR: role \"gophenberg\" already exists\n", + err: errors.New("installer: run createuser: exit status 1"), + } + return s.cutOffIn("database") +} + +// cutOffInBuild leaves the server as a first run left it when it was cut off while it unpacked this installer's build. +func (s *installScenario) cutOffInBuild() error { + s.server.write(ownBuild+"/gophenberg", "#!/bin/sh\n# cut off while it was written\n") + s.server.folder(ownBuild + "/LICENSES") + return s.cutOffIn("build") +} + +// holdsPrintedFiles fails unless the server holds every file the dry run printed, with its text, owner and mode. +func (s *installScenario) holdsPrintedFiles() error { + if s.planned == "" { + return errors.New("the scenario ran no dry run") + } + for path, printed := range (&installScenario{stdout: s.planned}).printedFiles() { + owned, _ := strings.CutPrefix(printed.header, " writes "+path+", owned by ") + owner, mode, _ := strings.Cut(strings.TrimSuffix(owned, ":"), " with mode ") + err := s.holdsPrinted(path, printed.text) + if err == nil { + err = s.ownedAs(path, owner, mode, 0) + } + if err != nil { + return err + } + } + return nil +} +// holdsPrinted fails unless the file at path holds printed line for line, each hidden value as the scenario set it. +func (s *installScenario) holdsPrinted(path, printed string) error { + held, err := os.ReadFile(s.server.at(path)) + if err != nil { + return fmt.Errorf("the server holds no file %s: %w", path, err) + } + if !slices.EqualFunc(strings.Split(string(held), "\n"), strings.Split(printed, "\n"), s.heldAsPrinted) { + return fmt.Errorf("the server holds %s as\n%s\nwant it as the dry run printed it\n%s", path, held, printed) + } + return nil +} + +// heldAsPrinted reports whether held is the line printed, or sets the value the scenario set for the key it hides. +func (s *installScenario) heldAsPrinted(held, printed string) bool { + key, hidden := strings.CutSuffix(printed, hiddenValue) + if !hidden { + return held == printed + } + set, _ := godotenv.Unmarshal(held) + planted, found := s.planted[key] + return found && maps.Equal(set, map[string]string{key: planted}) +} + +// holdsFile fails unless the server holds a regular file at path that holds text, owned by owner with mode. +func (s *installScenario) holdsFile(path, owner, mode, text string) error { + held, err := os.ReadFile(s.server.at(path)) + if err != nil { + return fmt.Errorf("the server holds no file %s: %w", path, err) + } + if string(held) != text { + return fmt.Errorf("the server holds %s as\n%s\nwant\n%s", path, held, text) + } + return s.ownedAs(path, owner, mode, 0) +} + +// holdsFolders fails unless the server holds each folder the table names, not a link, owned by its owner with its mode. +func (s *installScenario) holdsFolders(folders *godog.Table) error { + for _, row := range folders.Rows[1:] { + if err := s.ownedAs(row.Cells[0].Value, row.Cells[1].Value, row.Cells[2].Value, fs.ModeDir); err != nil { + return err + } + } + return nil +} + +// ownedAs fails unless the server holds an entry of the type kind at path, owned by owner with mode. +func (s *installScenario) ownedAs(path, owner, mode string, kind fs.FileMode) error { + info, err := os.Lstat(s.server.at(path)) + if err != nil { + return fmt.Errorf("the server holds nothing at %s: %w", path, err) + } + if info.Mode().Type() != kind { + return fmt.Errorf("the server holds %s as %s, want the type %s", path, info.Mode(), kind) + } + if got := fmt.Sprintf("%04o", info.Mode().Perm()); got != mode { + return fmt.Errorf("the server holds %s with mode %s, want %s", path, got, mode) + } + return s.ownedBy(path, owner) +} + +// ownedBy fails unless the entry at path, not what a link leads to, belongs to the ids the server gives owner. +func (s *installScenario) ownedBy(path, owner string) error { + info, err := os.Lstat(s.server.at(path)) + if err != nil { + return err + } + uid, gid, err := s.idsOf(owner) + if err != nil { + return err + } + stat := info.Sys().(*syscall.Stat_t) + if int(stat.Uid) != uid || int(stat.Gid) != gid { + return fmt.Errorf("%s belongs to %d:%d, want %s's %d:%d", path, stat.Uid, stat.Gid, owner, uid, gid) + } + return nil +} + +// idsOf returns the ids of owner on the server, root's own the test's, since a test may give a file to no other ids. +func (s *installScenario) idsOf(owner string) (uid, gid int, err error) { + if owner == "root" { + return os.Getuid(), os.Getgid(), nil + } + found, held := s.server.users[owner] + if !held { + return 0, 0, fmt.Errorf("the server has no user %s", owner) + } + uid, uidErr := strconv.Atoi(found.Uid) + gid, gidErr := strconv.Atoi(found.Gid) + return uid, gid, errors.Join(uidErr, gidErr) +} + +// holdsRecord fails unless the server holds an install record it can read, owned by owner with mode. +func (s *installScenario) holdsRecord(owner, mode string) error { + if _, err := readRecord(s.server.machine()); err != nil { + return err + } + return s.ownedAs(recordPath, owner, mode, 0) +} + +// holdsInstallerCopy fails unless the installer's own copy holds the installer that ran, owned by owner with mode. +func (s *installScenario) holdsInstallerCopy(owner, mode string) error { + running, err := os.ReadFile(s.server.at(fakeInstaller)) + if err != nil { + return err + } + return s.holdsFile(installerCopy, owner, mode, string(running)) +} + +// holdsOwnBuild fails unless this installer's build folder holds the files of its archive alone, owned by gophenberg. +func (s *installScenario) holdsOwnBuild() error { + want, err := archived(s.server.at(s.archive)) + if err != nil { + return err + } + got := map[string]string{} + err = filepath.WalkDir(s.server.at(ownBuild), func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if err := s.ownedBy(s.server.serverPath(path), "gophenberg"); err != nil || entry.IsDir() { + return err + } + held, err := os.ReadFile(path) + got[strings.TrimPrefix(path, s.server.at(ownBuild)+"/")] = string(held) + return err + }) + if err != nil { + return err + } + if !maps.Equal(got, want) { + return fmt.Errorf("the build folder %s holds %v, want the files of its archive %v", ownBuild, got, want) + } + return nil +} + +// archived returns each file of the release archive at path by its name, with what it holds. +func archived(path string) (map[string]string, error) { + archive, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = archive.Close() }() + unzipped, err := gzip.NewReader(archive) + if err != nil { + return nil, err + } + entries, files := tar.NewReader(unzipped), map[string]string{} + for { + header, err := entries.Next() + if errors.Is(err, io.EOF) { + return files, nil + } + if err != nil { + return nil, err + } + body, err := io.ReadAll(entries) + if err != nil { + return nil, err + } + files[header.Name] = string(body) + } +} + +// pointsAtOwnBuild fails unless the current link names this installer's build and belongs to gophenberg. +func (s *installScenario) pointsAtOwnBuild() error { + const current = "/opt/gophenberg/current" + target, err := os.Readlink(s.server.at(current)) + if err != nil { + return err + } + if want := "versions/" + version.Version(); target != want { + return fmt.Errorf("the current link names %s, want %s", target, want) + } + return s.ownedBy(current, "gophenberg") +} + +// ranInOrder fails unless the run ran the program line first, then second when order is after, or the other way. +func (s *installScenario) ranInOrder(first, order, second string) error { + at, other := slices.Index(s.server.ran, first), slices.Index(s.server.ran, second) + ran := strings.Join(s.server.ran, "\n") + if at < 0 || other < 0 { + return fmt.Errorf("the run ran\n%s\nwant it to run both %q and %q", ran, first, second) + } + if (at < other) != (order == "before") { + return fmt.Errorf("the run ran\n%s\nwant %q %s %q", ran, first, order, second) + } + return nil +} + +// recordMarks fails unless the install record on the server marks the step called id as mark. +func (s *installScenario) recordMarks(id, mark string) error { + record, err := readRecord(s.server.machine()) + if err != nil { + return err + } + if got := record.Steps[id]; got != mark { + return fmt.Errorf("the install record marks the step %s %q, want %s", id, got, mark) + } + return nil +} + +// runFeature runs the feature at path on fake servers through the one initializer, @wip left out unless set. +func runFeature(t *testing.T, path string) { + t.Helper() tags := "~@wip" if os.Getenv("GOPHENBERG_BDD_WIP") != "" { tags = "" @@ -565,10 +1073,16 @@ func TestServerInstall(t *testing.T) { suite := godog.TestSuite{ ScenarioInitializer: initializeServerInstall(t), Options: &godog.Options{ - Format: "pretty", Paths: []string{serverInstallFeature}, Tags: tags, Strict: true, TestingT: t, + Format: "pretty", Paths: []string{path}, Tags: tags, Strict: true, TestingT: t, }, } if suite.Run() != 0 { t.Error("the feature did not pass") } } + +func TestServerInstall(t *testing.T) { + t.Parallel() + + runFeature(t, serverInstallFeature) +} diff --git a/internal/installer/server_uninstall_steps_internal_test.go b/internal/installer/server_uninstall_steps_internal_test.go new file mode 100644 index 00000000..8f8d6556 --- /dev/null +++ b/internal/installer/server_uninstall_steps_internal_test.go @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "errors" + "flag" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/cucumber/godog" +) + +// serverUninstallFeature is the feature server:uninstall answers to on a fake server. +const serverUninstallFeature = "../../test/features/features/server-uninstall.feature" + +// personSite is a site a person adds to the Caddyfile, which caddy validate takes. +const personSite = "\nexample.org {\n\trespond \"Maria Perez's own site\"\n}\n" + +// ownCaddyfile is the Caddyfile of a Caddy the person set up to send example.com to the site. +const ownCaddyfile = "example.com {\n\treverse_proxy 127.0.0.1:8081\n}\n" + +// initializeServerUninstall binds the steps that run server:uninstall and check what it removed and kept. +func initializeServerUninstall(sc *godog.ScenarioContext, s *installScenario) { + sc.Given(`^server:install installed Gophenberg for example.com( with -web-server none)? on the server$`, + s.installFirst) + sc.Given(`^a first run for example.com was cut off in the caddy step once it wrote the Caddyfile$`, s.cutOffInCaddy) + sc.Given(`^server:uninstall -yes was cut off at its (first|second) "(systemctl [^"]*)"$`, s.cutOffUninstall) + sc.Given(`^the operator ran "([^"]*)"$`, s.ranEarlier) + sc.Given(`^the person added the site example.org to /etc/caddy/Caddyfile$`, s.addPersonSite) + sc.Given(`^the person deleted (/\S+)$`, s.deleteByHand) + sc.Given(`^systemd lets go of the site's units once the site stops$`, s.letGoOfUnits) + sc.Given(`^the person moved /etc/caddy/Caddyfile to /etc/caddy/(\S+) and left a link to it in its place$`, + s.linkCaddyfile) + sc.Then(`^/etc/caddy/Caddyfile is still a link to /etc/caddy/(\S+)$`, s.caddyfileLinksTo) + sc.Then(`^(/\S+) still holds the line "([^"]*)"$`, s.stillHoldsLine) + sc.Given(`^the person gave /etc/caddy/Caddyfile the group caddy and mode 0640$`, s.narrowCaddyfile) + sc.Then(`^/etc/caddy/Caddyfile still belongs to root and the group caddy with mode 0640$`, s.keepsNarrowCaddyfile) + sc.When(`^the person puts the line "([^"]*)" back in /etc/caddy/Caddyfile$`, s.putCaddyfileLineBack) + sc.Given(`^the person's own Caddy serves example.com from /etc/caddy/Caddyfile$`, s.serveOwnCaddy) + sc.Then(`^the server holds nothing at these paths:$`, s.holdsNothingAtPaths) + sc.Then(`^the server still holds these paths:$`, s.stillHoldsPaths) + sc.Then(`^the server holds /etc/caddy/Caddyfile as the caddy package wrote it$`, s.holdsPackageCaddyfile) + sc.Then(`^/etc/caddy/Caddyfile lacks the line "([^"]*)"$`, s.caddyfileLacks) + sc.Then(`^/etc/caddy/Caddyfile is as the person wrote it$`, s.caddyfileIsOwn) + sc.Then(`^the server still has the system user gophenberg$`, s.keepsSiteUser) + sc.Then(`^the server has no system user gophenberg$`, s.lostSiteUser) + sc.Then(`^the install record marks only these steps ended: (.+)$`, s.recordMarksOnly) + sc.Then(`^the install record marks no step$`, s.recordMarksNothing) + sc.Then(`^the install record owns only these pieces: (.+)$`, s.recordOwnsOnly) + sc.Then(`^the install record holds no SHA-256 of a Caddyfile$`, s.recordHoldsNoSum) + sc.Then(`^the install record still holds the SHA-256 of the Caddyfile server:install wrote$`, s.recordKeepsSum) +} + +// installFirst runs a first server:install for example.com that the flags answer, failing unless it went through. +func (s *installScenario) installFirst(withOwnWebServer string) error { + line := "server:install -domain example.com -archive " + archivePath + " -admin-email admin@example.com " + + "-admin-name Maria -admin-password-file " + passwordFile + if withOwnWebServer != "" { + line += " -web-server none" + } + s.putOwnArchive(archivePath) + s.writeLine(passwordFile, goodPassword) + return s.ranEarlier(line) +} + +// ranEarlier runs line on the scenario's server before the run the scenario checks, failing unless it went through. +func (s *installScenario) ranEarlier(line string) error { + if err := s.runLine(line); err != nil { + return err + } + if s.err != nil { + return fmt.Errorf("%s stopped with %w", line, s.err) + } + return nil +} + +// cutOffInCaddy runs a first install whose caddy validate fails, so it stops in the caddy step after the Caddyfile. +func (s *installScenario) cutOffInCaddy() error { + s.server.effects["caddy"] = func(Program) answer { + return failed("caddy", "Error: adapting config using caddyfile: unrecognized directive: lb_try_duration\n") + } + err := s.installFirst("") + s.server.effects["caddy"] = s.server.caddyValidate + if err == nil { + return errors.New("the first server:install went through, want it cut off in the caddy step") + } + if err := s.recordMarks(stepCaddy, stepStarted); err != nil { + return err + } + return s.recordHoldsNoSum() +} + +// cutOffUninstall runs server:uninstall -yes with the nth run of the systemctl line failing, failing unless it stopped. +func (s *installScenario) cutOffUninstall(nth, line string) error { + s.server.answerRemovals() + s.server.cutSystemctl(line, slices.Index([]string{"first", "second"}, nth)+1) + _, _, err := uninstallOn(s.server, s.env, true, false) + s.server.answerRemovals() + if err == nil { + return fmt.Errorf("server:uninstall -yes went through, want it cut off at its %s %s", nth, line) + } + return nil +} + +// cutSystemctl has the nth run of the systemctl line fail as a run cut off there stops, every other run answered. +func (s *fakeServer) cutSystemctl(line string, nth int) { + answering, seen := s.effects["systemctl"], 0 + s.effects["systemctl"] = func(program Program) answer { + if lineOf(program) != line { + return answering(program) + } + seen++ + if seen != nth { + return answering(program) + } + return failed("systemctl", "Failed to connect to bus: Connection reset by peer\n") + } +} + +// runUninstall runs the server:uninstall line on the scenario's server, its programs answered as a real server does. +func (s *installScenario) runUninstall(line string) error { + flags := flag.NewFlagSet(uninstallCommand, flag.ContinueOnError) + apply, deleteData := flags.Bool("yes", false, ""), flags.Bool("delete-data", false, "") + if err := flags.Parse(strings.Fields(line)[1:]); err != nil { + return err + } + s.server.answerRemovals() + s.before = s.server.snapshot() + s.server.forgetRun() + s.stdout, s.stderr, s.err = uninstallOn(s.server, s.env, *apply, *deleteData) + return nil +} + +// answerRemovals has the fake server answer the programs server:uninstall runs, as a real server does. +func (s *fakeServer) answerRemovals() { + s.answers["dropdb"], s.answers["dropuser"] = answer{}, answer{} + s.effects["userdel"], s.effects["systemctl"] = s.userdel, s.removingSystemctl +} + +// removingSystemctl stops the unit systemctl disable --now names, then answers as systemctl does. +func (s *fakeServer) removingSystemctl(program Program) answer { + if slices.Contains(program.Args, "--now") { + s.running[program.Args[len(program.Args)-1]] = false + } + if s.unloads && program.Args[0] == "reset-failed" { + return failed("systemctl", notLoadedLines(program.Args[1:])) + } + return s.systemctl(program) +} + +// notLoadedLines returns what systemctl reset-failed prints for each of units that systemd no longer holds. +func notLoadedLines(units []string) string { + var printed strings.Builder + for _, unit := range units { + printed.WriteString("Failed to reset failed state of unit " + unit + ": Unit " + unit + " not loaded.\n") + } + return printed.String() +} + +// letGoOfUnits has systemd let go of the site's units once the site stops, as systemd 255 does. +func (s *installScenario) letGoOfUnits() { + s.server.unloads = true +} + +// userdel removes the OS user its line names, as userdel does. +func (s *fakeServer) userdel(program Program) answer { + delete(s.users, program.Args[len(program.Args)-1]) + return answer{} +} + +// addPersonSite adds a site caddy validate takes to the Caddyfile, as a person does by hand. +func (s *installScenario) addPersonSite() error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err != nil { + return err + } + s.server.write(caddyfilePath, string(held)+personSite) + return nil +} + +// deleteByHand takes away the file at path, as a person does by hand. +func (s *installScenario) deleteByHand(path string) { + s.server.remove(path) +} + +// linkCaddyfile moves the Caddyfile to name in its folder and puts a link to it in its place, as a person does by hand. +func (s *installScenario) linkCaddyfile(name string) error { + moved := filepath.Join(filepath.Dir(caddyfilePath), name) + if err := os.Rename(s.server.at(caddyfilePath), s.server.at(moved)); err != nil { + return err + } + return os.Symlink(name, s.server.at(caddyfilePath)) +} + +// caddyfileLinksTo fails unless the Caddyfile on the server is still a link to name in its folder. +func (s *installScenario) caddyfileLinksTo(name string) error { + target, err := os.Readlink(s.server.at(caddyfilePath)) + if err == nil && target != name { + err = fmt.Errorf("the Caddyfile is a link to %s, want one to %s", target, name) + } + return err +} + +// stillHoldsLine fails unless the file at path on the server holds line. +func (s *installScenario) stillHoldsLine(path, line string) error { + held, err := os.ReadFile(s.server.at(path)) + if err == nil && !slices.Contains(trimmedLines(string(held)), line) { + err = fmt.Errorf("the server holds %s as\n%s\nwant the line %q", path, held, line) + } + return err +} + +// narrowCaddyfile gives the Caddyfile the group of the OS user caddy and mode 0640, as a person does by hand. +func (s *installScenario) narrowCaddyfile() error { + _, gid, err := s.idsOf("caddy") + if err != nil { + return err + } + s.server.chmod(caddyfilePath, 0o640) + return os.Chown(s.server.at(caddyfilePath), -1, gid) +} + +// keepsNarrowCaddyfile fails unless the Caddyfile has mode 0640 and the ids of caddy, root's uid and caddy's group. +func (s *installScenario) keepsNarrowCaddyfile() error { + return s.ownedAs(caddyfilePath, "caddy", "0640", 0) +} + +// putCaddyfileLineBack adds line to the end of the Caddyfile, as a person does by hand. +func (s *installScenario) putCaddyfileLineBack(line string) error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err != nil { + return err + } + s.server.write(caddyfilePath, string(held)+line+"\n") + return nil +} + +// serveOwnCaddy writes the Caddyfile of a Caddy the person set up to send example.com to the site. +func (s *installScenario) serveOwnCaddy() { + s.server.write(caddyfilePath, ownCaddyfile) +} + +// holdsNothingAtPaths fails when the server holds anything at one of the paths listed, one a line. +func (s *installScenario) holdsNothingAtPaths(paths *godog.DocString) error { + for _, path := range strings.Split(paths.Content, "\n") { + if err := s.holdsNothingAt(path); err != nil { + return err + } + } + return nil +} + +// stillHoldsPaths fails unless the server holds something at each of the paths listed, one a line. +func (s *installScenario) stillHoldsPaths(paths *godog.DocString) error { + for _, path := range strings.Split(paths.Content, "\n") { + if _, err := os.Lstat(s.server.at(path)); err != nil { + return fmt.Errorf("the server holds nothing at %s, want it kept: %w", path, err) + } + } + return nil +} + +// holdsPackageCaddyfile fails unless the Caddyfile on the server is the one the caddy package wrote. +func (s *installScenario) holdsPackageCaddyfile() error { + return s.holdsFile(caddyfilePath, "root", "0644", packageCaddyfile) +} + +// caddyfileLacks fails when the Caddyfile on the server holds line. +func (s *installScenario) caddyfileLacks(line string) error { + held, err := os.ReadFile(s.server.at(caddyfilePath)) + if err == nil && slices.Contains(trimmedLines(string(held)), line) { + err = fmt.Errorf("the server holds the Caddyfile\n%s\nwant no line %q", held, line) + } + return err +} + +// caddyfileIsOwn fails unless the Caddyfile on the server is the one the person wrote. +func (s *installScenario) caddyfileIsOwn() error { + return s.holdsFile(caddyfilePath, "root", "0644", ownCaddyfile) +} + +// keepsSiteUser fails unless the server still has the OS user gophenberg. +func (s *installScenario) keepsSiteUser() error { + if _, held := s.server.users[siteUser]; !held { + return errors.New("the server has no user gophenberg, want it kept") + } + return nil +} + +// lostSiteUser fails when the server still has the OS user gophenberg. +func (s *installScenario) lostSiteUser() error { + if _, held := s.server.users[siteUser]; held { + return errors.New("the server still has the user gophenberg, want it removed") + } + return nil +} + +// recordOnServer returns the install record on the scenario's server. +func (s *installScenario) recordOnServer() (Record, error) { + return readRecord(s.server.machine()) +} + +// recordMarksOnly fails unless the install record marks each step the list names ended, and no other step at all. +func (s *installScenario) recordMarksOnly(list string) error { + record, err := s.recordOnServer() + if err != nil { + return err + } + want := map[string]string{} + for _, id := range strings.Split(list, ", ") { + want[id] = stepEnded + } + if !maps.Equal(record.Steps, want) { + return fmt.Errorf("the install record marks %v, want %v", record.Steps, want) + } + return nil +} + +// recordMarksNothing fails when the install record marks any step. +func (s *installScenario) recordMarksNothing() error { + record, err := s.recordOnServer() + if err == nil && len(record.Steps) != 0 { + err = fmt.Errorf("the install record marks %v, want no step", record.Steps) + } + return err +} + +// recordOwnsOnly fails unless the install record owns the pieces the list names, in order, and no other. +func (s *installScenario) recordOwnsOnly(list string) error { + record, err := s.recordOnServer() + if err == nil && !slices.Equal(record.Installed, strings.Split(list, ", ")) { + err = fmt.Errorf("the install record owns %q, want %s", record.Installed, list) + } + return err +} + +// recordHoldsNoSum fails when the install record holds the SHA-256 of any Caddyfile. +func (s *installScenario) recordHoldsNoSum() error { + record, err := s.recordOnServer() + if err == nil && record.Caddyfile != "" { + err = fmt.Errorf("the install record holds the Caddyfile sum %q, want none", record.Caddyfile) + } + return err +} + +// recordKeepsSum fails unless the install record holds the SHA-256 of the Caddyfile a first run for example.com writes. +func (s *installScenario) recordKeepsSum() error { + record, err := s.recordOnServer() + if err == nil && record.Caddyfile != caddyfileSum(wantedCaddyfile) { + err = fmt.Errorf("the install record holds the Caddyfile sum %q, want that of\n%s", record.Caddyfile, + wantedCaddyfile) + } + return err +} + +func TestServerUninstall(t *testing.T) { + t.Parallel() + + runFeature(t, serverUninstallFeature) +} diff --git a/internal/installer/service_steps.go b/internal/installer/service_steps.go new file mode 100644 index 00000000..0f1eb0fb --- /dev/null +++ b/internal/installer/service_steps.go @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "cmp" + "context" + "crypto/x509" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "time" + + "github.com/gopherium/gophenberg/internal/updater" +) + +// keptCaddyfilePath is where the first run keeps the Caddyfile the caddy package wrote, before it writes its own. +const keptCaddyfilePath = caddyfilePath + ".before-gophenberg" + +// caddyLocalRoot is the root certificate Caddy signs localhost with, under the home folder of the caddy user. +const caddyLocalRoot = ".local/share/caddy/pki/authorities/local/root.crt" + +// maxLocalRootBytes is the largest local root the HTTPS check reads, far above the one certificate Caddy writes there. +const maxLocalRootBytes = 1 << 16 + +// caddyUnit is the unit of Caddy, which its package installs. +const caddyUnit = "caddy.service" + +// adminRole is the role the first administrator starts under. +const adminRole = "admin" + +// emailTaken is what account:create-admin prints for an email an account already holds. +const emailTaken = "email already taken" + +// firewallLine is the line of the summary that names the ports the hosting provider's firewall must let in. +const firewallLine = "The hosting provider's firewall must let TCP 80, TCP 443 and UDP 443 in for visitors to reach " + + "the site.\n" + +// commandsToKnow ends every summary with the commands a person runs next. +const commandsToKnow = `Commands to know: + sudo gophenberg update:status shows the build that runs and the update on offer + sudo gophenberg update:apply installs the update on offer + sudo journalctl -u gophenberg shows what the site logged + sudo gophenberg server:uninstall removes Gophenberg and keeps its data +` + +// caddyValidateArgs have caddy validate check the Caddyfile and every file it imports. +var caddyValidateArgs = []string{"validate", "--config", caddyfilePath, "--adapter", "caddyfile"} + +// unitPrograms are the programs the units step runs once it wrote the units, in order. +var unitPrograms = []Program{ + {Name: "systemctl", Args: []string{"daemon-reload"}}, unitProgram("enable", pieceService), +} + +// unitProgram returns the systemctl line that does verb to unit. +func unitProgram(verb, unit string) Program { + return Program{Name: "systemctl", Args: []string{verb, unit}} +} + +// siteCommand returns the command args of the current build under root, run as gophenberg in root. +func siteCommand(root string, args ...string) Program { + return Program{Name: filepath.Join(root, updater.Current, siteUser), Args: args, User: siteUser, Dir: root} +} + +// placed returns the tree written goes into, its name there and its owner ids, the install tree for gophenberg's. +func (r *installing) placed(written file) (*os.Root, string, *owner) { + if written.owner != siteUser { + return r.system, inSystemTree(written.path), nil + } + return r.tree, strings.TrimPrefix(written.path, r.site.settings.Root+"/"), r.ids +} + +// writeSiteEnv writes the site's .env, then has the site check its settings as gophenberg in the install root. +func (r *installing) writeSiteEnv(ctx context.Context, planned step) error { + err := r.writeAll(planned.files) + if err == nil { + err = r.runStep(ctx, siteCommand(r.site.settings.Root, "check")) + } + return err +} + +// units owns gophenberg.service in the record, writes the units, then reloads systemd and enables the site. +func (r *installing) units(ctx context.Context, planned step) error { + r.record.own(pieceService) + err := r.save() + if err == nil { + err = r.writeAll(planned.files) + } + for _, program := range unitPrograms { + if err == nil { + err = r.runStep(ctx, program) + } + } + return err +} + +// caddy writes Caddy's files and checks them, writing the Caddyfile only while the record holds no sum of it. +func (r *installing) caddy(ctx context.Context, planned step) error { + if r.site.caddyfileWritten() { + return r.laterCaddy(ctx, planned) + } + return r.firstCaddy(ctx, planned) +} + +// firstCaddy keeps the package's Caddyfile, writes and checks Caddy's files, saves the sum, then restarts Caddy. +func (r *installing) firstCaddy(ctx context.Context, planned step) error { + err := r.keepPackageCaddyfile() + if err == nil { + err = r.checkedCaddy(ctx, planned.files) + } + if err == nil { + r.record.Caddyfile = caddyfileSum(render("Caddyfile", r.site.data())) + err = r.save() + } + if err == nil { + err = r.runStep(ctx, unitProgram("restart", caddyUnit)) + } + return err +} + +// laterCaddy writes and checks Caddy's files, the person's Caddyfile left alone, then restarts or reloads Caddy. +func (r *installing) laterCaddy(ctx context.Context, planned step) error { + err := r.checkedCaddy(ctx, planned.files) + if err == nil { + err = r.runStep(ctx, unitProgram(r.site.caddyVerb(), caddyUnit)) + } + return err +} + +// keepPackageCaddyfile renames the caddy package's Caddyfile to Caddyfile.before-gophenberg, unless one is kept. +func (r *installing) keepPackageCaddyfile() error { + kept := inSystemTree(keptCaddyfilePath) + _, err := r.system.Lstat(kept) + if errors.Is(err, fs.ErrNotExist) { + err = r.renamePackageCaddyfile(kept) + } + if err == nil || errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("installer: keep the caddy package's Caddyfile as %s: %w", keptCaddyfilePath, err) +} + +// renamePackageCaddyfile renames the Caddyfile to kept in the system tree, unless it imports Gophenberg's site. +func (r *installing) renamePackageCaddyfile(kept string) error { + held, err := r.system.ReadFile(inSystemTree(caddyfilePath)) + if err != nil || slices.Contains(trimmedLines(string(held)), caddyImport) { + return err + } + return r.system.Rename(inSystemTree(caddyfilePath), kept) +} + +// checkedCaddy writes files, then has caddy validate check them as the caddy user, at home where Caddy keeps its data. +func (r *installing) checkedCaddy(ctx context.Context, files []file) error { + if err := r.writeAll(files); err != nil { + return err + } + home, err := r.caddyHome() + if err != nil { + return err + } + validate := Program{Name: pieceCaddy, Args: caddyValidateArgs, User: pieceCaddy, Env: []string{"HOME=" + home}} + if printed, err := r.machine.Run(ctx, validate); err != nil { + return fmt.Errorf("caddy validate refused Caddy's files, so Caddy runs on with the settings it has: %w", + withPrinted(err, printed)) + } + return nil +} + +// caddyHome returns the home folder of the caddy user, where Caddy keeps its data and its local root. +func (r *installing) caddyHome() (string, error) { + caddy, err := r.machine.LookupUser(pieceCaddy) + if err != nil { + return "", fmt.Errorf("installer: %w", err) + } + return caddy.HomeDir, nil +} + +// start starts the site, or restarts it once the record owns its unit, then waits until its pages answer. +func (r *installing) start(ctx context.Context, _ step) error { + err := r.runStep(ctx, unitProgram("reset-failed", pieceService)) + if err == nil { + err = r.runStep(ctx, unitProgram(r.site.startVerb(), pieceService)) + } + if err != nil { + return err + } + return r.waitForSite(ctx) +} + +// waitForSite waits within the start wait until the site's pages answer as the start check of an update wants. +func (r *installing) waitForSite(ctx context.Context) error { + settings := r.site.settings + err := r.within(ctx, settings.Checks.StartWait, func(ctx context.Context) error { + return updater.CheckPages(ctx, r.machine.Fetch, settings.Addr.String()) + }) + if err == nil || ctx.Err() != nil { + return err + } + return fmt.Errorf("%s did not pass its start check within %s, the time %s allows, as at the last try %w, so read "+ + "why with sudo journalctl -u gophenberg", pieceService, settings.Checks.StartWait, + r.site.env.Key("UPDATE_VERIFY_TIMEOUT"), err) +} + +// within tries try every poll interval until it passes or wait is over, returning why the last try failed or ctx's end. +func (r *installing) within(ctx context.Context, wait time.Duration, try func(context.Context) error) error { + waiting, cancel := context.WithTimeout(ctx, wait) + defer cancel() + for { + err := try(waiting) + if err == nil || ctx.Err() != nil { + return cmp.Or(ctx.Err(), err) + } + select { + case <-waiting.Done(): + return err + case <-time.After(r.site.settings.Checks.PollInterval): + } + } +} + +// createAdmin creates the first administrator with account:create-admin as gophenberg, the password on its input. +func (r *installing) createAdmin(ctx context.Context, _ step) error { + program := siteCommand(r.site.settings.Root, "account:create-admin", "-email", r.admin.email, "-name", + r.admin.name, "-role", adminRole) + program.Stdin = r.admin.password + "\n" + printed, err := r.machine.Run(ctx, program) + if strings.Contains(printed, emailTaken) { + return r.takenByAdmin(ctx) + } + r.created = err == nil + return withPrinted(err, printed) +} + +// takenByAdmin accepts the email account:create-admin found taken once account:list lists that email under admin. +func (r *installing) takenByAdmin(ctx context.Context) error { + listed, err := listedAccounts(ctx, r.machine, r.site.settings.Root) + if err != nil || slices.Contains(listed, account{Email: r.admin.email, Role: adminRole}) { + return err + } + return fmt.Errorf("%s already belongs to an account that is not an administrator, so run sudo gophenberg "+ + "server:install again and give the first administrator another email", r.admin.email) +} + +// https asks for the admin page over HTTPS within its wait, warning and leaving the step open when it never answers. +func (r *installing) https(ctx context.Context, _ step) error { + settings := r.site.settings + address := "https://" + settings.Domain + "/admin/" + err := r.within(ctx, settings.Checks.HTTPSWait, func(ctx context.Context) error { + return r.askHTTPS(ctx, address) + }) + if err == nil || ctx.Err() != nil { + return err + } + _, warnErr := fmt.Fprintf(r.call.Stderr, "warning: %s did not answer 200 within %s, the time %s allows, as at the "+ + "last try %v. The site runs, so check that the hosting provider's firewall lets TCP 80, TCP 443 and UDP 443 "+ + "in, then read sudo journalctl -u caddy, and the next sudo gophenberg server:install checks again\n", address, + settings.Checks.HTTPSWait, r.site.env.Key("INSTALL_HTTPS_WAIT"), err) + return cmp.Or(warnErr, errLeftOpen) +} + +// askHTTPS asks for address over HTTPS once, refusing any answer but 200. +func (r *installing) askHTTPS(ctx context.Context, address string) error { + roots, err := r.httpsRoots() + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) + var response *http.Response + if err == nil { + response, err = r.machine.FetchHTTPS(request, roots) + } + if err != nil { + return fmt.Errorf("it could not be reached: %w", err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("it answered %d", response.StatusCode) + } + return nil +} + +// httpsRoots returns the roots the HTTPS check trusts: Caddy's local root for localhost, read again, else the system's. +func (r *installing) httpsRoots() (*x509.CertPool, error) { + if r.site.settings.Domain != "localhost" { + return nil, nil + } + home, err := r.caddyHome() + if err != nil { + return nil, err + } + path := filepath.Join(home, caddyLocalRoot) + held, err := r.machine.localRoot(path) + if err != nil || held == nil { + return nil, cmp.Or(err, fmt.Errorf("the local root Caddy signs localhost with was not at %s yet", path)) + } + roots := x509.NewCertPool() + if len(held) > maxLocalRootBytes || !roots.AppendCertsFromPEM(held) { + return nil, fmt.Errorf("%s holds no certificate", path) + } + return roots, nil +} + +// localRoot reads up to one byte past maxLocalRootBytes at path, nil when absent, refusing a link or a pipe at once. +func (m Machine) localRoot(path string) ([]byte, error) { + file, err := absentAsNil(os.OpenFile(filepath.Join(m.Root, path), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, + 0)) + if err != nil || file == nil { + return nil, err + } + defer func() { _ = file.Close() }() + if err := regularFile(file.Stat()); err != nil { + return nil, fmt.Errorf("installer: read %s: %w", path, err) + } + return io.ReadAll(io.LimitReader(file, maxLocalRootBytes+1)) +} + +// regularFile refuses what info, or the err of describing it, shows to be a pipe, a device or anything but a file. +func regularFile(info fs.FileInfo, err error) error { + if err == nil && !info.Mode().IsRegular() { + return errors.New("it is not a regular file") + } + return err +} + +// summary prints where the site runs, how to sign in and the commands to know, the step that ends the install record. +func (r *installing) summary(_ context.Context, _ step) error { + settings := r.site.settings + current, err := updater.Layout{Root: filepath.Join(r.machine.Root, settings.Root)}.Named(updater.Current) + if err != nil { + return err + } + _, err = io.WriteString(r.call.Stdout, "\n"+r.signIn(current)+commandsToKnow) + return err +} + +// signIn returns the lines of the summary that say where the build current answers and where to sign in to it. +func (r *installing) signIn(current string) string { + settings := r.site.settings + admin := "https://" + settings.Domain + "/admin/" + if r.created { + admin += " as " + r.admin.email + } + if !settings.withCaddy() { + return fmt.Sprintf("Gophenberg %s answers on %s, the %s the site listens on.\nOnce your own web server sends %s "+ + "there, sign in at %s.\n", current, settings.Addr, r.site.env.Key("ADDR"), settings.Domain, admin) + } + return fmt.Sprintf("Gophenberg %s runs at https://%s. Sign in at %s.\n", current, settings.Domain, admin) + + firewallLine +} + +// printWebServer prints the lines the person's web server needs, the same text the dry run prints. +func (r *installing) printWebServer(_ context.Context, planned step) error { + _, err := io.WriteString(r.call.Stdout, "\n"+planned.prints) + return err +} diff --git a/internal/installer/service_steps_internal_test.go b/internal/installer/service_steps_internal_test.go new file mode 100644 index 00000000..3995ae8f --- /dev/null +++ b/internal/installer/service_steps_internal_test.go @@ -0,0 +1,1119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net/http" + "net/netip" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + "testing/synctest" + "time" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/gophenberg/internal/version" +) + +// checkLine is the line the site-env step runs check with, as gophenberg in the default install root. +const checkLine = "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg check" + +// caddyValidateLine is the line the caddy step checks Caddy's files with, as the caddy user. +const caddyValidateLine = "runuser -u caddy -- caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile" + +// createAdminLine is the line the admin step creates Maria Perez as the first administrator with. +const createAdminLine = "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email " + + "admin@example.com -name Maria Perez -role admin" + +// mariaPerez is the first administrator the tests answer for. +var mariaPerez = admin{email: "admin@example.com", name: "Maria Perez", password: goodPassword} + +// servedOn returns the run that takes the steps of planned on server, its user and its install root already made. +func servedOn(t *testing.T, server *fakeServer, planned site) (*installing, *strings.Builder) { + t.Helper() + check := server.check() + check.settings, check.record = planned.settings, planned.record + answer := &strings.Builder{} + running, err := installingOn(check, planned, gonsole.Call{Stdout: answer, Stderr: &server.notes}) + if err != nil { + t.Fatalf("installingOn() = %v, want the run ready", err) + } + t.Cleanup(running.close) + for _, id := range []string{"user", "install-root"} { + if err := running.take(t.Context(), plannedStep(t, running, id)); err != nil { + t.Fatalf("take(%s) = %v, want it taken", id, err) + } + } + server.forgetRun() + return running, answer +} + +// laterSite returns the site of a later run for example.com, whose record holds what an earlier run installed. +func laterSite(t *testing.T) site { + t.Helper() + planned := firstSite(t, "example.com", 255) + planned.current = true + planned.record = Record{ + Installer: "0.0.1", Domain: "example.com", Caddyfile: caddyfileSum(wantedCaddyfile), + Installed: []string{piecePostgreSQL, pieceCaddy, pieceService, "/opt/gophenberg"}, + Steps: map[string]string{stepCaddy: stepEnded, stepStart: stepEnded, stepAdmin: stepEnded}, + } + return planned +} + +// wantMark fails t unless the install record on server marks the step called id as mark. +func wantMark(t *testing.T, server *fakeServer, id, mark string) { + t.Helper() + if got := marks(t, server)[id]; got != mark { + t.Errorf("the record marks the step %s %q, want %s", id, got, mark) + } +} + +func TestEveryStepOfEveryRunHasAnApply(t *testing.T) { + t.Parallel() + + for _, planned := range []site{firstSite(t, "example.com", 255), ownSite(t, "example.com"), laterSite(t)} { + for _, taken := range planned.steps() { + if taken.apply == nil { + t.Errorf("the step %s has no apply", taken.id) + } + } + } +} + +func TestTheSiteEnvStepWritesTheSitesSettingsAsGophenbergsThenRunsCheckInTheRoot(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, "site-env")) + + if err != nil { + t.Fatalf("take(site-env) = %v, want .env written and checked", err) + } + wantText(t, server.at(siteEnvAt), wantedSiteEnv) + wantEntry(t, server.at(siteEnvAt), 0o600, &owner{uid: os.Getuid(), gid: siteGroup(t)}) + wantRan(t, server, checkLine) + if dir := server.runs[0].Dir; dir != "/opt/gophenberg" { + t.Errorf("check ran in %q, want /opt/gophenberg", dir) + } +} + +func TestTheSiteEnvStepStopsWithWhatCheckPrinted(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.answers["gophenberg check"] = failed("/opt/gophenberg/current/gophenberg", "GOPHENBERG_ADDR: taken\n") + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, "site-env")) + + wantRefused(t, err, "installer: run /opt/gophenberg/current/gophenberg: exit status 1, after it printed:\n"+ + "GOPHENBERG_ADDR: taken") + wantMark(t, server, "site-env", stepStarted) +} + +func TestTheUnitsStepOwnsTheServiceAndWritesTheUnitsBeforeItReloadsSystemd(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + var owned, written bool + server.effects["systemctl"] = func(program Program) answer { + if program.Args[0] == "daemon-reload" { + record, _ := readRecord(server.machine()) + owned, written = record.owns(pieceService), server.machine().there("/etc/systemd/system/gophenberg.service") + } + return server.systemctl(program) + } + + err := running.take(t.Context(), plannedStep(t, running, "units")) + + if err != nil { + t.Fatalf("take(units) = %v, want the units written", err) + } + wantRan(t, server, "systemctl daemon-reload", "systemctl enable gophenberg.service") + if !owned || !written { + t.Errorf("daemon-reload found the record owning the service %t and the unit written %t, want both", owned, + written) + } + for _, unit := range unitsOf(wantedSiteUnit) { + wantText(t, server.at(unit.path), unit.text) + wantEntry(t, server.at(unit.path), 0o644, nil) + } +} + +func TestTheUnitsStepOwnsTheServiceInTheRecordBeforeItWritesTheUnits(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder("/etc/systemd/system/gophenberg-rollback.service") + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, "units")) + + want := "installer: write /etc/systemd/system/gophenberg-rollback.service: " + if got := fmt.Sprint(err); !strings.HasPrefix(got, want) { + t.Errorf("take(units) = %v, want the rollback unit refused", err) + } + if record, _ := readRecord(server.machine()); !record.owns(pieceService) { + t.Errorf("the record owns %q, want %s among them", record.Installed, pieceService) + } + wantRan(t, server) +} + +func TestTheUnitsStepStopsWhenSystemdCannotReload(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + server.effects["systemctl"] = func(Program) answer { return failed("systemctl", "Access denied\n") } + + err := running.take(t.Context(), plannedStep(t, running, "units")) + + wantRefused(t, err, "installer: run systemctl: exit status 1, after it printed:\nAccess denied") + wantRan(t, server, "systemctl daemon-reload") +} + +func TestTheCommandStepWritesTheCommandOnThePath(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, "command")) + + if err != nil { + t.Fatalf("take(command) = %v, want the command written", err) + } + wantText(t, server.at(commandPath), wantedCommand) + wantEntry(t, server.at(commandPath), 0o755, nil) +} + +func TestTheCaddyStepOfAFirstRunKeepsThePackagesCaddyfileThenRestartsCaddy(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if err != nil { + t.Fatalf("take(caddy) = %v, want Caddy set up", err) + } + wantText(t, server.at(keptCaddyfilePath), packageCaddyfile) + for _, written := range caddyFilesOf(wantedCaddySite, wantedCaddyfile) { + wantText(t, server.at(written.path), written.text) + wantEntry(t, server.at(written.path), 0o644, nil) + } + wantRan(t, server, caddyValidateLine, "systemctl restart caddy.service") + if env := server.runs[0].Env; !slices.Equal(env, []string{"HOME=/var/lib/caddy"}) { + t.Errorf("caddy validate ran with the settings %q, want HOME=/var/lib/caddy alone", env) + } + record, err := readRecord(server.machine()) + if err != nil || record.Caddyfile != caddyfileSum(wantedCaddyfile) { + t.Errorf("the record holds the Caddyfile sum %q, %v, want that of the Caddyfile written", record.Caddyfile, err) + } +} + +func TestTheCaddyStepOfAFirstRunSavesTheSumBeforeCaddyRestarts(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + var saved string + server.effects["systemctl"] = func(Program) answer { + record, _ := readRecord(server.machine()) + saved = record.Caddyfile + return failed("systemctl", "Job for caddy.service failed.\n") + } + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + wantRefused(t, err, "installer: run systemctl: exit status 1, after it printed:\nJob for caddy.service failed.") + wantText(t, server.at(caddyfilePath), wantedCaddyfile) + if saved != caddyfileSum(wantedCaddyfile) { + t.Errorf("the restart found the Caddyfile sum %q in the record, want that of the Caddyfile written, so the "+ + "next run keeps the Caddyfile", saved) + } + wantMark(t, server, stepCaddy, stepStarted) +} + +func TestTheCaddyStepOfARunAfterAFailedRestartKeepsThePersonsCaddyfileAndRestartsCaddy(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.write(keptCaddyfilePath, packageCaddyfile) + server.write(caddyfilePath, wantedCaddyfile+personSite) + planned := laterSite(t) + planned.record.Steps[stepCaddy] = stepStarted + running, _ := servedOn(t, server, planned) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if err != nil { + t.Fatalf("take(caddy) = %v, want Caddy restarted", err) + } + wantText(t, server.at(caddyfilePath), wantedCaddyfile+personSite) + wantText(t, server.at(keptCaddyfilePath), packageCaddyfile) + wantRan(t, server, caddyValidateLine, "systemctl restart caddy.service") + wantMark(t, server, stepCaddy, stepEnded) +} + +func TestTheCaddyStepKeepsTheCaddyfileACutOffRunAlreadyKept(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.write(keptCaddyfilePath, packageCaddyfile) + server.write(caddyfilePath, wantedCaddyfile) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if err != nil { + t.Fatalf("take(caddy) = %v, want Caddy set up", err) + } + wantText(t, server.at(keptCaddyfilePath), packageCaddyfile) + wantText(t, server.at(caddyfilePath), wantedCaddyfile) +} + +func TestTheCaddyStepNeverKeepsACaddyfileThatImportsGophenbergsSiteAsThePackages(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.write(caddyfilePath, wantedLocalCaddyfile+personSite) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if err != nil { + t.Fatalf("take(caddy) = %v, want Caddy set up", err) + } + wantText(t, server.at(caddyfilePath), wantedCaddyfile) + if server.machine().there(keptCaddyfilePath) { + t.Errorf("the run kept a Caddyfile that imports %s as %s, want it never kept as the package's", + caddySitePath, keptCaddyfilePath) + } +} + +func TestTheCaddyStepWritesTheCaddyfileWhereThePackageLeftNone(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.remove(caddyfilePath) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if err != nil { + t.Fatalf("take(caddy) = %v, want Caddy set up", err) + } + wantText(t, server.at(caddyfilePath), wantedCaddyfile) + if server.machine().there(keptCaddyfilePath) { + t.Errorf("the run kept a %s where the package left no Caddyfile", keptCaddyfilePath) + } +} + +func TestTheCaddyStepOfALaterRunLeavesTheCaddyfileAloneAndReloadsCaddy(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.running["caddy.service"] = true + own := wantedCaddyfile + mistypedSite + server.write(caddyfilePath, strings.Replace(own, "respnd", "respond", 1)) + running, _ := servedOn(t, server, laterSite(t)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if err != nil { + t.Fatalf("take(caddy) = %v, want Caddy reloaded", err) + } + wantText(t, server.at(caddyfilePath), strings.Replace(own, "respnd", "respond", 1)) + wantText(t, server.at(caddySitePath), wantedCaddySite) + wantRan(t, server, caddyValidateLine, "systemctl reload caddy.service") + if server.machine().there(keptCaddyfilePath) { + t.Errorf("a later run kept a %s, want the Caddyfile left alone", keptCaddyfilePath) + } +} + +func TestTheCaddyStepStopsBeforeCaddyRestartsWhenCaddyValidateRefusesTheFiles(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.answers["caddy"] = failed("caddy", "Error: unrecognized directive: lb_try_duration\n") + delete(server.effects, "caddy") + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + wantRefused(t, err, "caddy validate refused Caddy's files, so Caddy runs on with the settings it has: installer: "+ + "run caddy: exit status 1, after it printed:\nError: unrecognized directive: lb_try_duration") + wantRan(t, server, caddyValidateLine) + if record, _ := readRecord(server.machine()); record.Caddyfile != "" { + t.Errorf("the record holds the Caddyfile sum %q after caddy validate refused, want none", record.Caddyfile) + } +} + +func TestTheCaddyStepStopsBeforeCaddyValidateWhenItCannotWriteCaddysFiles(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.write(maintenanceFolder, "a file where the folder goes\n") + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write "+maintenanceFolder+"/index.html: ") { + t.Errorf("take(caddy) = %v, want the maintenance page refused", err) + } + wantRan(t, server) +} + +func TestTheCaddyStepRefusesAServerWithoutTheCaddyUser(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + delete(server.users, pieceCaddy) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + wantRefused(t, err, "installer: user: unknown user caddy") +} + +func TestTheCaddyStepRefusesAPackageCaddyfileItCannotKeep(t *testing.T) { + t.Parallel() + + skipAsRoot(t) + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.chmod("/etc/caddy", 0o555) + t.Cleanup(func() { server.chmod("/etc/caddy", 0o755) }) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: keep the caddy package's Caddyfile as "+ + keptCaddyfilePath+": ") { + t.Errorf("take(caddy) = %v, want the rename refused", err) + } +} + +func TestTheCaddyStepRefusesACaddyFolderItCannotDescribe(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.remove("/etc/caddy") + server.write("/etc/caddy", "a file where the folder goes\n") + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, stepCaddy)) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: keep the caddy package's Caddyfile as ") { + t.Errorf("take(caddy) = %v, want the Lstat refused", err) + } +} + +func TestTheStartStepStartsTheSiteAndWaitsUntilItsPagesAnswer(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + started := time.Now() + server.answerFetch = func(request *http.Request) (*http.Response, error) { + if time.Since(started) < 10*time.Second { + return nil, errors.New("dial tcp 127.0.0.1:8081: connect: connection refused") + } + return server.sitePage(request) + } + + err := running.take(t.Context(), plannedStep(t, running, stepStart)) + + if err != nil || time.Since(started) != 10*time.Second { + t.Errorf("take(start) = %v after %s, want the pages answered after 10s", err, time.Since(started)) + } + wantRan(t, server, "systemctl reset-failed gophenberg.service", "systemctl start gophenberg.service") + want := []string{"127.0.0.1:8081 http://127.0.0.1:8081/admin/", "127.0.0.1:8081 http://127.0.0.1:8081/api/locale", + "127.0.0.1:8081 http://127.0.0.1:8081/"} + if got := server.fetched[len(server.fetched)-3:]; len(server.fetched) != 13 || !slices.Equal(got, want) { + t.Errorf("the start step fetched %q, want ten tries of /admin/, then the three pages", server.fetched) + } + }) +} + +func TestTheStartStepRestartsASiteWhoseUnitsAnEarlierRunWrote(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + cutOff := firstSite(t, "example.com", 255) + cutOff.record = Record{Installed: []string{pieceService}, Steps: map[string]string{stepStart: stepStarted}} + running, _ := servedOn(t, server, cutOff) + + err := running.take(t.Context(), plannedStep(t, running, stepStart)) + + if err != nil { + t.Fatalf("take(start) = %v, want the site restarted", err) + } + wantRan(t, server, "systemctl reset-failed gophenberg.service", "systemctl restart gophenberg.service") +} + +func TestTheStartStepStopsOnceTheSiteDoesNotAnswerWithinTheStartWait(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + server.siteDown = true + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + started := time.Now() + + err := running.take(t.Context(), plannedStep(t, running, stepStart)) + + wantRefused(t, err, "gophenberg.service did not pass its start check within 1m0s, the time "+ + "GOPHENBERG_UPDATE_VERIFY_TIMEOUT allows, as at the last try it could not ask for /admin/: dial tcp "+ + "127.0.0.1:8081: connect: connection refused, so read why with sudo journalctl -u gophenberg") + if waited := time.Since(started); waited != time.Minute { + t.Errorf("the start step waited %s, want 1m0s", waited) + } + wantMark(t, server, stepStart, stepStarted) + }) +} + +func TestTheStartStepStopsWhenSystemdCannotStartTheSite(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + server.effects["systemctl"] = func(Program) answer { return failed("systemctl", "Unit not found\n") } + + err := running.take(t.Context(), plannedStep(t, running, stepStart)) + + wantRefused(t, err, "installer: run systemctl: exit status 1, after it printed:\nUnit not found") + if len(server.fetched) != 0 { + t.Errorf("the start step fetched %q once systemd refused, want nothing", server.fetched) + } +} + +func TestTheStartStepEndsAtOnceWhenTheRunEnds(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.siteDown = true + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := running.start(ctx, plannedStep(t, running, stepStart)) + + if !errors.Is(err, context.Canceled) { + t.Errorf("start() = %v, want the run's own end", err) + } +} + +func TestTheAdminStepCreatesTheFirstAdministratorAsGophenbergWithThePasswordOnItsInput(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.admin = mariaPerez + + err := running.take(t.Context(), plannedStep(t, running, stepAdmin)) + + if err != nil || !running.created { + t.Fatalf("take(admin) = %v, created %t, want Maria Perez created", err, running.created) + } + wantRan(t, server, createAdminLine) + want := [2]string{"/opt/gophenberg", goodPassword + "\n"} + if got := [2]string{server.runs[0].Dir, server.runs[0].Stdin}; got != want { + t.Errorf("account:create-admin ran in %q with %q on its input, want /opt/gophenberg and the password", got[0], + got[1]) + } +} + +func TestTheAdminStepTakesAnEmailAlreadyTakenAsMade(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.accounts = []listedAccount{{Email: "admin@example.com", Role: "admin"}} + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.admin = mariaPerez + + err := running.take(t.Context(), plannedStep(t, running, stepAdmin)) + + if err != nil || running.created { + t.Errorf("take(admin) = %v, created %t, want the account taken as made by an earlier run", err, running.created) + } + wantMark(t, server, stepAdmin, stepEnded) +} + +func TestTheAdminStepStopsWhenTheEmailBelongsToAnAccountUnderAnotherRole(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.accounts = []listedAccount{{Email: "admin@example.com", Role: "editor"}} + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.admin = mariaPerez + + err := running.take(t.Context(), plannedStep(t, running, stepAdmin)) + + wantRefused(t, err, "admin@example.com already belongs to an account that is not an administrator, so run sudo "+ + "gophenberg server:install again and give the first administrator another email") + if running.created { + t.Error("the run says it created the administrator, want none created") + } + wantMark(t, server, stepAdmin, stepStarted) +} + +func TestTheAdminStepStopsWhenTheEmailBelongsToAnEditorWhileAnotherEmailIsUnderAdmin(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.accounts = []listedAccount{ + {Email: "owner@example.com", Role: "admin"}, {Email: "admin@example.com", Role: "editor"}, + } + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.admin = mariaPerez + + err := running.take(t.Context(), plannedStep(t, running, stepAdmin)) + + wantRefused(t, err, "admin@example.com already belongs to an account that is not an administrator, so run sudo "+ + "gophenberg server:install again and give the first administrator another email") + wantMark(t, server, stepAdmin, stepStarted) +} + +func TestTheAdminStepStopsWhenAccountListFailsOnAnEmailAlreadyTaken(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.accounts = []listedAccount{{Email: "admin@example.com", Role: "admin"}} + server.effects["gophenberg account:list"] = func(program Program) answer { + return failed(program.Name, "database: connection refused\n") + } + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.admin = mariaPerez + + err := running.take(t.Context(), plannedStep(t, running, stepAdmin)) + + wantRefused(t, err, "installer: run /opt/gophenberg/current/gophenberg: exit status 1, after it printed:\n"+ + "database: connection refused") + wantMark(t, server, stepAdmin, stepStarted) +} + +func TestTheAdminStepStopsWithWhatCreateAdminPrinted(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.effects["gophenberg account:create-admin"] = func(program Program) answer { + return failed(program.Name, "Password: unknown role admin\n") + } + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.admin = mariaPerez + + err := running.take(t.Context(), plannedStep(t, running, stepAdmin)) + + wantRefused(t, err, "installer: run /opt/gophenberg/current/gophenberg: exit status 1, after it printed:\n"+ + "Password: unknown role admin") + wantMark(t, server, stepAdmin, stepStarted) +} + +func TestTheHTTPSStepTrustsTheSystemsRootsForARealDomain(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.running["caddy.service"] = true + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, "https")) + + if err != nil || !slices.Equal(server.fetchedHTTPS, []string{"https://example.com/admin/ with roots false"}) { + t.Errorf("take(https) = %v, fetching %q, want https://example.com/admin/ once with the system's roots", err, + server.fetchedHTTPS) + } + wantMark(t, server, "https", stepEnded) +} + +func TestTheHTTPSStepReadsCaddysLocalRootAgainOnEachTryForLocalhost(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + server.installPackage(pieceCaddy) + server.running["caddy.service"] = true + running, _ := servedOn(t, server, firstSite(t, "localhost", 255)) + time.AfterFunc(5*time.Second, func() { server.write(localRootAt, fakeCaddyRoot()) }) + + err := running.take(t.Context(), plannedStep(t, running, "https")) + + if err != nil || len(server.fetchedHTTPS) != 1 { + t.Fatalf("take(https) = %v, fetching %q, want one fetch once Caddy made its local root", err, + server.fetchedHTTPS) + } + if !server.httpsRoots[0].Equal(fakeCaddyRoots()) { + t.Error("the HTTPS check trusted other roots than Caddy's local root") + } + }) +} + +// httpsWarning is the warning of an HTTPS check whose every try ended as last says. +func httpsWarning(last string) string { + return "warning: https://example.com/admin/ did not answer 200 within 2m0s, the time GOPHENBERG_INSTALL_HTTPS_WAIT " + + "allows, as at the last try " + last + ". The site runs, so check that the hosting provider's firewall lets " + + "TCP 80, TCP 443 and UDP 443 in, then read sudo journalctl -u caddy, and the next sudo gophenberg " + + "server:install checks again\n" +} + +func TestTheHTTPSStepWarnsAndLeavesTheStepOpenOnceHTTPSDoesNotAnswerWithinItsWait(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + server.answerHTTPS = answered(http.StatusBadGateway, "") + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + started := time.Now() + + err := running.take(t.Context(), plannedStep(t, running, "https")) + + if err != nil || time.Since(started) != 2*time.Minute { + t.Errorf("take(https) = %v after %s, want nil after 2m0s", err, time.Since(started)) + } + if got, want := server.notes.String(), httpsWarning("it answered 502"); got != want { + t.Errorf("the run noted %q, want %q", got, want) + } + wantMark(t, server, "https", stepStarted) + }) +} + +func TestTheHTTPSStepTriesAgainPastACaddyThatCannotBeReached(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.take(t.Context(), plannedStep(t, running, "https")) + + want := httpsWarning("it could not be reached: dial tcp example.com:443: connect: connection refused") + if got := server.notes.String(); err != nil || got != want { + t.Errorf("take(https) = %v, noting %q, want nil and %q", err, got, want) + } + }) +} + +func TestTheHTTPSStepTriesAgainPastALocalRootThatIsMissingOrHoldsNoCertificate(t *testing.T) { + t.Parallel() + + for held, last := range map[string]string{ + "": "the local root Caddy signs localhost with was not at " + localRootAt + " yet", + "not a certificate": localRootAt + " holds no certificate", + } { + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + server.installPackage(pieceCaddy) + if held != "" { + server.write(localRootAt, held) + } + running, _ := servedOn(t, server, firstSite(t, "localhost", 255)) + + err := running.https(t.Context(), plannedStep(t, running, "https")) + + want := strings.Replace(httpsWarning(last), "https://example.com/", "https://localhost/", 1) + if got := server.notes.String(); !errors.Is(err, errLeftOpen) || got != want { + t.Errorf("https() = %v, noting %q, want the step left open and %q", err, got, want) + } + }) + } +} + +// localRootAt is where the fake Caddy keeps the local root it signs localhost with, named from the top of the server. +const localRootAt = "/var/lib/caddy/" + caddyLocalRoot + +// localhostOn returns the run that takes the steps of a first run for localhost on server, which Caddy is on. +func localhostOn(t *testing.T, server *fakeServer) *installing { + t.Helper() + server.installPackage(pieceCaddy) + running, _ := servedOn(t, server, firstSite(t, "localhost", 255)) + return running +} + +func TestTheHTTPSCheckRefusesALocalRootThatIsALink(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := localhostOn(t, server) + server.write("/var/lib/caddy/elsewhere.crt", fakeCaddyRoot()) + server.link(localRootAt, "/var/lib/caddy/elsewhere.crt") + + roots, err := running.httpsRoots() + + wantRefused(t, err, "installer: open "+server.at(localRootAt)+": too many levels of symbolic links") + if roots != nil { + t.Error("httpsRoots() trusted the root a link leads to, want no roots") + } +} + +func TestTheHTTPSCheckRefusesALocalRootThatIsAPipeWithoutWaitingOnIt(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := localhostOn(t, server) + server.folder(filepath.Dir(localRootAt)) + if err := syscall.Mkfifo(server.at(localRootAt), 0o600); err != nil { + t.Fatalf("planting the pipe: %v", err) + } + read := make(chan error, 1) + + go func() { + _, err := running.httpsRoots() + read <- err + }() + + select { + case err := <-read: + wantRefused(t, err, "installer: read "+localRootAt+": it is not a regular file") + case <-time.After(2 * time.Second): + t.Error("httpsRoots() still waits on a pipe after 2s, want the pipe refused at once") + } +} + +func TestTheHTTPSCheckFindsNoCertificateInALocalRootPastItsBound(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running := localhostOn(t, server) + server.write(localRootAt, fakeCaddyRoot()+strings.Repeat("\n", maxLocalRootBytes)) + + _, err := running.httpsRoots() + + wantRefused(t, err, localRootAt+" holds no certificate") +} + +func TestTheHTTPSStepRefusesALocalhostWithoutTheCaddyUser(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "localhost", 255)) + + err := running.https(t.Context(), plannedStep(t, running, "https")) + + want := strings.Replace(httpsWarning("installer: user: unknown user caddy"), "https://example.com/", + "https://localhost/", 1) + if got := server.notes.String(); !errors.Is(err, errLeftOpen) || got != want { + t.Errorf("https() = %v, noting %q, want the step left open and %q", err, got, want) + } + }) +} + +func TestTheHTTPSStepEndsAtOnceWhenTheRunEnds(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := running.https(ctx, plannedStep(t, running, "https")) + + if !errors.Is(err, context.Canceled) || server.notes.String() != "" { + t.Errorf("https() = %v, noting %q, want the run's own end and no warning", err, server.notes.String()) + } +} + +func TestTheHTTPSStepReturnsWhatStopsItsWarning(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + server := newFakeServer(t) + running, _ := servedOn(t, server, firstSite(t, "example.com", 255)) + running.call.Stderr = refusingWriter{} + + err := running.https(t.Context(), plannedStep(t, running, "https")) + + wantRefused(t, err, "the terminal is gone") + }) +} + +// wantedCommandsToKnow are the last lines of every summary. +const wantedCommandsToKnow = `Commands to know: + sudo gophenberg update:status shows the build that runs and the update on offer + sudo gophenberg update:apply installs the update on offer + sudo journalctl -u gophenberg shows what the site logged + sudo gophenberg server:uninstall removes Gophenberg and keeps its data +` + +func TestTheSummaryNamesTheBuildTheAddressAndTheEmailOfTheAdministratorTheRunCreated(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, answer := servedOn(t, server, firstSite(t, "example.com", 255)) + server.pointCurrent() + running.admin, running.created = mariaPerez, true + + err := running.summary(t.Context(), plannedStep(t, running, "summary")) + + want := "\nGophenberg 0.19.0 runs at https://example.com. Sign in at https://example.com/admin/ as " + + "admin@example.com.\nThe hosting provider's firewall must let TCP 80, TCP 443 and UDP 443 in for visitors " + + "to reach the site.\n" + wantedCommandsToKnow + if err != nil || answer.String() != want { + t.Errorf("summary() = %v, printing\n%s\nwant\n%s", err, answer.String(), want) + } +} + +func TestTheSummaryWithoutCaddyNamesTheSiteAddressAndSignsInOnceTheirOwnWebServerSendsTheDomainThere(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + moved := ownSite(t, "blog.example.com") + moved.settings.Addr = netip.MustParseAddrPort("[::1]:8090") + running, answer := servedOn(t, server, moved) + server.pointCurrent() + running.admin, running.created = mariaPerez, true + + err := running.summary(t.Context(), plannedStep(t, running, "summary")) + + want := "\nGophenberg 0.19.0 answers on [::1]:8090, the GOPHENBERG_ADDR the site listens on.\nOnce your own web " + + "server sends blog.example.com there, sign in at https://blog.example.com/admin/ as admin@example.com.\n" + + wantedCommandsToKnow + if err != nil || answer.String() != want { + t.Errorf("summary() = %v, printing\n%s\nwant\n%s", err, answer.String(), want) + } +} + +func TestTheSummaryOfARunThatCreatedNoAdministratorWithoutCaddyNamesNoEmailAndNoFirewall(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, answer := servedOn(t, server, ownSite(t, "example.com")) + server.pointCurrent() + + err := running.summary(t.Context(), plannedStep(t, running, "summary")) + + want := "\nGophenberg 0.19.0 answers on 127.0.0.1:8081, the GOPHENBERG_ADDR the site listens on.\nOnce your own web " + + "server sends example.com there, sign in at https://example.com/admin/.\n" + wantedCommandsToKnow + if err != nil || answer.String() != want { + t.Errorf("summary() = %v, printing\n%s\nwant\n%s", err, answer.String(), want) + } +} + +func TestTheSummaryStopsWhenCurrentNamesNoBuild(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, answer := servedOn(t, server, firstSite(t, "example.com", 255)) + + err := running.summary(t.Context(), plannedStep(t, running, "summary")) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "updater: read the current link: ") || answer.Len() != 0 { + t.Errorf("summary() = %v, printing %q, want the link refused and nothing printed", err, answer.String()) + } +} + +func TestTheWebServerStepPrintsTheLinesItsDryRunPrints(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, answer := servedOn(t, server, ownSite(t, "example.com")) + + err := running.take(t.Context(), plannedStep(t, running, "web-server")) + + if err != nil || answer.String() != "\n"+wantedWebServerText { + t.Errorf("take(web-server) = %v, printing\n%s\nwant\n%s", err, answer.String(), wantedWebServerText) + } +} + +// flaggedRunOn puts this installer's archive and the password file on server, and returns a run every flag answers. +func flaggedRunOn(t *testing.T, server *fakeServer) Request { + t.Helper() + server.write(archivePath, releaseArchiveOf(t, version.Version(), serverPlatform)) + server.write(passwordFile, goodPassword+"\n") + return Request{ + Domain: "example.com", Archive: archivePath, AdminEmail: mariaPerez.email, AdminName: mariaPerez.name, + AdminPasswordFile: passwordFile, + } +} + +func TestInstallWithoutDryRunTakesEveryStepAndEndsTheRecord(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + + stdout, _, err := runInstall(server, flaggedRunOn(t, server)) + + if err != nil { + t.Fatalf("Install() = %v, want every step taken", err) + } + for n, does := range firstSteps { + if line := fmt.Sprintf("Step %d: %s\n", n+1, does); !strings.Contains(stdout, line) { + t.Errorf("Install() printed\n%s\nwant the line %q", stdout, line) + } + } + for _, id := range stepIDs { + wantMark(t, server, id, stepEnded) + } + if !slices.Equal(server.accounts, []listedAccount{{Email: "admin@example.com", Name: "Maria Perez", Role: "admin"}}) { + t.Errorf("the run made the accounts %+v, want Maria Perez alone", server.accounts) + } +} + +// cutOffInAdmin returns a fake server an earlier run for example.com left cut off in the admin step, with accounts. +func cutOffInAdmin(t *testing.T, accounts ...listedAccount) *fakeServer { + t.Helper() + scenario := &installScenario{t: t, server: newFakeServer(t)} + scenario.installEarlier() + record, err := readRecord(scenario.server.machine()) + if err != nil { + t.Fatalf("readRecord() = %v, want the earlier run's record", err) + } + record.Steps[stepAdmin] = stepStarted + scenario.server.saveRecord(record) + scenario.server.accounts = accounts + return scenario.server +} + +func TestInstallAsksNothingOnceAccountListShowsTheCutOffRunMadeAnAdministrator(t *testing.T) { + t.Parallel() + + server := cutOffInAdmin(t, listedAccount{Email: "admin@example.com", Role: "admin"}) + answering(server, "admin@example.com") + + stdout, _, err := runInstall(server, Request{}) + + if err != nil || strings.Contains(stdout, firstSteps[12]) { + t.Errorf("Install() = %v, printing\n%s\nwant every step but the admin step", err, stdout) + } + wantNoTerminal(t, server) + if server.createdAdmin() { + t.Error("the run ran account:create-admin, want the administrator the cut-off run made kept") + } + wantMark(t, server, stepAdmin, stepEnded) +} + +func TestInstallAsksForTheAdministratorWhenTheCutOffRunMadeNone(t *testing.T) { + t.Parallel() + + server := cutOffInAdmin(t, listedAccount{Email: "editor@example.com", Role: "editor"}) + prompt := answering(server, "admin@example.com", "Maria Perez", goodPassword, goodPassword) + + _, _, err := runInstall(server, Request{}) + + if err != nil || !server.createdAdmin() { + t.Errorf("Install() = %v, creating an admin %t, want the questions asked and the admin made", err, + server.createdAdmin()) + } + wantAsked(t, prompt, allAsked) +} + +func TestInstallReadsTheAccountsPastWhatTheSiteLogsBeforeThem(t *testing.T) { + t.Parallel() + + server := cutOffInAdmin(t, listedAccount{Email: "admin@example.com", Role: "admin"}) + server.effects["gophenberg account:list"] = func(program Program) answer { + listed := server.listAccounts(program) + listed.printed = "time=2026-10-27T09:00:00Z level=INFO msg=\"opened {the database}\"\n" + listed.printed + return listed + } + + _, _, err := runInstall(server, Request{}) + + if err != nil || server.createdAdmin() { + t.Errorf("Install() = %v, creating an admin %t, want the listed admin found", err, server.createdAdmin()) + } +} + +func TestInstallStopsWhenItCannotTellWhetherTheCutOffRunMadeTheAdministrator(t *testing.T) { + t.Parallel() + + for printed, want := range map[string]string{ + "listing\n": "cannot tell whether the run cut off in the admin step made the first administrator: " + + "account:list -json printed no accounts, so nothing was changed", + "{\n \"accounts\": [\n": "cannot tell whether the run cut off in the admin step made the first " + + "administrator: account:list -json printed accounts that cannot be read: unexpected EOF, so nothing was " + + "changed", + } { + server := cutOffInAdmin(t) + server.effects["gophenberg account:list"] = func(Program) answer { return answer{printed: printed} } + + _, _, err := runInstall(server, Request{}) + + wantRefused(t, err, want) + } +} + +func TestInstallStopsWhenAccountListFails(t *testing.T) { + t.Parallel() + + server := cutOffInAdmin(t) + server.effects["gophenberg account:list"] = func(program Program) answer { + return failed(program.Name, "database: connection refused\n") + } + + _, _, err := runInstall(server, Request{}) + + wantRefused(t, err, "cannot tell whether the run cut off in the admin step made the first administrator: "+ + "installer: run /opt/gophenberg/current/gophenberg: exit status 1, after it printed:\ndatabase: connection "+ + "refused, so nothing was changed") + wantRan(t, server, "systemctl --version", sandboxLine, clockLine, + "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:list -json") + wantMark(t, server, stepAdmin, stepStarted) +} + +func TestInstallStopsAtAStepThatFailsAndClosesWhatItOpened(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.effects["useradd"] = func(Program) answer { return failed("useradd", "useradd: cannot lock /etc/passwd\n") } + + _, _, err := runInstall(server, flaggedRunOn(t, server)) + + wantRefused(t, err, "installer: run useradd: exit status 1, after it printed:\nuseradd: cannot lock /etc/passwd") + wantMark(t, server, "user", stepStarted) +} + +func TestInstallRefusesASiteEnvServeCannotReadBeforeAnyStep(t *testing.T) { + t.Parallel() + + server := installedServer(t, "GOPHENBERG_SHUTDOWN_GRACE=soon\n") + + _, _, err := runInstall(server, flaggedRunOn(t, server)) + + wantRefused(t, err, `/opt/gophenberg/.env: GOPHENBERG_SHUTDOWN_GRACE: must be a duration like 30s, got "soon"`) + wantRan(t, server, "systemctl --version", sandboxLine, clockLine) +} + +func TestInstallRefusesAServerItCannotOpenAsATree(t *testing.T) { + t.Parallel() + + skipAsRoot(t) + server := newFakeServer(t) + request := flaggedRunOn(t, server) + if err := os.Chmod(server.root, 0o311); err != nil { + t.Fatalf("taking the right to list the top of the server: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(server.root, 0o755) }) + + _, _, err := runInstall(server, request) + + if !errors.Is(err, fs.ErrPermission) || server.machine().there(recordPath) { + t.Errorf("Install() = %v, want the server it cannot open refused before any change", err) + } +} diff --git a/internal/installer/settings.go b/internal/installer/settings.go index b2d77d1a..8d154730 100644 --- a/internal/installer/settings.go +++ b/internal/installer/settings.go @@ -103,6 +103,7 @@ type Checks struct { DomainCheckTimeout time.Duration HTTPSWait time.Duration PollInterval time.Duration + StartWait time.Duration } // withCaddy reports whether the run sets up Caddy in front of the site, not the person's own web server. @@ -112,7 +113,8 @@ func (s Settings) withCaddy() bool { // settingsOn reads one run's settings from the flags, env, server.env then the defaults, refusing a bad flag first. func settingsOn(machine Machine, env gonsole.Env, request Request) (Settings, error) { - flagged := errors.Join(domainFlag(request.Domain), archiveFlag(request.Archive), webServerFlag(request.WebServer)) + flagged := errors.Join(domainFlag(request.Domain), archiveFlag(request.Archive), webServerFlag(request.WebServer), + passwordFileFlag(request.AdminPasswordFile)) if flagged != nil { return Settings{}, flagged } @@ -121,7 +123,7 @@ func settingsOn(machine Machine, env gonsole.Env, request Request) (Settings, er return Settings{}, err } asked := layered(lookup(flagLines(env, request)), env.Getenv) - if err := keptWebServer(env, asked, file); err != nil { + if err := errors.Join(keptWebServer(env, asked, file), keptInstallRoot(env, asked, file)); err != nil { return Settings{}, err } given := layered(asked, lookup(file)) @@ -151,6 +153,14 @@ func archiveFlag(archive string) error { installerFolder, archive) } +// passwordFileFlag refuses an -admin-password-file that is given and is not an absolute path. +func passwordFileFlag(path string) error { + if path == "" || filepath.IsAbs(path) { + return nil + } + return fmt.Errorf("-admin-password-file: must be an absolute path, got %q", path) +} + // webServerFlag refuses a -web-server that is given and is neither caddy nor none. func webServerFlag(webServer string) error { if webServer == "" { @@ -180,14 +190,27 @@ func keptWebServer(env gonsole.Env, asked func(string) string, file map[string]s if wanted == kept { return nil } - return webServerMoved(env, kept, wanted) + return settingMoved(env, "INSTALL_WEB_SERVER", kept, wanted) +} + +// keptInstallRoot refuses a later run that asks for another install root than the one server.env file keeps. +func keptInstallRoot(env gonsole.Env, asked func(string) string, file map[string]string) error { + key := env.Key("INSTALL_ROOT") + kept, keptErr := installRoot(layered(lookup(file))(key)) + wanted, wantedErr := installRoot(asked(key)) + if keptErr != nil || wantedErr != nil { + return nil + } + if wanted == kept { + return nil + } + return settingMoved(env, "INSTALL_ROOT", kept, wanted) } -// webServerMoved refuses a later run that asks for the web server wanted on a server set up with kept. -func webServerMoved(env gonsole.Env, kept, wanted string) error { +// settingMoved refuses a later run that asks for wanted as the setting called name on a server set up with kept. +func settingMoved(env gonsole.Env, name, kept, wanted string) error { return fmt.Errorf("%s: this server was set up with %s, and a later run cannot move it to %s, so run sudo "+ - "gophenberg server:uninstall first, then change the line in %s", env.Key("INSTALL_WEB_SERVER"), kept, wanted, - serverEnvPath) + "gophenberg server:uninstall first, then change the line in %s", env.Key(name), kept, wanted, serverEnvPath) } // readServerEnv returns the settings server.env on machine holds, none before a first run wrote it. @@ -290,11 +313,12 @@ func checksFrom(env gonsole.Env) (Checks, error) { domain, domainErr := env.Duration("INSTALL_DOMAIN_CHECK_TIMEOUT", 10*time.Second) https, httpsErr := env.Duration("INSTALL_HTTPS_WAIT", 2*time.Minute) poll, pollErr := env.Duration("INSTALL_POLL_INTERVAL", time.Second) + start, startErr := env.Duration("UPDATE_VERIFY_TIMEOUT", time.Minute) checks := Checks{ MemoryFloorMB: memory, DiskFloorMB: disk, AptLockWait: apt, DomainCheckTimeout: domain, HTTPSWait: https, - PollInterval: poll, + PollInterval: poll, StartWait: start, } - return checks, errors.Join(memoryErr, diskErr, aptErr, domainErr, httpsErr, pollErr) + return checks, errors.Join(memoryErr, diskErr, aptErr, domainErr, httpsErr, pollErr, startErr) } // seconds returns the duration setting called name, refusing one that is not a whole number of seconds. diff --git a/internal/installer/settings_internal_test.go b/internal/installer/settings_internal_test.go index 2de43233..613ad36b 100644 --- a/internal/installer/settings_internal_test.go +++ b/internal/installer/settings_internal_test.go @@ -13,6 +13,8 @@ import ( "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/testkit" + + "github.com/gopherium/gophenberg/internal/updater" ) // firstServerEnv is the server.env a first run on example.com writes with every default. @@ -91,7 +93,7 @@ func TestSettingsOnAFirstRunTakeEveryDefaultAndWriteItIntoServerEnv(t *testing.T }, Checks: Checks{ MemoryFloorMB: 900, DiskFloorMB: 1024, AptLockWait: 5 * time.Minute, DomainCheckTimeout: 10 * time.Second, - HTTPSWait: 2 * time.Minute, PollInterval: time.Second, + HTTPSWait: 2 * time.Minute, PollInterval: time.Second, StartWait: time.Minute, }, } if settings != want { @@ -99,6 +101,48 @@ func TestSettingsOnAFirstRunTakeEveryDefaultAndWriteItIntoServerEnv(t *testing.T } } +func TestSettingsOnWaitForTheFirstStartAsLongAsServeChecksANewBuild(t *testing.T) { + t.Parallel() + + for _, values := range []map[string]string{ + {"GOPHENBERG_INSTALL_ROOT": "/opt/gophenberg"}, + {"GOPHENBERG_INSTALL_ROOT": "/opt/gophenberg", "GOPHENBERG_UPDATE_VERIFY_TIMEOUT": "90s"}, + } { + served, err := updater.SettingsFrom(envOf(values)) + if err != nil { + t.Fatalf("updater.SettingsFrom() = %v, want the update settings", err) + } + + settings, err := settingsOn(serverWith(t, nil), envOf(values), firstRun) + + if err != nil || settings.Checks.StartWait != served.VerifyTimeout { + t.Errorf("settingsOn() waits %s for the first start with %v, want the %s serve checks a build within", + settings.Checks.StartWait, err, served.VerifyTimeout) + } + } +} + +func TestSettingsOnRefuseAStartWaitServeCannotRead(t *testing.T) { + t.Parallel() + + env := envOf(map[string]string{"GOPHENBERG_UPDATE_VERIFY_TIMEOUT": "soon"}) + + _, err := settingsOn(serverWith(t, nil), env, firstRun) + + wantRefused(t, err, `GOPHENBERG_UPDATE_VERIFY_TIMEOUT: must be a duration like 30s, got "soon"`) +} + +func TestSettingsOnRefuseAPasswordFileThatIsNotAnAbsolutePath(t *testing.T) { + t.Parallel() + + request := firstRun + request.AdminPasswordFile = "root/admin-password" + + _, err := settingsOn(Machine{}, envOf(nil), request) + + wantRefused(t, err, `-admin-password-file: must be an absolute path, got "root/admin-password"`) +} + func TestSettingsOnAFirstRunTakeTheFlagThenTheEnvironmentThenTheDefault(t *testing.T) { t.Parallel() @@ -252,6 +296,43 @@ func TestSettingsOnRefuseALaterRunThatAsksForTheOtherWebServer(t *testing.T) { } } +func TestSettingsOnRefuseALaterRunThatAsksForAnotherInstallRoot(t *testing.T) { + t.Parallel() + + machine := serverWith(t, map[string]string{serverEnvPath: firstServerEnv}) + env := envOf(map[string]string{"GOPHENBERG_INSTALL_ROOT": "/srv/gophenberg/"}) + + _, err := settingsOn(machine, env, Request{}) + + wantRefused(t, err, "GOPHENBERG_INSTALL_ROOT: this server was set up with /opt/gophenberg, and a later run cannot "+ + "move it to /srv/gophenberg, so run sudo gophenberg server:uninstall first, then change the line in "+ + "/etc/gophenberg/server.env") +} + +func TestSettingsOnALaterRunTakeTheInstallRootServerEnvKeepsWrittenWithATrailingSlash(t *testing.T) { + t.Parallel() + + machine := serverWith(t, map[string]string{serverEnvPath: firstServerEnv}) + env := envOf(map[string]string{"GOPHENBERG_INSTALL_ROOT": "/opt/gophenberg/"}) + + settings, err := settingsOn(machine, env, Request{}) + + if err != nil || settings.Root != "/opt/gophenberg" { + t.Errorf("settingsOn() = %q, %v, want the install root /opt/gophenberg of server.env", settings.Root, err) + } +} + +func TestSettingsOnALaterRunRefuseAnInstallRootTheEnvironmentNamesByItsRuleBeforeAnyMove(t *testing.T) { + t.Parallel() + + machine := serverWith(t, map[string]string{serverEnvPath: firstServerEnv}) + env := envOf(map[string]string{"GOPHENBERG_INSTALL_ROOT": "srv/gophenberg"}) + + _, err := settingsOn(machine, env, Request{}) + + wantRefused(t, err, `GOPHENBERG_INSTALL_ROOT: must be an absolute path, got "srv/gophenberg"`) +} + func TestSettingsOnALaterRunRefuseAWebServerTheEnvironmentNamesThatIsNeitherCaddyNorNone(t *testing.T) { t.Parallel() diff --git a/internal/installer/setup_steps.go b/internal/installer/setup_steps.go new file mode 100644 index 00000000..2cde9736 --- /dev/null +++ b/internal/installer/setup_steps.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os/user" + "slices" + "strconv" + "strings" +) + +// siteUser is the OS user that runs the site, signs in to PostgreSQL and owns the install root. +const siteUser = "gophenberg" + +// statusFormat has dpkg-query print the status of each package in three letters, "ii " or "hi " once it is installed. +const statusFormat = `${db:Status-Abbrev}\n` + +// aptEnv are the settings every apt and dpkg program gets, so it asks nothing and restarts services on its own. +var aptEnv = []string{"DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a"} + +// databasePrograms make the role gophenberg that signs in by peer, then its database, sorted by ICU's root collation. +var databasePrograms = []Program{ + {Name: "createuser", User: "postgres", Args: []string{"--no-superuser", "--no-createdb", "--no-createrole", siteUser}}, + {Name: "createdb", User: "postgres", Args: []string{ + "--owner=" + siteUser, "--encoding=UTF8", "--template=template0", "--locale-provider=icu", "--icu-locale=und", + siteUser, + }}, +} + +// rootFolder is one folder of the install root, by its name in the root, with its mode. +type rootFolder struct { + name string + mode fs.FileMode +} + +// rootFolders are the folders of the install root, each with its mode, all owned by gophenberg. +var rootFolders = []rootFolder{ + {"versions", 0o755}, {"downloads", 0o755}, {"state", 0o700}, {"backups", 0o700}, {"media", 0o750}, + {"themes", 0o750}, +} + +// installRootDoes says what the install-root step does, naming any missing folder above the root it makes as root's. +func (s site) installRootDoes() string { + does := "make the install root " + s.settings.Root + " and its folders, owned by gophenberg" + if len(s.missing) == 0 { + return does + } + return does + ", after the missing " + joined(s.missing) + " above it, owned by root with mode 0755" +} + +// writeOnly writes the files of planned, the only work of its step. +func (r *installing) writeOnly(_ context.Context, planned step) error { + return r.writeAll(planned.files) +} + +// writeAll writes each of files, root's own through the system tree and gophenberg's through the install tree. +func (r *installing) writeAll(files []file) error { + for _, written := range files { + tree, name, ids := r.placed(written) + if err := writeFile(tree, name, written, ids); err != nil { + return err + } + } + return nil +} + +// packages writes the apt files of planned, then installs the packages of the run unless dpkg has them installed. +func (r *installing) packages(ctx context.Context, planned step) error { + if err := r.repairPackages(ctx, planned.id); err != nil { + return err + } + if err := r.writeAll(planned.files); err != nil { + return err + } + names := r.packageNames() + if r.installed(ctx, names) { + return nil + } + return r.aptInstall(ctx, names) +} + +// repairPackages has dpkg set up what a run cut off in the step called id left half set up. +func (r *installing) repairPackages(ctx context.Context, id string) error { + if !r.found.started(id) { + return nil + } + return r.runStep(ctx, aptProgram("dpkg", "--configure", "-a")) +} + +// packageNames returns the packages the run installs: PostgreSQL, and Caddy unless the person keeps a web server. +func (r *installing) packageNames() []string { + if !r.site.settings.withCaddy() { + return []string{piecePostgreSQL} + } + return []string{piecePostgreSQL, pieceCaddy} +} + +// installed reports whether dpkg has every one of names installed, held or not, one it cannot find counted as missing. +func (r *installing) installed(ctx context.Context, names []string) bool { + printed, err := r.machine.Run(ctx, aptProgram("dpkg-query", append([]string{"-W", "-f", statusFormat}, names...)...)) + if err != nil { + return false + } + statuses := strings.Split(strings.TrimSuffix(printed, "\n"), "\n") + return len(statuses) == len(names) && !slices.ContainsFunc(statuses, notInstalled) +} + +// notInstalled reports whether status, as dpkg abbreviates it, shows a package missing or installed with an error. +func notInstalled(status string) bool { + return len(status) != 3 || status[1:] != "i " +} + +// aptInstall saves names as the record's own, then installs them with apt-get, waiting on the apt lock as set. +func (r *installing) aptInstall(ctx context.Context, names []string) error { + for _, name := range names { + r.record.own(name) + } + if err := r.save(); err != nil { + return err + } + lock := "DPkg::Lock::Timeout=" + strconv.FormatInt(inSeconds(r.site.settings.Checks.AptLockWait), 10) + if err := r.runStep(ctx, aptProgram("apt-get", "-o", lock, "update")); err != nil { + return err + } + return r.runStep(ctx, aptProgram("apt-get", append([]string{"-o", lock, "install", "-y", "--no-install-recommends"}, + names...)...)) +} + +// aptProgram returns the apt or dpkg program called name with args and the settings every such program gets. +func aptProgram(name string, args ...string) Program { + return Program{Name: name, Args: args, Env: aptEnv} +} + +// runStep runs program and returns its error with what it printed, so the person sees why the step stopped. +func (r *installing) runStep(ctx context.Context, program Program) error { + printed, err := r.machine.Run(ctx, program) + return withPrinted(err, printed) +} + +// withPrinted returns err, the error of a program, followed by what the program printed when it printed anything. +func withPrinted(err error, printed string) error { + if err == nil || strings.TrimSpace(printed) == "" { + return err + } + return fmt.Errorf("%w, after it printed:\n%s", err, strings.TrimRight(printed, "\n")) +} + +// user makes the OS user gophenberg unless it is there, then keeps its ids for everything it owns. +func (r *installing) user(ctx context.Context, _ step) error { + found, err := r.lookupSiteUser() + if errors.As(err, new(user.UnknownUserError)) { + found, err = r.addSiteUser(ctx) + } + if err != nil { + return err + } + r.ids, err = idsOf(found) + return err +} + +// addSiteUser makes the system user gophenberg, at home in the install root and unable to sign in, then looks it up. +func (r *installing) addSiteUser(ctx context.Context) (*user.User, error) { + err := r.runStep(ctx, Program{Name: "useradd", Args: []string{ + "--system", "--user-group", "--home-dir", r.site.settings.Root, "--no-create-home", "--shell", "/usr/sbin/nologin", + siteUser, + }}) + if err != nil { + return nil, err + } + return r.lookupSiteUser() +} + +// lookupSiteUser looks up the OS user gophenberg. +func (r *installing) lookupSiteUser() (*user.User, error) { + found, err := r.machine.LookupUser(siteUser) + if err != nil { + return nil, fmt.Errorf("installer: %w", err) + } + return found, nil +} + +// idsOf returns the ids of the OS user found, refusing ids that are not numbers. +func idsOf(found *user.User) (*owner, error) { + uid, uidErr := strconv.Atoi(found.Uid) + gid, gidErr := strconv.Atoi(found.Gid) + if err := errors.Join(uidErr, gidErr); err != nil { + return nil, fmt.Errorf("installer: the user %s has ids that are not numbers: %w", found.Username, err) + } + return &owner{uid: uid, gid: gid}, nil +} + +// installRoot owns the install root in the record, then makes it and its folders and opens it as the install tree. +func (r *installing) installRoot(_ context.Context, _ step) error { + root := r.site.settings.Root + r.record.own(root) + if err := r.save(); err != nil { + return err + } + if err := r.makeRoot(inSystemTree(root)); err != nil { + return fmt.Errorf("installer: make the install root %s: %w", root, err) + } + return nil +} + +// makeRoot makes each missing folder above name as root's, then name and its folders as gophenberg's, as the tree. +func (r *installing) makeRoot(name string) error { + err := makeParents(r.system, name, nil) + if err == nil { + err = makeFolder(r.system, name, 0o750, r.ids) + } + if err == nil { + r.tree, err = r.system.OpenRoot(name) + } + for _, folder := range rootFolders { + if err == nil { + err = makeFolder(r.tree, folder.name, folder.mode, r.ids) + } + } + return err +} + +// database makes the role gophenberg and its database as postgres, unless the record says an earlier run made them. +func (r *installing) database(ctx context.Context, planned step) error { + if r.found.ended(planned.id) { + return nil + } + for _, program := range databasePrograms { + if err := r.runMade(ctx, program); err != nil { + return err + } + } + return nil +} + +// runMade runs program, taking a failure that says what it makes already exists as made. +func (r *installing) runMade(ctx context.Context, program Program) error { + printed, err := r.machine.Run(ctx, program) + if strings.Contains(printed, "already exists") { + return nil + } + return withPrinted(err, printed) +} diff --git a/internal/installer/setup_steps_internal_test.go b/internal/installer/setup_steps_internal_test.go new file mode 100644 index 00000000..fba90065 --- /dev/null +++ b/internal/installer/setup_steps_internal_test.go @@ -0,0 +1,621 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/gopherium/gophenberg/internal/updater" +) + +// aptLines are the programs the packages step of a first run with Caddy runs, in order. +var aptLines = []string{ + `dpkg-query -W -f ${db:Status-Abbrev}\n postgresql caddy`, + "apt-get -o DPkg::Lock::Timeout=300 update", + "apt-get -o DPkg::Lock::Timeout=300 install -y --no-install-recommends postgresql caddy", +} + +// useraddLine is the useradd line the user step runs for the default install root. +const useraddLine = "useradd --system --user-group --home-dir /opt/gophenberg --no-create-home --shell " + + "/usr/sbin/nologin gophenberg" + +// databaseLines are the programs the database step runs as postgres, in order. +var databaseLines = []string{ + "runuser -u postgres -- createuser --no-superuser --no-createdb --no-createrole gophenberg", + "runuser -u postgres -- createdb --owner=gophenberg --encoding=UTF8 --template=template0 --locale-provider=icu " + + "--icu-locale=und gophenberg", +} + +// plannedStep returns the step called id of the steps the run running takes. +func plannedStep(t *testing.T, running *installing, id string) step { + t.Helper() + for _, planned := range running.site.steps() { + if planned.id == id { + return planned + } + } + t.Fatalf("the run takes no step %s", id) + return step{} +} + +// otherGroupIDs returns the test's own user with another group it belongs to, so a chown shows, or skips t. +func otherGroupIDs(t *testing.T) *owner { + t.Helper() + group := siteGroup(t) + if group == os.Getgid() { + t.Skip("the test's user belongs to one group alone, so no chown would show") + } + return &owner{uid: os.Getuid(), gid: group} +} + +// wantRan fails t unless server ran the program lines want, in order, and nothing else. +func wantRan(t *testing.T, server *fakeServer, want ...string) { + t.Helper() + if !slices.Equal(server.ran, want) { + t.Errorf("the run ran\n%s\nwant\n%s", strings.Join(server.ran, "\n"), strings.Join(want, "\n")) + } +} + +func TestTheServerEnvStepWritesTheServerSettingsAsRootsAlone(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "server-env")) + + if err != nil { + t.Fatalf("take(server-env) = %v, want server.env written", err) + } + wantText(t, server.at(serverEnvPath), firstServerEnv) + wantEntry(t, server.at(serverEnvPath), 0o600, nil) + wantEntry(t, server.at("/etc/gophenberg"), fs.ModeDir|0o755, nil) + if got := marks(t, server)["server-env"]; got != "ended" { + t.Errorf("the record marks the step %q, want ended", got) + } +} + +func TestTheServerEnvStepStopsWhenItCannotWriteTheServerSettings(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write("/etc/gophenberg", "a file where the folder goes\n") + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "server-env")) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write "+serverEnvPath+": ") { + t.Errorf("take(server-env) = %v, want the write refused", err) + } +} + +func TestThePackagesStepWritesTheAptFilesAndOwnsThePackagesBeforeAptGetStarts(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + var owned []string + var sourced bool + server.effects["apt-get"] = func(program Program) answer { + if program.Args[len(program.Args)-1] == "update" { + record, _ := readRecord(server.machine()) + owned, sourced = record.Installed, server.machine().there("/etc/apt/sources.list.d/caddy.sources") + } + return server.aptGet(program) + } + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil { + t.Fatalf("take(packages) = %v, want PostgreSQL and Caddy installed", err) + } + wantRan(t, server, aptLines...) + if want := []string{piecePostgreSQL, pieceCaddy}; !slices.Equal(owned, want) || !sourced { + t.Errorf("apt-get update found the record owning %q, Caddy's source written %t, want %q and true", owned, + sourced, want) + } + for _, written := range aptFilesOf()[:3] { + wantText(t, server.at(written.path), written.text) + wantEntry(t, server.at(written.path), 0o644, nil) + } +} + +func TestThePackagesStepRunsEveryProgramWithoutQuestionsAndRestartsServicesOnItsOwn(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil { + t.Fatalf("take(packages) = %v, want the packages installed", err) + } + for _, program := range server.runs { + if want := []string{"DEBIAN_FRONTEND=noninteractive", "NEEDRESTART_MODE=a"}; !slices.Equal(program.Env, want) { + t.Errorf("%s ran with the settings %q, want %q", lineOf(program), program.Env, want) + } + } +} + +func TestThePackagesStepWaitsOnTheAptLockAsLongAsTheSettingsSay(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.site.settings.Checks.AptLockWait = 90 * time.Second + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil || !slices.Contains(server.ran, "apt-get -o DPkg::Lock::Timeout=90 update") { + t.Errorf("take(packages) = %v, running\n%s\nwant apt-get to wait 90 seconds", err, strings.Join(server.ran, "\n")) + } +} + +func TestThePackagesStepWithTheirOwnWebServerInstallsPostgreSQLAlone(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.site = ownSite(t, "example.com") + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil { + t.Fatalf("take(packages) = %v, want PostgreSQL installed", err) + } + wantRan(t, server, `dpkg-query -W -f ${db:Status-Abbrev}\n postgresql`, "apt-get -o DPkg::Lock::Timeout=300 update", + "apt-get -o DPkg::Lock::Timeout=300 install -y --no-install-recommends postgresql") + if server.machine().there("/etc/apt/sources.list.d/caddy.sources") || server.machine().there("/usr/bin/caddy") { + t.Error("take(packages) brought Caddy to a server that keeps its own web server") + } +} + +func TestThePackagesStepLeavesAptGetAloneOnceDpkgHasEveryPackageInstalledHeldOrNot(t *testing.T) { + t.Parallel() + + for _, status := range []string{"ii", "hi"} { + server := newFakeServer(t) + server.dpkg[piecePostgreSQL], server.dpkg[pieceCaddy] = status, "ii" + running, _ := runOn(t, server, Record{Installed: []string{piecePostgreSQL, pieceCaddy}}) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil || !slices.Equal(server.ran, aptLines[:1]) { + t.Errorf("take(packages) with PostgreSQL %q = %v, running %q, want dpkg-query alone", status, err, + server.ran) + } + wantText(t, server.at(caddyKeyPath), caddyKey) + } +} + +func TestThePackagesStepInstallsAPackageDpkgListsWithoutHavingItCleanlyInstalled(t *testing.T) { + t.Parallel() + + for _, status := range []string{"rc", "un", "iU", "iF", "iiR", "hiR"} { + server := newFakeServer(t) + server.dpkg[piecePostgreSQL], server.dpkg[pieceCaddy] = status, "ii" + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil || !slices.Equal(server.ran, aptLines) { + t.Errorf("take(packages) with PostgreSQL %q = %v, running %q, want %q", status, err, server.ran, aptLines) + } + } +} + +func TestThePackagesStepOfARunCutOffInItSetsUpWhatDpkgLeftFirst(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.dpkg[piecePostgreSQL] = "iF" + found := Record{Steps: map[string]string{"server-env": "ended", "packages": "started"}, + Installed: []string{piecePostgreSQL, pieceCaddy}} + running, _ := runOn(t, server, found) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if err != nil { + t.Fatalf("take(packages) = %v, want the packages installed", err) + } + wantRan(t, server, slices.Concat([]string{"dpkg --configure -a"}, aptLines)...) + if want := []string{piecePostgreSQL, pieceCaddy}; !slices.Equal(running.record.Installed, want) { + t.Errorf("the record owns %q, want %q once each", running.record.Installed, want) + } +} + +func TestThePackagesStepStopsWithWhatAFailedProgramPrinted(t *testing.T) { + t.Parallel() + + for _, failed := range []struct { + name string + cut bool + want string + }{ + {"dpkg", true, "installer: run dpkg: exit status 2, after it printed:\ndpkg: error processing package"}, + {"apt-get", false, "installer: run apt-get: exit status 100, after it printed:\ndpkg: error processing package"}, + } { + server := newFakeServer(t) + server.effects[failed.name] = func(program Program) answer { + return answer{printed: "dpkg: error processing package\n", err: fmt.Errorf("installer: run %s: exit "+ + "status %d", program.Name, map[string]int{"dpkg": 2, "apt-get": 100}[program.Name])} + } + found := Record{} + if failed.cut { + found.Steps = map[string]string{"packages": "started"} + } + running, _ := runOn(t, server, found) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + wantRefused(t, err, failed.want) + } +} + +func TestThePackagesStepNamesAFailedProgramThatPrintedNothing(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.effects["apt-get"] = func(Program) answer { + return answer{printed: " \n", err: errors.New("installer: run apt-get: signal: killed")} + } + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + wantRefused(t, err, "installer: run apt-get: signal: killed") +} + +func TestThePackagesStepStopsBeforeAptGetWhenItCannotSaveTheRecord(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + planned := plannedStep(t, running, "packages") + server.folder(recordPath + "/kept") + + err := planned.apply(running, t.Context(), planned) + + if err == nil || slices.Contains(server.ran, aptLines[1]) { + t.Errorf("the packages step = %v, running\n%s\nwant it stopped before apt-get", err, + strings.Join(server.ran, "\n")) + } +} + +func TestThePackagesStepStopsWhenItCannotWriteTheAptFiles(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write("/etc/apt/keyrings", "a file where the folder goes\n") + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "packages")) + + if got := fmt.Sprint(err); !strings.HasPrefix(got, "installer: write "+caddyKeyPath+": ") || len(server.ran) != 0 { + t.Errorf("take(packages) = %v, running %q, want the write refused before any program", err, server.ran) + } +} + +func TestTheUserStepMakesTheSystemUserGophenbergAndKeepsItsIDs(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "user")) + + if err != nil { + t.Fatalf("take(user) = %v, want the user made", err) + } + if want := (owner{uid: os.Getuid(), gid: siteGroup(t)}); running.ids == nil || *running.ids != want { + t.Errorf("take(user) kept the ids %v, want the ids %v of the user useradd made", running.ids, want) + } + wantRan(t, server, useraddLine) +} + +func TestTheUserStepLeavesTheUserGophenbergAloneOnceItIsThere(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.users["gophenberg"] = &user.User{Username: "gophenberg", Uid: "998", Gid: "997", HomeDir: "/opt/gophenberg"} + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "user")) + + if err != nil { + t.Fatalf("take(user) = %v, want the user taken", err) + } + if running.ids == nil || *running.ids != (owner{uid: 998, gid: 997}) { + t.Errorf("take(user) kept the ids %v, want 998:997", running.ids) + } + wantRan(t, server) +} + +func TestTheUserStepStopsWhenUseraddFails(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.effects["useradd"] = func(Program) answer { + return answer{printed: "useradd: cannot lock /etc/passwd, try again later.\n", + err: errors.New("installer: run useradd: exit status 1")} + } + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "user")) + + wantRefused(t, err, "installer: run useradd: exit status 1, after it printed:\nuseradd: cannot lock /etc/passwd, "+ + "try again later.") +} + +func TestTheUserStepStopsWhenItCannotLookUpTheUser(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.machine.LookupUser = func(name string) (*user.User, error) { + return nil, fmt.Errorf("user: lookup username %s: %w", name, syscall.EIO) + } + + err := running.take(t.Context(), plannedStep(t, running, "user")) + + wantRefused(t, err, "installer: user: lookup username gophenberg: input/output error") + wantRan(t, server) +} + +func TestTheUserStepRefusesIDsThatAreNotNumbers(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.users["gophenberg"] = &user.User{Username: "gophenberg", Uid: "x", Gid: "997"} + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "user")) + + wantRefused(t, err, `installer: the user gophenberg has ids that are not numbers: strconv.Atoi: parsing "x": `+ + "invalid syntax") +} + +// rootFolderModes are the install root and its folders, each with its mode, named from the top of the server. +var rootFolderModes = map[string]fs.FileMode{ + "/opt/gophenberg": 0o750, "/opt/gophenberg/versions": 0o755, "/opt/gophenberg/downloads": 0o755, + "/opt/gophenberg/state": 0o700, "/opt/gophenberg/backups": 0o700, "/opt/gophenberg/media": 0o750, + "/opt/gophenberg/themes": 0o750, +} + +func TestTheInstallRootStepMakesTheRootAndItsFoldersAsGophenbergs(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.ids = otherGroupIDs(t) + + err := running.take(t.Context(), plannedStep(t, running, "install-root")) + + if err != nil { + t.Fatalf("take(install-root) = %v, want the install root made", err) + } + for path, mode := range rootFolderModes { + wantEntry(t, server.at(path), fs.ModeDir|mode, running.ids) + } + wantOnly(t, server.at("/opt/gophenberg"), "backups", "downloads", "media", "state", "themes", "versions") + if running.tree == nil || running.tree.Name() != server.at("/opt/gophenberg") { + t.Errorf("take(install-root) left the install tree %v, want it open on the install root", running.tree) + } +} + +func TestTheInstallRootStepOwnsTheRootInTheRecordBeforeItMakesIt(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.write("/opt/gophenberg", "a file where the install root goes\n") + running, _ := runOn(t, server, Record{}) + running.ids = testIDs() + + err := running.take(t.Context(), plannedStep(t, running, "install-root")) + + wantRefused(t, err, "installer: make the install root /opt/gophenberg: make the folder opt/gophenberg: it is "+ + "not a real folder") + if record, _ := readRecord(server.machine()); !record.owns("/opt/gophenberg") { + t.Errorf("the record owns %q, want the install root among them", record.Installed) + } +} + +func TestTheInstallRootStepMakesTheMissingFoldersAboveTheRootAsRootsAt0755(t *testing.T) { + ids := otherGroupIDs(t) + kept := syscall.Umask(0o077) + defer syscall.Umask(kept) + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + running.site.settings.Root, running.ids = "/srv/sites/gophenberg", ids + + err := running.take(t.Context(), plannedStep(t, running, "install-root")) + + if err != nil { + t.Fatalf("take(install-root) = %v, want the install root made", err) + } + wantEntry(t, server.at("/srv"), fs.ModeDir|0o755, nil) + wantEntry(t, server.at("/srv/sites"), fs.ModeDir|0o755, nil) + wantEntry(t, server.at("/srv/sites/gophenberg"), fs.ModeDir|0o750, ids) + wantEntry(t, server.at("/srv/sites/gophenberg/state"), fs.ModeDir|0o700, ids) +} + +func TestTheInstallRootStepGivesTheFoldersThereTheirModesAgain(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder("/opt/gophenberg/media") + server.chmod("/opt/gophenberg/media", 0o777) + server.write("/opt/gophenberg/media/big.mp4", "kept\n") + running, _ := runOn(t, server, Record{Installed: []string{"/opt/gophenberg"}}) + running.ids = testIDs() + + err := running.take(t.Context(), plannedStep(t, running, "install-root")) + + if err != nil { + t.Fatalf("take(install-root) = %v, want the folders taken", err) + } + wantEntry(t, server.at("/opt/gophenberg/media"), fs.ModeDir|0o750, nil) + wantText(t, server.at("/opt/gophenberg/media/big.mp4"), "kept\n") +} + +func TestTheInstallRootStepRefusesALinkAtAFolderOfTheRoot(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.folder("/opt/gophenberg", "/etc/ssh") + server.chmod("/etc/ssh", 0o700) + if err := os.Symlink(server.at("/etc/ssh"), server.at("/opt/gophenberg/media")); err != nil { + t.Fatalf("linking media to /etc/ssh: %v", err) + } + running, _ := runOn(t, server, Record{}) + running.ids = testIDs() + + err := running.take(t.Context(), plannedStep(t, running, "install-root")) + + wantRefused(t, err, "installer: make the install root /opt/gophenberg: make the folder media: it is not a real "+ + "folder") + wantEntry(t, server.at("/etc/ssh"), fs.ModeDir|0o700, nil) +} + +func TestTheInstallRootStepStopsWhenItCannotSaveTheRecord(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + planned := plannedStep(t, running, "install-root") + server.folder(recordPath + "/kept") + + err := planned.apply(running, t.Context(), planned) + + if err == nil || server.machine().there("/opt/gophenberg") { + t.Errorf("the install-root step = %v, want it stopped before the install root is made", err) + } +} + +func TestTheInstallRootFoldersTheUpdaterWritesAreTheOnesItNames(t *testing.T) { + t.Parallel() + + layout := updater.Layout{Root: "/opt/gophenberg"} + for _, path := range []string{ + filepath.Dir(layout.Build("0.20.0")), filepath.Dir(layout.Download("0.20.0")), layout.Backups(), + filepath.Dir(layout.StateFile()), + } { + if !slices.ContainsFunc(rootFolders, func(folder rootFolder) bool { return folder.name == filepath.Base(path) }) { + t.Errorf("the install root step makes no folder %s, which the updater writes", path) + } + } +} + +func TestADryRunSaysTheInstallRootStepMakesTheMissingFoldersAboveTheRoot(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + + stdout, _, err := runInstallUnder(server, map[string]string{"GOPHENBERG_INSTALL_ROOT": "/srv/sites/gophenberg"}, + firstRun) + + line := "\nStep 4: make the install root /srv/sites/gophenberg and its folders, owned by gophenberg, after the " + + "missing /srv and /srv/sites above it, owned by root with mode 0755\n" + if err != nil || !strings.Contains(stdout, line) { + t.Errorf("Install() = %v, printing\n%s\nwant the line %q", err, stdout, line) + } +} + +func TestTheInstallRootStepNamesOneMissingFolderAboveTheRoot(t *testing.T) { + t.Parallel() + + running := firstSite(t, "example.com", 255) + running.missing = []string{"/srv"} + running.settings.Root = "/srv/gophenberg" + + if got, want := running.installRootDoes(), "make the install root /srv/gophenberg and its folders, owned by "+ + "gophenberg, after the missing /srv above it, owned by root with mode 0755"; got != want { + t.Errorf("the install-root step does %q, want %q", got, want) + } +} + +func TestTheDatabaseStepMakesTheRoleAndTheDatabaseAsPostgres(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "database")) + + if err != nil { + t.Fatalf("take(database) = %v, want the role and the database made", err) + } + wantRan(t, server, databaseLines...) +} + +func TestTheDatabaseStepTakesARoleOrADatabaseThatAlreadyExistsAsMade(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + for name, what := range map[string]string{"createuser": `role "gophenberg"`, "createdb": `database "gophenberg"`} { + server.answers[name] = answer{printed: name + ": error: creation failed: ERROR: " + what + " already exists\n", + err: errors.New("installer: run " + name + ": exit status 1")} + } + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "database")) + + if err != nil { + t.Fatalf("take(database) = %v, want what already exists taken as made", err) + } + wantRan(t, server, databaseLines...) +} + +func TestTheDatabaseStepStopsWithWhatAFailedCommandPrinted(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + server.answers["createuser"] = answer{printed: "createuser: error: connection to server on socket failed\n", + err: errors.New("installer: run createuser: exit status 1")} + running, _ := runOn(t, server, Record{}) + + err := running.take(t.Context(), plannedStep(t, running, "database")) + + wantRefused(t, err, "installer: run createuser: exit status 1, after it printed:\ncreateuser: error: connection to "+ + "server on socket failed") + wantRan(t, server, databaseLines[0]) +} + +func TestTheDatabaseStepRunsNothingOnceTheRecordSaysAnEarlierRunEndedIt(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + running, _ := runOn(t, server, Record{Steps: map[string]string{"database": "ended"}}) + + err := running.take(t.Context(), plannedStep(t, running, "database")) + + if err != nil { + t.Fatalf("take(database) = %v, want nothing to do", err) + } + wantRan(t, server) +} + +func TestOwnAddsAPieceToTheRecordOnce(t *testing.T) { + t.Parallel() + + var record Record + + record.own(piecePostgreSQL) + record.own(pieceCaddy) + record.own(piecePostgreSQL) + + if want := []string{piecePostgreSQL, pieceCaddy}; !slices.Equal(record.Installed, want) { + t.Errorf("the record owns %q, want %q", record.Installed, want) + } +} diff --git a/internal/installer/site_env.go b/internal/installer/site_env.go index 4b781b57..1d0607a2 100644 --- a/internal/installer/site_env.go +++ b/internal/installer/site_env.go @@ -71,33 +71,47 @@ func StopGraces(env gonsole.Env) ([]time.Duration, error) { return graces, nil } +// envSetting is one line of the site's .env, by its key and its value. +type envSetting struct { + key, value string +} + // siteEnvFile returns the site's .env: its header, the lines the server decides, then every other line the site holds. func (s site) siteEnvFile() file { - root := s.settings.Root - kept := maps.Clone(s.dotEnv) lines := []string{siteEnvHeader} - for _, owned := range []struct{ name, value string }{ - {"DATABASE_URL", DatabaseAddress}, - {"SUPERUSER_POLICY", "refuse"}, - {"INSTALL_ROOT", root}, - {"ADDR", s.settings.Addr.String()}, - {"PUBLIC_URL", "https://" + s.settings.Domain}, - {"TRUSTED_PROXIES", "127.0.0.1/32,::1/128"}, - {"THEMES_DIR", root + "/themes"}, - {"MEDIA_DIR", root + "/media"}, - } { - key := s.env.Key(owned.name) - lines = append(lines, envLine(key, owned.value)) - delete(kept, key) - } - wait := s.env.Key("DATABASE_WAIT") - lines = append(lines, envLine(wait, cmp.Or(kept[wait], DatabaseWait))) - delete(kept, wait) - for _, key := range slices.Sorted(maps.Keys(kept)) { - lines = append(lines, envLine(key, kept[key])) + for _, decided := range s.decidedSettings() { + lines = append(lines, envLine(decided.key, decided.value)) + } + for _, key := range s.keptKeys() { + lines = append(lines, envLine(key, s.dotEnv[key])) } text := strings.Join(lines, "\n") + "\n" - return file{path: filepath.Join(root, ".env"), owner: "gophenberg", mode: 0o600, text: text} + return file{path: filepath.Join(s.settings.Root, ".env"), owner: "gophenberg", mode: 0o600, text: text} +} + +// decidedSettings returns the lines of the site's .env the server decides, in order, keeping a database wait it sets. +func (s site) decidedSettings() []envSetting { + root, wait := s.settings.Root, s.env.Key("DATABASE_WAIT") + return []envSetting{ + {s.env.Key("DATABASE_URL"), DatabaseAddress}, + {s.env.Key("SUPERUSER_POLICY"), "refuse"}, + {s.env.Key("INSTALL_ROOT"), root}, + {s.env.Key("ADDR"), s.settings.Addr.String()}, + {s.env.Key("PUBLIC_URL"), "https://" + s.settings.Domain}, + {s.env.Key("TRUSTED_PROXIES"), "127.0.0.1/32,::1/128"}, + {s.env.Key("THEMES_DIR"), root + "/themes"}, + {s.env.Key("MEDIA_DIR"), root + "/media"}, + {wait, cmp.Or(s.dotEnv[wait], DatabaseWait)}, + } +} + +// keptKeys returns the sorted keys of the lines the person added to the site's .env, which a run keeps as they are. +func (s site) keptKeys() []string { + kept := maps.Clone(s.dotEnv) + for _, decided := range s.decidedSettings() { + delete(kept, decided.key) + } + return slices.Sorted(maps.Keys(kept)) } // siteEnv returns the settings of the site's .env under root, read through root itself, none before one is there. diff --git a/internal/installer/site_env_internal_test.go b/internal/installer/site_env_internal_test.go index 8e3b7130..400c4db7 100644 --- a/internal/installer/site_env_internal_test.go +++ b/internal/installer/site_env_internal_test.go @@ -70,6 +70,26 @@ GOPHENBERG_THEME=starter wantFiles(t, []file{got}, []file{{"/srv/gophenberg/.env", "gophenberg", 0o600, want}}) } +func TestTheStepsHideTheValueOfEachLineThePersonAddedToTheSiteEnvAndOfNoLineTheServerDecides(t *testing.T) { + t.Parallel() + + running := firstSite(t, "example.com", 255) + running.dotEnv = map[string]string{ + "GOPHENBERG_INSTALL_ROOT": "/etc", "GOPHENBERG_DATABASE_WAIT": "10s", "GOPHENBERG_THEME": "starter", + "GOPHENBERG_SMTP_PASSWORD": "Maria's mail password", + } + + for _, planned := range running.steps() { + var want []string + if planned.id == "site-env" { + want = []string{"GOPHENBERG_SMTP_PASSWORD", "GOPHENBERG_THEME"} + } + if !slices.Equal(planned.hides, want) { + t.Errorf("the step %s hides the values of %q, want %q", planned.id, planned.hides, want) + } + } +} + func TestStopGracesReadTheFiveGracesServeStopsWithinInTheOrderItWaitsThroughThem(t *testing.T) { t.Parallel() diff --git a/internal/installer/steps.go b/internal/installer/steps.go index 262e773f..0941fb3c 100644 --- a/internal/installer/steps.go +++ b/internal/installer/steps.go @@ -3,11 +3,13 @@ package installer import ( + "context" "errors" "fmt" "io" "io/fs" "path/filepath" + "slices" "strings" "time" @@ -29,16 +31,21 @@ const stepEnded = "ended" // unattendedUpgrade is the program of unattended-upgrades, there while that package is installed. const unattendedUpgrade = "/usr/bin/unattended-upgrade" +// keptValue is what a dry run prints for the value of a line the person added to the site's .env. +const keptValue = "(kept as it is)" + // dryRunLead is the first line a dry run prints, once every check passed. const dryRunLead = "Every check passed, and nothing was changed. Without -dry-run, server:install would take these " + "steps.\n" -// step is one step server:install takes once the checks pass, with the files it writes and the text it prints. +// step is one step server:install takes, with the files it writes, the keys a dry run hides, its text and its change. type step struct { id string does string files []file + hides []string prints string + apply func(*installing, context.Context, step) error } // file is one file a step writes: its path from the top of the server, the user that owns it, its mode and its text. @@ -59,6 +66,7 @@ type site struct { runuser string unattended bool current bool + missing []string dotEnv map[string]string graces []time.Duration uploadCapMB int @@ -86,7 +94,8 @@ func siteOn(machine Machine, check *machineCheck) (site, error) { return site{ settings: check.settings, record: check.record, env: check.env, systemd: check.found.systemd, systemctl: systemctl, runuser: runuser, unattended: machine.there(unattendedUpgrade), - current: machine.there(filepath.Join(root, "current")), dotEnv: dotEnv, graces: graces, uploadCapMB: uploadCap, + current: check.found.current != "", missing: slices.DeleteFunc(parentsOf(root), machine.there), dotEnv: dotEnv, + graces: graces, uploadCapMB: uploadCap, }, nil } @@ -118,6 +127,7 @@ func (s site) steps() []step { if !s.record.ended(stepAdmin) { planned = append(planned, step{ id: stepAdmin, does: "create the first administrator with account:create-admin as gophenberg", + apply: (*installing).createAdmin, }) } return append(planned, s.closingSteps()...) @@ -128,28 +138,32 @@ func (s site) setupSteps() []step { root := s.settings.Root return []step{ {id: "server-env", does: "start the install record " + recordPath + " and write the server settings", - files: []file{{path: serverEnvPath, owner: "root", mode: 0o600, text: s.settings.serverEnv}}}, + files: []file{{path: serverEnvPath, owner: "root", mode: 0o600, text: s.settings.serverEnv}}, + apply: (*installing).writeOnly}, s.packagesStep(), - {id: "user", does: "make the system user gophenberg, which runs the site and signs in to PostgreSQL"}, - {id: "install-root", does: "make the install root " + root + " and its folders, owned by gophenberg"}, - {id: "database", does: "make the database role gophenberg and its database gophenberg as the postgres user"}, + {id: "user", does: "make the system user gophenberg, which runs the site and signs in to PostgreSQL", + apply: (*installing).user}, + {id: "install-root", does: s.installRootDoes(), apply: (*installing).installRoot}, + {id: "database", does: "make the database role gophenberg and its database gophenberg as the postgres user", + apply: (*installing).database}, s.buildStep(), {id: "installer-copy", does: "keep a copy of this installer at " + installerCopy + ", the one root runs for " + - "server:install and server:uninstall"}, + "server:install and server:uninstall", apply: (*installing).keepCopy}, {id: "site-env", does: "write the site's settings, then run check as gophenberg in " + root, - files: []file{s.siteEnvFile()}}, + files: []file{s.siteEnvFile()}, hides: s.keptKeys(), apply: (*installing).writeSiteEnv}, s.unitsStep(), - {id: "command", does: "write the gophenberg command on the path", files: []file{s.commandFile()}}, + {id: "command", does: "write the gophenberg command on the path", files: []file{s.commandFile()}, + apply: (*installing).writeOnly}, } } // packagesStep returns the step that installs PostgreSQL, and Caddy from its own repository when the run sets it up. func (s site) packagesStep() step { if !s.settings.withCaddy() { - return step{id: "packages", does: "install PostgreSQL with apt-get"} + return step{id: "packages", does: "install PostgreSQL with apt-get", apply: (*installing).packages} } return step{id: "packages", does: "install PostgreSQL and Caddy with apt-get, Caddy from its own apt repository", - files: s.aptFiles()} + files: s.aptFiles(), apply: (*installing).packages} } // unitsStep returns the step that writes the systemd units, and Caddy's drop-in when the run sets Caddy up. @@ -158,57 +172,76 @@ func (s site) unitsStep() step { if !s.settings.withCaddy() { does = "write the systemd units, then reload systemd and enable gophenberg.service" } - return step{id: "units", does: does, files: s.unitFiles()} + return step{id: "units", does: does, files: s.unitFiles(), apply: (*installing).units} } // closingSteps returns the steps after the site starts: the HTTPS check with Caddy, else the person's web server lines. func (s site) closingSteps() []step { - summary := step{id: "summary", does: "print the summary and end the install record"} + summary := step{id: "summary", does: "print the summary and end the install record", apply: (*installing).summary} if !s.settings.withCaddy() { return []step{summary, s.webServerStep()} } https := step{id: "https", does: fmt.Sprintf("check that https://%s/admin/ answers within %s", s.settings.Domain, - s.settings.Checks.HTTPSWait)} + s.settings.Checks.HTTPSWait), apply: (*installing).https} return []step{https, summary} } // webServerStep returns the step that prints the lines the person's web server needs to send the domain to the site. func (s site) webServerStep() step { return step{id: "web-server", does: fmt.Sprintf("print the lines for nginx, Caddy, Traefik and Apache that send %s "+ - "to the site at %s", s.settings.Domain, s.settings.Addr), prints: render("web-server.txt", s.data())} + "to the site at %s", s.settings.Domain, s.settings.Addr), prints: render("web-server.txt", s.data()), + apply: (*installing).printWebServer} } // buildStep returns the step that unpacks the release archive, or keeps the build current names once there is one. func (s site) buildStep() step { current := s.settings.Root + "/current" if s.current { - return step{id: "build", does: "keep the build " + current + " names, since updates go through update:apply"} + return step{id: "build", does: "keep the build " + current + " names, since updates go through update:apply", + apply: (*installing).build} } return step{id: "build", does: "unpack the release archive into " + s.settings.Root + "/versions and point " + - current + " at it"} + current + " at it", apply: (*installing).build} } -// caddyStep returns the step that writes Caddy's files and restarts Caddy, or reloads it once the step ended. +// caddyStep returns the step that writes Caddy's files and restarts Caddy, or reloads it once the record ended it. func (s site) caddyStep() step { - does := "write Caddy's files, keep the package's Caddyfile as /etc/caddy/Caddyfile.before-gophenberg, check them " + - "with caddy validate and restart Caddy" + does := "write Caddy's files, keep the package's Caddyfile as " + keptCaddyfilePath + ", check them with caddy " + + "validate and restart Caddy" + if s.caddyfileWritten() { + does = "write Caddy's files, check them with caddy validate and " + s.caddyVerb() + " Caddy" + } + return step{id: stepCaddy, does: does, files: s.caddyFiles(), apply: (*installing).caddy} +} + +// caddyfileWritten reports whether the record holds the first run's Caddyfile sum, cleared by server:uninstall alone. +func (s site) caddyfileWritten() bool { + return s.record.Caddyfile != "" +} + +// caddyVerb returns how a run that keeps the Caddyfile gets Caddy to take its files: restart until the step ended. +func (s site) caddyVerb() string { if s.record.ended(stepCaddy) { - does = "write Caddy's files, check them with caddy validate and reload Caddy" + return "reload" } - return step{id: stepCaddy, does: does, files: s.caddyFiles()} + return "restart" } -// startStep returns the step that starts the site and waits for its pages, or restarts it once the step ended. +// startStep returns the step that starts the site and waits for its pages, or restarts it once the record owns it. func (s site) startStep() step { - start := "start" - if s.record.ended(stepStart) { - start = "restart" + return step{id: stepStart, does: s.startVerb() + " gophenberg.service and wait until /admin/, /api/locale and / " + + "answer on " + s.settings.Addr.String(), apply: (*installing).start} +} + +// startVerb returns how the run starts the site: start, or restart once the record owns gophenberg.service. +func (s site) startVerb() string { + if s.record.owns(pieceService) { + return "restart" } - return step{id: stepStart, does: start + " gophenberg.service and wait until /admin/, /api/locale and / answer " + - "on " + s.settings.Addr.String()} + return "start" } -// printSteps prints each of steps with the full text of every file it writes and of what it prints, after the lead. +// printSteps prints each of steps, the text of every file it writes with the values it hides left out, and its text. func printSteps(w io.Writer, steps []step) error { var answer strings.Builder answer.WriteString(dryRunLead) @@ -217,7 +250,7 @@ func printSteps(w io.Writer, steps []step) error { for _, written := range planned.files { fmt.Fprintf(&answer, " writes %s, owned by %s with mode %04o:\n", written.path, written.owner, written.mode) - writeIndented(&answer, written.text) + writeIndented(&answer, hidden(written.text, planned.hides)) } if planned.prints != "" { answer.WriteString(" prints:\n") @@ -228,6 +261,18 @@ func printSteps(w io.Writer, steps []step) error { return err } +// hidden returns text with keptValue in place of the value of each line whose key is one of keys. +func hidden(text string, keys []string) string { + var shown strings.Builder + for line := range strings.Lines(text) { + if key, _, _ := strings.Cut(line, "="); slices.Contains(keys, key) { + line = key + "=" + keptValue + "\n" + } + shown.WriteString(line) + } + return shown.String() +} + // writeIndented writes text to answer with every line that is not empty indented by four spaces. func writeIndented(answer *strings.Builder, text string) { for line := range strings.Lines(text) { diff --git a/internal/installer/steps_internal_test.go b/internal/installer/steps_internal_test.go index 0d94f83a..181a76c1 100644 --- a/internal/installer/steps_internal_test.go +++ b/internal/installer/steps_internal_test.go @@ -3,6 +3,7 @@ package installer import ( + "context" "errors" "slices" "strconv" @@ -97,7 +98,8 @@ func TestTheStepsOfALaterRunKeepTheBuildReloadCaddyRestartTheSiteAndCreateNoAdmi running := firstSite(t, "example.com", 255) running.current = true - running.record.Steps = map[string]string{stepCaddy: stepEnded, stepStart: stepEnded, stepAdmin: stepEnded} + running.record.Steps = map[string]string{stepAdmin: stepEnded, stepCaddy: stepEnded} + running.record.Caddyfile, running.record.Installed = caddyfileSum(wantedCaddyfile), []string{pieceService} does, ids := doneOf(running.steps()) @@ -115,6 +117,24 @@ func TestTheStepsOfALaterRunKeepTheBuildReloadCaddyRestartTheSiteAndCreateNoAdmi } } +func TestTheStepsStartTheSiteUntilTheRecordOwnsItsUnitAndRestartItAfter(t *testing.T) { + t.Parallel() + + for installed, verb := range map[string]string{"": "start", pieceService: "restart"} { + running := firstSite(t, "example.com", 255) + running.record.Steps = map[string]string{stepStart: stepEnded} + if installed != "" { + running.record.Steps, running.record.Installed = map[string]string{stepStart: stepStarted}, []string{installed} + } + + does, _ := doneOf(running.steps()) + + if want := verb + " " + strings.TrimPrefix(firstSteps[11], "start "); does[11] != want { + t.Errorf("the start step of a record that owns %q does %q, want %q", installed, does[11], want) + } + } +} + func TestTheStepsOfARunCutOffBeforeTheAdministratorWasCreatedStillCreateIt(t *testing.T) { t.Parallel() @@ -179,6 +199,35 @@ Step 2: write the units } } +func TestPrintStepsPrintsEachLineAStepKeepsByItsKeyAloneAndLeavesTheFileWhole(t *testing.T) { + t.Parallel() + + var answer strings.Builder + dotEnv := "# Written by gophenberg server:install.\nGOPHENBERG_ADDR=127.0.0.1:8081\n" + + "GOPHENBERG_SMTP_PASSWORD=\"Maria's mail password\"\nGOPHENBERG_SMTP_USER=maria=perez\nGOPHENBERG_THEME=starter\n" + planned := step{id: "site-env", does: "write the site's settings", hides: []string{ + "GOPHENBERG_SMTP_PASSWORD", "GOPHENBERG_SMTP_USER", + }, files: []file{{"/opt/gophenberg/.env", "gophenberg", 0o600, dotEnv}}} + + err := printSteps(&answer, []step{planned}) + + want := wantedLead + ` +Step 1: write the site's settings + writes /opt/gophenberg/.env, owned by gophenberg with mode 0600: + # Written by gophenberg server:install. + GOPHENBERG_ADDR=127.0.0.1:8081 + GOPHENBERG_SMTP_PASSWORD=(kept as it is) + GOPHENBERG_SMTP_USER=(kept as it is) + GOPHENBERG_THEME=starter +` + if err != nil || answer.String() != want { + t.Errorf("printSteps() = %v, printing\n%s\nwant\n%s", err, answer.String(), want) + } + if planned.files[0].text != dotEnv { + t.Errorf("printSteps() left the file holding\n%s\nwant it whole\n%s", planned.files[0].text, dotEnv) + } +} + func TestPrintStepsPrintsTheTextAStepPrintsUnderItsFiles(t *testing.T) { t.Parallel() @@ -215,7 +264,7 @@ func TestInstallPrintsTheLinesForTheirOwnWebServerWithTheUploadCapOfTheSitesEnv( server := installedServer(t, wantedSiteEnv+"GOPHENBERG_MEDIA_UPLOAD_CAP_MB=512\n") server.write(serverEnvPath, strings.Replace(firstServerEnv, "WEB_SERVER=caddy", "WEB_SERVER=none", 1)) - stdout, stderr, err := dryRun(server, Request{DryRun: true}) + stdout, stderr, err := runInstall(server, Request{DryRun: true}) if err != nil || stderr != "" { t.Fatalf("Install() = %v, noting %q, want nil and no note", err, stderr) @@ -233,7 +282,7 @@ func TestInstallRefusesASiteEnvWithAnUploadCapServeCannotReadForTheirOwnWebServe server := installedServer(t, "GOPHENBERG_MEDIA_UPLOAD_CAP_MB=big\n") server.write(serverEnvPath, strings.Replace(firstServerEnv, "WEB_SERVER=caddy", "WEB_SERVER=none", 1)) - stdout, _, err := dryRun(server, Request{DryRun: true}) + stdout, _, err := runInstall(server, Request{DryRun: true}) wantRefused(t, err, `/opt/gophenberg/.env: GOPHENBERG_MEDIA_UPLOAD_CAP_MB: must be a whole number, got "big"`) if stdout != "" { @@ -246,7 +295,7 @@ func TestInstallLeavesTheUploadCapOfTheSitesEnvUnreadWithCaddy(t *testing.T) { server := installedServer(t, "GOPHENBERG_MEDIA_UPLOAD_CAP_MB=big\n") - stdout, _, err := dryRun(server, Request{DryRun: true}) + stdout, _, err := runInstall(server, Request{DryRun: true}) if err != nil || !strings.HasPrefix(stdout, wantedLead) { t.Errorf("Install() = %v, printing\n%s\nwant every step, the upload cap left unread", err, stdout) @@ -261,16 +310,22 @@ func TestPrintStepsReturnsWhatStopsTheAnswer(t *testing.T) { wantRefused(t, err, "the terminal is gone") } -// dryRun runs server:install as request asks on server, returning its answer, its notes and its error. -func dryRun(server *fakeServer, request Request) (stdout, stderr string, err error) { - return dryRunUnder(server, nil, request) +// runInstall runs server:install on server as request asks, with -dry-run or not, returning answer, notes and error. +func runInstall(server *fakeServer, request Request) (stdout, stderr string, err error) { + return runInstallUnder(server, nil, request) +} + +// runInstallUnder runs server:install as request asks on server under the settings env holds, as runInstall returns. +func runInstallUnder(server *fakeServer, env map[string]string, request Request) (stdout, stderr string, err error) { + return installUnder(server.t.Context(), server, env, request) } -// dryRunUnder runs server:install as request asks on server under the settings env holds, as dryRun returns. -func dryRunUnder(server *fakeServer, env map[string]string, request Request) (stdout, stderr string, err error) { +// installUnder runs server:install within ctx as request asks on server under env, as runInstall returns. +func installUnder( + ctx context.Context, server *fakeServer, env map[string]string, request Request, +) (stdout, stderr string, err error) { var answer, notes strings.Builder - err = Install(server.t.Context(), server.machine(), gonsole.Call{Env: envOf(env), Stdout: &answer, Stderr: ¬es}, - request) + err = Install(ctx, server.machine(), gonsole.Call{Env: envOf(env), Stdout: &answer, Stderr: ¬es}, request) return answer.String(), notes.String(), err } @@ -279,7 +334,7 @@ func TestInstallPrintsEveryStepOfADryRunOnceEveryCheckPasses(t *testing.T) { server := newFakeServer(t) - stdout, stderr, err := dryRun(server, firstRun) + stdout, stderr, err := runInstall(server, firstRun) if err != nil || stderr != wantedFirewallNote { t.Fatalf("Install() = %v, noting %q, want nil and the firewall note", err, stderr) @@ -309,20 +364,6 @@ func indentedText(text string) string { return printed.String() } -func TestInstallStopsARunWithoutDryRunOnceEveryCheckPasses(t *testing.T) { - t.Parallel() - - server := newFakeServer(t) - - stdout, _, err := dryRun(server, Request{Domain: "example.com", AdminEmail: "admin@example.com"}) - - wantRefused(t, err, "this build of server:install only checks the server, and -dry-run prints the steps it "+ - "would take, so nothing was changed") - if stdout != "" { - t.Errorf("Install() printed %q without -dry-run, want nothing", stdout) - } -} - func TestInstallRefusesAServerWhereTheRunnerFindsNoSystemctlOrNoRunuser(t *testing.T) { t.Parallel() @@ -330,7 +371,7 @@ func TestInstallRefusesAServerWhereTheRunnerFindsNoSystemctlOrNoRunuser(t *testi server := newFakeServer(t) server.chmod(missing, 0o644) - _, _, err := dryRun(server, firstRun) + _, _, err := runInstall(server, firstRun) wantRefused(t, err, "installer: no program "+missing[strings.LastIndex(missing, "/")+1:]+ " in /usr/sbin:/usr/bin:/sbin:/bin") @@ -352,7 +393,7 @@ func TestInstallRefusesASiteEnvWithAStopGraceServeCannotRead(t *testing.T) { server := installedServer(t, "GOPHENBERG_SHUTDOWN_GRACE=soon\n") - stdout, _, err := dryRun(server, Request{DryRun: true}) + stdout, _, err := runInstall(server, Request{DryRun: true}) wantRefused(t, err, `/opt/gophenberg/.env: GOPHENBERG_SHUTDOWN_GRACE: must be a duration like 30s, got "soon"`) if stdout != "" { @@ -365,7 +406,7 @@ func TestInstallRefusesASiteEnvItCannotParse(t *testing.T) { server := installedServer(t, "not a setting line\n") - _, _, err := dryRun(server, Request{DryRun: true}) + _, _, err := runInstall(server, Request{DryRun: true}) wantRefused(t, err, `installer: /opt/gophenberg/.env: unexpected character "\n" in variable name near `+ `"not a setting line\n"`) @@ -377,7 +418,7 @@ func TestInstallWritesTheUnattendedUpgradesLineWhereThatPackageIsInstalled(t *te server := newFakeServer(t) server.program("/usr/bin/unattended-upgrade") - stdout, _, err := dryRun(server, firstRun) + stdout, _, err := runInstall(server, firstRun) header := " writes /etc/apt/apt.conf.d/52gophenberg-caddy, owned by root with mode 0644:\n" if err != nil || !strings.Contains(stdout, header+indentedText(wantedCaddyUpgrades)) { diff --git a/internal/installer/templates/gophenberg-rollback.service b/internal/installer/templates/gophenberg-rollback.service index c5c8736f..8642b147 100644 --- a/internal/installer/templates/gophenberg-rollback.service +++ b/internal/installer/templates/gophenberg-rollback.service @@ -10,6 +10,7 @@ Type=oneshot User=gophenberg Group=gophenberg WorkingDirectory={{.Root}} +ExecCondition=+{{.Systemctl}} is-failed --quiet gophenberg.service ExecStart={{.Root}}/previous/gophenberg update:rollback -after-failure ExecStartPost=+{{.Systemctl}} reset-failed gophenberg.service ExecStartPost=+{{.Systemctl}} start gophenberg.service diff --git a/internal/installer/uninstall.go b/internal/installer/uninstall.go new file mode 100644 index 00000000..f9910c27 --- /dev/null +++ b/internal/installer/uninstall.go @@ -0,0 +1,740 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "maps" + "os" + "os/user" + "path/filepath" + "slices" + "strings" + "syscall" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/gophenberg/internal/updater" +) + +// uninstallCommand is the name of the command that removes Gophenberg from a server. +const uninstallCommand = "server:uninstall" + +// caddyDropIn is the drop-in the units step writes that gives Caddy the folder of its admin socket. +const caddyDropIn = "/etc/systemd/system/caddy.service.d/gophenberg.conf" + +// caddyImport is the line of the Caddyfile a first run writes that has Caddy serve Gophenberg's site. +const caddyImport = "import " + caddySitePath + +// databaseLine names the database and the role server:install makes, kept or dropped. +const databaseLine = "the database gophenberg and its role gophenberg" + +// siteUserLine names the OS user server:install makes, kept or removed. +const siteUserLine = "the system user gophenberg" + +// removedLine is the last line server:uninstall prints once it made every change it lists. +const removedLine = "server:uninstall removed what it lists and kept the rest.\n" + +// keptSteps are the steps a run without -delete-data leaves as they were, since what they made stays on the server. +var keptSteps = []string{"server-env", "packages", "user", "database", stepAdmin} + +// unitPaths are the site's unit and its rollback unit, as the units step writes them for every web server. +var unitPaths = writtenPaths(site{}.unitFiles()) + +// repositoryPaths are the files that bring Caddy from its own apt repository, its unattended-upgrades line included. +var repositoryPaths = writtenPaths(site{unattended: true}.aptFiles()) + +// builtNames are the builds, downloads, links and update state server:uninstall removes, by their names in the root. +var builtNames = []string{ + updater.Layout{}.Build(""), filepath.Dir(updater.Layout{}.Download("")), updater.Current, updater.Previous, + updater.Layout{}.StateFile(), +} + +// caddyLeftovers are the files of Caddy's a run keeps while it keeps the person's Caddyfile, each with why. +var caddyLeftovers = []struct{ path, why string }{ + {caddyDropIn, ", which gives Caddy the folder of the admin socket its Caddyfile names"}, + {keptCaddyfilePath, ", the Caddyfile the caddy package wrote"}, +} + +// unitNotLoaded ends each line systemctl reset-failed prints for a unit systemd no longer holds. +const unitNotLoaded = " not loaded." + +// daemonReload has systemd read its units again once a unit or a drop-in is gone. +var daemonReload = Program{Name: "systemctl", Args: []string{"daemon-reload"}} + +// dropPrograms drop the database gophenberg, then its role, as the postgres user. +var dropPrograms = []Program{ + {Name: "dropdb", User: "postgres", Args: []string{siteUser}}, + {Name: "dropuser", User: "postgres", Args: []string{siteUser}}, +} + +// removing is one run of server:uninstall: what it acts on, what it found and saved, its trees, lists and changes. +type removing struct { + machine Machine + call gonsole.Call + settings Settings + found Record + saved Record + deleteData bool + system *os.Root + tree *os.Root + held []byte + caddyfile string + removed []string + kept []string + changes []func(context.Context) error +} + +// datum is a piece of the site's data, by the line that names it kept and the one that names it removed, and how. +type datum struct { + kept, removed string + remove func(context.Context) error +} + +// spot is one file or folder the run removes: the tree it sits in, its name there and its path from the top. +type spot struct { + tree *os.Root + name, path string +} + +// listing is one list server:uninstall prints, by its header and its lines. +type listing struct { + header string + lines []string +} + +// writtenPaths returns the path of each of files. +func writtenPaths(files []file) []string { + paths := make([]string, 0, len(files)) + for _, written := range files { + paths = append(paths, written.path) + } + return paths +} + +// Uninstall removes what server:install set up on machine and keeps the data, or with deleteData removes that too. +func Uninstall(ctx context.Context, machine Machine, call gonsole.Call, deleteData bool) error { + unlock, err := lockedFor(machine, uninstallCommand, call.Apply) + if err != nil { + return err + } + defer unlock() + u, err := removingOn(machine, call) + if err != nil { + return err + } + defer u.close() + u.deleteData = deleteData + if err := u.plan(); err != nil { + return err + } + if err := u.print(); err != nil || !call.Apply { + return err + } + return u.apply(ctx) +} + +// removingOn returns the run of server:uninstall on machine, refusing one that finds no record. +func removingOn(machine Machine, call gonsole.Call) (*removing, error) { + settings, err := removalSettings(machine, call.Env) + if err != nil { + return nil, err + } + found, err := readRecord(machine) + if err != nil { + return nil, err + } + if found.Installer == "" { + return nil, fmt.Errorf("this server has no install record at %s, so %s has nothing to remove", recordPath, + uninstallCommand) + } + system, err := machine.tree("/") + if err != nil { + return nil, err + } + return &removing{machine: machine, call: call, settings: settings, found: found, saved: found, system: system}, nil +} + +// removalSettings reads what server:uninstall acts on from server.env alone and the defaults, never the environment. +func removalSettings(machine Machine, env gonsole.Env) (Settings, error) { + file, err := readServerEnv(machine) + if err != nil { + return Settings{}, err + } + read := gonsole.Env{Prefix: env.Prefix, Getenv: layered(lookup(file), lookup(defaultLines(env, Request{})))} + root, rootErr := gonsole.Parse(read, "INSTALL_ROOT", "", installRoot) + domain, domainErr := gonsole.Parse(read, "PUBLIC_URL", "", httpsHost) + webServer, webServerErr := gonsole.Parse(read, "INSTALL_WEB_SERVER", "", webServerName) + email, emailErr := gonsole.Parse(read, "INSTALL_CERTIFICATE_EMAIL", "", certificateEmail) + settings := Settings{Root: root, Domain: domain, WebServer: webServer, CertificateEmail: email} + return settings, errors.Join(rootErr, domainErr, webServerErr, emailErr) +} + +// close closes the system tree and, once the run opened it, the install tree. +func (u *removing) close() { + _ = u.system.Close() + if u.tree != nil { + _ = u.tree.Close() + } +} + +// plan lists what the run removes and keeps, in the order it removes them. +func (u *removing) plan() error { + u.planService() + if err := u.planCaddy(); err != nil { + return err + } + u.removesPaths(u.present([]string{caddySitePath, maintenanceFolder})...) + u.planCommand() + if err := u.planRoot(); err != nil { + return err + } + if err := u.planData(); err != nil { + return err + } + u.planKept() + return nil +} + +// planService lists the site's units, the site stopped and disabled and both cleared before they go, then reloads. +func (u *removing) planService() { + units := u.present(unitPaths) + if len(units) == 0 { + return + } + names := make([]string, 0, len(units)) + for _, unit := range units { + names = append(names, filepath.Base(unit)) + } + if slices.Contains(names, pieceService) { + u.removed = append(u.removed, pieceService+", stopped and disabled") + u.then(u.runs(Program{Name: "systemctl", Args: []string{"disable", "--now", pieceService}})) + } + u.then(u.resetFailed(names)) + u.removesPaths(units...) + u.then(u.runs(daemonReload)) +} + +// resetFailed returns the change that clears the failed state of units, taking units systemd let go of as cleared. +func (u *removing) resetFailed(units []string) func(context.Context) error { + program := Program{Name: "systemctl", Args: append([]string{"reset-failed"}, units...)} + return func(ctx context.Context) error { + printed, err := u.machine.Run(ctx, program) + if notLoaded(printed) { + return nil + } + return withPrinted(err, printed) + } +} + +// notLoaded reports whether printed holds a line and each line it holds says systemd holds no such unit. +func notLoaded(printed string) bool { + lines := slices.DeleteFunc(trimmedLines(printed), func(line string) bool { return line == "" }) + return len(lines) > 0 && !slices.ContainsFunc(lines, func(line string) bool { + return !strings.HasSuffix(line, unitNotLoaded) + }) +} + +// planCaddy lists how the run takes Gophenberg's site out of Caddy and what of Caddy's it keeps, with why. +func (u *removing) planCaddy() error { + u.caddyfile = u.found.Caddyfile + if !u.withCaddy() { + return nil + } + held, err := u.machine.read(caddyfilePath) + if err != nil { + return err + } + u.held = held + switch { + case u.putsBack(): + u.putBack() + case u.held == nil && u.caddyOwed(): + return stop("%s needs %s to take Gophenberg's site out of Caddy, and it is not there", uninstallCommand, + caddyfilePath) + case u.restartOwed(): + u.finishPutBack() + default: + return u.keepCaddyfile() + } + return nil +} + +// importsSite reports whether the Caddyfile on the server holds the line that has Caddy serve Gophenberg's site. +func (u *removing) importsSite() bool { + return slices.Contains(trimmedLines(string(u.held)), caddyImport) +} + +// withCaddy reports whether Caddy serves Gophenberg's site: server.env says so, or the site's Caddy file is there. +func (u *removing) withCaddy() bool { + return u.settings.withCaddy() || u.machine.there(caddySitePath) +} + +// putsBack reports whether the package's Caddyfile goes back: it is kept, and the Caddyfile is gone or the installer's. +func (u *removing) putsBack() bool { + if !u.machine.there(keptCaddyfilePath) { + return false + } + return u.held == nil || u.installersCaddyfile() +} + +// installersCaddyfile reports whether the Caddyfile is the one server:install wrote, by the record's sum or its text. +func (u *removing) installersCaddyfile() bool { + if u.found.Caddyfile == "" { + return string(u.held) == u.renderedCaddyfile() + } + return caddyfileSum(string(u.held)) == u.found.Caddyfile +} + +// renderedCaddyfile returns the Caddyfile a first run writes under the settings of the run. +func (u *removing) renderedCaddyfile() string { + return render("Caddyfile", site{settings: u.settings}.data()) +} + +// caddyOwed reports whether Caddy still has to let go of Gophenberg's site, by a reload or a restart. +func (u *removing) caddyOwed() bool { + return u.machine.there(caddySitePath) || u.restartOwed() +} + +// restartOwed reports whether Caddy still owes the restart of a put back or a first run that a cut-off left undone. +func (u *removing) restartOwed() bool { + if u.importsSite() || u.found.Caddyfile != "" { + return false + } + if u.machine.there(keptCaddyfilePath) { + return false + } + return u.found.Steps[stepCaddy] != "" || u.machine.there(caddyDropIn) +} + +// putBack lists the Caddyfile going back to the package's once the record shows it, then the restart of Caddy. +func (u *removing) putBack() { + u.removed = append(u.removed, caddyfilePath+", putting back "+keptCaddyfilePath+", the one the caddy package wrote") + u.then(u.reopenCaddy) + u.then(func(context.Context) error { + if err := u.system.Rename(inSystemTree(keptCaddyfilePath), inSystemTree(caddyfilePath)); err != nil { + return fmt.Errorf("installer: put %s back as %s: %w", keptCaddyfilePath, caddyfilePath, err) + } + return nil + }) + u.restartCaddy() +} + +// finishPutBack lists the restart a cut-off run owes Caddy on the package's Caddyfile, once the record shows it. +func (u *removing) finishPutBack() { + u.removed = append(u.removed, "Gophenberg's settings in Caddy, restarting Caddy on "+caddyfilePath+", the one "+ + "the caddy package wrote") + u.then(u.reopenCaddy) + u.restartCaddy() +} + +// restartCaddy lists Caddy's drop-in as removed, then reloads systemd and restarts Caddy on its own admin, no sum kept. +func (u *removing) restartCaddy() { + u.removesPaths(u.present([]string{caddyDropIn})...) + u.then(u.runs(daemonReload)) + u.then(u.runs(unitProgram("restart", caddyUnit))) + u.caddyfile = "" +} + +// reopenCaddy saves the record found with no Caddyfile sum and the caddy step started, before Caddy's files change. +func (u *removing) reopenCaddy(context.Context) error { + reopened := u.found + reopened.Caddyfile, reopened.Steps = "", map[string]string{} + maps.Copy(reopened.Steps, u.found.Steps) + reopened.Steps[stepCaddy] = stepStarted + return u.leave(reopened) +} + +// keepCaddyfile keeps a Caddyfile the person changed with its sum, reloads or restarts Caddy and lists what it keeps. +func (u *removing) keepCaddyfile() error { + if err := u.keepHeld(); err != nil { + return err + } + if u.importsSite() || u.machine.there(caddySitePath) { + u.then(u.runs(unitProgram(site{record: u.found}.caddyVerb(), caddyUnit))) + } + for _, leftover := range caddyLeftovers { + if u.machine.there(leftover.path) { + u.kept = append(u.kept, leftover.path+leftover.why) + } + } + return nil +} + +// keepHeld lists a Caddyfile server:install wrote as kept and saves its sum, then lists its import line going out. +func (u *removing) keepHeld() error { + if u.held == nil || !u.wroteCaddyfile() { + return nil + } + if u.importsSite() && u.caddyfileLinked() { + return stop("%s is a link, and the line %q must come out of the file it leads to before %s runs again", + caddyfilePath, caddyImport, uninstallCommand) + } + u.caddyfile = cmp.Or(u.found.Caddyfile, caddyfileSum(u.renderedCaddyfile())) + if u.caddyfile != u.found.Caddyfile { + u.then(u.saveSum) + } + u.takeOutImport() + return nil +} + +// wroteCaddyfile reports whether server:install wrote the Caddyfile, as a sum, the import line or the kept one shows. +func (u *removing) wroteCaddyfile() bool { + if u.found.Caddyfile != "" || u.importsSite() { + return true + } + return u.machine.there(keptCaddyfilePath) +} + +// caddyfileLinked reports whether the Caddyfile is a link. +func (u *removing) caddyfileLinked() bool { + info, _ := u.machine.lstat(caddyfilePath) + return info != nil && info.Mode()&fs.ModeSymlink != 0 +} + +// saveSum saves the record found with the sum of the Caddyfile the run keeps, its steps as found, before Caddy changes. +func (u *removing) saveSum(context.Context) error { + kept := u.found + kept.Caddyfile = u.caddyfile + return u.leave(kept) +} + +// takeOutImport lists the Caddyfile as kept and, where it holds the import line, that line going out. +func (u *removing) takeOutImport() { + asks := "the line " + caddyImport + " in it" + if u.importsSite() { + asks = "that line back" + u.removed = append(u.removed, "the line "+caddyImport+" of "+caddyfilePath) + u.then(u.rewriteCaddyfile) + } + u.kept = append(u.kept, caddyfilePath+", which changed since server:install wrote it, so a reinstall asks for "+asks) +} + +// rewriteCaddyfile writes the Caddyfile as it reads now without the import line, with the mode, owner and group it has. +func (u *removing) rewriteCaddyfile(context.Context) error { + name := inSystemTree(caddyfilePath) + held, readErr := u.system.ReadFile(name) + info, statErr := u.system.Stat(name) + if err := cmp.Or(readErr, statErr); err != nil { + return fmt.Errorf("installer: %w", err) + } + var ids *owner + if stat, described := info.Sys().(*syscall.Stat_t); described { + ids = &owner{uid: int(stat.Uid), gid: int(stat.Gid)} + } + kept := file{path: caddyfilePath, owner: "root", mode: info.Mode().Perm(), text: withoutImport(string(held))} + return writeFile(u.system, name, kept, ids) +} + +// withoutImport returns text without each of its lines that reads the import line. +func withoutImport(text string) string { + var kept strings.Builder + for line := range strings.Lines(text) { + if strings.TrimSpace(line) != caddyImport { + kept.WriteString(line) + } + } + return kept.String() +} + +// planCommand lists the command on the path when server:install wrote it, else keeps it, then the installer's copy. +func (u *removing) planCommand() { + info, _ := u.machine.lstat(commandPath) + switch { + case info == nil: + case u.machine.wroteCommand(info): + u.removesPaths(commandPath) + default: + u.kept = append(u.kept, commandPath+", which server:install did not write") + } + u.removesPaths(u.present([]string{installerCopy})...) +} + +// wroteCommand reports whether info shows a regular file at commandPath that begins as server:install writes it. +func (m Machine) wroteCommand(info fs.FileInfo) bool { + if !info.Mode().IsRegular() { + return false + } + head, err := m.head(commandPath, len(commandHeader)) + return err == nil && head == commandHeader +} + +// planRoot opens the install root the record owns as a tree and lists its builds, downloads, links and update state. +func (u *removing) planRoot() error { + u.keepOtherRoots() + root := u.settings.Root + if !u.machine.there(root) { + return nil + } + if !u.found.owns(root) { + u.kept = append(u.kept, root+", since the install record does not show that server:install made it") + return nil + } + tree, err := u.openRoot() + if err != nil { + return fmt.Errorf("installer: open the install root %s: %w", root, err) + } + u.tree = tree + return u.planBuilt() +} + +// keepOtherRoots lists as kept each install root the record owns that is on the server and server.env does not name. +func (u *removing) keepOtherRoots() { + for _, piece := range u.found.Installed { + if u.otherRoot(piece) { + u.kept = append(u.kept, piece+", an install root server:install made, kept since "+serverEnvPath+ + " does not name it") + } + } +} + +// otherRoot reports whether piece is an install root on the server that server.env does not name. +func (u *removing) otherRoot(piece string) bool { + if !filepath.IsAbs(piece) || piece == u.settings.Root { + return false + } + return u.machine.there(piece) +} + +// openRoot opens the install root, a real folder, as a tree that no link inside it leads out of. +func (u *removing) openRoot() (*os.Root, error) { + name := inSystemTree(u.settings.Root) + if err := realFolder(u.system.Lstat(name)); err != nil { + return nil, err + } + return u.system.OpenRoot(name) +} + +// planBuilt lists each build, download, link and update state the install root holds, removed through its tree. +func (u *removing) planBuilt() error { + for _, name := range builtNames { + built := spot{tree: u.tree, name: name, path: filepath.Join(u.settings.Root, name)} + _, err := u.tree.Lstat(name) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return fmt.Errorf("installer: cannot tell whether %s is there: %w", built.path, err) + } + u.removed = append(u.removed, built.path) + u.then(built.remove) + } + return nil +} + +// planData lists the database, the install root, server.env and the site's user, removed with -delete-data, else kept. +func (u *removing) planData() error { + held, err := u.data() + if err != nil { + return err + } + for _, piece := range held { + if !u.deleteData { + u.kept = append(u.kept, piece.kept) + continue + } + u.removed = append(u.removed, piece.removed) + u.then(piece.remove) + } + return nil +} + +// data returns the pieces of the site's data on the server: its database, install root, server.env and OS user. +func (u *removing) data() ([]datum, error) { + var held []datum + if u.found.Steps["database"] != "" { + held = append(held, datum{kept: databaseLine, removed: databaseLine, remove: u.dropDatabase}) + } + if u.tree != nil { + root := "the install root " + u.settings.Root + held = append(held, datum{ + kept: root + ", with the site's .env, media, themes and backups", removed: root + " and everything in it", + remove: u.removeRoot, + }) + } + if u.machine.there(serverEnvPath) { + settings := u.systemSpot(serverEnvPath) + held = append(held, datum{kept: serverEnvPath, removed: serverEnvPath, remove: settings.remove}) + } + _, err := u.machine.LookupUser(siteUser) + if errors.As(err, new(user.UnknownUserError)) { + return held, nil + } + if err != nil { + return nil, fmt.Errorf("installer: %w", err) + } + userdel := u.runs(Program{Name: "userdel", Args: []string{siteUser}}) + return append(held, datum{kept: siteUserLine, removed: siteUserLine, remove: userdel}), nil +} + +// dropDatabase drops the database gophenberg and its role as postgres, taking one that does not exist as dropped. +func (u *removing) dropDatabase(ctx context.Context) error { + for _, program := range dropPrograms { + printed, err := u.machine.Run(ctx, program) + if strings.Contains(printed, `"`+siteUser+`" does not exist`) { + continue + } + if err != nil { + return withPrinted(err, printed) + } + } + return nil +} + +// removeRoot removes every entry of the install root through its tree, following no link, then the root itself. +func (u *removing) removeRoot(context.Context) error { + entries, err := fs.ReadDir(u.tree.FS(), ".") + for _, entry := range entries { + if err == nil { + err = u.tree.RemoveAll(entry.Name()) + } + } + if err == nil { + err = u.system.Remove(inSystemTree(u.settings.Root)) + } + if err != nil { + return fmt.Errorf("installer: remove the install root %s: %w", u.settings.Root, err) + } + return nil +} + +// planKept lists what every run keeps: the install record, the packages and the files of Caddy's own repository. +func (u *removing) planKept() { + reason := ", so a later install picks up what is kept" + if u.deleteData { + reason = ", holding the packages server:install installed, so a later install takes them as its own" + } + u.kept = append(u.kept, recordPath+reason) + if packages := u.ownedPackages(); len(packages) > 0 { + u.kept = append(u.kept, "the packages server:install installed, which sudo apt-get purge "+ + strings.Join(packages, " ")+" removes") + } + if files := u.present(repositoryPaths); len(files) > 0 { + u.kept = append(u.kept, joined(files)+", which bring Caddy's security fixes from its own apt repository") + } +} + +// ownedPackages returns the packages the record says server:install installed, in the order it installed them. +func (u *removing) ownedPackages() []string { + return slices.DeleteFunc(slices.Clone(u.found.Installed), func(piece string) bool { + return piece != piecePostgreSQL && piece != pieceCaddy + }) +} + +// print prints what the run removes, or that nothing is left to remove, then what it keeps. +func (u *removing) print() error { + var text strings.Builder + if len(u.removed) == 0 { + text.WriteString(uninstallCommand + " finds nothing left to remove.\n") + } else { + listing{header: uninstallCommand + " removes:", lines: u.removed}.writeTo(&text) + } + listing{header: uninstallCommand + " keeps:", lines: u.kept}.writeTo(&text) + _, err := io.WriteString(u.call.Stdout, text.String()) + return err +} + +// writeTo writes the header of l to text, then each of its lines indented by two spaces. +func (l listing) writeTo(text *strings.Builder) { + text.WriteString(l.header + "\n") + for _, line := range l.lines { + text.WriteString(" " + line + "\n") + } +} + +// apply makes each change in the order listed, then leaves the install record and says it is done. +func (u *removing) apply(ctx context.Context) error { + for _, change := range u.changes { + if err := change(ctx); err != nil { + return err + } + } + if err := u.leave(u.left()); err != nil { + return err + } + _, err := io.WriteString(u.call.Stdout, removedLine) + return err +} + +// left returns the install record the run leaves: the packages, the domain, and the pieces a later install reuses. +func (u *removing) left() Record { + left := Record{ + Installer: u.found.Installer, Domain: u.found.Domain, Steps: map[string]string{}, Installed: u.ownedPackages(), + Caddyfile: u.caddyfile, + } + if !u.deleteData { + left.Installed = slices.DeleteFunc(slices.Clone(u.found.Installed), func(piece string) bool { + return piece == pieceService + }) + for _, id := range keptSteps { + if mark := u.found.Steps[id]; mark != "" { + left.Steps[id] = mark + } + } + } + if left.Caddyfile != "" { + left.Steps[stepCaddy] = stepEnded + } + return left +} + +// leave saves record as the install record, unless it holds what the record the run found or last saved holds. +func (u *removing) leave(record Record) error { + saved, _ := json.Marshal(u.saved) + leaving, _ := json.Marshal(record) + if bytes.Equal(saved, leaving) { + return nil + } + saving := installing{system: u.system, record: record} + if err := saving.save(); err != nil { + return err + } + u.saved = record + return nil +} + +// present returns those of paths that something is at, a link that leads nowhere included. +func (u *removing) present(paths []string) []string { + return slices.DeleteFunc(slices.Clone(paths), func(path string) bool { return !u.machine.there(path) }) +} + +// then adds change to what the run makes, in order, with -yes. +func (u *removing) then(change func(context.Context) error) { + u.changes = append(u.changes, change) +} + +// removesPaths lists each of paths as removed, removing it through the system tree with -yes. +func (u *removing) removesPaths(paths ...string) { + for _, path := range paths { + u.removed = append(u.removed, path) + u.then(u.systemSpot(path).remove) + } +} + +// systemSpot returns the spot of path, named from the top of the server, in the system tree. +func (u *removing) systemSpot(path string) spot { + return spot{tree: u.system, name: inSystemTree(path), path: path} +} + +// runs returns the change that runs program, stopping with what it printed when it fails. +func (u *removing) runs(program Program) func(context.Context) error { + return func(ctx context.Context) error { + printed, err := u.machine.Run(ctx, program) + return withPrinted(err, printed) + } +} + +// remove removes the spot and all it holds through its tree, following no link. +func (s spot) remove(context.Context) error { + if err := s.tree.RemoveAll(s.name); err != nil { + return fmt.Errorf("installer: remove %s: %w", s.path, err) + } + return nil +} diff --git a/internal/installer/uninstall_caddyfile_internal_test.go b/internal/installer/uninstall_caddyfile_internal_test.go new file mode 100644 index 00000000..d4736f8f --- /dev/null +++ b/internal/installer/uninstall_caddyfile_internal_test.go @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "os" + "slices" + "strings" + "testing" +) + +// siteAddedMidRun is a site a person adds to the Caddyfile while server:uninstall runs. +const siteAddedMidRun = "\nexample.net {\n}\n" + +func TestUninstallKeepsWhatThePersonAddsToTheCaddyfileWhileTheRunGoesOn(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, wantedCaddyfile+personSite) + answering := server.effects["systemctl"] + server.effects["systemctl"] = func(program Program) answer { + if lineOf(program) == "systemctl disable --now gophenberg.service" { + held, err := os.ReadFile(server.at(caddyfilePath)) + if err != nil || !strings.Contains(string(held), caddyImport+"\n") { + t.Errorf("the Caddyfile reads %q, %v while the site stops, want the import line still in it", held, err) + } + server.write(caddyfilePath, string(held)+siteAddedMidRun) + } + return answering(program) + } + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the import line taken out", err) + } + wantText(t, server.at(caddyfilePath), + strings.Replace(wantedCaddyfile, caddyImport+"\n", "", 1)+personSite+siteAddedMidRun) +} + +// wantedLostImport is the stop of a later server:install whose Caddyfile lacks the import line. +const wantedLostImport = `/etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier ` + + "run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so nothing " + + "was changed" + +// caddyTurns are the lines that have Caddy take its files, by a reload or a restart. +var caddyTurns = []string{"systemctl reload caddy.service", "systemctl restart caddy.service"} + +// failCaddyTurns has every reload and restart of Caddy on server fail, as on a Caddyfile Caddy cannot take. +func failCaddyTurns(server *fakeServer) { + answering := server.effects["systemctl"] + server.effects["systemctl"] = func(program Program) answer { + if slices.Contains(caddyTurns, lineOf(program)) { + return failed("systemctl", "Job for caddy.service failed.\n") + } + return answering(program) + } +} + +// wantReinstallStopsAtTheImportLine fails t unless a later server:install on server stops at the lost import line. +func wantReinstallStopsAtTheImportLine(t *testing.T, server *fakeServer) { + t.Helper() + server.answerRemovals() + server.forgetRun() + + _, _, err := runInstall(server, Request{Archive: archivePath}) + + wantRefused(t, err, wantedLostImport) +} + +func TestUninstallSavesTheSumOfACaddyfileThePersonChangedAfterACutOffRunBeforeCaddyTakesIt(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + cutOffInCaddyOn(t, server) + server.write(caddyfilePath, wantedCaddyfile+personSite) + want := recordOf(t, server) + want.Caddyfile = caddyfileSum(wantedCaddyfile) + failCaddyTurns(server) + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, "installer: run systemctl: exit status 1, after it printed:\nJob for caddy.service failed.") + if got := recordOf(t, server); !recordsEqual(got, want) { + t.Errorf("the failed run left the record %+v, want the one found with the sum %+v", got, want) + } + kept := strings.Replace(wantedCaddyfile, caddyImport+"\n", "", 1) + personSite + wantText(t, server.at(caddyfilePath), kept) + wantReinstallStopsAtTheImportLine(t, server) + wantText(t, server.at(caddyfilePath), kept) +} + +func TestUninstallSavesTheSumOfACaddyfileThePersonChangedAfterACutOffRunBeforeItsImportLineComesOut(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + cutOffInCaddyOn(t, server) + server.write(caddyfilePath, wantedCaddyfile+personSite) + answering := server.effects["systemctl"] + server.effects["systemctl"] = func(program Program) answer { + if lineOf(program) == "systemctl daemon-reload" { + server.remove(caddyfilePath) + } + return answering(program) + } + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, "installer: openat etc/caddy/Caddyfile: no such file or directory") + if got := recordOf(t, server).Caddyfile; got != caddyfileSum(wantedCaddyfile) { + t.Errorf("the run that stopped at the Caddyfile left the sum %q, want %q", got, caddyfileSum(wantedCaddyfile)) + } +} + +func TestUninstallKeepsTheSumWithACaddyfileThePersonWroteAfterACutOffRunSoAReinstallLeavesItAlone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + cutOffInCaddyOn(t, server) + server.write(caddyfilePath, ownCaddyfile) + + stdout, _, err := uninstallOn(server, nil, true, false) + + want := slices.Concat(removedPrograms[:3], []string{"systemctl restart caddy.service"}) + if err != nil || !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, want) + } + if !strings.Contains(stdout, wantedKeptOwnCaddyfile) { + t.Errorf("Uninstall() printed\n%s\nwant it to hold%s", stdout, wantedKeptOwnCaddyfile) + } + wantKeptCaddy(t, server, caddyfileSum(wantedCaddyfile)) + wantReinstallStopsAtTheImportLine(t, server) + wantText(t, server.at(caddyfilePath), ownCaddyfile) +} + +func TestUninstallRestartsACaddyAFailedRestartLeftStoppedOnACaddyfileThePersonChanged(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + cutOffInCaddyOn(t, server) + record := recordOf(t, server) + record.Caddyfile = caddyfileSum(wantedCaddyfile) + server.saveRecord(record) + server.running[caddyUnit] = false + server.write(caddyfilePath, wantedCaddyfile+personSite) + + _, _, err := uninstallOn(server, nil, true, false) + + want := slices.Concat(removedPrograms[:3], []string{"systemctl restart caddy.service"}) + if err != nil || !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, want) + } + if !server.running[caddyUnit] { + t.Errorf("Caddy is stopped, want it started on the Caddyfile the person changed") + } + wantKeptCaddy(t, server, caddyfileSum(wantedCaddyfile)) +} + +// wantedLinkedCaddyfile is the stop of a run whose Caddyfile is a link while its import line has to come out. +const wantedLinkedCaddyfile = "/etc/caddy/Caddyfile is a link, and the line \"import /etc/caddy/gophenberg.caddy\" " + + "must come out of the file it leads to before server:uninstall runs again, so nothing was changed" + +// linkCaddyfileOn writes text to main.caddy beside the Caddyfile of server and puts a link to it in its place. +func linkCaddyfileOn(t *testing.T, server *fakeServer, text string) { + t.Helper() + server.remove(caddyfilePath) + server.write("/etc/caddy/main.caddy", text) + if err := os.Symlink("main.caddy", server.at(caddyfilePath)); err != nil { + t.Fatalf("linking the Caddyfile: %v", err) + } +} + +func TestUninstallStopsBeforeAnyChangeWhereACaddyfileThePersonChangedIsALink(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + linkCaddyfileOn(t, server, wantedCaddyfile+personSite) + before := server.snapshot() + + stdout, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, wantedLinkedCaddyfile) + if stdout != "" { + t.Errorf("Uninstall() printed %q, want nothing", stdout) + } + wantUnchanged(t, server, before) +} + +func TestUninstallLeavesALinkedCaddyfileWithoutTheImportLineALink(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + linkCaddyfileOn(t, server, ownCaddyfile) + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the run through", err) + } + if target, err := os.Readlink(server.at(caddyfilePath)); err != nil || target != "main.caddy" { + t.Errorf("the Caddyfile reads as a link to %q, %v, want the link to main.caddy kept", target, err) + } + wantText(t, server.at("/etc/caddy/main.caddy"), ownCaddyfile) +} + +func TestUninstallPutsThePackagesCaddyfileBackOverALinkToTheOneTheInstallWrote(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + linkCaddyfileOn(t, server, wantedCaddyfile) + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the package's Caddyfile put back", err) + } + if target, err := os.Readlink(server.at(caddyfilePath)); err == nil { + t.Errorf("the Caddyfile is still a link to %q, want a plain file in its place", target) + } + wantText(t, server.at(caddyfilePath), packageCaddyfile) + wantText(t, server.at("/etc/caddy/main.caddy"), wantedCaddyfile) +} diff --git a/internal/installer/uninstall_internal_test.go b/internal/installer/uninstall_internal_test.go new file mode 100644 index 00000000..a89ec99e --- /dev/null +++ b/internal/installer/uninstall_internal_test.go @@ -0,0 +1,1175 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "maps" + "os" + "os/user" + "slices" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/gophenberg/internal/version" +) + +// wantedRemoves is what server:uninstall lists as removed on a server a first run for example.com set up. +const wantedRemoves = `server:uninstall removes: + gophenberg.service, stopped and disabled + /etc/systemd/system/gophenberg.service + /etc/systemd/system/gophenberg-rollback.service + /etc/caddy/Caddyfile, putting back /etc/caddy/Caddyfile.before-gophenberg, the one the caddy package wrote + /etc/systemd/system/caddy.service.d/gophenberg.conf + /etc/caddy/gophenberg.caddy + /usr/local/share/gophenberg/maintenance + /usr/local/bin/gophenberg + /usr/local/lib/gophenberg/gophenberg + /opt/gophenberg/versions + /opt/gophenberg/downloads + /opt/gophenberg/current +` + +// wantedRestart is the line server:uninstall lists for the restart of Caddy that a run cut off part way left undone. +const wantedRestart = " Gophenberg's settings in Caddy, restarting Caddy on /etc/caddy/Caddyfile, the one the caddy " + + "package wrote\n" + +// wantedPackagesKept is what server:uninstall lists as kept once the install record and the site's data are listed. +const wantedPackagesKept = " the packages server:install installed, which sudo apt-get purge postgresql caddy " + + "removes\n /etc/apt/keyrings/caddy.asc, /etc/apt/sources.list.d/caddy.sources and " + + "/etc/apt/preferences.d/gophenberg-caddy, which bring Caddy's security fixes from its own apt repository\n" + +// wantedKeeps is what server:uninstall lists as kept on a server a first run for example.com set up. +const wantedKeeps = `server:uninstall keeps: + the database gophenberg and its role gophenberg + the install root /opt/gophenberg, with the site's .env, media, themes and backups + /etc/gophenberg/server.env + the system user gophenberg + /var/lib/gophenberg-installer/install.json, so a later install picks up what is kept +` + wantedPackagesKept + +// uninstalledPaths are the paths server:uninstall removes on a server a first run for example.com set up. +var uninstalledPaths = []string{ + "/etc/systemd/system/gophenberg.service", "/etc/systemd/system/gophenberg-rollback.service", caddyDropIn, + keptCaddyfilePath, caddySitePath, maintenanceFolder, commandPath, installerCopy, "/opt/gophenberg/versions", + "/opt/gophenberg/downloads", "/opt/gophenberg/current", +} + +// keptPaths are the paths server:uninstall keeps on a server a first run for example.com set up. +var keptPaths = []string{ + siteEnvAt, "/opt/gophenberg/media", "/opt/gophenberg/themes", "/opt/gophenberg/backups", "/opt/gophenberg/state", + serverEnvPath, recordPath, caddyKeyPath, "/etc/apt/sources.list.d/caddy.sources", + "/etc/apt/preferences.d/gophenberg-caddy", +} + +// removedPrograms are the programs server:uninstall runs on a server a first run for example.com set up. +var removedPrograms = []string{ + "systemctl disable --now gophenberg.service", "systemctl reset-failed gophenberg.service gophenberg-rollback.service", + "systemctl daemon-reload", "systemctl daemon-reload", "systemctl restart caddy.service", +} + +// droppedPrograms are the programs server:uninstall runs after removedPrograms with -delete-data. +var droppedPrograms = []string{ + "runuser -u postgres -- dropdb gophenberg", "runuser -u postgres -- dropuser gophenberg", "userdel gophenberg", +} + +// uninstallOn runs server:uninstall on server under env, applying its changes or not, deleting the data or not. +func uninstallOn(server *fakeServer, env map[string]string, apply, deleteData bool) (stdout, stderr string, err error) { + return uninstallWith(server.t, server.machine(), gonsole.Call{Env: envOf(env), Apply: apply}, deleteData) +} + +// uninstallWith runs server:uninstall on machine as call asks, catching its answer and notes, as uninstallOn returns. +func uninstallWith( + t *testing.T, machine Machine, call gonsole.Call, deleteData bool, +) (stdout, stderr string, err error) { + var answer, notes strings.Builder + call.Stdout, call.Stderr = &answer, ¬es + err = Uninstall(t.Context(), machine, call, deleteData) + return answer.String(), notes.String(), err +} + +// firstInstalled returns a fake server a first run for example.com installed Gophenberg on, its removals answered. +func firstInstalled(t *testing.T) *fakeServer { + t.Helper() + server := newFakeServer(t) + if _, _, err := runInstall(server, flaggedRunOn(t, server)); err != nil { + t.Fatalf("Install() = %v, want Gophenberg installed", err) + } + server.answerRemovals() + server.forgetRun() + return server +} + +// recordOf returns the install record on server. +func recordOf(t *testing.T, server *fakeServer) Record { + t.Helper() + record, err := readRecord(server.machine()) + if err != nil { + t.Fatalf("readRecord() = %v, want the install record", err) + } + return record +} + +// cutOffInCaddyOn leaves the record of server as a first run cut off in the caddy step once it wrote the Caddyfile. +func cutOffInCaddyOn(t *testing.T, server *fakeServer) { + t.Helper() + record := recordOf(t, server) + record.Caddyfile, record.Steps[stepCaddy] = "", stepStarted + for _, id := range stepIDs[slices.Index(stepIDs, stepCaddy)+1:] { + delete(record.Steps, id) + } + server.saveRecord(record) +} + +// wantNothingAt fails t when server holds anything at one of paths. +func wantNothingAt(t *testing.T, server *fakeServer, paths ...string) { + t.Helper() + for _, path := range paths { + if server.machine().there(path) { + t.Errorf("the server holds %s, want it removed", path) + } + } +} + +// wantThere fails t unless server holds something at each of paths. +func wantThere(t *testing.T, server *fakeServer, paths ...string) { + t.Helper() + for _, path := range paths { + if !server.machine().there(path) { + t.Errorf("the server holds nothing at %s, want it kept", path) + } + } +} + +// wantUnchanged fails t when server holds other files than before, or ran a program. +func wantUnchanged(t *testing.T, server *fakeServer, before map[string]string) { + t.Helper() + if !maps.Equal(server.snapshot(), before) { + t.Errorf("the server changed, want it as it was") + } + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run", server.ran) + } +} + +func TestUninstallRefusesARunThatIsNotRootsAndChangesNothing(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.euid = 1000 + before := server.snapshot() + + stdout, _, err := uninstallOn(server, nil, true, true) + + wantRefused(t, err, "server:uninstall must run as root, such as with sudo, so nothing was changed") + if stdout != "" { + t.Errorf("Uninstall() printed %q as a plain user, want nothing", stdout) + } + wantUnchanged(t, server, before) +} + +func TestUninstallStopsOnAServerWithNoInstallRecord(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + before := server.snapshot() + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, "this server has no install record at /var/lib/gophenberg-installer/install.json, so "+ + "server:uninstall has nothing to remove") + wantUnchanged(t, server, before) +} + +func TestUninstallStopsAtAnInstallerFolderAnotherUserOwnsBeforeItReadsTheRecord(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.owners[installerFolder] = 1000 + server.write(recordPath, "{") + before := server.snapshot() + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, installerFolderOpen) + wantUnchanged(t, server, before) +} + +func TestUninstallRefusesServerSettingsAndARecordItCannotRead(t *testing.T) { + t.Parallel() + + unreadable := firstInstalled(t) + unreadable.remove(serverEnvPath) + unreadable.folder(serverEnvPath) + relative := firstInstalled(t) + relative.write(serverEnvPath, "GOPHENBERG_INSTALL_ROOT=opt/gophenberg\nGOPHENBERG_PUBLIC_URL=http://example.com\n") + broken := firstInstalled(t) + broken.write(recordPath, "{") + + for server, want := range map[*fakeServer]string{ + unreadable: "installer: read " + unreadable.at(serverEnvPath) + ": is a directory", + relative: `GOPHENBERG_INSTALL_ROOT: must be an absolute path, got "opt/gophenberg"` + "\n" + + `GOPHENBERG_PUBLIC_URL: must be https:// followed by ` + wantedHost + `, got "http://example.com"`, + broken: "installer: /var/lib/gophenberg-installer/install.json is not an install record: unexpected end " + + "of JSON input", + } { + _, _, err := uninstallOn(server, nil, true, true) + + wantRefused(t, err, want) + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run", server.ran) + } + } +} + +func TestUninstallWithoutApplyListsWhatItRemovesAndKeepsAndChangesNothing(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + before := server.snapshot() + + stdout, stderr, err := uninstallOn(server, nil, false, false) + + if err != nil || stdout != wantedRemoves+wantedKeeps { + t.Errorf("Uninstall() = %v, printing\n%s\nwant\n%s", err, stdout, wantedRemoves+wantedKeeps) + } + if stderr != "" { + t.Errorf("Uninstall() noted %q, want nothing", stderr) + } + wantUnchanged(t, server, before) +} + +func TestUninstallTakesItsSettingsFromServerEnvAloneNeverTheEnvironment(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + env := map[string]string{"GOPHENBERG_INSTALL_ROOT": "/srv/planted", "GOPHENBERG_INSTALL_WEB_SERVER": "none"} + + stdout, _, err := uninstallOn(server, env, false, false) + + if err != nil || stdout != wantedRemoves+wantedKeeps { + t.Errorf("Uninstall() = %v, printing\n%s\nwant the install root and the web server of server.env\n%s", err, + stdout, wantedRemoves+wantedKeeps) + } +} + +func TestUninstallTakesTheDefaultsOfTheSettingsServerEnvLeavesOut(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(serverEnvPath, "# Written by gophenberg server:install.\n") + + stdout, _, err := uninstallOn(server, nil, false, false) + + if err != nil || stdout != wantedRemoves+wantedKeeps { + t.Errorf("Uninstall() = %v, printing\n%s\nwant the default install root and web server", err, stdout) + } +} + +func TestUninstallWithApplyRemovesWhatServerInstallSetUpInOrderAndKeepsTheData(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + + stdout, stderr, err := uninstallOn(server, nil, true, false) + + if err != nil || stdout != wantedRemoves+wantedKeeps+removedLine { + t.Fatalf("Uninstall() = %v, printing\n%s\nwant the lists and the last line", err, stdout) + } + if stderr != "" { + t.Errorf("Uninstall() noted %q, want nothing", stderr) + } + if !slices.Equal(server.ran, removedPrograms) { + t.Errorf("the run ran %q, want %q", server.ran, removedPrograms) + } + wantNothingAt(t, server, uninstalledPaths...) + wantThere(t, server, keptPaths...) + wantText(t, server.at(caddyfilePath), packageCaddyfile) + if _, held := server.users[siteUser]; !held || server.running[pieceService] { + t.Errorf("the server has the user gophenberg %t and runs the site %t, want the user kept and the site "+ + "stopped", held, server.running[pieceService]) + } +} + +func TestUninstallLeavesARecordALaterInstallPicksUpWhatIsKeptFrom(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + + _, _, err := uninstallOn(server, nil, true, false) + + want := Record{ + Installer: version.Version(), Domain: "example.com", + Installed: []string{piecePostgreSQL, pieceCaddy, "/opt/gophenberg"}, Steps: map[string]string{ + "server-env": stepEnded, "packages": stepEnded, "user": stepEnded, "database": stepEnded, stepAdmin: stepEnded, + }, + } + got := recordOf(t, server) + if err != nil || !recordsEqual(got, want) { + t.Errorf("Uninstall() = %v, leaving the record %+v, want %+v", err, got, want) + } + wantEntry(t, server.at(recordPath), 0o600, nil) +} + +// recordsEqual reports whether got and want hold the same installer, steps, pieces, domain and Caddyfile sum. +func recordsEqual(got, want Record) bool { + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + return bytes.Equal(gotJSON, wantJSON) +} + +func TestUninstallKeepsACaddyfileThePersonChangedAndTakesOutOnlyTheImportLine(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, wantedCaddyfile+personSite) + + stdout, _, err := uninstallOn(server, nil, true, false) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the import line taken out", err) + } + wantText(t, server.at(caddyfilePath), strings.Replace(wantedCaddyfile, caddyImport+"\n", "", 1)+personSite) + wantEntry(t, server.at(caddyfilePath), 0o644, nil) + wantThere(t, server, caddyDropIn, keptCaddyfilePath) + want := slices.Concat(removedPrograms[:3], []string{"systemctl reload caddy.service"}) + if !slices.Equal(server.ran, want) { + t.Errorf("the run ran %q, want %q", server.ran, want) + } + listed := " the line import /etc/caddy/gophenberg.caddy of /etc/caddy/Caddyfile\n /etc/caddy/gophenberg.caddy\n" + kept := "server:uninstall keeps:\n /etc/caddy/Caddyfile, which changed since server:install wrote it, so a " + + "reinstall asks for that line back\n /etc/systemd/system/caddy.service.d/gophenberg.conf, which gives Caddy " + + "the folder of the admin socket its Caddyfile names\n /etc/caddy/Caddyfile.before-gophenberg, the Caddyfile " + + "the caddy package wrote\n the database" + if !strings.Contains(stdout, listed) || !strings.Contains(stdout, kept) { + t.Errorf("Uninstall() printed\n%s\nwant the import line removed and the Caddyfile and drop-in kept", stdout) + } + wantKeptCaddy(t, server, caddyfileSum(wantedCaddyfile)) +} + +func TestUninstallKeepsTheModeOwnerAndGroupOfACaddyfileThePersonChanged(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, wantedCaddyfile+personSite) + server.chmod(caddyfilePath, 0o640) + if err := os.Chown(server.at(caddyfilePath), -1, siteGroup(t)); err != nil { + t.Fatalf("giving the Caddyfile a group: %v", err) + } + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the import line taken out", err) + } + wantText(t, server.at(caddyfilePath), strings.Replace(wantedCaddyfile, caddyImport+"\n", "", 1)+personSite) + wantEntry(t, server.at(caddyfilePath), 0o640, &owner{uid: os.Getuid(), gid: siteGroup(t)}) +} + +func TestUninstallStopsWhereTheCaddyfileThePersonChangedIsGoneBeforeItsImportLineComesOut(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, wantedCaddyfile+personSite) + running, err := removingOn(server.machine(), gonsole.Call{Env: envOf(nil), Stdout: io.Discard, Apply: true}) + if err != nil { + t.Fatalf("removingOn() = %v, want the run ready", err) + } + t.Cleanup(running.close) + if err := running.plan(); err != nil { + t.Fatalf("plan() = %v, want the lists made", err) + } + server.remove(caddyfilePath) + + err = running.apply(t.Context()) + + wantRefused(t, err, "installer: openat etc/caddy/Caddyfile: no such file or directory") + wantNothingAt(t, server, caddyfilePath) +} + +// wantKeptCaddy fails t unless the record on server holds sum as its Caddyfile's and marks the caddy step ended. +func wantKeptCaddy(t *testing.T, server *fakeServer, sum string) { + t.Helper() + record := recordOf(t, server) + if record.Caddyfile != sum || record.Steps[stepCaddy] != stepEnded { + t.Errorf("the record holds the sum %q and marks caddy %q, want %q and ended", record.Caddyfile, + record.Steps[stepCaddy], sum) + } +} + +func TestUninstallPutsBackThePackagesCaddyfileOverTheOneARunCutOffInTheCaddyStepWrote(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + cutOffInCaddyOn(t, server) + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil || !slices.Contains(server.ran, "systemctl restart caddy.service") { + t.Errorf("Uninstall() = %v, running %q, want Caddy restarted on the package's Caddyfile", err, server.ran) + } + wantText(t, server.at(caddyfilePath), packageCaddyfile) + wantNothingAt(t, server, keptCaddyfilePath, caddyDropIn) + if record := recordOf(t, server); record.Caddyfile != "" || record.Steps[stepCaddy] != "" { + t.Errorf("the record holds the sum %q and marks caddy %q, want neither", record.Caddyfile, + record.Steps[stepCaddy]) + } +} + +func TestUninstallTakesOutTheImportLineOfACaddyfileThePersonChangedAfterACutOffRunAndRestartsCaddy(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + cutOffInCaddyOn(t, server) + server.write(caddyfilePath, wantedCaddyfile+personSite) + + _, _, err := uninstallOn(server, nil, true, false) + + want := slices.Concat(removedPrograms[:3], []string{"systemctl restart caddy.service"}) + if err != nil || !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want the Caddyfile kept and Caddy restarted %q", err, server.ran, want) + } + wantText(t, server.at(caddyfilePath), strings.Replace(wantedCaddyfile, caddyImport+"\n", "", 1)+personSite) + wantKeptCaddy(t, server, caddyfileSum(wantedCaddyfile)) +} + +func TestUninstallPutsBackThePackagesCaddyfileWhereTheCaddyfileIsGone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove(caddyfilePath) + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil || !slices.Equal(server.ran, removedPrograms) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, removedPrograms) + } + wantText(t, server.at(caddyfilePath), packageCaddyfile) +} + +// wantedNoCaddyfile is the stop of a run that owes Caddy a change while both the Caddyfile and the package's are gone. +const wantedNoCaddyfile = "server:uninstall needs /etc/caddy/Caddyfile to take Gophenberg's site out of Caddy, and " + + "it is not there, so nothing was changed" + +func TestUninstallStopsBeforeAnyChangeWhereTheCaddyfileAndThePackagesOneAreGone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove(caddyfilePath) + server.remove(keptCaddyfilePath) + before := server.snapshot() + + stdout, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, wantedNoCaddyfile) + if stdout != "" { + t.Errorf("Uninstall() printed %q, want nothing", stdout) + } + wantUnchanged(t, server, before) +} + +func TestUninstallStopsBeforeAnyChangeWhereARestartIsOwedAndBothCaddyfilesAreGone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove(caddySitePath) + server.remove(caddyfilePath) + server.remove(keptCaddyfilePath) + record := recordOf(t, server) + record.Caddyfile, record.Steps[stepCaddy] = "", stepStarted + server.saveRecord(record) + before := server.snapshot() + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, wantedNoCaddyfile) + wantUnchanged(t, server, before) +} + +// wantedKeptOwnCaddyfile is the line server:uninstall keeps a Caddyfile the person changed under with no import line. +const wantedKeptOwnCaddyfile = "\n /etc/caddy/Caddyfile, which changed since server:install wrote it, so a " + + "reinstall asks for the line import /etc/caddy/gophenberg.caddy in it\n" + +func TestUninstallKeepsACaddyfileWithoutTheImportLineAndTheSumOfTheRecordAsTheyAre(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, ownCaddyfile) + + stdout, _, err := uninstallOn(server, nil, true, false) + + want := slices.Concat(removedPrograms[:3], []string{"systemctl reload caddy.service"}) + if err != nil || !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want Caddy reloaded while gophenberg.caddy is there", err, server.ran) + } + if !strings.Contains(stdout, "server:uninstall keeps:"+wantedKeptOwnCaddyfile) { + t.Errorf("Uninstall() printed\n%s\nwant the Caddyfile kept first with the line%s", stdout, + wantedKeptOwnCaddyfile) + } + wantText(t, server.at(caddyfilePath), ownCaddyfile) + wantKeptCaddy(t, server, caddyfileSum(wantedCaddyfile)) +} + +// ownWebServerOn installs Gophenberg on server for example.com beside a Caddy the person set up, with -web-server none. +func ownWebServerOn(t *testing.T, server *fakeServer) { + t.Helper() + server.folder("/etc/caddy") + server.program("/usr/bin/caddy") + server.write(caddyfilePath, ownCaddyfile) + request := flaggedRunOn(t, server) + request.WebServer = webServerNone + if _, _, err := runInstall(server, request); err != nil { + t.Fatalf("Install() = %v, want Gophenberg installed beside the person's own web server", err) + } + server.answerRemovals() + server.forgetRun() +} + +func TestUninstallLeavesCaddyAloneOnAServerThatKeepsItsOwnWebServer(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + ownWebServerOn(t, server) + + stdout, _, err := uninstallOn(server, nil, true, false) + + if err != nil || !slices.Equal(server.ran, removedPrograms[:3]) { + t.Errorf("Uninstall() = %v, running %q, want Caddy left alone", err, server.ran) + } + wantText(t, server.at(caddyfilePath), ownCaddyfile) + want := " the packages server:install installed, which sudo apt-get purge postgresql removes\n" + removedLine + if !strings.HasSuffix(stdout, "\n"+want) { + t.Errorf("Uninstall() printed\n%s\nwant it to end with\n%s", stdout, want) + } +} + +func TestUninstallTakesGophenbergsSiteOutOfCaddyWhileItIsThereWhateverServerEnvSays(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + (&installScenario{server: server}).keepOwnWebServerOrFail(t) + + _, _, err := uninstallOn(server, nil, true, false) + + if err != nil || !slices.Equal(server.ran, removedPrograms) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, removedPrograms) + } + wantText(t, server.at(caddyfilePath), packageCaddyfile) + wantNothingAt(t, server, caddySitePath) +} + +// keepOwnWebServerOrFail changes the web server line of server.env to none, failing t when it cannot. +func (s *installScenario) keepOwnWebServerOrFail(t *testing.T) { + t.Helper() + if err := s.keepOwnWebServer(); err != nil { + t.Fatalf("changing the web server line: %v", err) + } +} + +func TestUninstallKeepsAGophenbergCommandServerInstallDidNotWrite(t *testing.T) { + t.Parallel() + + for what, put := range map[string]func(*fakeServer){ + "another script": func(s *fakeServer) { s.write(commandPath, "#!/bin/sh\nexec /opt/other/gophenberg \"$@\"\n") }, + "a folder": func(s *fakeServer) { + s.remove(commandPath) + s.folder(commandPath) + }, + "a link to the command server:install writes": func(s *fakeServer) { + s.write("/usr/local/share/command", wantedCommand) + s.remove(commandPath) + s.link(commandPath, "/usr/local/share/command") + }, + } { + server := firstInstalled(t) + put(server) + + stdout, _, err := uninstallOn(server, nil, true, false) + + kept := "\n /usr/local/bin/gophenberg, which server:install did not write\n" + if err != nil || !strings.Contains(stdout, kept) { + t.Errorf("Uninstall() over %s = %v, printing\n%s\nwant the command kept", what, err, stdout) + } + wantThere(t, server, commandPath) + } +} + +func TestUninstallFindsNothingLeftToRemoveOnASecondRunAndLeavesTheRecordUnwritten(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + if _, _, err := uninstallOn(server, nil, true, false); err != nil { + t.Fatalf("Uninstall() = %v, want the first run through", err) + } + server.forgetRun() + before := server.snapshot() + written, err := os.Stat(server.at(recordPath)) + if err != nil { + t.Fatalf("describing the record: %v", err) + } + + stdout, _, err := uninstallOn(server, nil, true, false) + + if want := "server:uninstall finds nothing left to remove.\n" + wantedKeeps + removedLine; err != nil || + stdout != want { + t.Errorf("Uninstall() = %v, printing\n%s\nwant\n%s", err, stdout, want) + } + wantUnchanged(t, server, before) + if held, err := os.Stat(server.at(recordPath)); err != nil || !os.SameFile(held, written) { + t.Errorf("the second run wrote the record again (%v), want it left as it was", err) + } +} + +func TestUninstallWithDeleteDataDropsTheDatabaseAndRemovesTheRootServerEnvAndTheUser(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + + stdout, _, err := uninstallOn(server, nil, true, true) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the data removed too", err) + } + if want := slices.Concat(removedPrograms, droppedPrograms); !slices.Equal(server.ran, want) { + t.Errorf("the run ran %q, want %q", server.ran, want) + } + wantNothingAt(t, server, "/opt/gophenberg", serverEnvPath) + wantThere(t, server, "/opt", recordPath) + if _, held := server.users[siteUser]; held { + t.Errorf("the server still has the user gophenberg, want it removed") + } + want := Record{Installer: version.Version(), Domain: "example.com", Installed: []string{piecePostgreSQL, pieceCaddy}} + if got := recordOf(t, server); !recordsEqual(got, want) || len(got.Steps) != 0 { + t.Errorf("Uninstall() left the record %+v, want %+v", got, want) + } + listed := " /opt/gophenberg/current\n the database gophenberg and its role gophenberg\n the install root " + + "/opt/gophenberg and everything in it\n /etc/gophenberg/server.env\n the system user gophenberg\n" + + "server:uninstall keeps:\n /var/lib/gophenberg-installer/install.json, holding the packages server:install " + + "installed, so a later install takes them as its own\n" + wantedPackagesKept + removedLine + if !strings.HasSuffix(stdout, listed) { + t.Errorf("Uninstall() printed\n%s\nwant it to end with\n%s", stdout, listed) + } +} + +func TestUninstallWithDeleteDataTakesADatabaseAndARoleThatDoNotExistAsDropped(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.answers["dropdb"] = failed("dropdb", "dropdb: error: database removal failed: ERROR: database "+ + "\"gophenberg\" does not exist\n") + server.answers["dropuser"] = failed("dropuser", "dropuser: error: removal of role \"gophenberg\" failed: ERROR: "+ + "role \"gophenberg\" does not exist\n") + + _, _, err := uninstallOn(server, nil, true, true) + + if err != nil || !slices.Contains(server.ran, "userdel gophenberg") { + t.Errorf("Uninstall() = %v, running %q, want the missing database taken as dropped", err, server.ran) + } +} + +func TestUninstallWithDeleteDataSkipsADatabaseTheRecordNeverMade(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + record := recordOf(t, server) + delete(record.Steps, "database") + server.saveRecord(record) + + stdout, _, err := uninstallOn(server, nil, true, true) + + if err != nil || slices.Contains(server.ran, "runuser -u postgres -- dropdb gophenberg") { + t.Errorf("Uninstall() = %v, running %q, want no database dropped", err, server.ran) + } + if strings.Contains(stdout, "the database gophenberg") { + t.Errorf("Uninstall() printed\n%s\nwant no line for a database the record never made", stdout) + } +} + +func TestUninstallStopsWithWhatAFailedProgramPrintedAndLeavesTheRecordItSavedBeforeTheCaddyfileWentBack(t *testing.T) { + t.Parallel() + + noPostgres := "runuser: user postgres does not exist or the user entry does not contain all the required fields\n" + for _, row := range []struct{ program, printed string }{ + {"dropdb", "dropdb: error: database removal failed: ERROR: database \"gophenberg\" is being accessed\n"}, + {"dropdb", noPostgres}, + {"dropuser", noPostgres}, + {"userdel", "userdel: user gophenberg is currently used by process 4242\n"}, + } { + program, printed := row.program, row.printed + server := firstInstalled(t) + want := recordOf(t, server) + want.Caddyfile, want.Steps[stepCaddy] = "", stepStarted + server.answers[program] = failed(program, printed) + delete(server.effects, program) + + _, _, err := uninstallOn(server, nil, true, true) + + wantRefused(t, err, "installer: run "+program+": exit status 1, after it printed:\n"+ + strings.TrimSuffix(printed, "\n")) + if got := recordOf(t, server); !recordsEqual(got, want) { + t.Errorf("a failed %s left the record %+v, want the one saved before the Caddyfile went back %+v", + program, got, want) + } + } +} + +func TestUninstallFinishesWhatARunCutOffOnceTheCaddyfileWentBackLeftUndone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.cutSystemctl("systemctl daemon-reload", 2) + if _, _, err := uninstallOn(server, nil, true, false); err == nil { + t.Fatalf("Uninstall() went through, want it cut off at its second daemon-reload") + } + if cut := recordOf(t, server); cut.Caddyfile != "" || cut.Steps[stepCaddy] != stepStarted { + t.Errorf("the cut-off run left the sum %q and marks caddy %q, want no sum and caddy started", cut.Caddyfile, + cut.Steps[stepCaddy]) + } + server.answerRemovals() + server.forgetRun() + + stdout, _, err := uninstallOn(server, nil, true, false) + + if want := []string{"systemctl daemon-reload", "systemctl restart caddy.service"}; err != nil || + !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, want) + } + listed := "server:uninstall removes:\n" + wantedRestart + " /etc/caddy/gophenberg.caddy\n" + if !strings.HasPrefix(stdout, listed) || strings.Contains(stdout, caddyDropIn) { + t.Errorf("Uninstall() printed\n%s\nwant it to begin with\n%s", stdout, listed) + } + wantNothingAt(t, server, caddyDropIn, keptCaddyfilePath, caddySitePath) + wantText(t, server.at(caddyfilePath), packageCaddyfile) + if record := recordOf(t, server); record.Caddyfile != "" || record.Steps[stepCaddy] != "" { + t.Errorf("the record holds the sum %q and marks caddy %q, want neither", record.Caddyfile, + record.Steps[stepCaddy]) + } +} + +func TestUninstallRemovesTheDropInAFirstRunCutOffBeforeItsCaddyStepLeftAndRestartsCaddy(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove(caddySitePath) + server.write(caddyfilePath, packageCaddyfile) + server.remove(keptCaddyfilePath) + record := recordOf(t, server) + record.Caddyfile = "" + for _, id := range stepIDs[slices.Index(stepIDs, stepCaddy):] { + delete(record.Steps, id) + } + server.saveRecord(record) + + stdout, _, err := uninstallOn(server, nil, true, false) + + if err != nil || !slices.Equal(server.ran, removedPrograms) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, removedPrograms) + } + listed := "\n" + wantedRestart + " " + caddyDropIn + "\n" + if !strings.Contains(stdout, listed) { + t.Errorf("Uninstall() printed\n%s\nwant the drop-in removed and Caddy restarted", stdout) + } + wantNothingAt(t, server, caddyDropIn) + if record := recordOf(t, server); record.Caddyfile != "" || record.Steps[stepCaddy] != "" { + t.Errorf("the record holds the sum %q and marks caddy %q, want neither", record.Caddyfile, + record.Steps[stepCaddy]) + } +} + +func TestUninstallLeavesNoCaddyStepOnceItRestartedCaddyForADropInBackAfterAnUninstall(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + if _, _, err := uninstallOn(server, nil, true, false); err != nil { + t.Fatalf("Uninstall() = %v, want the first run through", err) + } + server.write(caddyDropIn, "[Service]\nRuntimeDirectory=caddy\n") + found := recordOf(t, server) + server.forgetRun() + + _, _, err := uninstallOn(server, nil, true, false) + + if want := []string{"systemctl daemon-reload", "systemctl restart caddy.service"}; err != nil || + !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, want) + } + if got := recordOf(t, server); !recordsEqual(got, found) { + t.Errorf("Uninstall() left the record %+v, want it as found %+v", got, found) + } +} + +func TestUninstallRestartsNoCaddyOverACaddyfileThePersonWroteWhileThePackagesOneIsKept(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, ownCaddyfile) + record := recordOf(t, server) + record.Caddyfile = "" + server.saveRecord(record) + + stdout, _, err := uninstallOn(server, nil, true, false) + + want := slices.Concat(removedPrograms[:3], []string{"systemctl reload caddy.service"}) + if err != nil || !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, want) + } + if strings.Contains(stdout, "restarting Caddy") { + t.Errorf("Uninstall() printed\n%s\nwant no restart of Caddy on the Caddyfile the person wrote", stdout) + } + wantText(t, server.at(caddyfilePath), ownCaddyfile) + wantThere(t, server, keptCaddyfilePath, caddyDropIn) +} + +func TestUninstallReloadsCaddyOnTheRunAfterOneCutOffBeforeItsReload(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, wantedCaddyfile+personSite) + server.cutSystemctl("systemctl reload caddy.service", 1) + if _, _, err := uninstallOn(server, nil, true, false); err == nil { + t.Fatalf("Uninstall() went through, want it cut off at the reload of Caddy") + } + wantThere(t, server, caddySitePath) + server.answerRemovals() + server.forgetRun() + + _, _, err := uninstallOn(server, nil, true, false) + + if want := []string{"systemctl reload caddy.service"}; err != nil || !slices.Equal(server.ran, want) { + t.Errorf("Uninstall() = %v, running %q, want %q", err, server.ran, want) + } + wantNothingAt(t, server, caddySitePath, maintenanceFolder) + wantText(t, server.at(caddyfilePath), strings.Replace(wantedCaddyfile, caddyImport+"\n", "", 1)+personSite) + wantKeptCaddy(t, server, caddyfileSum(wantedCaddyfile)) +} + +func TestUninstallWithDeleteDataRemovesOnlyAnInstallRootTheRecordOwns(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + record := recordOf(t, server) + record.Installed = slices.DeleteFunc(record.Installed, func(piece string) bool { return piece == "/opt/gophenberg" }) + server.saveRecord(record) + + stdout, _, err := uninstallOn(server, nil, true, true) + + kept := "\n /opt/gophenberg, since the install record does not show that server:install made it\n" + if err != nil || !strings.Contains(stdout, kept) { + t.Errorf("Uninstall() = %v, printing\n%s\nwant the install root kept", err, stdout) + } + if strings.Contains(stdout, "/opt/gophenberg/versions") { + t.Errorf("Uninstall() printed\n%s\nwant nothing of an install root the record does not own", stdout) + } + wantThere(t, server, siteEnvAt, "/opt/gophenberg/versions", "/opt/gophenberg/current") +} + +func TestUninstallListsAsKeptAnInstallRootTheRecordOwnsThatServerEnvNoLongerNames(t *testing.T) { + t.Parallel() + + server := newFakeServer(t) + env := map[string]string{"GOPHENBERG_INSTALL_ROOT": "/srv/gophenberg"} + if _, _, err := runInstallUnder(server, env, flaggedRunOn(t, server)); err != nil { + t.Fatalf("Install() = %v, want Gophenberg installed in /srv/gophenberg", err) + } + server.answerRemovals() + server.forgetRun() + server.remove(serverEnvPath) + + stdout, _, err := uninstallOn(server, nil, true, true) + + kept := "server:uninstall keeps:\n /srv/gophenberg, an install root server:install made, kept since " + + "/etc/gophenberg/server.env does not name it\n" + if err != nil || !strings.Contains(stdout, kept) { + t.Errorf("Uninstall() = %v, printing\n%s\nwant it to hold\n%s", err, stdout, kept) + } + wantThere(t, server, "/srv/gophenberg/.env") + want := []string{piecePostgreSQL, pieceCaddy} + if got := recordOf(t, server).Installed; !slices.Equal(got, want) { + t.Errorf("Uninstall() left the record owning %q, want %q", got, want) + } +} + +func TestUninstallListsNoInstallRootServerEnvNoLongerNamesThatIsGoneAndLeavesItOutOfTheRecord(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + if err := (&installScenario{server: server}).setServerLine("GOPHENBERG_INSTALL_ROOT", "/srv/gophenberg"); err != nil { + t.Fatalf("changing the install root line: %v", err) + } + server.remove("/opt/gophenberg") + + stdout, _, err := uninstallOn(server, nil, true, true) + + if err != nil || strings.Contains(stdout, "/opt/gophenberg") { + t.Errorf("Uninstall() = %v, printing\n%s\nwant nothing of an install root that is gone", err, stdout) + } + want := []string{piecePostgreSQL, pieceCaddy} + if got := recordOf(t, server).Installed; !slices.Equal(got, want) { + t.Errorf("Uninstall() left the record owning %q, want %q", got, want) + } +} + +func TestUninstallWithDeleteDataKeepsTheSumOfACaddyfileThePersonChanged(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write(caddyfilePath, wantedCaddyfile+personSite) + + _, _, err := uninstallOn(server, nil, true, true) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the data removed and the Caddyfile kept", err) + } + want := Record{ + Installer: version.Version(), Domain: "example.com", Installed: []string{piecePostgreSQL, pieceCaddy}, + Caddyfile: caddyfileSum(wantedCaddyfile), Steps: map[string]string{stepCaddy: stepEnded}, + } + if got := recordOf(t, server); !recordsEqual(got, want) { + t.Errorf("Uninstall() left the record %+v, want %+v", got, want) + } +} + +func TestUninstallStopsAtAnInstallRootThatIsNotARealFolder(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove("/opt/gophenberg") + server.folder("/srv/elsewhere") + if err := os.Symlink(server.at("/srv/elsewhere"), server.at("/opt/gophenberg")); err != nil { + t.Fatalf("linking the install root: %v", err) + } + + _, _, err := uninstallOn(server, nil, true, true) + + wantRefused(t, err, "installer: open the install root /opt/gophenberg: it is not a real folder") + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run", server.ran) + } +} + +func TestUninstallRemovesThroughTheInstallTreeFollowingNoLink(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write("/srv/outside/archive.tar.gz", "kept\n") + server.remove("/opt/gophenberg/downloads") + if err := os.Symlink("../../srv/outside", server.at("/opt/gophenberg/downloads")); err != nil { + t.Fatalf("linking the downloads folder: %v", err) + } + server.link("/opt/gophenberg/media/outside", "/srv/outside") + + _, _, err := uninstallOn(server, nil, true, true) + + if err != nil { + t.Fatalf("Uninstall() = %v, want the links removed", err) + } + wantText(t, server.at("/srv/outside/archive.tar.gz"), "kept\n") + wantNothingAt(t, server, "/opt/gophenberg") +} + +func TestUninstallRefusesAnUpdateStateItCannotReachInsideTheInstallRoot(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.write("/srv/outside/update.json", "{}\n") + server.remove("/opt/gophenberg/state") + server.link("/opt/gophenberg/state", "/srv/outside") + + _, _, err := uninstallOn(server, nil, true, false) + + want := "installer: cannot tell whether /opt/gophenberg/state/update.json is there: " + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("Uninstall() = %v, want the state link that leads out refused", err) + } + wantThere(t, server, "/srv/outside/update.json", unitPaths[0]) +} + +func TestUninstallRefusesACaddyfileItCannotRead(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove(caddyfilePath) + server.folder(caddyfilePath) + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, "installer: read "+server.at(caddyfilePath)+": is a directory") +} + +func TestUninstallStopsWhenItCannotTellWhetherTheSiteUserIsThere(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + machine := server.machine() + machine.LookupUser = func(string) (*user.User, error) { return nil, errors.New("cannot read /etc/passwd") } + + _, _, err := uninstallWith(t, machine, gonsole.Call{Env: envOf(nil), Apply: true}, false) + + wantRefused(t, err, "installer: cannot read /etc/passwd") + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run", server.ran) + } +} + +func TestUninstallReturnsWhatStopsItsLists(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + call := gonsole.Call{Env: envOf(nil), Stdout: refusingWriter{}, Apply: true} + + err := Uninstall(t.Context(), server.machine(), call, false) + + wantRefused(t, err, "the terminal is gone") + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run once its lists could not be printed", server.ran) + } +} + +func TestUninstallReturnsWhatStopsItsLastLineOnceItMadeEveryChange(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + running, err := removingOn(server.machine(), gonsole.Call{Env: envOf(nil), Stdout: refusingWriter{}, Apply: true}) + if err != nil { + t.Fatalf("removingOn() = %v, want the run ready", err) + } + t.Cleanup(running.close) + if err := running.plan(); err != nil { + t.Fatalf("plan() = %v, want the lists made", err) + } + + err = running.apply(t.Context()) + + wantRefused(t, err, "the terminal is gone") + wantNothingAt(t, server, uninstalledPaths...) +} + +func TestUninstallStopsWhereItCannotPutBackThePackagesCaddyfile(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove(keptCaddyfilePath) + server.folder(keptCaddyfilePath) + + _, _, err := uninstallOn(server, nil, true, false) + + if err == nil || !strings.HasPrefix(err.Error(), "installer: put "+keptCaddyfilePath+" back as "+caddyfilePath+": ") { + t.Errorf("Uninstall() = %v, want the put back refused", err) + } + if slices.Contains(server.ran, "systemctl restart caddy.service") { + t.Errorf("the run ran %q, want Caddy left running as it was", server.ran) + } +} + +func TestUninstallStopsWhereAFolderRootAloneWritesRefusesItsChange(t *testing.T) { + t.Parallel() + skipAsRoot(t) + + for folder, want := range map[string]string{ + "/etc/systemd/system": "installer: remove /etc/systemd/system/gophenberg.service: ", + "/etc/caddy": "installer: write /etc/caddy/Caddyfile: ", + "/opt/gophenberg": "installer: remove /opt/gophenberg/versions: ", + "/opt": "installer: remove the install root /opt/gophenberg: ", + } { + server := firstInstalled(t) + if folder == "/etc/caddy" { + server.write(caddyfilePath, wantedCaddyfile+personSite) + } + server.chmod(folder, 0o555) + t.Cleanup(func() { server.chmod(folder, 0o755) }) + + _, _, err := uninstallOn(server, nil, true, folder == "/opt") + + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("Uninstall() under a locked %s = %v, want %q", folder, err, want) + } + } +} + +func TestUninstallSkipsAnInstallRootThatIsGone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.remove("/opt/gophenberg") + + stdout, _, err := uninstallOn(server, nil, true, true) + + if err != nil || strings.Contains(stdout, "/opt/gophenberg") { + t.Errorf("Uninstall() = %v, printing\n%s\nwant nothing of an install root that is gone", err, stdout) + } + wantNothingAt(t, server, serverEnvPath) +} + +func TestUninstallRefusesAServerItCannotOpenAsATree(t *testing.T) { + t.Parallel() + skipAsRoot(t) + + server := firstInstalled(t) + server.chmod("/", 0o311) + t.Cleanup(func() { server.chmod("/", 0o755) }) + + _, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, "installer: open "+server.root+": permission denied") + if len(server.ran) != 0 { + t.Errorf("the run ran %q, want nothing run", server.ran) + } +} + +func TestUninstallStopsWhereItCannotLeaveTheInstallRecord(t *testing.T) { + t.Parallel() + + for caddyfile, last := range map[string]bool{"the installer's": false, "one the person changed": true} { + server := firstInstalled(t) + if last { + server.write(caddyfilePath, wantedCaddyfile+personSite) + } + if err := os.Rename(server.at(installerFolder), server.at("/var/lib/kept-installer")); err != nil { + t.Fatalf("moving the installer's folder: %v", err) + } + kept, err := os.Lstat(server.at("/var/lib/kept-installer")) + if err != nil { + t.Fatalf("describing the installer's folder: %v", err) + } + if err := os.Symlink("kept-installer", server.at(installerFolder)); err != nil { + t.Fatalf("linking the installer's folder: %v", err) + } + server.described[installerFolder] = ownedInfo{FileInfo: kept} + + stdout, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, "installer: make the folder var/lib/gophenberg-installer: it is not a real folder") + if strings.HasSuffix(stdout, removedLine) { + t.Errorf("Uninstall() over %s Caddyfile printed\n%s\nwant no last line once the record could not be left", + caddyfile, stdout) + } + if held := server.machine().there(caddySitePath); held == last { + t.Errorf("Uninstall() over %s Caddyfile left gophenberg.caddy there %t, want %t, the record saved first "+ + "while the Caddyfile goes back and last otherwise", caddyfile, held, !last) + } + } +} + +func TestTheUninstallPathsAndStepsAreTheOnesTheInstallWrites(t *testing.T) { + t.Parallel() + + wantThere(t, firstInstalled(t), slices.Concat(uninstalledPaths, keptPaths)...) + written := writtenPaths(firstSite(t, "example.com", 255).unitFiles()) + if want := append(slices.Clone(unitPaths), caddyDropIn); !slices.Equal(written, want) { + t.Errorf("the units step writes %q, want %q", written, want) + } + if want := []string{"versions", "downloads", "current", "previous", "state/update.json"}; !slices.Equal( + builtNames, want) { + t.Errorf("server:uninstall removes %q in the install root, want %q", builtNames, want) + } + for _, id := range keptSteps { + if !slices.Contains(stepIDs, id) { + t.Errorf("server:uninstall keeps the step %s, which no run takes", id) + } + } +} diff --git a/internal/installer/uninstall_units_internal_test.go b/internal/installer/uninstall_units_internal_test.go new file mode 100644 index 00000000..89f825da --- /dev/null +++ b/internal/installer/uninstall_units_internal_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "slices" + "strings" + "testing" +) + +func TestUninstallTakesAResetFailedOfUnitsSystemdAlreadyLetGoOfAsDone(t *testing.T) { + t.Parallel() + + server := firstInstalled(t) + server.unloads = true + + stdout, _, err := uninstallOn(server, nil, true, false) + + if err != nil || stdout != wantedRemoves+wantedKeeps+removedLine { + t.Fatalf("Uninstall() = %v, printing\n%s\nwant the run through to its last line", err, stdout) + } + if !slices.Equal(server.ran, removedPrograms) { + t.Errorf("the run ran %q, want %q", server.ran, removedPrograms) + } + wantNothingAt(t, server, uninstalledPaths...) +} + +// deniedReset is what systemctl reset-failed prints for a unit it may not clear. +const deniedReset = "Failed to reset failed state of unit gophenberg.service: Access denied\n" + +func TestUninstallStopsWithWhatAResetFailedPrintedUnlessEachLineSaysTheUnitIsNotLoaded(t *testing.T) { + t.Parallel() + + for _, row := range []struct{ printed, want string }{ + {deniedReset, "installer: run systemctl: exit status 1, after it printed:\n" + + strings.TrimSuffix(deniedReset, "\n")}, + {notLoadedLines([]string{pieceService}) + deniedReset, "installer: run systemctl: exit status 1, after it " + + "printed:\nFailed to reset failed state of unit gophenberg.service: Unit gophenberg.service not loaded.\n" + + strings.TrimSuffix(deniedReset, "\n")}, + {"", "installer: run systemctl: exit status 1"}, + } { + server := firstInstalled(t) + answering := server.effects["systemctl"] + server.effects["systemctl"] = func(program Program) answer { + if program.Args[0] == "reset-failed" { + return failed("systemctl", row.printed) + } + return answering(program) + } + + stdout, _, err := uninstallOn(server, nil, true, false) + + wantRefused(t, err, row.want) + if !slices.Equal(server.ran, removedPrograms[:2]) || strings.HasSuffix(stdout, removedLine) { + t.Errorf("the run ran %q, printing\n%s\nwant it stopped at the reset-failed", server.ran, stdout) + } + wantThere(t, server, unitPaths...) + } +} diff --git a/internal/installer/web_server_internal_test.go b/internal/installer/web_server_internal_test.go index 42756a67..5d0678e7 100644 --- a/internal/installer/web_server_internal_test.go +++ b/internal/installer/web_server_internal_test.go @@ -192,7 +192,7 @@ func TestTheStepsWithTheirOwnWebServerOfALaterRunStillPrintTheLinesForIt(t *test running := ownSite(t, "example.com") running.current = true - running.record.Steps = map[string]string{stepStart: stepEnded, stepAdmin: stepEnded} + running.record.Steps, running.record.Installed = map[string]string{stepAdmin: stepEnded}, []string{pieceService} does, _ := doneOf(running.steps()) diff --git a/internal/updater/guard.go b/internal/updater/guard.go index e3176947..84b41fcb 100644 --- a/internal/updater/guard.go +++ b/internal/updater/guard.go @@ -237,22 +237,29 @@ func (g *Guard) verify(ctx context.Context, addresses <-chan string, inside func } select { case address := <-addresses: - return askPages(checking, loopback(address)) + return CheckPages(checking, pageClient().Do, loopback(address)) case <-checking.Done(): return fmt.Errorf("it did not listen within %v, the time %s allows", g.settings.VerifyTimeout, g.settings.names.Key("UPDATE_VERIFY_TIMEOUT")) } } -// askPages asks the server at address for each checked page, refusing the first answer a page does not take. -func askPages(ctx context.Context, address string) error { - client := &http.Client{ +// pageClient returns the client the start check asks with, on a new connection each time and following no redirect. +func pageClient() *http.Client { + return &http.Client{ Transport: &http.Transport{DisableKeepAlives: true}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } +} + +// CheckPages asks the site at address for each checked page through fetch, refusing the first answer it does not take. +func CheckPages(ctx context.Context, fetch func(*http.Request) (*http.Response, error), address string) error { for _, page := range checkedPages { - request, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address+page.path, nil) - response, err := client.Do(request) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address+page.path, nil) + var response *http.Response + if err == nil { + response, err = fetch(request) + } if err != nil { return fmt.Errorf("it could not ask for %s: %w", page.path, err) } diff --git a/internal/updater/guard_test.go b/internal/updater/guard_test.go index 9adfa72e..2547077a 100644 --- a/internal/updater/guard_test.go +++ b/internal/updater/guard_test.go @@ -6,6 +6,7 @@ import ( "cmp" "context" "errors" + "io" "log/slog" "net" "net/http" @@ -741,6 +742,142 @@ func TestServeLeavesAnUpdateThatStayedDownAsItWasWhenTheCheckFailsAgain(t *testi } } +// checkedAddress is the address a test of the start check asks its pages at. +const checkedAddress = "127.0.0.1:8081" + +// fetchedSite is a fetch answering the status statuses names for its paths and 200 for any other, or err when set. +type fetchedSite struct { + statuses map[string]int + err error + asked []string + handed []context.Context + closed int +} + +// fetch notes the address and the context of request and answers it with its status or the error of the site. +func (s *fetchedSite) fetch(request *http.Request) (*http.Response, error) { + s.asked = append(s.asked, request.URL.String()) + s.handed = append(s.handed, request.Context()) + if s.err != nil { + return nil, s.err + } + status := cmp.Or(s.statuses[request.URL.Path], http.StatusOK) + return &http.Response{StatusCode: status, Body: closeCounter{closed: &s.closed}}, nil +} + +// closeCounter is an empty answer body that counts each time it is closed. +type closeCounter struct { + closed *int +} + +// Read reports the end of the empty body. +func (closeCounter) Read([]byte) (int, error) { + return 0, io.EOF +} + +// Close counts one more close of the body. +func (c closeCounter) Close() error { + *c.closed++ + return nil +} + +// pagesAt returns the address of each of the paths at the address the start check tests ask. +func pagesAt(paths ...string) []string { + addresses := make([]string, 0, len(paths)) + for _, path := range paths { + addresses = append(addresses, "http://"+checkedAddress+path) + } + return addresses +} + +func TestCheckPagesTakesASiteThatAnswersEachPageThroughTheFetchHandedIn(t *testing.T) { + t.Parallel() + + site := &fetchedSite{statuses: map[string]int{"/": http.StatusNotFound}} + ctx := t.Context() + + if err := updater.CheckPages(ctx, site.fetch, checkedAddress); err != nil { + t.Fatalf("CheckPages() = %v, want nil", err) + } + + if want := pagesAt("/admin/", "/api/locale", "/"); !slices.Equal(site.asked, want) { + t.Errorf("the check asked for %v, want %v", site.asked, want) + } + for _, handed := range site.handed { + if handed != ctx { + t.Errorf("the check asked under %v, want the context handed in", handed) + } + } + if site.closed != 3 { + t.Errorf("the check closed %d answers, want each of the 3", site.closed) + } +} + +func TestCheckPagesRefusesTheFirstPageThatAnswersBadly(t *testing.T) { + t.Parallel() + + for name, failing := range map[string]struct { + statuses map[string]int + asked []string + says string + }{ + "the admin": { + statuses: map[string]int{"/admin/": http.StatusFound, "/": http.StatusBadGateway}, + asked: pagesAt("/admin/"), says: "/admin/ answered 302, want 200", + }, + "the locale": { + statuses: map[string]int{"/api/locale": http.StatusNotFound}, + asked: pagesAt("/admin/", "/api/locale"), says: "/api/locale answered 404, want 200", + }, + "the home": { + statuses: map[string]int{"/": http.StatusInternalServerError}, + asked: pagesAt("/admin/", "/api/locale", "/"), says: "/ answered 500, want below 500", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + site := &fetchedSite{statuses: failing.statuses} + + err := updater.CheckPages(t.Context(), site.fetch, checkedAddress) + + if err == nil || err.Error() != failing.says { + t.Errorf("CheckPages() = %v, want it refused saying %q", err, failing.says) + } + if !slices.Equal(site.asked, failing.asked) || site.closed != len(failing.asked) { + t.Errorf("the check asked for %v and closed %d answers, want %v each closed", site.asked, site.closed, + failing.asked) + } + }) + } +} + +func TestCheckPagesRefusesASiteItCannotAsk(t *testing.T) { + t.Parallel() + + errUnreachable := errors.New("dial tcp 127.0.0.1:8081: connect: connection refused") + for name, unreachable := range map[string]struct { + address string + asked []string + }{ + "a site that does not answer": {address: checkedAddress, asked: pagesAt("/admin/")}, + "an address it cannot ask at": {address: "127.0.0.1:8081 and more"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + site := &fetchedSite{err: errUnreachable} + + err := updater.CheckPages(t.Context(), site.fetch, unreachable.address) + + wantRefused(t, err, "it could not ask for /admin/: ") + if !slices.Equal(site.asked, unreachable.asked) { + t.Errorf("the check asked for %v, want %v", site.asked, unreachable.asked) + } + }) + } +} + func TestServeStopsWhenItCannotKeepTheUpdate(t *testing.T) { t.Parallel() diff --git a/internal/updater/layout.go b/internal/updater/layout.go index b451f2e9..c8d2f7cb 100644 --- a/internal/updater/layout.go +++ b/internal/updater/layout.go @@ -71,20 +71,30 @@ func (l Layout) Named(link string) (string, error) { return name, nil } -// Point points the link called link at the build called name, renaming a new link over the old one. -func (l Layout) Point(link, name string) error { - staged := filepath.Join(l.Root, "."+link+partialSuffix) - _ = os.Remove(staged) - err := os.Symlink(filepath.Join(versionsFolder, name), staged) +// PointIn points the link called link of tree at the build called name, renaming a new link over the old one. +func PointIn(tree *os.Root, link, name string) error { + staged := "." + link + partialSuffix + _ = tree.Remove(staged) + err := tree.Symlink(filepath.Join(versionsFolder, name), staged) if err == nil { - err = os.Rename(staged, filepath.Join(l.Root, link)) + err = tree.Rename(staged, link) } if err == nil { - err = syncFolder(l.Root) + err = syncIn(tree, ".") } if err != nil { - _ = os.Remove(staged) + _ = tree.Remove(staged) return fmt.Errorf("updater: point %s at %s: %w", link, name, err) } return nil } + +// Point points the link called link at the build called name, renaming a new link over the old one. +func (l Layout) Point(link, name string) error { + tree, err := os.OpenRoot(l.Root) + if err != nil { + return fmt.Errorf("updater: point %s at %s: %w", link, name, err) + } + defer func() { _ = tree.Close() }() + return PointIn(tree, link, name) +} diff --git a/internal/updater/layout_test.go b/internal/updater/layout_test.go index f850431a..151f1231 100644 --- a/internal/updater/layout_test.go +++ b/internal/updater/layout_test.go @@ -109,15 +109,80 @@ func TestPointRefusesALinkItCannotReplace(t *testing.T) { wantMissing(t, filepath.Join(layout.Root, ".current.partial")) } -func TestPointRefusesARootItCannotSaveToDisk(t *testing.T) { +func TestPointRefusesARootItCannotOpen(t *testing.T) { + t.Parallel() + + for name, root := range map[string]func(t *testing.T) string{ + "a root that is not there": func(t *testing.T) string { return filepath.Join(t.TempDir(), "gophenberg") }, + "a root it may not read": func(t *testing.T) string { + root := t.TempDir() + denied(t, root, 0o300) + return root + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + layout := updater.Layout{Root: root(t)} + + err := layout.Point(updater.Current, "0.21.0") + + wantRefused(t, err, "updater: point current at 0.21.0") + wantMissing(t, filepath.Join(layout.Root, "current")) + }) + } +} + +func TestPointInMakesARelativeLinkInsideTheTree(t *testing.T) { t.Parallel() layout := updater.Layout{Root: t.TempDir()} - denied(t, layout.Root, 0o300) - err := layout.Point(updater.Current, "0.21.0") + if err := updater.PointIn(openTree(t, layout.Root), updater.Current, "0.21.0"); err != nil { + t.Fatalf("PointIn() = %v, want nil", err) + } + + wantLink(t, filepath.Join(layout.Root, "current"), filepath.Join("versions", "0.21.0")) + if name, err := layout.Named(updater.Current); err != nil || name != "0.21.0" { + t.Errorf("Named() = %q, %v, want the build 0.21.0", name, err) + } +} + +func TestPointInReplacesLinksThatLedOutOfTheTreeWithoutFollowingThem(t *testing.T) { + t.Parallel() + + root, outside := t.TempDir(), t.TempDir() + kept := filepath.Join(outside, "notes.txt") + if err := os.WriteFile(kept, []byte("a person's notes"), 0o644); err != nil { + t.Fatalf("writing a file outside the tree: %v", err) + } + for link, target := range map[string]string{"current": outside, ".current.partial": kept} { + if err := os.Symlink(target, filepath.Join(root, link)); err != nil { + t.Fatalf("linking %s out of the tree: %v", link, err) + } + } + + if err := updater.PointIn(openTree(t, root), updater.Current, "0.21.0"); err != nil { + t.Fatalf("PointIn() = %v, want nil", err) + } + + wantLink(t, filepath.Join(root, "current"), filepath.Join("versions", "0.21.0")) + wantMissing(t, filepath.Join(root, ".current.partial")) + wantEntries(t, outside, "notes.txt") + wantFile(t, kept, "a person's notes") +} + +func TestPointInRefusesARootItCannotSaveToDisk(t *testing.T) { + t.Parallel() + + root := t.TempDir() + tree := openTree(t, root) + denied(t, root, 0o300) + + err := updater.PointIn(tree, updater.Current, "0.21.0") wantRefused(t, err, "updater: point current at 0.21.0") + wantMissing(t, filepath.Join(root, ".current.partial")) } func TestNamedRefusesALinkThatNamesNoBuild(t *testing.T) { diff --git a/internal/updater/rollback.go b/internal/updater/rollback.go index 9bbcbc12..2723637a 100644 --- a/internal/updater/rollback.go +++ b/internal/updater/rollback.go @@ -6,11 +6,10 @@ import ( "errors" "fmt" "slices" - "strconv" ) -// startLimitHit is the service result the service manager reports once the starts of a service hit their limit. -const startLimitHit = "start-limit-hit" +// succeeded is the service result the service manager reports for a service that did not fail. +const succeeded = "success" // takenBackSteps are the steps of an update that switched builds, the ones update:rollback takes back. var takenBackSteps = []Step{StepSwitched, StepCommitted, StepFailed} @@ -21,12 +20,11 @@ var failedSteps = []Step{StepSwitched, StepFailed} // Failure is what the service manager says about the start that failed, as it hands it to its rollback unit. type Failure struct { ServiceResult string - ExitStatus string } -// failedStart reports whether the service manager says a start failed its start check or the starts hit the limit. +// failedStart reports whether the service manager names a service result other than success. func (f Failure) failedStart() bool { - return f.ServiceResult == startLimitHit || f.ExitStatus == strconv.Itoa(ExitFailedCheck) + return f.ServiceResult != "" && f.ServiceResult != succeeded } // Rollback takes back the last update that switched builds, pointing current at the build before it. @@ -46,9 +44,8 @@ func RollbackAfterFailure(settings Settings, failure Failure) (State, error) { return State{}, nothingToTakeBack("update:rollback -after-failure", state) } if !failure.failedStart() { - return State{}, fmt.Errorf("updater: the service manager says the service result is %q and the exit "+ - "status is %q, not a start that failed its check or hit the start limit, so update:rollback "+ - "-after-failure leaves the update alone", failure.ServiceResult, failure.ExitStatus) + return State{}, fmt.Errorf("updater: the service manager says the service result is %q, not a failed "+ + "start, so update:rollback -after-failure leaves the update alone", failure.ServiceResult) } if mayHaveMigrated(state) { return State{}, layout.stayDown(state) diff --git a/internal/updater/rollback_test.go b/internal/updater/rollback_test.go index 66e3f108..c6c117f5 100644 --- a/internal/updater/rollback_test.go +++ b/internal/updater/rollback_test.go @@ -12,11 +12,8 @@ import ( "github.com/gopherium/gophenberg/internal/updater" ) -// failedCheck is what the service manager hands its rollback unit after a start that failed its start check. -var failedCheck = updater.Failure{ServiceResult: "exit-code", ExitStatus: "76"} - -// startLimitHit is what the service manager hands its rollback unit once the starts of a build hit the start limit. -var startLimitHit = updater.Failure{ServiceResult: "start-limit-hit", ExitStatus: "1"} +// exited is what the service manager hands its rollback unit once it gave up on a build that exits with an error code. +var exited = updater.Failure{ServiceResult: "exit-code"} // takenBack is the state an update from 0.20.0 to 0.21.0 ends at once update:rollback takes it back. var takenBack = updater.State{ @@ -83,7 +80,7 @@ func TestRollbackRefusesAnInstallWithNoRoot(t *testing.T) { for name, rollback := range map[string]func(updater.Settings) (updater.State, error){ "update:rollback": updater.Rollback, "update:rollback -after-failure": func(settings updater.Settings) (updater.State, error) { - return updater.RollbackAfterFailure(settings, failedCheck) + return updater.RollbackAfterFailure(settings, exited) }, } { t.Run(name, func(t *testing.T) { @@ -151,7 +148,7 @@ func TestRollbackTakesBackAnUpdateThatRaisedTheMinimumVersionBeforeItsBuildReach for name, rollback := range map[string]func(updater.Settings) (updater.State, error){ "update:rollback": updater.Rollback, "update:rollback -after-failure": func(settings updater.Settings) (updater.State, error) { - return updater.RollbackAfterFailure(settings, startLimitHit) + return updater.RollbackAfterFailure(settings, exited) }, } { for attempts, started := range map[int]string{0: "never started", 3: "started three times"} { @@ -222,8 +219,8 @@ func TestRollbackAfterFailureTakesBackAStartThatFailed(t *testing.T) { t.Parallel() for name, failure := range map[string]updater.Failure{ - "a start that failed its check": failedCheck, - "starts that hit the limit": startLimitHit, + "a build that failed its check or keeps crashing": exited, + "a build a signal stopped": {ServiceResult: "signal"}, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -247,7 +244,7 @@ func TestRollbackAfterFailureFinishesWhatTheBootGuardStarted(t *testing.T) { f := newGuardFixture(t, wentBack) pointLink(t, f.layout, updater.Current, running) - state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), failedCheck) + state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), exited) f.wantTakenBack(state, err) } @@ -258,12 +255,12 @@ func TestRollbackAfterFailureTakesBackEachUpdateOnce(t *testing.T) { switched := switchedTo switched.Attempts = 1 f := newGuardFixture(t, switched) - if _, err := updater.RollbackAfterFailure(settingsOf(t, f.env), failedCheck); err != nil { + if _, err := updater.RollbackAfterFailure(settingsOf(t, f.env), exited); err != nil { t.Fatalf("the first rollback = %v, want the update taken back", err) } pointLink(t, f.layout, updater.Current, offered) - state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), failedCheck) + state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), exited) f.wantLeftAlone(state, err, takenBack, "updater: the last update, from 0.20.0 to 0.21.0, stands at the step "+ "rolled-back, so update:rollback -after-failure has nothing to take back") @@ -276,7 +273,7 @@ func TestRollbackAfterFailureLeavesAloneAnUpdateThatPassedItsCheck(t *testing.T) kept.Step = updater.StepCommitted f := newGuardFixture(t, kept) - state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), startLimitHit) + state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), exited) f.wantLeftAlone(state, err, kept, "stands at the step committed, so update:rollback -after-failure has nothing "+ "to take back") @@ -285,15 +282,18 @@ func TestRollbackAfterFailureLeavesAloneAnUpdateThatPassedItsCheck(t *testing.T) func TestRollbackAfterFailureLeavesAloneAStartTheServiceManagerSaysDidNotFail(t *testing.T) { t.Parallel() - f := newGuardFixture(t, switchedTo) + for name, result := range map[string]string{"a start that succeeded": "success", "a run by hand": ""} { + t.Run(name, func(t *testing.T) { + t.Parallel() - state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), updater.Failure{ - ServiceResult: "exit-code", ExitStatus: "1", - }) + f := newGuardFixture(t, switchedTo) - f.wantLeftAlone(state, err, switchedTo, `updater: the service manager says the service result is "exit-code" `+ - `and the exit status is "1", not a start that failed its check or hit the start limit, so `+ - `update:rollback -after-failure leaves the update alone`) + state, err := updater.RollbackAfterFailure(settingsOf(t, f.env), updater.Failure{ServiceResult: result}) + + f.wantLeftAlone(state, err, switchedTo, `updater: the service manager says the service result is "`+ + result+`", not a failed start, so update:rollback -after-failure leaves the update alone`) + }) + } } func TestRollbackAfterFailureKeepsTheSiteDownAfterAnUpdateThatRaisedTheMinimumVersion(t *testing.T) { @@ -303,9 +303,9 @@ func TestRollbackAfterFailureKeepsTheSiteDownAfterAnUpdateThatRaisedTheMinimumVe state updater.State failure updater.Failure }{ - "starts that hit the limit before the guard gave up": {state: longJumped(updater.StepSwitched), - failure: startLimitHit}, - "a start the guard kept down": {state: stayedDown(), failure: failedCheck}, + "a build that kept crashing before the guard gave up": {state: longJumped(updater.StepSwitched), + failure: exited}, + "a start the guard kept down": {state: stayedDown(), failure: exited}, } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -332,7 +332,7 @@ func TestRollbackAfterFailureSaysWhenItCannotWriteThatTheSiteStaysDown(t *testin f := newGuardFixture(t, longJumped(updater.StepSwitched)) denied(t, filepath.Dir(f.layout.StateFile()), 0o500) - _, err := updater.RollbackAfterFailure(settingsOf(t, f.env), startLimitHit) + _, err := updater.RollbackAfterFailure(settingsOf(t, f.env), exited) wantRefused(t, err, "updater: write the update state") wantRefused(t, err, "updater: restore the backup taken before the update") diff --git a/internal/updater/unpack.go b/internal/updater/unpack.go index 4378fd28..ccaed771 100644 --- a/internal/updater/unpack.go +++ b/internal/updater/unpack.go @@ -31,43 +31,59 @@ var knownFiles = map[string]fs.FileMode{ "NOTICE": 0o644, } -// unpacker writes the entries of one release archive into its build folder. +// unpacker writes the entries of one release archive into its build folder dir of tree. type unpacker struct { reader *tar.Reader + tree *os.Root dir string } -// Unpack writes the known files of the release archive into dir, a folder it makes, deleting dir when any entry fails. -func Unpack(archive io.Reader, dir string) error { - if err := os.Mkdir(dir, 0o755); err != nil { - return fmt.Errorf("updater: make the build folder: %w", err) +// UnpackIn writes the known files of the release archive into dir, a folder it makes in tree, deleting it on a failure. +func UnpackIn(tree *os.Root, archive io.Reader, dir string) error { + if err := tree.Mkdir(dir, 0o755); err != nil { + return folderNotMade(err) } - if err := unpackInto(archive, dir); err != nil { - _ = os.RemoveAll(dir) + if err := unpackInto(tree, archive, dir); err != nil { + _ = tree.RemoveAll(dir) return err } return nil } -// unpackInto saves the new build folder dir to disk, then writes the entries of the gzip archive and saves them. -func unpackInto(archive io.Reader, dir string) error { - if err := saveFolders(filepath.Dir(dir)); err != nil { +// Unpack writes the known files of the release archive into dir, a folder it makes, deleting dir when any entry fails. +func Unpack(archive io.Reader, dir string) error { + tree, err := os.OpenRoot(filepath.Dir(dir)) + if err != nil { + return folderNotMade(err) + } + defer func() { _ = tree.Close() }() + return UnpackIn(tree, archive, filepath.Base(dir)) +} + +// folderNotMade says the build folder could not be made, and why. +func folderNotMade(err error) error { + return fmt.Errorf("updater: make the build folder: %w", err) +} + +// unpackInto saves the new build folder dir of tree to disk, then writes the entries of the archive and saves them. +func unpackInto(tree *os.Root, archive io.Reader, dir string) error { + if err := saveFolders(tree, filepath.Dir(dir)); err != nil { return err } unzipped, err := gzip.NewReader(archive) if err != nil { return fmt.Errorf("updater: refused the archive, which is not gzip: %w", err) } - if err := (unpacker{reader: tar.NewReader(unzipped), dir: dir}).entries(); err != nil { + if err := (unpacker{reader: tar.NewReader(unzipped), tree: tree, dir: dir}).entries(); err != nil { return err } - return saveFolders(filepath.Join(dir, licensesFolder), dir) + return saveFolders(tree, filepath.Join(dir, licensesFolder), dir) } -// saveFolders flushes the entries of each folder to disk, so a power cut leaves no build half written. -func saveFolders(folders ...string) error { +// saveFolders flushes the entries of each folder of tree to disk, so a power cut leaves no build half written. +func saveFolders(tree *os.Root, folders ...string) error { for _, folder := range folders { - if err := syncFolder(folder); err != nil { + if err := syncIn(tree, folder); err != nil { return fmt.Errorf("updater: save the build to disk: %w", err) } } @@ -76,7 +92,16 @@ func saveFolders(folders ...string) error { // syncFolder flushes the entries of the folder at name to disk, doing nothing for a folder that is not there. func syncFolder(name string) error { - folder, err := os.Open(name) + return syncOpened(os.Open(name)) +} + +// syncIn flushes the entries of the folder at name in tree to disk, doing nothing for a folder that is not there. +func syncIn(tree *os.Root, name string) error { + return syncOpened(tree.Open(name)) +} + +// syncOpened flushes the entries of the folder an open gave, or returns its error, taking a folder not there as done. +func syncOpened(folder *os.File, err error) error { if errors.Is(err, fs.ErrNotExist) { return nil } @@ -150,8 +175,8 @@ func isLicensesFolder(header *tar.Header) bool { func (u unpacker) write(header *tar.Header, mode fs.FileMode) error { name := path.Clean(header.Name) target := filepath.Join(u.dir, filepath.FromSlash(name)) - _ = os.MkdirAll(filepath.Dir(target), 0o755) - out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + _ = u.tree.MkdirAll(filepath.Dir(target), 0o755) + out, err := u.tree.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return fmt.Errorf("updater: unpack: %w", err) } diff --git a/internal/updater/unpack_test.go b/internal/updater/unpack_test.go index 947565ed..35d3eaec 100644 --- a/internal/updater/unpack_test.go +++ b/internal/updater/unpack_test.go @@ -8,6 +8,7 @@ import ( "compress/gzip" "os" "path/filepath" + "slices" "strings" "testing" @@ -286,23 +287,28 @@ func TestUnpackRefusesAnArchiveItCannotRead(t *testing.T) { } } -func TestUnpackRefusesABuildItCannotSaveToDisk(t *testing.T) { +func TestUnpackRefusesABuildFolderWhoseParentItCannotOpen(t *testing.T) { t.Parallel() - if os.Geteuid() == 0 { - t.Skip("running as root, which reads a folder whatever its mode says") - } - dir := buildFolder(t) - versions := filepath.Dir(dir) - if err := os.Chmod(versions, 0o300); err != nil { - t.Fatalf("denying read on %s: %v", versions, err) - } - t.Cleanup(func() { _ = os.Chmod(versions, 0o755) }) + for name, parent := range map[string]func(t *testing.T) string{ + "a parent that is not there": func(t *testing.T) string { return filepath.Join(t.TempDir(), "versions") }, + "a parent it may not read": func(t *testing.T) string { + versions := t.TempDir() + denied(t, versions, 0o300) + return versions + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() - err := updater.Unpack(bytes.NewReader(packed(t, releaseEntries()...)), dir) + dir := filepath.Join(parent(t), "0.21.0") - wantRefused(t, err, "save the build to disk") - wantMissing(t, dir) + err := updater.Unpack(bytes.NewReader(packed(t, releaseEntries()...)), dir) + + wantRefused(t, err, "updater: make the build folder") + wantMissing(t, dir) + }) + } } func TestUnpackLeavesAFolderThatIsThereAlone(t *testing.T) { @@ -322,3 +328,114 @@ func TestUnpackLeavesAFolderThatIsThereAlone(t *testing.T) { wantRefused(t, err, "file exists") wantFile(t, kept, "the running binary") } + +// openTree opens the folder at path as a tree that closes once t ends, failing t when it cannot. +func openTree(t *testing.T, path string) *os.Root { + t.Helper() + tree, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("opening %s as a tree: %v", path, err) + } + t.Cleanup(func() { _ = tree.Close() }) + return tree +} + +// installTree returns the path of a new install root holding an empty versions folder, and the tree open on it. +func installTree(t *testing.T) (string, *os.Root) { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "versions"), 0o755); err != nil { + t.Fatalf("making the versions folder: %v", err) + } + return root, openTree(t, root) +} + +// wantEntries fails t unless the folder at path holds the entries called names, in order. +func wantEntries(t *testing.T, path string, names ...string) { + t.Helper() + entries, err := os.ReadDir(path) + held := make([]string, 0, len(entries)) + for _, entry := range entries { + held = append(held, entry.Name()) + } + if err != nil || !slices.Equal(held, names) { + t.Errorf("%s holds %v, %v, want %v", path, held, err, names) + } +} + +func TestUnpackInWritesTheFilesOfAReleaseThroughTheTree(t *testing.T) { + t.Parallel() + + root, tree := installTree(t) + + if err := updater.UnpackIn(tree, bytes.NewReader(packed(t, releaseEntries()...)), "versions/0.21.0"); err != nil { + t.Fatalf("UnpackIn() = %v, want nil", err) + } + + dir := filepath.Join(root, "versions", "0.21.0") + for _, e := range releaseEntries() { + if e.typeflag == tar.TypeReg { + wantFile(t, filepath.Join(dir, filepath.FromSlash(e.name)), e.body) + } + } + if !executable(t, filepath.Join(dir, "gophenberg")) { + t.Errorf("gophenberg is not executable, want it run as the next build") + } +} + +func TestUnpackInRefusesABuildFolderWhoseParentIsALinkOutOfTheTree(t *testing.T) { + t.Parallel() + + root, outside := t.TempDir(), t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "versions")); err != nil { + t.Fatalf("linking versions out of the tree: %v", err) + } + + err := updater.UnpackIn(openTree(t, root), bytes.NewReader(packed(t, releaseEntries()...)), "versions/0.21.0") + + wantRefused(t, err, "updater: make the build folder") + wantEntries(t, outside) +} + +func TestUnpackInDeletesTheBuildFolderWhenAnEntryFails(t *testing.T) { + t.Parallel() + + root, tree := installTree(t) + + err := updater.UnpackIn(tree, bytes.NewReader(packed(t, file("LICENSE", "the license"), binary(), binary())), + "versions/0.21.0") + + wantRefused(t, err, "file exists") + wantEntries(t, filepath.Join(root, "versions")) +} + +func TestUnpackInLeavesAFolderThatIsThereAlone(t *testing.T) { + t.Parallel() + + root, tree := installTree(t) + kept := filepath.Join(root, "versions", "0.21.0", "gophenberg") + if err := os.Mkdir(filepath.Dir(kept), 0o755); err != nil { + t.Fatalf("making the folder: %v", err) + } + if err := os.WriteFile(kept, []byte("the running binary"), 0o755); err != nil { + t.Fatalf("writing the running binary: %v", err) + } + + err := updater.UnpackIn(tree, bytes.NewReader(packed(t, releaseEntries()...)), "versions/0.21.0") + + wantRefused(t, err, "file exists") + wantFile(t, kept, "the running binary") +} + +func TestUnpackInRefusesABuildItCannotSaveToDisk(t *testing.T) { + t.Parallel() + + versions := t.TempDir() + tree := openTree(t, versions) + denied(t, versions, 0o300) + + err := updater.UnpackIn(tree, bytes.NewReader(packed(t, releaseEntries()...)), "0.21.0") + + wantRefused(t, err, "updater: save the build to disk") + wantMissing(t, filepath.Join(versions, "0.21.0")) +} diff --git a/test/features/features/core-updates.feature b/test/features/features/core-updates.feature index e12aa838..92a4473f 100644 --- a/test/features/features/core-updates.feature +++ b/test/features/features/core-updates.feature @@ -127,7 +127,7 @@ Feature: Operators and administrators update the core and see where it stands And the database refuses every connection And serve waits "0" for the database And the site failed to start 3 times - When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "start-limit-hit" + When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "exit-code" Then the command succeeds And the current build is "0.18.0" And the update status lists this version as refused @@ -156,29 +156,43 @@ Feature: Operators and administrators update the core and see where it stands Scenario Outline: The service manager takes the site back after Given an install root where an update switched from this version to "99.0.0" - When the service manager runs "update:rollback -after-failure" with set to "" + When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "" Then the command succeeds And the current build is this version And the update status lists version "99.0.0" as refused Examples: - | failure | variable | value | - | the new build failed its check | MONITOR_EXIT_STATUS | 76 | - | the new build hit the start limit | MONITOR_SERVICE_RESULT | start-limit-hit | + | failure | result | + | the new build failed its check or kept crashing | exit-code | + | a signal stopped the new build | signal | Scenario: The service manager leaves an update that passed its check alone Given an install root where an update switched from this version to "99.0.0" And the new build passed its check - When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "start-limit-hit" + When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "exit-code" Then the command exits with code 1 And the current build is still "99.0.0" + Scenario: The service manager leaves the update alone when it says the site did not fail + Given an install root where an update switched from this version to "99.0.0" + When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "success" + Then the command exits with code 1 + And the error says "not a failed start" + And the current build is still "99.0.0" + + Scenario: update:rollback -after-failure run by hand leaves the update alone + Given an install root where an update switched from this version to "99.0.0" + When the operator runs "update:rollback -after-failure" + Then the command exits with code 1 + And the error says "not a failed start" + And the current build is still "99.0.0" + Scenario Outline: The service manager does not take back a long jump that reached the database after Given an install root where an update switched from this version to "99.0.0" And the update raised the minimum version, with a backup taken before the switch And the new build already tried to start 3 times And the new build reached the database - When the service manager runs "update:rollback -after-failure" with set to "" + When the service manager runs "update:rollback -after-failure" with MONITOR_SERVICE_RESULT set to "" Then the command exits with code 1 And the current build is still "99.0.0" And the error names the backup taken before the switch @@ -186,9 +200,9 @@ Feature: Operators and administrators update the core and see where it stands And the update status names the backup taken before the switch Examples: - | failure | variable | value | - | the new build failed its check | MONITOR_EXIT_STATUS | 76 | - | the new build hit the start limit | MONITOR_SERVICE_RESULT | start-limit-hit | + | failure | result | + | the new build failed its check or kept crashing | exit-code | + | a signal stopped the new build | signal | Scenario: An update that fails at a step leaves when, where and why Given an install root running this version as an official build diff --git a/test/features/features/server-install.feature b/test/features/features/server-install.feature index 1b5a9ac4..84f7bb8f 100644 --- a/test/features/features/server-install.feature +++ b/test/features/features/server-install.feature @@ -1,23 +1,42 @@ -Feature: Check a Linux server before installing Gophenberg +Feature: Install Gophenberg on a Linux server sudo gophenberg server:install sets up Gophenberg on a fresh Ubuntu 24.04, Debian 12 or Debian 13 server. Before it changes anything it - reads its settings, reads its install record and checks the server: - root, the system, systemd and the sandbox of the site's service, memory, - disk, the ports, any PostgreSQL or Caddy it did not install, the folders - above the install root, and that the domain points at this server. A - check that fails stops the run with a line that says why and that - nothing was changed. A later run skips the ports and the pieces the - install record says an earlier run installed. With -dry-run the run then - prints each step it would take and the full text of every file it would - write, and still changes nothing. Root never takes a setting from the - site's .env, since the site's own user can write it. It reads only the - five stop graces from it, as plain numbers, for the time systemd gives - the site to stop, and on a server that keeps its own web server the - upload cap. With -web-server none the server keeps a web server of its - own: the run leaves out Caddy, the ports Caddy answers on and the - domain check, and its last step prints the lines that web server needs - to send the domain to the site. A later run never moves a server from - one web server to the other. + checks that root runs it and that its installer folder is a real folder + root alone may write, then reads its settings, reads its install record + and checks the server: the system, systemd and the sandbox of the site's + service, memory, disk, the ports, any PostgreSQL or Caddy it did not + install, the folders above the install root, and that the domain points + at this server. A check that fails stops the run with a line that says + why and that nothing was changed. A later run skips the ports and the + pieces the install record says an earlier run installed. With -dry-run + the run then prints each step it would take and the full text of every + file it would write, and still changes nothing. A line the person added + to the site's .env prints by its key alone, its value hidden, and the + file keeps that value. Without -dry-run it then opens the release + archive -archive names and checks that its release.json names this + installer's version and this server's platform. While the first + administrator is still to be made, it asks on the terminal for what the + flags leave out: the email, the name and the password, typed twice and + never shown. A typed answer the account rules refuse is asked again. + Then it takes the steps its dry run prints, in order, and writes each + file its dry run prints. The install record marks each step started + before it and ended after it, so a run cut off part way picks up where + it stopped, and a step an earlier run ended never opens again. A later + run leaves the running build alone, asks nothing, keeps the person's + Caddyfile, reloads Caddy and restarts the site. A site that never + answers stops the run, and an HTTPS check that gets no answer only warns + and checks again on the next run. The checks also stop at a gophenberg + command on the path that the installer did not write, at a Caddyfile + that lost a line an earlier run wrote, and at a current link that names + no build. Root never takes a setting from the site's .env, since the + site's own user can write it. It reads only the five stop graces from + it, as plain numbers, for the time systemd gives the site to stop, and + on a server that keeps its own web server the upload cap. With + -web-server none the server keeps a web server of its own: the run + leaves out Caddy, the ports Caddy answers on and the domain check, and + its last step prints the lines that web server needs to send the domain + to the site. A later run never moves a server from one web server to + the other. Background: Given a fresh Ubuntu 24.04 server run by systemd 255 @@ -97,6 +116,13 @@ Feature: Check a Linux server before installing Gophenberg Then the run stops with "GOPHENBERG_INSTALL_WEB_SERVER: this server was set up with caddy, and a later run cannot move it to none, so run sudo gophenberg server:uninstall first, then change the line in /etc/gophenberg/server.env" And nothing on the server changed + Scenario: A later run that asks for another install root is refused + Given an earlier run installed Gophenberg for example.com on the server + And the environment sets GOPHENBERG_INSTALL_ROOT to /srv/gophenberg + When the operator runs "server:install" + Then the run stops with "GOPHENBERG_INSTALL_ROOT: this server was set up with /opt/gophenberg, and a later run cannot move it to /srv/gophenberg, so run sudo gophenberg server:uninstall first, then change the line in /etc/gophenberg/server.env" + And nothing on the server changed + Scenario: A domain with no address is refused Given example.com has no A or AAAA record When the operator runs "server:install -dry-run -domain example.com" @@ -155,6 +181,21 @@ Feature: Check a Linux server before installing Gophenberg Then the run stops with "/opt, a parent of the install root /opt/gophenberg, must be owned by root and writable by root alone, so nothing was changed" And nothing on the server changed + Scenario: An installer folder another user can write is refused + Given another user can write /var/lib/gophenberg-installer + When the operator runs "server:install -dry-run -domain example.com" + Then the run stops with "/var/lib/gophenberg-installer, which holds the install record, must be a real folder owned by root and writable by root alone, so nothing was changed" + And nothing on the server changed + + Scenario: A plain file where the installer folder belongs is refused before the run reads the install record + Given a plain file sits at /var/lib/gophenberg-installer + When the operator runs "server:install -dry-run -domain example.com" + Then the run stops with "/var/lib/gophenberg-installer, which holds the install record, must be a real folder owned by root and writable by root alone, so nothing was changed" + And nothing on the server changed + When the operator runs "server:install -domain example.com" + Then the run stops with "/var/lib/gophenberg-installer, which holds the install record, must be a real folder owned by root and writable by root alone, so nothing was changed" + And nothing on the server changed + Scenario: An install root that would hand the site's user a folder root trusts is refused Given the environment sets GOPHENBERG_INSTALL_ROOT to /usr/local When the operator runs "server:install -dry-run -domain example.com" @@ -167,6 +208,15 @@ Feature: Check a Linux server before installing Gophenberg Then the run stops because this installer is older than the 1000.0.0 installer And nothing on the server changed + Scenario: A run while another run is going is refused, and a dry run still goes through + Given an earlier run installed Gophenberg for example.com on the server + And another server:install is running on the server + When the operator runs "server:install" + Then the run stops with "another server:install or server:uninstall is running on this server, so nothing was changed" + And nothing on the server changed + When the operator runs "server:install -dry-run" + Then the run succeeds + Scenario: A later run skips the ports and the pieces an earlier run installed Given an earlier run installed Gophenberg for example.com on the server When the operator runs "server:install -dry-run" @@ -211,13 +261,38 @@ Feature: Check a Linux server before installing Gophenberg GOPHENBERG_THEMES_DIR=/opt/gophenberg/themes GOPHENBERG_MEDIA_DIR=/opt/gophenberg/media GOPHENBERG_DATABASE_WAIT=60s - GOPHENBERG_SHUTDOWN_GRACE=4m - GOPHENBERG_SITE_TITLE="Maria Perez's site" + GOPHENBERG_SHUTDOWN_GRACE=(kept as it is) + GOPHENBERG_SITE_TITLE=(kept as it is) """ And the site's unit holds the line "WorkingDirectory=/opt/gophenberg" And the site's unit holds the line "ReadWritePaths=/opt/gophenberg" And the site's unit holds the line "TimeoutStopSec=260s" + Scenario: A dry run hides the value of a password line the person added to the site's .env, and the run keeps it + Given an earlier run installed Gophenberg for example.com on the server + And the site's .env holds the line "GOPHENBERG_SMTP_PASSWORD=Maria's mail password" + When the operator runs "server:install -dry-run" + Then the run succeeds + And nothing the run printed holds "Maria's mail password" + And the dry run writes "/opt/gophenberg/.env", owned by gophenberg with mode 0600: + """ + # Written by gophenberg server:install. + GOPHENBERG_DATABASE_URL=postgres:///gophenberg?host=/var/run/postgresql&user=gophenberg + GOPHENBERG_SUPERUSER_POLICY=refuse + GOPHENBERG_INSTALL_ROOT=/opt/gophenberg + GOPHENBERG_ADDR=127.0.0.1:8081 + GOPHENBERG_PUBLIC_URL=https://example.com + GOPHENBERG_TRUSTED_PROXIES=127.0.0.1/32,::1/128 + GOPHENBERG_THEMES_DIR=/opt/gophenberg/themes + GOPHENBERG_MEDIA_DIR=/opt/gophenberg/media + GOPHENBERG_DATABASE_WAIT=60s + GOPHENBERG_SMTP_PASSWORD=(kept as it is) + """ + When the operator runs "server:install" + Then the run succeeds + And nothing the run printed holds "Maria's mail password" + And the server holds every file the dry run printed, with its owner and mode + Scenario: A line of the site's .env that holds a terminal control sequence is refused Given an earlier run installed Gophenberg for example.com on the server And the site's .env holds the line "GOPHENBERG_SITE_TITLE=Maria[2JPerez", standing for the escape character @@ -365,6 +440,7 @@ Feature: Check a Linux server before installing Gophenberg User=gophenberg Group=gophenberg WorkingDirectory=/opt/gophenberg + ExecCondition=+/usr/bin/systemctl is-failed --quiet gophenberg.service ExecStart=/opt/gophenberg/previous/gophenberg update:rollback -after-failure ExecStartPost=+/usr/bin/systemctl reset-failed gophenberg.service ExecStartPost=+/usr/bin/systemctl start gophenberg.service @@ -645,3 +721,533 @@ Feature: Check a Linux server before installing Gophenberg ProxyPass / http://127.0.0.1:8081/ ProxyPassReverse / http://127.0.0.1:8081/ """ + + Scenario: A dry run asks no question and opens no archive + Given the operator's terminal answers: + """ + admin@example.com + """ + When the operator runs "server:install -dry-run -domain example.com -archive /var/lib/gophenberg-installer/missing.tar.gz" + Then the run succeeds + And the run asked nothing on the terminal + And nothing on the server changed + + Scenario: A first run without -archive is refused before anything changes + Given the operator's terminal answers: + """ + admin@example.com + """ + When the operator runs "server:install -domain example.com" + Then the run stops with "-archive is required while /opt/gophenberg/current names no build, such as the archive install.sh downloads into /var/lib/gophenberg-installer, so nothing was changed" + And the run asked nothing on the terminal + And nothing on the server changed + + Scenario: An archive of another version than this installer is refused + Given a release archive of Gophenberg 0.0.1 is at /var/lib/gophenberg-installer/release.tar.gz + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run stops because the archive /var/lib/gophenberg-installer/release.tar.gz holds Gophenberg 0.0.1 + And the run asked nothing on the terminal + And nothing on the server changed + + Scenario: An archive built for another platform is refused + Given a release archive of this installer built for windows/arm64 is at /var/lib/gophenberg-installer/release.tar.gz + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run stops because the archive /var/lib/gophenberg-installer/release.tar.gz is built for windows/arm64 + And the run asked nothing on the terminal + And nothing on the server changed + + Scenario: A password file that is not an absolute path is refused before the server is read + When the operator runs "server:install -domain example.com -admin-password-file admin-password" + Then the run stops with "-admin-password-file: must be an absolute path, got "admin-password"" + And the checks listened on no port + And nothing on the server changed + + Scenario: A password file whose first line ends in a carriage return is refused + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" ending in a carriage return + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run stops with "-admin-password-file: the first line of /root/admin-password ends in a carriage return, as a file saved with Windows line endings does, and a password never holds one, so nothing was changed" + And the run asked nothing on the terminal + And nothing on the server changed + + Scenario: A password shorter than 12 characters is refused before anything changes + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "too short" + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run stops with "-admin-password-file: password shorter than 12 characters, so nothing was changed" + And the run asked nothing on the terminal + And nothing on the server changed + + Scenario: A first run takes each step's programs in order and creates the first administrator from the flags + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the run asked nothing on the terminal + And the run ran these programs, the checks' own left out: + """ + dpkg-query -W -f ${db:Status-Abbrev}\n postgresql caddy + apt-get -o DPkg::Lock::Timeout=300 update + apt-get -o DPkg::Lock::Timeout=300 install -y --no-install-recommends postgresql caddy + useradd --system --user-group --home-dir /opt/gophenberg --no-create-home --shell /usr/sbin/nologin gophenberg + runuser -u postgres -- createuser --no-superuser --no-createdb --no-createrole gophenberg + runuser -u postgres -- createdb --owner=gophenberg --encoding=UTF8 --template=template0 --locale-provider=icu --icu-locale=und gophenberg + runuser -u gophenberg -- /opt/gophenberg/current/gophenberg check + systemctl daemon-reload + systemctl enable gophenberg.service + runuser -u caddy -- caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + systemctl restart caddy.service + systemctl reset-failed gophenberg.service + systemctl start gophenberg.service + runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email admin@example.com -name Maria -role admin + """ + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg check" in /opt/gophenberg + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email admin@example.com -name Maria -role admin" in /opt/gophenberg, the line "correct horse battery staple" on its standard input + And the run ran "runuser -u caddy -- caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile" with HOME=/var/lib/caddy in its environment + And the run checked https://example.com/admin/ trusting the system's root certificates + And the server holds /etc/caddy/Caddyfile.before-gophenberg as the caddy package wrote it + And the install record holds the SHA-256 of the Caddyfile the run wrote + And the install record marks every step it took ended + And the run ends with: + """ + Step 15: print the summary and end the install record + + Gophenberg 0.19.0 runs at https://example.com. Sign in at https://example.com/admin/ as admin@example.com. + The hosting provider's firewall must let TCP 80, TCP 443 and UDP 443 in for visitors to reach the site. + Commands to know: + sudo gophenberg update:status shows the build that runs and the update on offer + sudo gophenberg update:apply installs the update on offer + sudo journalctl -u gophenberg shows what the site logged + sudo gophenberg server:uninstall removes Gophenberg and keeps its data + """ + + Scenario: A first run asks on the terminal for the first administrator the flags leave out, the password unseen and twice + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the operator's terminal answers: + """ + Maria Perez + correct horse battery staple + correct horse battery staple + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com" + Then the run succeeds + And the terminal showed: + """ + Name of the first administrator [admin]: + Password: (unseen) + Password again: (unseen) + """ + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email admin@example.com -name Maria Perez -role admin" in /opt/gophenberg, the line "correct horse battery staple" on its standard input + + Scenario: An empty name takes the part of the email before @ + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + And the operator's terminal answers: + """ + maria.perez@example.com + + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-password-file /root/admin-password" + Then the run succeeds + And the terminal showed: + """ + Email of the first administrator: + Name of the first administrator [maria.perez]: + """ + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email maria.perez@example.com -name maria.perez -role admin" in /opt/gophenberg, the line "correct horse battery staple" on its standard input + + Scenario: A first run for localhost checks HTTPS against Caddy's own root certificate + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -domain localhost -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the run notes nothing + And the run checked https://localhost/admin/ trusting Caddy's own root certificate + + Scenario: A first run with -web-server none installs everything but Caddy and prints the lines for the person's web server + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -dry-run -domain example.com -admin-email admin@example.com -web-server none" + And the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password -web-server none" + Then the run succeeds + And the run notes nothing + And the server holds every file the dry run printed, with its owner and mode + And the run ran these programs, the checks' own left out: + """ + dpkg-query -W -f ${db:Status-Abbrev}\n postgresql + apt-get -o DPkg::Lock::Timeout=300 update + apt-get -o DPkg::Lock::Timeout=300 install -y --no-install-recommends postgresql + useradd --system --user-group --home-dir /opt/gophenberg --no-create-home --shell /usr/sbin/nologin gophenberg + runuser -u postgres -- createuser --no-superuser --no-createdb --no-createrole gophenberg + runuser -u postgres -- createdb --owner=gophenberg --encoding=UTF8 --template=template0 --locale-provider=icu --icu-locale=und gophenberg + runuser -u gophenberg -- /opt/gophenberg/current/gophenberg check + systemctl daemon-reload + systemctl enable gophenberg.service + systemctl reset-failed gophenberg.service + systemctl start gophenberg.service + runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email admin@example.com -name Maria -role admin + """ + And the server holds nothing at /etc/caddy + And the run checked no page over HTTPS + And the run prints: + """ + Step 13: print the summary and end the install record + + Gophenberg 0.19.0 answers on 127.0.0.1:8081, the GOPHENBERG_ADDR the site listens on. + Once your own web server sends example.com there, sign in at https://example.com/admin/ as admin@example.com. + Commands to know: + sudo gophenberg update:status shows the build that runs and the update on offer + sudo gophenberg update:apply installs the update on offer + sudo journalctl -u gophenberg shows what the site logged + sudo gophenberg server:uninstall removes Gophenberg and keeps its data + Step 14: print the lines for nginx, Caddy, Traefik and Apache that send example.com to the site at 127.0.0.1:8081 + """ + And the run ends with the lines for the person's web server that the dry run printed + + Scenario: Two typed passwords that differ are asked again + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the operator's terminal answers: + """ + admin@example.com + Maria Perez + correct horse battery staple + correct horse battery stable + correct horse battery staple + correct horse battery staple + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And the terminal showed: + """ + Email of the first administrator: + Name of the first administrator [admin]: + Password: (unseen) + Password again: (unseen) + the two passwords differ, so type them again + Password: (unseen) + Password again: (unseen) + """ + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email admin@example.com -name Maria Perez -role admin" in /opt/gophenberg, the line "correct horse battery staple" on its standard input + + Scenario: A typed answer the account rules refuse is asked again + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the operator's terminal answers: + """ + maria + admin@example.com + + too short + correct horse battery staple + correct horse battery staple + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And the terminal showed: + """ + Email of the first administrator: + invalid email, so type it again + Email of the first administrator: + Name of the first administrator [admin]: + Password: (unseen) + password shorter than 12 characters, so type it again + Password: (unseen) + Password again: (unseen) + """ + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email admin@example.com -name admin -role admin" in /opt/gophenberg, the line "correct horse battery staple" on its standard input + + Scenario: A typed email that cannot become the certificate email is asked again + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the operator's terminal answers: + """ + maria{perez}@example.com + admin@example.com + + correct horse battery staple + correct horse battery staple + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And the terminal showed: + """ + Email of the first administrator: + the email also becomes GOPHENBERG_INSTALL_CERTIFICATE_EMAIL, which must be one plain email address such as admin@example.com, got "maria{perez}@example.com", so type it again + Email of the first administrator: + Name of the first administrator [admin]: + Password: (unseen) + Password again: (unseen) + """ + And the server holds /etc/caddy/Caddyfile with the certificate email admin@example.com + + Scenario: A run that must ask for the first administrator without a terminal is refused, naming the flags + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com" + Then the run stops with "this run has no terminal to ask the first administrator's name and password on, so give -admin-name and -admin-password-file: open /dev/tty: no such device or address, so nothing was changed" + And nothing on the server changed + + Scenario: A terminal that ends before every answer is given stops the run + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the operator's terminal answers: + """ + admin@example.com + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run stops with "asking for the first administrator's name ended: the terminal gave no more input, so nothing was changed" + And nothing on the server changed + + Scenario: A later run leaves the running build alone, asks nothing, restarts the site and reloads Caddy + Given an earlier run installed Gophenberg for example.com on the server + And the operator's terminal answers: + """ + admin@example.com + """ + When the operator runs "server:install" + Then the run succeeds + And the run asked nothing on the terminal + And the run ran these programs, the checks' own left out: + """ + dpkg-query -W -f ${db:Status-Abbrev}\n postgresql caddy + runuser -u gophenberg -- /opt/gophenberg/current/gophenberg check + systemctl daemon-reload + systemctl enable gophenberg.service + runuser -u caddy -- caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + systemctl reload caddy.service + systemctl reset-failed gophenberg.service + systemctl restart gophenberg.service + """ + And the current link still names the earlier build 0.19.0 + And /etc/caddy/Caddyfile is as the earlier run left it + And the run ends with: + """ + Step 14: print the summary and end the install record + + Gophenberg 0.19.0 runs at https://example.com. Sign in at https://example.com/admin/. + The hosting provider's firewall must let TCP 80, TCP 443 and UDP 443 in for visitors to reach the site. + Commands to know: + sudo gophenberg update:status shows the build that runs and the update on offer + sudo gophenberg update:apply installs the update on offer + sudo journalctl -u gophenberg shows what the site logged + sudo gophenberg server:uninstall removes Gophenberg and keeps its data + """ + + Scenario: A gophenberg command on the path that the installer did not write is refused + Given another package put a gophenberg command at /usr/local/bin/gophenberg + When the operator runs "server:install -dry-run -domain example.com" + Then the run stops with "this server already has a /usr/local/bin/gophenberg that server:install did not write, and server:install puts the gophenberg command there, so nothing was changed" + And nothing on the server changed + + Scenario Outline: A later run whose Caddyfile lost a line an earlier run wrote is refused + Given an earlier run installed Gophenberg for example.com on the server + And the line "" was taken out of /etc/caddy/Caddyfile + When the operator runs "server:install -dry-run" + Then the run stops with "/etc/caddy/Caddyfile lacks the line "" that an earlier run wrote, , and it must go back in before server:install runs again, so nothing was changed" + And nothing on the server changed + + Examples: + | line | why | + | import /etc/caddy/gophenberg.caddy | which has Caddy serve the site | + | admin unix//run/caddy/admin.sock | which keeps Caddy's admin address off the loopback port any local user can reach | + + Scenario Outline: A current link that names no build is refused + Given an earlier run installed Gophenberg for example.com on the server + And the current link of the install root + When the operator runs "server:install -dry-run" + Then the run stops with "cannot tell which build /opt/gophenberg/current names: , so nothing was changed" + And nothing on the server changed + + Examples: + | names | why | + | names /opt/gophenberg/versions/0.19.0 by its full path | updater: the current link names /opt/gophenberg/versions/0.19.0, not a build in versions | + | names versions/0.19.0, a build folder deleted by hand | /opt/gophenberg/versions/0.19.0/gophenberg is not there | + | names versions/0.19.0, whose program nobody may run | /opt/gophenberg/versions/0.19.0/gophenberg is not executable | + + Scenario: A first run installs Gophenberg and writes every file its dry run prints, with its owner and mode + Given unattended-upgrades is installed on the server + And the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -dry-run -domain example.com -admin-email admin@example.com" + And the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the server holds every file the dry run printed, with its owner and mode + And the server holds these folders: + | folder | owner | mode | + | /var/lib/gophenberg-installer | root | 0700 | + | /etc/gophenberg | root | 0755 | + | /opt/gophenberg | gophenberg | 0750 | + | /opt/gophenberg/versions | gophenberg | 0755 | + | /opt/gophenberg/downloads | gophenberg | 0755 | + | /opt/gophenberg/state | gophenberg | 0700 | + | /opt/gophenberg/backups | gophenberg | 0700 | + | /opt/gophenberg/media | gophenberg | 0750 | + | /opt/gophenberg/themes | gophenberg | 0750 | + And the server holds the install record, owned by root with mode 0600 + And the server holds the installer's own copy, owned by root with mode 0755 + And the build folder of this installer holds only the files of its archive, owned by gophenberg + And the current link names the build of this installer, owned by gophenberg + + Scenario: A run cut off while it installed the packages runs dpkg --configure -a first + Given a first run for example.com was cut off while apt-get installed PostgreSQL + And the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the run ran "dpkg --configure -a" before "apt-get -o DPkg::Lock::Timeout=300 update" + And the run ran "apt-get -o DPkg::Lock::Timeout=300 install -y --no-install-recommends postgresql caddy" after "apt-get -o DPkg::Lock::Timeout=300 update" + And the install record marks the step packages ended + + Scenario: A run cut off after createuser takes the role that already exists as made + Given a first run for example.com was cut off after createuser made the database role gophenberg + And the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the run ran "runuser -u postgres -- createdb --owner=gophenberg --encoding=UTF8 --template=template0 --locale-provider=icu --icu-locale=und gophenberg" after "runuser -u postgres -- createuser --no-superuser --no-createdb --no-createrole gophenberg" + And the install record marks the step database ended + + Scenario: A run cut off after the unpack removes the leftover build folder + Given a first run for example.com was cut off after it unpacked part of the build of this installer + And the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the build folder of this installer holds only the files of its archive, owned by gophenberg + And the current link names the build of this installer, owned by gophenberg + And the install record marks the step build ended + + Scenario: A run cut off after account:create-admin asks nothing on the next run + Given an earlier run for example.com was cut off after account:create-admin made the first administrator admin@example.com + And the operator's terminal answers: + """ + admin@example.com + """ + When the operator runs "server:install" + Then the run succeeds + And the run asked nothing on the terminal + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:list -json" in /opt/gophenberg + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:list -json" before "systemctl daemon-reload" + And the run created no administrator + And the install record marks the step admin ended + And the install record marks the step summary ended + + Scenario: An administrator email an account under another role holds stops the run, and the next run asks again + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + And an account a person made by hand holds admin@example.com under the role editor + And the operator's terminal answers: + """ + maria.perez@example.com + Maria Perez + correct horse battery staple + correct horse battery staple + """ + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run stops with "admin@example.com already belongs to an account that is not an administrator, so run sudo gophenberg server:install again and give the first administrator another email" + And the install record marks the step admin started + When the operator runs "server:install" + Then the run succeeds + And the terminal showed: + """ + Email of the first administrator: + Name of the first administrator [maria.perez]: + Password: (unseen) + Password again: (unseen) + """ + And the run ran "runuser -u gophenberg -- /opt/gophenberg/current/gophenberg account:create-admin -email maria.perez@example.com -name Maria Perez -role admin" in /opt/gophenberg, the line "correct horse battery staple" on its standard input + And the install record marks the step admin ended + + Scenario: An administrator email an account under another role holds stops the run while another email is under admin + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + And an account a person made by hand holds owner@example.com under the role admin + And an account a person made by hand holds admin@example.com under the role editor + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run stops with "admin@example.com already belongs to an account that is not an administrator, so run sudo gophenberg server:install again and give the first administrator another email" + And the install record marks the step admin started + + Scenario: A later run keeps the person's .env lines and writes a changed restart delay from server.env + Given an earlier run installed Gophenberg for example.com on the server + And the site's .env holds the line "GOPHENBERG_SITE_TITLE=Maria Perez's site" + And the server settings set GOPHENBERG_SERVICE_RESTART_DELAY to 3s + When the operator runs "server:install" + Then the run succeeds + And the server holds "/opt/gophenberg/.env", owned by gophenberg with mode 0600: + """ + # Written by gophenberg server:install. + GOPHENBERG_DATABASE_URL=postgres:///gophenberg?host=/var/run/postgresql&user=gophenberg + GOPHENBERG_SUPERUSER_POLICY=refuse + GOPHENBERG_INSTALL_ROOT=/opt/gophenberg + GOPHENBERG_ADDR=127.0.0.1:8081 + GOPHENBERG_PUBLIC_URL=https://example.com + GOPHENBERG_TRUSTED_PROXIES=127.0.0.1/32,::1/128 + GOPHENBERG_THEMES_DIR=/opt/gophenberg/themes + GOPHENBERG_MEDIA_DIR=/opt/gophenberg/media + GOPHENBERG_DATABASE_WAIT=60s + GOPHENBERG_SITE_TITLE="Maria Perez's site" + """ + And the site's unit on the server holds the line "RestartSec=3s" + And the run ran "systemctl daemon-reload" before "systemctl restart gophenberg.service" + + Scenario: A run whose HTTPS check gets no answer warns, ends with exit 0 and checks again on the next run + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + And the hosting provider's firewall keeps TCP port 443 closed + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the run notes "warning: https://example.com/admin/ did not answer 200 within 2m0s, the time GOPHENBERG_INSTALL_HTTPS_WAIT allows, as at the last try it could not be reached: dial tcp 203.0.113.7:443: i/o timeout. The site runs, so check that the hosting provider's firewall lets TCP 80, TCP 443 and UDP 443 in, then read sudo journalctl -u caddy, and the next sudo gophenberg server:install checks again" + And the install record marks the step https started + And the install record marks the step summary ended + When the hosting provider's firewall lets TCP port 443 in + And the operator runs "server:install" + Then the run succeeds + And the run notes nothing + And the run checked https://example.com/admin/ trusting the system's root certificates + And the install record marks the step https ended + + Scenario: A run whose site never answers stops, names the journal and leaves the start step open + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + And the site never answers on 127.0.0.1:8081 + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run stops with "gophenberg.service did not pass its start check within 1m0s, the time GOPHENBERG_UPDATE_VERIFY_TIMEOUT allows, as at the last try it could not ask for /admin/: dial tcp 127.0.0.1:8081: connect: connection refused, so read why with sudo journalctl -u gophenberg" + And the install record marks the step start started + And the run created no administrator + + Scenario: A run from the installer's own copy leaves the copy alone + Given an earlier run installed Gophenberg for example.com on the server + And the operator runs the installer's own copy + When the operator runs "server:install" + Then the run succeeds + And the installer's own copy is the file the run ran, left as it was + + Scenario: A later run whose caddy validate fails keeps the person's Caddyfile on the next run + Given an earlier run installed Gophenberg for example.com on the server + And the person added a site to /etc/caddy/Caddyfile whose directive respnd caddy validate does not know + When the operator runs "server:install" + Then the run stops with "caddy validate refused Caddy's files, so Caddy runs on with the settings it has: installer: run caddy: exit status 1", followed by what the program printed + And /etc/caddy/Caddyfile still holds the person's site + And the install record marks the step caddy ended + When the person fixes the typo in /etc/caddy/Caddyfile + And the operator runs "server:install" + Then the run succeeds + And /etc/caddy/Caddyfile still holds the person's site + And the run ran "systemctl reload caddy.service" + And the run never ran "systemctl restart caddy.service" + + Scenario: A first run whose Caddy restart fails keeps the site the person adds to the Caddyfile on the next run + Given the release archive of this installer is at /var/lib/gophenberg-installer/release.tar.gz + And the file /root/admin-password holds the line "correct horse battery staple" + And Caddy fails to restart on the server + When the operator runs "server:install -domain example.com -archive /var/lib/gophenberg-installer/release.tar.gz -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run stops with "installer: run systemctl: exit status 1", followed by what the program printed + And the install record marks the step caddy started + When Caddy restarts again on the server + And the person adds the site example.org to /etc/caddy/Caddyfile + And the operator runs "server:install -admin-email admin@example.com -admin-name Maria -admin-password-file /root/admin-password" + Then the run succeeds + And the run prints: + """ + Step 11: write Caddy's files, check them with caddy validate and restart Caddy + """ + And /etc/caddy/Caddyfile still holds the person's site + And the run ran "systemctl restart caddy.service" + And the run never ran "systemctl reload caddy.service" + And the install record marks the step caddy ended diff --git a/test/features/features/server-uninstall.feature b/test/features/features/server-uninstall.feature new file mode 100644 index 00000000..8d64ace8 --- /dev/null +++ b/test/features/features/server-uninstall.feature @@ -0,0 +1,511 @@ +Feature: Remove Gophenberg from a Linux server + sudo gophenberg server:uninstall removes what server:install set up and + keeps the site's data. It reads its settings from + /etc/gophenberg/server.env alone, never from the environment or the + site's .env. It stops on a server with no install record, on a run with + -yes while another server:install or server:uninstall is running, and + at a Caddyfile the person changed that is a link while its import line + has to come out. It lists what it removes and what it keeps, and + without -yes it changes nothing. With + -yes it stops and removes the site's service, takes Gophenberg's site + out of Caddy, and removes the command on the path, the installer's own + copy, the builds, the downloads and the links. It keeps the database and + its role, the install root with the site's .env, media, themes and + backups, server.env, the install record, the system user gophenberg and + the packages. A Caddyfile still as server:install wrote it goes back to + the one the caddy package wrote, and a Caddyfile the person changed + stays and loses only its import line, so a later server:install asks + for that line back and never writes over it. The install record keeps + the sum of that Caddyfile before Caddy's files change, and Caddy + restarts on it where server:install never ended its caddy step, else + it reloads. With -delete-data it also drops the + database and its role and removes the install root, server.env and the + user, and it removes an install root only when the install record shows + that server:install made it. The record keeps the domain, since the + kept Caddy still holds port 80, so a later install for that domain + skips the domain check. A unit systemd already let go of once the site + stopped counts as cleared. Each piece already gone is skipped, so a + second run finds nothing left to remove. The install record shows that + the Caddyfile is going back before it does, so a run cut off part way + is finished by the next one, which restarts or reloads Caddy where the + cut-off run did not. + + Background: + Given a fresh Ubuntu 24.04 server run by systemd 255 + + Scenario: A plain user is refused + Given server:install installed Gophenberg for example.com on the server + And the operator is not root + When the operator runs "server:uninstall -yes" + Then the run stops with "server:uninstall must run as root, such as with sudo, so nothing was changed" + And nothing on the server changed + + Scenario: A server with no install record has nothing to remove + When the operator runs "server:uninstall -yes" + Then the run stops with "this server has no install record at /var/lib/gophenberg-installer/install.json, so server:uninstall has nothing to remove" + And nothing on the server changed + + Scenario: An installer folder another user can write is refused + Given server:install installed Gophenberg for example.com on the server + And another user can write /var/lib/gophenberg-installer + When the operator runs "server:uninstall -yes" + Then the run stops with "/var/lib/gophenberg-installer, which holds the install record, must be a real folder owned by root and writable by root alone, so nothing was changed" + And nothing on the server changed + + Scenario: A run with -yes while another run is going is refused, and a run without -yes still lists + Given server:install installed Gophenberg for example.com on the server + And another server:install is running on the server + When the operator runs "server:uninstall -yes" + Then the run stops with "another server:install or server:uninstall is running on this server, so nothing was changed" + And nothing on the server changed + When the operator runs "server:uninstall" + Then the run succeeds + + Scenario: A server whose Caddyfile and the caddy package's one are both gone is refused + Given server:install installed Gophenberg for example.com on the server + And the person deleted /etc/caddy/Caddyfile + And the person deleted /etc/caddy/Caddyfile.before-gophenberg + When the operator runs "server:uninstall -yes" + Then the run stops with "server:uninstall needs /etc/caddy/Caddyfile to take Gophenberg's site out of Caddy, and it is not there, so nothing was changed" + And nothing on the server changed + + Scenario: A Caddyfile the person changed that is a link to another file holding the import line is refused + Given server:install installed Gophenberg for example.com on the server + And the person added the site example.org to /etc/caddy/Caddyfile + And the person moved /etc/caddy/Caddyfile to /etc/caddy/main.caddy and left a link to it in its place + When the operator runs "server:uninstall -yes" + Then the run stops with "/etc/caddy/Caddyfile is a link, and the line "import /etc/caddy/gophenberg.caddy" must come out of the file it leads to before server:uninstall runs again, so nothing was changed" + And nothing on the server changed + And /etc/caddy/Caddyfile is still a link to /etc/caddy/main.caddy + And /etc/caddy/main.caddy still holds the line "import /etc/caddy/gophenberg.caddy" + + Scenario: Without -yes it lists what it removes and keeps and changes nothing + Given server:install installed Gophenberg for example.com on the server + When the operator runs "server:uninstall" + Then the run succeeds + And the run prints: + """ + server:uninstall removes: + gophenberg.service, stopped and disabled + /etc/systemd/system/gophenberg.service + /etc/systemd/system/gophenberg-rollback.service + /etc/caddy/Caddyfile, putting back /etc/caddy/Caddyfile.before-gophenberg, the one the caddy package wrote + /etc/systemd/system/caddy.service.d/gophenberg.conf + /etc/caddy/gophenberg.caddy + /usr/local/share/gophenberg/maintenance + /usr/local/bin/gophenberg + /usr/local/lib/gophenberg/gophenberg + /opt/gophenberg/versions + /opt/gophenberg/downloads + /opt/gophenberg/current + server:uninstall keeps: + the database gophenberg and its role gophenberg + the install root /opt/gophenberg, with the site's .env, media, themes and backups + /etc/gophenberg/server.env + the system user gophenberg + /var/lib/gophenberg-installer/install.json, so a later install picks up what is kept + the packages server:install installed, which sudo apt-get purge postgresql caddy removes + /etc/apt/keyrings/caddy.asc, /etc/apt/sources.list.d/caddy.sources and /etc/apt/preferences.d/gophenberg-caddy, which bring Caddy's security fixes from its own apt repository + """ + And nothing on the server changed + + Scenario: With -yes it removes the service, Caddy's site, the command and the builds and keeps the data + Given server:install installed Gophenberg for example.com on the server + And the file /opt/gophenberg/media/2026/10/photo.jpg holds the line "a photo of Maria Perez's garden" + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run ran these programs, the checks' own left out: + """ + systemctl disable --now gophenberg.service + systemctl reset-failed gophenberg.service gophenberg-rollback.service + systemctl daemon-reload + systemctl daemon-reload + systemctl restart caddy.service + """ + And the run ends with: + """ + server:uninstall removed what it lists and kept the rest. + """ + And the server holds nothing at these paths: + """ + /etc/systemd/system/gophenberg.service + /etc/systemd/system/gophenberg-rollback.service + /etc/systemd/system/caddy.service.d/gophenberg.conf + /etc/caddy/Caddyfile.before-gophenberg + /etc/caddy/gophenberg.caddy + /usr/local/share/gophenberg/maintenance + /usr/local/bin/gophenberg + /usr/local/lib/gophenberg/gophenberg + /opt/gophenberg/versions + /opt/gophenberg/downloads + /opt/gophenberg/current + """ + And the server still holds these paths: + """ + /opt/gophenberg/.env + /opt/gophenberg/media/2026/10/photo.jpg + /opt/gophenberg/themes + /opt/gophenberg/backups + /etc/gophenberg/server.env + /var/lib/gophenberg-installer/install.json + /etc/apt/keyrings/caddy.asc + /etc/apt/sources.list.d/caddy.sources + /etc/apt/preferences.d/gophenberg-caddy + """ + And the server holds /etc/caddy/Caddyfile as the caddy package wrote it + And the server still has the system user gophenberg + And the install record marks only these steps ended: server-env, packages, user, database, admin + And the install record owns only these pieces: postgresql, caddy, /opt/gophenberg + And the install record holds no SHA-256 of a Caddyfile + + Scenario: Units systemd already let go of once the site stopped count as cleared, and the run goes on + Given server:install installed Gophenberg for example.com on the server + And systemd lets go of the site's units once the site stops + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run ran these programs, the checks' own left out: + """ + systemctl disable --now gophenberg.service + systemctl reset-failed gophenberg.service gophenberg-rollback.service + systemctl daemon-reload + systemctl daemon-reload + systemctl restart caddy.service + """ + And the run ends with: + """ + server:uninstall removed what it lists and kept the rest. + """ + And the server holds nothing at these paths: + """ + /etc/systemd/system/gophenberg.service + /etc/systemd/system/gophenberg-rollback.service + /etc/caddy/gophenberg.caddy + /usr/local/bin/gophenberg + /opt/gophenberg/versions + """ + + Scenario: It keeps a Caddyfile the person changed, with the drop-in, and takes out only the import line + Given server:install installed Gophenberg for example.com on the server + And the person added the site example.org to /etc/caddy/Caddyfile + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run prints: + """ + server:uninstall removes: + gophenberg.service, stopped and disabled + /etc/systemd/system/gophenberg.service + /etc/systemd/system/gophenberg-rollback.service + the line import /etc/caddy/gophenberg.caddy of /etc/caddy/Caddyfile + /etc/caddy/gophenberg.caddy + """ + And the run prints: + """ + server:uninstall keeps: + /etc/caddy/Caddyfile, which changed since server:install wrote it, so a reinstall asks for that line back + /etc/systemd/system/caddy.service.d/gophenberg.conf, which gives Caddy the folder of the admin socket its Caddyfile names + /etc/caddy/Caddyfile.before-gophenberg, the Caddyfile the caddy package wrote + the database gophenberg and its role gophenberg + """ + And /etc/caddy/Caddyfile still holds the person's site + And /etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" + And the server still holds these paths: + """ + /etc/systemd/system/caddy.service.d/gophenberg.conf + /etc/caddy/Caddyfile.before-gophenberg + """ + And the run ran "systemctl reload caddy.service" + And the run never ran "systemctl restart caddy.service" + And the install record still holds the SHA-256 of the Caddyfile server:install wrote + And the install record marks the step caddy ended + + Scenario: A Caddyfile the person changed keeps its mode, owner and group once the import line comes out + Given server:install installed Gophenberg for example.com on the server + And the person added the site example.org to /etc/caddy/Caddyfile + And the person gave /etc/caddy/Caddyfile the group caddy and mode 0640 + When the operator runs "server:uninstall -yes" + Then the run succeeds + And /etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" + And /etc/caddy/Caddyfile still belongs to root and the group caddy with mode 0640 + + Scenario: A reinstall over a Caddyfile the person changed stops at the import line and never writes over it + Given server:install installed Gophenberg for example.com on the server + And the person added the site example.org to /etc/caddy/Caddyfile + When the operator runs "server:uninstall -yes" + And the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run stops with "/etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so nothing was changed" + And nothing on the server changed + When the person puts the line "import /etc/caddy/gophenberg.caddy" back in /etc/caddy/Caddyfile + And the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And /etc/caddy/Caddyfile still holds the person's site + And the run ran "systemctl reload caddy.service" + And the run never ran "systemctl restart caddy.service" + + Scenario: With -delete-data it also drops the database, its role, the install root, server.env and the user + Given server:install installed Gophenberg for example.com on the server + When the operator runs "server:uninstall -delete-data -yes" + Then the run succeeds + And the run ran these programs, the checks' own left out: + """ + systemctl disable --now gophenberg.service + systemctl reset-failed gophenberg.service gophenberg-rollback.service + systemctl daemon-reload + systemctl daemon-reload + systemctl restart caddy.service + runuser -u postgres -- dropdb gophenberg + runuser -u postgres -- dropuser gophenberg + userdel gophenberg + """ + And the run prints: + """ + /opt/gophenberg/current + the database gophenberg and its role gophenberg + the install root /opt/gophenberg and everything in it + /etc/gophenberg/server.env + the system user gophenberg + server:uninstall keeps: + /var/lib/gophenberg-installer/install.json, holding the packages server:install installed, so a later install takes them as its own + the packages server:install installed, which sudo apt-get purge postgresql caddy removes + """ + And the server holds nothing at these paths: + """ + /opt/gophenberg + /etc/gophenberg/server.env + """ + And the server has no system user gophenberg + And the install record marks no step + And the install record owns only these pieces: postgresql, caddy + + Scenario: On a server that keeps its own web server it leaves Caddy alone + Given Caddy is installed on the server + And the person's own Caddy serves example.com from /etc/caddy/Caddyfile + And server:install installed Gophenberg for example.com with -web-server none on the server + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run ran these programs, the checks' own left out: + """ + systemctl disable --now gophenberg.service + systemctl reset-failed gophenberg.service gophenberg-rollback.service + systemctl daemon-reload + """ + And /etc/caddy/Caddyfile is as the person wrote it + And the run prints: + """ + the packages server:install installed, which sudo apt-get purge postgresql removes + server:uninstall removed what it lists and kept the rest. + """ + + Scenario: A second run with -yes finds nothing left to remove + Given server:install installed Gophenberg for example.com on the server + When the operator runs "server:uninstall -yes" + And the operator runs "server:uninstall -yes" + Then the run succeeds + And the run prints: + """ + server:uninstall finds nothing left to remove. + server:uninstall keeps: + the database gophenberg and its role gophenberg + """ + And nothing on the server changed + + Scenario: A reinstall after server:uninstall reuses the kept database and asks nothing + Given server:install installed Gophenberg for example.com on the server + And the operator's terminal answers: + """ + admin@example.com + """ + When the operator runs "server:uninstall -yes" + And the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And the run asked nothing on the terminal + And the run ran these programs, the checks' own left out: + """ + dpkg-query -W -f ${db:Status-Abbrev}\n postgresql caddy + runuser -u gophenberg -- /opt/gophenberg/current/gophenberg check + systemctl daemon-reload + systemctl enable gophenberg.service + runuser -u caddy -- caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + systemctl restart caddy.service + systemctl reset-failed gophenberg.service + systemctl start gophenberg.service + """ + And the run created no administrator + And the current link names the build of this installer, owned by gophenberg + And the install record holds the SHA-256 of the Caddyfile the run wrote + + Scenario: After server:uninstall, a reinstall with the web server line changed to none goes through and leaves Caddy to the person + Given server:install installed Gophenberg for example.com on the server + And the operator ran "server:uninstall -yes" + And the web server line of the server settings was changed by hand to none + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And the run never ran "systemctl restart caddy.service" + And the run never ran "systemctl reload caddy.service" + And the server holds /etc/caddy/Caddyfile as the caddy package wrote it + And the server holds nothing at /etc/caddy/gophenberg.caddy + And the server holds nothing at /etc/systemd/system/caddy.service.d/gophenberg.conf + + Scenario: A GOPHENBERG_INSTALL_ROOT line planted in the site's .env never steers -delete-data + Given server:install installed Gophenberg for example.com on the server + And the site's .env holds the line "GOPHENBERG_INSTALL_ROOT=/srv/planted" + And the file /srv/planted/notes.txt holds the line "Maria Perez's notes" + When the operator runs "server:uninstall -delete-data -yes" + Then the run succeeds + And the server holds nothing at /opt/gophenberg + And the server still holds these paths: + """ + /srv/planted/notes.txt + """ + + Scenario: A GOPHENBERG_INSTALL_ROOT set in the environment never steers -delete-data + Given server:install installed Gophenberg for example.com on the server + And the environment sets GOPHENBERG_INSTALL_ROOT to /srv/planted + And the file /srv/planted/notes.txt holds the line "Maria Perez's notes" + When the operator runs "server:uninstall -delete-data -yes" + Then the run succeeds + And the server holds nothing at /opt/gophenberg + And the server still holds these paths: + """ + /srv/planted/notes.txt + """ + + Scenario: A second -delete-data after server.env is gone removes no folder + Given the environment sets GOPHENBERG_INSTALL_ROOT to /srv/gophenberg + And server:install installed Gophenberg for example.com on the server + And the operator ran "server:uninstall -delete-data -yes" + And the file /opt/gophenberg/notes.txt holds the line "Maria Perez's notes" + When the operator runs "server:uninstall -delete-data -yes" + Then the run succeeds + And the server holds nothing at /srv/gophenberg + And the run prints: + """ + server:uninstall finds nothing left to remove. + server:uninstall keeps: + /opt/gophenberg, since the install record does not show that server:install made it + """ + And the server still holds these paths: + """ + /opt/gophenberg/notes.txt + """ + + Scenario: An install root the record owns that server.env no longer names is listed as kept and leaves the record + Given server:install installed Gophenberg for example.com on the server + And the server settings set GOPHENBERG_INSTALL_ROOT to /srv/gophenberg + When the operator runs "server:uninstall -delete-data -yes" + Then the run succeeds + And the run prints: + """ + server:uninstall keeps: + /opt/gophenberg, an install root server:install made, kept since /etc/gophenberg/server.env does not name it + """ + And the server still holds these paths: + """ + /opt/gophenberg/.env + """ + And the install record owns only these pieces: postgresql, caddy + + Scenario: A reinstall after -delete-data behind NAT skips the domain check while the kept Caddy holds port 80 + Given server:install installed Gophenberg for example.com on the server + And the operator ran "server:uninstall -delete-data -yes" + And something listens on TCP port 80 + And example.com points at 198.51.100.7, a public address a NAT in front of the server forwards to it + When the operator runs "server:install -dry-run -domain example.com" + Then the run succeeds + + Scenario: A first run cut off in the caddy step leaves the installer's own Caddyfile, which server:uninstall puts back as the package's + Given a first run for example.com was cut off in the caddy step once it wrote the Caddyfile + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run prints: + """ + /etc/caddy/Caddyfile, putting back /etc/caddy/Caddyfile.before-gophenberg, the one the caddy package wrote + """ + And the run ran "systemctl restart caddy.service" + And the server holds /etc/caddy/Caddyfile as the caddy package wrote it + And the server holds nothing at /etc/caddy/Caddyfile.before-gophenberg + And the install record holds no SHA-256 of a Caddyfile + + Scenario: A Caddyfile the person changed after a first run cut off in the caddy step stays, and Caddy restarts on it + Given a first run for example.com was cut off in the caddy step once it wrote the Caddyfile + And the person added the site example.org to /etc/caddy/Caddyfile + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run prints: + """ + server:uninstall keeps: + /etc/caddy/Caddyfile, which changed since server:install wrote it, so a reinstall asks for that line back + """ + And /etc/caddy/Caddyfile still holds the person's site + And /etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" + And the run ran "systemctl restart caddy.service" + And the run never ran "systemctl reload caddy.service" + And the install record still holds the SHA-256 of the Caddyfile server:install wrote + And the install record marks the step caddy ended + + Scenario: A Caddyfile the person wrote in place of the one a cut-off first run wrote stays, and a reinstall asks for the import line + Given a first run for example.com was cut off in the caddy step once it wrote the Caddyfile + And the person's own Caddy serves example.com from /etc/caddy/Caddyfile + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run prints: + """ + server:uninstall keeps: + /etc/caddy/Caddyfile, which changed since server:install wrote it, so a reinstall asks for the line import /etc/caddy/gophenberg.caddy in it + """ + And /etc/caddy/Caddyfile is as the person wrote it + And the run ran "systemctl restart caddy.service" + And the install record still holds the SHA-256 of the Caddyfile server:install wrote + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run stops with "/etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so nothing was changed" + And nothing on the server changed + And /etc/caddy/Caddyfile is as the person wrote it + + Scenario: A reinstall after a cut-off first run never writes over the Caddyfile the person changed, even once server:uninstall stopped at Caddy + Given a first run for example.com was cut off in the caddy step once it wrote the Caddyfile + And the person added the site example.org to /etc/caddy/Caddyfile + And server:uninstall -yes was cut off at its first "systemctl restart caddy.service" + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run stops with "/etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" that an earlier run wrote, which has Caddy serve the site, and it must go back in before server:install runs again, so nothing was changed" + And nothing on the server changed + And /etc/caddy/Caddyfile still holds the person's site + + Scenario: A run cut off once the Caddyfile went back is finished by the next run, and a reinstall writes the Caddyfile again + Given server:install installed Gophenberg for example.com on the server + And server:uninstall -yes was cut off at its second "systemctl daemon-reload" + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run prints: + """ + server:uninstall removes: + Gophenberg's settings in Caddy, restarting Caddy on /etc/caddy/Caddyfile, the one the caddy package wrote + /etc/caddy/gophenberg.caddy + """ + And the run ran these programs, the checks' own left out: + """ + systemctl daemon-reload + systemctl restart caddy.service + """ + And the server holds /etc/caddy/Caddyfile as the caddy package wrote it + And the server holds nothing at /etc/systemd/system/caddy.service.d/gophenberg.conf + And the install record marks only these steps ended: server-env, packages, user, database, admin + And the install record holds no SHA-256 of a Caddyfile + When the operator runs "server:install -archive /var/lib/gophenberg-installer/release.tar.gz" + Then the run succeeds + And the run ran "systemctl restart caddy.service" + And the server holds /etc/caddy/Caddyfile.before-gophenberg as the caddy package wrote it + And the install record holds the SHA-256 of the Caddyfile the run wrote + + Scenario: A run cut off before Caddy reloaded a Caddyfile the person changed has the next run reload it + Given server:install installed Gophenberg for example.com on the server + And the person added the site example.org to /etc/caddy/Caddyfile + And server:uninstall -yes was cut off at its first "systemctl reload caddy.service" + When the operator runs "server:uninstall -yes" + Then the run succeeds + And the run ran these programs, the checks' own left out: + """ + systemctl reload caddy.service + """ + And the server holds nothing at /etc/caddy/gophenberg.caddy + And /etc/caddy/Caddyfile still holds the person's site + And /etc/caddy/Caddyfile lacks the line "import /etc/caddy/gophenberg.caddy" + And the install record still holds the SHA-256 of the Caddyfile server:install wrote diff --git a/test/installer/scenarios.sh b/test/installer/scenarios.sh new file mode 100644 index 00000000..b257ab7b --- /dev/null +++ b/test/installer/scenarios.sh @@ -0,0 +1,796 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +trap 'echo "scenarios.sh: line $LINENO ran $BASH_COMMAND, which ended with $?" >&2' ERR + +wait_limit=60 +late_after_update=20 +late_at_boot=30 +(( upload_bytes = 40 << 20 )) +update_port=8070 +probe_pause=0.2 +root_key_iterations=600000 +self="$(readlink -f "${BASH_SOURCE[0]}")" +repo="$(dirname "$(dirname "$(dirname "$self")")")" +work="${RUNNER_TEMP:?the scenarios run on a GitHub runner, which sets RUNNER_TEMP}/server-install" +out="$work/out" +front_file="$out/front.txt" +back_file="$out/back.txt" +site="$work/update-site" +keys="$work/keys" +records="$work/records" +tool="$work/releasetool" +cookies="$work/cookies.txt" +caddy_root="$work/caddy-root.crt" +password_file="$RUNNER_TEMP/admin-password" +admin_email=admin@example.com +admin_name="Maria Perez" +domain=localhost +install_root=/opt/gophenberg +installer_folder=/var/lib/gophenberg-installer +ci_folder="$installer_folder/ci" +record="$installer_folder/install.json" +server_env=/etc/gophenberg/server.env +command_path=/usr/local/bin/gophenberg +installer_copy=/usr/local/lib/gophenberg/gophenberg +units=/etc/systemd/system +local_root=/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt +site_owner=gophenberg:gophenberg +root_owner=root:root +background=() + +# fail prints why the scenarios stop on standard error and ends the run with exit 1. +fail() { + echo "scenarios.sh: $*" >&2 + exit 1 +} + +# retry runs the command again every second until it succeeds, for the caller to bound with timeout. +retry() { + until "$@"; do + sleep 1 + done +} + +# wait_until runs the command again every second until it succeeds, for at most wait_limit seconds. +wait_until() { + timeout "$wait_limit" bash "$self" retry "$@" || fail "waited ${wait_limit}s for $* to succeed" +} + +# reset_failed clears the failed state and the start count of the site's unit while systemd has the unit loaded. +reset_failed() { + if [ "$(systemctl show -p LoadState --value gophenberg.service)" = loaded ]; then + sudo systemctl reset-failed gophenberg.service + fi +} + +# begin starts the scenario named: it prints the name, notes when the scenario began and runs reset_failed. +begin() { + scenario="$1" + scenario_began=$SECONDS + scenario_started="$(date +%s.%6N)" + echo "=== $scenario" + reset_failed +} + +# finish prints how long the scenario that begin started took. +finish() { + echo "=== $scenario took $((SECONDS - scenario_began))s" +} + +# logged runs a command with standard input from /dev/null, printing its output and keeping it under the name given. +logged() { + local name="$1" code=0 + shift + "$@" < /dev/null 2> "$out/$name.err" | tee "$out/$name.out" || code=$? + cat "$out/$name.err" >&2 + return "$code" +} + +# as_site runs a command of the current build as the site's user in the install root, so it reads the site's .env. +as_site() { + sudo sh -c 'cd "$1" && shift && exec runuser -u gophenberg -- ./current/gophenberg "$@"' as_site \ + "$install_root" "$@" +} + +# status_of prints what the jq filter given picks from update:status -json. +status_of() { + as_site update:status -json | jq -r "$1" +} + +# server_setting prints the value of the line of server.env with the key given. +server_setting() { + sudo sed -n "s/^$1=//p" "$server_env" +} + +# set_server_setting writes the value given in place of the value of the line of server.env with the key given. +set_server_setting() { + sudo sed -i "s/^$1=.*/$1=$2/" "$server_env" + [ "$(server_setting "$1")" = "$2" ] || fail "$server_env holds no line $1" +} + +# seconds_of prints a duration written in whole seconds, such as 10s, as a number. +seconds_of() { + [[ $1 =~ ^([0-9]+)s$ ]] || fail "the duration $1 is not written in whole seconds" + echo "${BASH_REMATCH[1]}" +} + +# add_site_line adds the line given to the site's .env as the site's user. +add_site_line() { + printf '%s\n' "$1" | sudo -u gophenberg tee -a "$install_root/.env" > /dev/null +} + +# set_site_setting writes the value given in place of the value of the site's .env line with the key given. +set_site_setting() { + sudo -u gophenberg sed -i "s/^$1=.*/$1=$2/" "$install_root/.env" + holds "$install_root/.env" "$1=$2" +} + +# drop_site_setting removes the line of the site's .env with the key given, as the site's user. +drop_site_setting() { + sudo -u gophenberg sed -i "/^$1=/d" "$install_root/.env" +} + +# holds stops unless the file given holds the whole line given, read as root. +holds() { + sudo grep -qx -- "$2" "$1" || fail "$1 holds no line $2" +} + +# root_copy_of writes into the runner's file named first what the command after it prints when root runs it. +root_copy_of() { + local file="$1" + shift + sudo "$@" | tee "$file" > /dev/null +} + +# root_copy copies the file named first, which only root reads, into the runner's file named second. +root_copy() { + root_copy_of "$2" cat "$1" +} + +# sum_of prints the SHA-256 of a file only root reads. +sum_of() { + sudo cat "$1" | sha256sum +} + +# fetch asks the site through Caddy over HTTPS, trusting Caddy's local root and keeping the sign-in cookie. +fetch() { + curl -s --max-time 30 --cacert "$caddy_root" -b "$cookies" -c "$cookies" "$@" +} + +# status_code prints the status the site answers the request given with through fetch. +status_code() { + fetch -o /dev/null -w '%{http_code}' "$@" || true +} + +# sign_in signs the first administrator in through POST /api/auth/login, keeping the sign-in cookie. +sign_in() { + local body code + body="$(jq -nc --arg email "$admin_email" --rawfile password "$password_file" \ + '{email: $email, password: ($password | rtrimstr("\n"))}')" + rm -f "$cookies" + code="$(fetch -o "$work/sign-in.json" -w '%{http_code}' -H 'Content-Type: application/json' --data "$body" \ + "https://$domain/api/auth/login" || true)" + [ "$code" = 200 ] || fail "the first administrator could not sign in, the site answered $code" +} + +# served_version prints the version the site answers /api/version with, signed in as the first administrator. +served_version() { + fetch -f "https://$domain/api/version" | jq -r .version +} + +# serves reports whether the site answers through Caddy as the version given. +serves() { + [ "$(served_version 2> /dev/null)" = "$1" ] +} + +# settled reports whether the last update stands at the step given while the site answers as the version given. +settled() { + [ "$(status_of .update.step 2> /dev/null)" = "$1" ] && serves "$2" +} + +# checked reports whether a check from the admin answers and offers the version given. +checked() { + [ "$(fetch -f --data '' "https://$domain/api/updates/check" | jq -r .offer.version)" = "$1" ] +} + +# journal prints what systemd and the unit given logged since the scenario began, one message a line. +journal() { + sudo journalctl --no-pager -o cat -u "$1" --since "@$scenario_started" +} + +# rollback_runs prints how many times the rollback unit started since the scenario began. +rollback_runs() { + journal gophenberg-rollback.service > "$work/journal.txt" + grep -cE '^Starting (gophenberg-rollback\.service|Roll Gophenberg back)' "$work/journal.txt" || true +} + +# rolled_back stops unless the rollback unit started as many times as given since the scenario began. +rolled_back() { + local runs + runs="$(rollback_runs)" + [ "$runs" = "$1" ] || fail "the rollback unit started $runs times in the scenario $scenario, want $1" +} + +# starts_before_limit prints how many times the journal file shows the site's unit started before its start limit hit. +starts_before_limit() { + awk '/Start request repeated too quickly/ { exit } + /^Started (gophenberg\.service|Gophenberg\.$)/ { starts++ } + END { print starts + 0 }' "$1" +} + +# owned_as stops unless stat shows the path given with the owner, the group and the mode given. +owned_as() { + local held + held="$(sudo stat -c '%U:%G %a' "$1")" + [ "$held" = "$2 $3" ] || fail "$1 is $held, want $2 $3" +} + +# check_owners stops unless every path the install wrote has the owner, the group and the mode the install promises. +check_owners() { + local path + owned_as "$install_root" "$site_owner" 750 + owned_as "$install_root/versions" "$site_owner" 755 + owned_as "$install_root/downloads" "$site_owner" 755 + owned_as "$install_root/state" "$site_owner" 700 + owned_as "$install_root/backups" "$site_owner" 700 + owned_as "$install_root/media" "$site_owner" 750 + owned_as "$install_root/themes" "$site_owner" 750 + owned_as "$install_root/.env" "$site_owner" 600 + owned_as "$install_root/current" "$site_owner" 777 + owned_as "$install_root/versions/$a/gophenberg" "$site_owner" 755 + owned_as /etc/gophenberg "$root_owner" 755 + owned_as "$server_env" "$root_owner" 600 + owned_as "$installer_folder" "$root_owner" 700 + owned_as "$record" "$root_owner" 600 + owned_as "$installer_copy" "$root_owner" 755 + owned_as "$command_path" "$root_owner" 755 + for path in /usr/local/share/gophenberg/maintenance/index.html "$units/gophenberg.service" \ + "$units/gophenberg-rollback.service" "$units/caddy.service.d/gophenberg.conf" /etc/caddy/Caddyfile \ + /etc/caddy/gophenberg.caddy /etc/apt/keyrings/caddy.asc /etc/apt/sources.list.d/caddy.sources \ + /etc/apt/preferences.d/gophenberg-caddy; do + owned_as "$path" "$root_owner" 644 + done + if [ -e /usr/bin/unattended-upgrade ]; then + owned_as /etc/apt/apt.conf.d/52gophenberg-caddy "$root_owner" 644 + fi +} + +# caddy_admin_closed stops when the site's user reaches Caddy's admin endpoint on its port or on its socket. +caddy_admin_closed() { + sudo test -S /run/caddy/admin.sock || fail "Caddy keeps no admin socket at /run/caddy/admin.sock" + if sudo -u gophenberg curl -s -o /dev/null --max-time 5 http://127.0.0.1:2019/config/; then + fail "the site's user reaches Caddy's admin endpoint on 127.0.0.1:2019" + fi + if sudo -u gophenberg curl -s -o /dev/null --max-time 5 --unix-socket /run/caddy/admin.sock \ + http://localhost/config/; then + fail "the site's user reaches Caddy's admin endpoint on /run/caddy/admin.sock" + fi +} + +# no_https_warning stops when the server:install run kept under the name given warned that HTTPS did not answer. +no_https_warning() { + if grep -q 'did not answer 200 within' "$out/$1.err"; then + fail "server:install warned that https://$domain/admin/ did not answer" + fi +} + +# gone stops when the path given is still there, as a file, a folder or a link. +gone() { + if sudo test -e "$1" || sudo test -L "$1"; then + fail "server:uninstall left $1" + fi +} + +# kept stops unless the path given is still there. +kept() { + sudo test -e "$1" || fail "server:uninstall removed $1" +} + +# probe asks the address every probe pause and adds when it asked, the status and how long the answer took to the file. +probe() { + local file="$1" address="$2" asked answer + shift 2 + while :; do + asked="$(date +%s.%N)" + answer="$(curl -s -o /dev/null --max-time 30 -w '%{http_code} %{time_total}' "$@" "$address" || true)" + printf '%s %s\n' "$asked" "$answer" >> "$file" + sleep "$probe_pause" + done +} + +# start_probes asks the home page through Caddy and the site's own address every probe pause in the background. +start_probes() { + : > "$front_file" + : > "$back_file" + timeout "$wait_limit" bash "$self" probe "$front_file" "https://$domain/" --cacert "$caddy_root" \ + > /dev/null 2>&1 & + front_probe=$! + background+=("$front_probe") + timeout "$wait_limit" bash "$self" probe "$back_file" "http://$(server_setting GOPHENBERG_ADDR)/" \ + > /dev/null 2>&1 & + back_probe=$! + background+=("$back_probe") + sleep 1 +} + +# stop_probes stops both probes once they asked the new build too, and waits for them to end. +stop_probes() { + sleep 1 + kill "$front_probe" "$back_probe" 2> /dev/null || true + wait "$front_probe" "$back_probe" 2> /dev/null || true +} + +# gap_of prints the longest time in seconds the site's own address went without an answer, from the file of a probe. +gap_of() { + awk '$2 == "000" { if (last) down = 1; next } + { if (down && $1 - last > gap) gap = $1 - last; last = $1; down = 0 } + END { printf "%.3f\n", gap }' "$1" +} + +# longest_of prints the longest time in seconds one request took, from the file of a probe. +longest_of() { + awk '$3 > longest { longest = $3 } END { printf "%.3f\n", longest }' "$1" +} + +# below reports whether the first number is smaller than the second. +below() { + awk -v first="$1" -v second="$2" 'BEGIN { exit !(first < second) }' +} + +# answered_only stops when a request of the probe file got a status the pattern does not name, such as 200|503. +answered_only() { + local others + others="$(awk -v allowed="^($2)\$" '$2 !~ allowed' "$1")" + [ -z "$others" ] || fail "requests through Caddy got other answers than $2: $others" +} + +# judge_held stops unless, under the retry duration, Caddy answered 200 alone and held a request past the held floor. +judge_held() { + local gap="$1" longest="$2" delay="$3" retry="$4" floor + if ! below "$gap" "$retry"; then + echo "the gap passed the retry duration of ${retry}s, so the maintenance page may answer" + answered_only "$front_file" '200|503' + return + fi + answered_only "$front_file" 200 + floor="$(held_floor "$delay")" + below "$floor" "$longest" || + fail "no request through Caddy was held past ${floor}s, the restart delay of ${delay}s less one probe pause" +} + +# held_floor prints the restart delay given less one probe pause, the least a request asked during the gap is held. +held_floor() { + awk -v delay="$1" -v pause="$probe_pause" 'BEGIN { printf "%.3f\n", delay - pause }' +} + +# host_facts prints what the runner image brings that the install depends on, and what already listens. +host_facts() { + systemctl --version + ps -p 1 -o comm= + openssl version + apt-cache policy postgresql caddy + free -m + df -h + sudo ss -ltnup + stat -c '%U:%G %a %n' / /opt /usr/local/bin +} + +# purge_postgresql purges the image's PostgreSQL packages, their folders and the PostgreSQL project's apt source. +purge_postgresql() { + local -a packages + local source + mapfile -t packages < <(dpkg-query -W -f '${db:Status-Abbrev} ${binary:Package}\n' 'postgresql*' 2> /dev/null | + awk '$1 !~ /^u/ { print $2 }') + if [ "${#packages[@]}" -gt 0 ]; then + sudo env DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq "${packages[@]}" + fi + sudo rm -rf /etc/postgresql /var/lib/postgresql /usr/lib/postgresql + for source in /etc/apt/sources.list.d/*; do + if grep -qs apt.postgresql.org "$source"; then + sudo rm -f "$source" + fi + done +} + +# close_opt gives /opt back to root alone at mode 0755, as a fresh Ubuntu 24.04 server has it. +close_opt() { + sudo chown root:root /opt + sudo chmod 0755 /opt +} + +# read_versions sets the versions of the archives A, B and D and of the stub C1, the four patches after the checkout's. +read_versions() { + local major minor patch + IFS=. read -r major minor patch < "$repo/internal/version/VERSION" + a="$major.$minor.$((patch + 1))" + b="$major.$minor.$((patch + 2))" + d="$major.$minor.$((patch + 3))" + c1="$major.$minor.$((patch + 4))" + echo "the archives are A $a, B $b and D $d, and the stub is C1 $c1" +} + +# build_releasetool builds releasetool from the standard library alone, as the release workflow does. +build_releasetool() { + GOMODCACHE="$work/empty-module-cache" GOPROXY=off GOFLAGS=-mod=readonly GOWORK=off \ + go -C "$repo" build -o "$tool" ./cmd/releasetool +} + +# archive_name prints the name of the linux/amd64 archive of the version given. +archive_name() { + echo "gophenberg_$1_linux_amd64.tar.gz" +} + +# archive prints where the linux/amd64 archive of the version given is built. +archive() { + echo "$work/dist-$1/$(archive_name "$1")" +} + +# build_archives builds the official linux/amd64 archives A, B and D from the checkout, each under its own version. +build_archives() { + local base version + base="$(cat "$repo/internal/version/VERSION")" + for version in "$a" "$b" "$d"; do + printf '%s\n' "$version" > "$repo/internal/version/VERSION" + make -C "$repo" release-archives RELEASE_TARGETS=linux/amd64 RELEASE_STAMP=official \ + RELEASE_DIR="$work/dist-$version" + done + printf '%s\n' "$base" > "$repo/internal/version/VERSION" +} + +# pack_stub packs C1, whose gophenberg answers about -json with B's release.json as C1 and exits 1 on serve. +pack_stub() { + local stub="$work/stub" + mkdir -p "$stub" "$work/dist-$c1" + tar -xzOf "$(archive "$b")" release.json | + jq --arg version "$c1" '.version = $version | .stamp = "official"' > "$stub/release.json" + printf '%s\n' '#!/bin/sh' "if [ \"\${1-}\" = about ]; then" " exec cat \"\$(dirname \"\$0\")/release.json\"" \ + 'fi' 'exit 1' > "$stub/gophenberg" + chmod 0755 "$stub/gophenberg" + cp "$repo/LICENSE" "$stub/LICENSE" + tar -C "$stub" --owner=0 --group=0 --numeric-owner -czf "$(archive "$c1")" gophenberg release.json LICENSE +} + +# multikey prints the Multikey of the throwaway key of the role given. +multikey() { + cat "$keys/$1.multikey" +} + +# on_site runs the releasetool command given over the local update site, naming its keys and its throwaway root. +on_site() { + local command="$1" + shift + "$tool" "$command" -site "$site" -release "$(multikey release)" -standby "$(multikey standby)" \ + -timestamp "$(multikey timestamp)" -root "$(multikey root)" "$@" +} + +# publish signs the archive of the version given with the release key and writes its record for the local update site. +publish() { + local folder="$site/releases/v$1" name + name="$(archive_name "$1")" + mkdir -p "$folder" + cp "$(archive "$1")" "$folder/$name" + "$tool" sign -key "$keys/release.pem" "$folder/$name" + "$tool" record -version "$1" -download-url "http://127.0.0.1:$update_port/releases/v$1" "$folder/$name" \ + > "$records/$1.json" + tar -xzOf "$folder/$name" release.json > "$records/$1.release.json" +} + +# offer adds the version given to the stable index of the local update site, then signs it and a new timestamp file. +offer() { + local index="$site/v1/core/stable.json" note="$site/v1/timestamp.json" + on_site index -channel stable -record "$records/$1.json" -release-json "$records/$1.release.json" -valid 24h \ + > "$work/stable.json" + mv "$work/stable.json" "$index" + rm -f "$index.sig" + "$tool" sign -key "$keys/release.pem" "$index" + on_site timestamp -newest "stable=$1" -valid 24h > "$work/timestamp.json" + mv "$work/timestamp.json" "$note" + rm -f "$note.sig" + "$tool" sign -key "$keys/timestamp.pem" "$note" +} + +# make_update_site makes the throwaway keys, the signed key list and every record, then offers A and B. +make_update_site() { + local role version + openssl rand -hex 32 > "$keys/root-passphrase" + "$tool" keygen -curve p256 -out "$keys/root.pem" -passphrase-file "$keys/root-passphrase" \ + -iterations "$root_key_iterations" | awk '$1 == "multikey" { print $2 }' > "$keys/root.multikey" + for role in release standby timestamp; do + "$tool" keygen -curve ed25519 -out "$keys/$role.pem" | awk '$1 == "multikey" { print $2 }' \ + > "$keys/$role.multikey" + done + "$tool" keys -release "$(multikey release)" -standby "$(multikey standby)" -timestamp "$(multikey timestamp)" \ + > "$site/v1/keys.json" + "$tool" sign-keys -key "$keys/root.pem" -passphrase-file "$keys/root-passphrase" "$site/v1/keys.json" + for version in "$a" "$b" "$d" "$c1"; do + publish "$version" + done + offer "$a" + offer "$b" +} + +# serve_update_site serves the local update site on the loopback address in the background. +serve_update_site() { + python3 -m http.server --bind 127.0.0.1 --directory "$site" "$update_port" > "$out/update-site.log" 2>&1 & + background+=("$!") + wait_until curl -fsS -o /dev/null "http://127.0.0.1:$update_port/v1/keys.json" +} + +# stop_background stops every process the run started in the background. +stop_background() { + if [ "${#background[@]}" -gt 0 ]; then + kill "${background[@]}" 2> /dev/null || true + fi +} + +# scenario_install installs A from the root-only folder with every flag of the first administrator. +scenario_install() { + local name + name="$(archive_name "$a")" + begin "install" + sudo install -d -m 0700 "$installer_folder" "$ci_folder" + sudo install -m 0600 "$(archive "$a")" "$ci_folder/$name" + sudo tar -xzf "$ci_folder/$name" -C "$ci_folder" gophenberg + (umask 077 && openssl rand -base64 18 > "$password_file") + logged install sudo "$ci_folder/gophenberg" server:install -archive "$ci_folder/$name" -domain "$domain" \ + -admin-email "$admin_email" -admin-name "$admin_name" -admin-password-file "$password_file" || + fail "the first server:install did not end with exit 0" + no_https_warning install + grep -q "Gophenberg $a runs at https://$domain" "$out/install.out" || + fail "the summary names no Gophenberg $a running at https://$domain" + root_copy "$local_root" "$caddy_root" + [ "$(status_code "https://$domain/admin/")" = 200 ] || fail "https://$domain/admin/ does not answer 200" + sign_in + check_install + finish +} + +# check_install stops unless .env, the units, Caddy's admin endpoint, the command and every owner are as promised. +check_install() { + holds "$install_root/.env" GOPHENBERG_SUPERUSER_POLICY=refuse + holds "$units/gophenberg.service" RestartMode=direct + holds "$units/gophenberg.service" StartLimitBurst=10 + holds "$units/gophenberg-rollback.service" StartLimitIntervalSec=0 + caddy_admin_closed + shellcheck -s sh "$command_path" + check_owners + sum_of /etc/caddy/Caddyfile.before-gophenberg > "$work/package-caddyfile.sum" +} + +# scenario_update_site points the site at the local update site and its throwaway root, then restarts it. +scenario_update_site() { + begin "the local update site" + add_site_line "GOPHENBERG_UPDATE_URL=http://127.0.0.1:$update_port" + add_site_line "GOPHENBERG_TRUSTED_KEYS=$(multikey root)" + add_site_line GOPHENBERG_UPDATES_SCREEN_CHECK_AGE=1s + sudo systemctl restart gophenberg.service + wait_until serves "$a" + finish +} + +# scenario_update updates A to B from the admin while two probes measure the gap and the requests Caddy held. +scenario_update() { + local delay retry code gap longest + begin "an update from the admin" + delay="$(seconds_of "$(server_setting GOPHENBERG_SERVICE_RESTART_DELAY)")" + retry="$(seconds_of "$(server_setting GOPHENBERG_PROXY_RETRY_DURATION)")" + [ "$(status_code "https://$domain/")" = 200 ] || fail "https://$domain/ does not answer 200 before the update" + wait_until checked "$b" + start_probes + code="$(fetch -o "$work/apply.json" -w '%{http_code}' -H 'Content-Type: application/json' \ + --data "{\"version\":\"$b\"}" "https://$domain/api/updates/apply" || true)" + [ "$code" = 202 ] || fail "the start of the update to $b from the admin answered $code" + wait_until settled committed "$b" + stop_probes + gap="$(gap_of "$back_file")" + longest="$(longest_of "$front_file")" + echo "the update gap was ${gap}s and the longest request Caddy held took ${longest}s" + judge_held "$gap" "$longest" "$delay" "$retry" + [ "$(status_of '.backups | length')" -ge 1 ] || fail "the update to $b kept no backup in $install_root/backups" + rolled_back 0 + finish +} + +# scenario_upload uploads a 40 MB big.mp4 of random bytes through Caddy and keeps where the site stored it. +scenario_upload() { + local code + begin "a 40 MB upload" + head -c "$upload_bytes" /dev/urandom > "$work/big.mp4" + code="$(fetch -o "$work/upload.json" -w '%{http_code}' -F "file=@$work/big.mp4" "https://$domain/api/media" || + true)" + [ "$code" = 201 ] || fail "the upload of big.mp4 answered $code, want 201" + jq -r .file "$work/upload.json" > "$work/big.path" + finish +} + +# scenario_second_run changes two timings of server.env and two lines of .env, then runs sudo gophenberg server:install. +scenario_second_run() { + begin "a second run" + [ "$(sudo sh -c 'command -v gophenberg')" = "$command_path" ] || + fail "sudo finds no gophenberg command at $command_path" + set_server_setting GOPHENBERG_SERVICE_RESTART_DELAY 2s + set_server_setting GOPHENBERG_SERVICE_START_LIMIT_INTERVAL 60s + add_site_line GOPHENBERG_HTTP_IDLE_TIMEOUT=2m + set_site_setting GOPHENBERG_DATABASE_WAIT 10s + logged second-run sudo gophenberg server:install || fail "the second server:install did not end with exit 0" + no_https_warning second-run + holds "$units/gophenberg.service" RestartSec=2s + holds "$units/gophenberg.service" StartLimitIntervalSec=60 + holds "$install_root/.env" GOPHENBERG_HTTP_IDLE_TIMEOUT=2m + holds "$install_root/.env" GOPHENBERG_DATABASE_WAIT=10s + wait_until serves "$b" + finish +} + +# scenario_failed_check offers D, which fails its start check under a 1ms verify timeout, and the rollback unit runs. +scenario_failed_check() { + begin "a failed start check" + add_site_line GOPHENBERG_UPDATE_VERIFY_TIMEOUT=1ms + offer "$d" + logged apply-d sudo gophenberg update:apply -to "$d" || + fail "sudo gophenberg update:apply -to $d did not end with exit 0" + wait_until settled rolled-back "$b" + journal gophenberg.service > "$work/journal.txt" + grep -q 'status=76' "$work/journal.txt" || fail "the journal shows no exit status 76 of Gophenberg $d" + rolled_back 1 + drop_site_setting GOPHENBERG_UPDATE_VERIFY_TIMEOUT + finish +} + +# scenario_late_database_after_update switches to D again, then starts it while PostgreSQL stays down for a while. +scenario_late_database_after_update() { + local code=0 + begin "a late database after an update" + logged apply-d-retry as_site update:apply -to "$d" -retry || code=$? + [ "$code" = 75 ] || fail "update:apply -to $d -retry ended with $code, want 75" + sudo systemctl stop postgresql.service + sudo systemctl reset-failed gophenberg.service + sudo systemctl restart gophenberg.service + sleep "$late_after_update" + sudo systemctl start postgresql.service + wait_until settled committed "$d" + rolled_back 0 + finish +} + +# scenario_late_database_at_boot restarts the site while PostgreSQL stays down for a while, with no update pending. +scenario_late_database_at_boot() { + begin "a late database at boot" + sudo systemctl stop postgresql.service + sudo systemctl restart gophenberg.service + sleep "$late_at_boot" + sudo systemctl start postgresql.service + wait_until settled committed "$d" + rolled_back 0 + finish +} + +# scenario_start_limit offers C1, whose serve fails at once, so the start limit hits and the rollback unit runs. +scenario_start_limit() { + local starts + begin "the start limit" + offer "$c1" + logged apply-c1 sudo gophenberg update:apply -to "$c1" || + fail "sudo gophenberg update:apply -to $c1 did not end with exit 0" + wait_until settled rolled-back "$d" + journal gophenberg.service > "$work/journal.txt" + grep -q 'Start request repeated too quickly' "$work/journal.txt" || + fail "the journal shows no start limit of Gophenberg $c1" + starts="$(starts_before_limit "$work/journal.txt")" + echo "Gophenberg $c1 started $starts times before the start limit hit" + [ "$starts" = "$(server_setting GOPHENBERG_SERVICE_START_LIMIT_BURST)" ] || + fail "Gophenberg $c1 started $starts times, not the start limit burst" + rolled_back 1 + if logged apply-c1-again sudo gophenberg update:apply -to "$c1"; then + fail "a second update:apply -to $c1 went through without -retry" + fi + grep -q -- -retry "$out/apply-c1-again.out" "$out/apply-c1-again.err" || + fail "the second update:apply -to $c1 does not name -retry" + finish +} + +# scenario_uninstall runs sudo gophenberg server:uninstall -yes and checks what it removed and what it kept. +scenario_uninstall() { + local path + begin "server:uninstall" + logged uninstall sudo gophenberg server:uninstall -yes || fail "server:uninstall -yes did not end with exit 0" + for path in "$units/gophenberg.service" "$units/gophenberg-rollback.service" \ + "$units/caddy.service.d/gophenberg.conf" /etc/caddy/gophenberg.caddy /etc/caddy/Caddyfile.before-gophenberg \ + /usr/local/share/gophenberg/maintenance "$command_path" "$installer_copy" "$install_root/versions" \ + "$install_root/downloads" "$install_root/current" "$install_root/previous" "$install_root/state/update.json"; do + gone "$path" + done + [ "$(sum_of /etc/caddy/Caddyfile)" = "$(cat "$work/package-caddyfile.sum")" ] || + fail "server:uninstall did not put the Caddy package's Caddyfile back" + for path in "$install_root/.env" "$install_root/media" "$server_env" "$record"; do + kept "$path" + done + [ -n "$(sudo ls -A "$install_root/media")" ] || fail "server:uninstall emptied $install_root/media" + finish +} + +# scenario_reinstall installs A again with no admin flag, then signs in and fetches the kept big.mp4 through Caddy. +scenario_reinstall() { + local name fetched + name="$(archive_name "$a")" + begin "a reinstall" + logged reinstall sudo "$ci_folder/gophenberg" server:install -archive "$ci_folder/$name" -domain "$domain" || + fail "the reinstall did not end with exit 0" + no_https_warning reinstall + sign_in + wait_until serves "$a" + fetched="$(fetch -o /dev/null -w '%{http_code} %{size_download}' "https://$domain/media/$(cat "$work/big.path")" || + true)" + [ "$fetched" = "200 $upload_bytes" ] || fail "the kept big.mp4 answered $fetched, want 200 $upload_bytes" + finish +} + +# run builds the archives and the local update site, then runs every scenario in order. +run() { + mkdir -p "$out" "$site/v1/core" "$keys" "$records" + trap stop_background EXIT + host_facts + purge_postgresql + close_opt + read_versions + build_releasetool + build_archives + pack_stub + make_update_site + serve_update_site + echo "=== the archives and the local update site took ${SECONDS}s" + cd / || fail "cannot change to /" + scenario_install + scenario_update_site + scenario_update + scenario_upload + scenario_second_run + scenario_failed_check + scenario_late_database_after_update + scenario_late_database_at_boot + scenario_start_limit + scenario_uninstall + scenario_reinstall + echo "=== every scenario passed, and the run took ${SECONDS}s" +} + +# gather_logs copies the journals, the update state, the install record and the command outputs, never .env. +gather_logs() { + local folder="$1" unit + mkdir -p "$folder" + for unit in gophenberg gophenberg-rollback caddy 'postgresql*'; do + root_copy_of "$folder/${unit%\*}.log" journalctl --no-pager -u "$unit" || true + done + root_copy "$install_root/state/update.json" "$folder/update.json" || true + root_copy "$record" "$folder/install.json" || true + if [ -d "$out" ]; then + cp -R "$out" "$folder/out" + fi +} + +# main runs what the first argument names: run, logs , or the retry and probe the waits start. +main() { + case "${1-}" in + run) + run + ;; + logs) + gather_logs "${2:?scenarios.sh logs needs the folder to gather the logs into}" + ;; + retry) + trap - ERR + shift + retry "$@" + ;; + probe) + trap - ERR + shift + probe "$@" + ;; + *) + fail "usage: scenarios.sh run, or scenarios.sh logs " + ;; + esac +} + +main "$@"