Files
ignacionelson 8f12c83d21 Tell a clone-and-build install to rebuild, not to pull
ProjectSend prints the update instructions for the way this server was
installed, and it knew two answers where it needed three: anything inside
a container was handed `docker compose pull && docker compose up -d`. On
the Compose stack that builds from a checkout there is no image behind
those containers, so `pull` skips every ProjectSend service and `up -d`
then finds them all current — the update reports success, changes
nothing, and the dashboard goes on offering the same release. Reported by
@mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661).

Those installations are now their own kind, told to `git pull` and
rebuild, with the two steps a checkout needs that an image does not: its
dependencies and its compiled frontend live outside git, so a release
that moved either leaves them stale.

Two signals decide it, in that order. The published image now declares
itself with PROJECTSEND_IMAGE, which is the only evidence an operator
bind-mounting over /var/www/html can neither hide nor forge; failing that
— images published before this — a working tree in the install directory,
which the image never has and the repository's own stack always does.
getenv() rather than env(), because a cached configuration makes env()
outside a config file return null, and the answer would flip silently on
exactly the installs most likely to have cached it.

The stale-code banner keeps treating both container kinds alike: what
clears it is recreating the container, whichever way its image was built.

The changelog also credits the reporter of #1663, which was missed when
that entry was written.
2026-08-21 14:35:49 -03:00

97 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Installation;
/**
* Whether this installation runs from a container image, from a container
* the operator builds themselves, or from files on a server somebody
* administers directly.
*
* It exists because the application tells administrators how to upgrade, and
* the three answers have nothing in common. A published container is
* replaced — `docker compose pull && docker compose up -d`, with the
* entrypoint running the migrations on the way up. A container built from a
* checkout has to be given new code and rebuilt. A manual install is a
* sequence somebody performs by hand: back up, take the site down, unpack
* the release over the directory, migrate, refresh the caches, bring it back
* (INSTALL.md).
*
* Printing the wrong one of those is worse than printing nothing. For a
* manual install the container command names a tool they do not have, for a
* stack they are not running, at the exact moment they are trying to do the
* right thing — that was the behaviour before this class existed, when the
* command was a hardcoded string in two React components. For a stack built
* from a checkout it is worse still, because the command runs: `pull` skips
* services that have no image to pull and `up -d` then finds every container
* already current, so the update reports success and changes nothing, and
* the dashboard goes on offering the same release forever (#1661).
*
* Two signals, in order:
*
* 1. The published image sets PROJECTSEND_IMAGE. A positive marker set at
* build time is the only one a bind mount can neither forge nor hide.
* 2. Failing that — images published before that variable existed — a
* working tree in the install directory. The image is built from an
* unpacked release artifact and has none; the Compose stack in the
* repository bind-mounts the repository itself.
*
* Being in a container at all is the presence of the file a container
* runtime leaves in the root filesystem. It is a deliberately conservative
* signal: something exotic enough to run neither Docker nor Podman is
* reported as a manual install, which is the safer wrong answer of the
* three — the manual instructions are steps a person follows and checks for
* themselves, while the container commands are ones they would paste.
*/
class Installation
{
public function kind(): InstallationKind
{
if (! $this->inContainer()) {
return InstallationKind::Manual;
}
return $this->builtFromSource()
? InstallationKind::ContainerSource
: InstallationKind::Container;
}
/**
* Protected so a test can answer for it: there is no way to be in a
* container and not in one within a single test run.
*/
protected function inContainer(): bool
{
// Docker writes the first; Podman writes the second.
//
// Suppressed, and it has to stay that way. Shared hosting sets
// open_basedir to the webspace, and probing a path outside it is a
// warning rather than a false — which the framework's error handler
// turns into an exception, so the one call that asks which install
// this is took the whole dashboard down with it (#1663). Under `@`
// the warning is filtered and the probe answers false, which is the
// right answer anyway: a host that restricts PHP to a vhost
// directory is not the container image.
return @file_exists('/.dockerenv') || @file_exists('/run/.containerenv');
}
/**
* Protected for the same reason as inContainer(), and answered the same
* way in tests.
*/
protected function builtFromSource(): bool
{
// getenv() rather than env(): once the configuration is cached,
// env() outside a config file returns null, and the answer would
// silently flip on the installs most likely to have cached it.
if (getenv('PROJECTSEND_IMAGE') === '1') {
return false;
}
// A worktree checkout writes .git as a file rather than a
// directory, so ask whether it exists, not what it is.
return file_exists(base_path('.git'));
}
}