From 11ae752d885acf41bca303ba86b06d4fb9c43c9b Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 21:05:46 -0400 Subject: [PATCH 01/18] feat(git): store whether a git stack is checked and deployed on a schedule git.json gets an optional autoDeploy key: what the scheduled check (next commits) does when a new commit changes the stack. "off" does nothing and is the default, so it is left out of the file and a stack written before this still loads; "notify" sends a notification; "deploy" deploys it. compose-git auto-deploy off|notify|deploy sets it, and status shows it. Co-Authored-By: Claude Opus 5.5 --- .../include/GitStackManager.php | 25 ++++++- .../include/GitStackSettings.php | 66 ++++++++++++++++--- .../compose.manager/scripts/compose_git.php | 12 ++++ tests/unit/GitStackManagerTest.php | 17 +++++ tests/unit/GitStackSettingsTest.php | 44 ++++++++++++- 5 files changed, 153 insertions(+), 11 deletions(-) diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index 7ca05b9b..b2d994bf 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -343,7 +343,7 @@ static function () use ($stack, $clone, $remote): array { /** * What is known about a git stack locally, without asking the remote. * - * @return array{stack: string, url: string, branch: string, composePath: string, cloneDir: string, recreateOnFolderChange: bool, credential: ?string, deployedCommit: ?string, failedCommit: ?string, checkedOutCommit: ?string, localChanges: list, commitMadeByHand: bool, problem: ?string} + * @return array{stack: string, url: string, branch: string, composePath: string, cloneDir: string, recreateOnFolderChange: bool, autoDeploy: string, credential: ?string, deployedCommit: ?string, failedCommit: ?string, checkedOutCommit: ?string, localChanges: list, commitMadeByHand: bool, problem: ?string} */ public function status(string $folder): array { @@ -369,6 +369,7 @@ public function status(string $folder): array 'composePath' => $settings->composePath, 'cloneDir' => $settings->cloneDir, 'recreateOnFolderChange' => $settings->recreateOnFolderChange, + 'autoDeploy' => $settings->autoDeploy, 'credential' => $settings->credentialId === null ? null : $this->credentialName($settings->credentialId), 'deployedCommit' => $state->deployedCommit, 'failedCommit' => $state->failedCommit, @@ -513,6 +514,28 @@ public function findCredentialFor(string $folder, string $nameOrId): string throw $this->notARepositoryDeployKey($folder, $keys); } + /** + * Choose what the scheduled check does when a new commit changes a git + * stack: nothing (off), send a notification (notify), or deploy it (deploy). + * + * @throws RuntimeException|InvalidArgumentException naming why nothing was changed + */ + public function setAutoDeploy(string $folder, string $autoDeploy): void + { + $this->assertArrayStarted(); + $stack = $this->stack($folder); + $settings = $this->settings($stack); + + $this->withStackLock($stack, function () use ($stack, $settings, $autoDeploy): void { + $settings->withAutoDeploy($autoDeploy)->save($stack->path); + }); + ($this->say)(match ($autoDeploy) { + GitStackSettings::AUTO_DEPLOY_NOTIFY => "'$folder' sends a notification when a new commit changes it.", + GitStackSettings::AUTO_DEPLOY_DEPLOY => "'$folder' is deployed when a new commit changes it.", + default => "'$folder' is no longer checked on a schedule.", + }); + } + /** * The id of a git repository credential, given its exact name or its id. * diff --git a/source/compose.manager/include/GitStackSettings.php b/source/compose.manager/include/GitStackSettings.php index aa313cfd..cf93fb22 100644 --- a/source/compose.manager/include/GitStackSettings.php +++ b/source/compose.manager/include/GitStackSettings.php @@ -40,7 +40,16 @@ final class GitStackSettings private const KEYS = ['version', 'url', 'branch', 'composePath', 'cloneId', 'cloneDir', 'recreateOnFolderChange']; /** @var string[] Keys added later, which a file may leave out. */ - private const OPTIONAL_KEYS = ['credentialId', 'sshKnownHosts']; + private const OPTIONAL_KEYS = ['credentialId', 'sshKnownHosts', 'autoDeploy']; + + /** Automatic deploys (autoDeploy): the scheduled check does nothing for the stack (the default). */ + public const AUTO_DEPLOY_OFF = 'off'; + /** The scheduled check sends a notification when a new commit changes the stack. */ + public const AUTO_DEPLOY_NOTIFY = 'notify'; + /** The scheduled check deploys a new commit that changes the stack. */ + public const AUTO_DEPLOY_DEPLOY = 'deploy'; + /** @var string[] */ + public const AUTO_DEPLOY_CHOICES = [self::AUTO_DEPLOY_OFF, self::AUTO_DEPLOY_NOTIFY, self::AUTO_DEPLOY_DEPLOY]; /** * @param bool $recreateOnFolderChange Recreate the stack's containers when a deploy changes @@ -51,6 +60,8 @@ final class GitStackSettings * ssh repository, the stack's deploy key. * @param string|null $sshKnownHosts The ssh repository host's keys, pinned when the stack was * set up, in known_hosts format; null for an https or local repository. + * @param string $autoDeploy What the scheduled check does when a new commit changes the stack: + * one of AUTO_DEPLOY_CHOICES. Off by default (format 1 had no scheduled check). */ private function __construct( public readonly string $url, @@ -60,7 +71,8 @@ private function __construct( public readonly string $cloneDir, public readonly bool $recreateOnFolderChange, public readonly ?string $credentialId = null, - public readonly ?string $sshKnownHosts = null + public readonly ?string $sshKnownHosts = null, + public readonly string $autoDeploy = self::AUTO_DEPLOY_OFF ) { } @@ -96,7 +108,8 @@ public function withRecreateOnFolderChange(bool $recreateOnFolderChange): self $this->cloneDir, $recreateOnFolderChange, $this->credentialId, - $this->sshKnownHosts + $this->sshKnownHosts, + $this->autoDeploy ); } @@ -115,7 +128,8 @@ public function withCredentialId(?string $credentialId): self $this->cloneDir, $this->recreateOnFolderChange, $credentialId, - $this->sshKnownHosts + $this->sshKnownHosts, + $this->autoDeploy ); } @@ -134,7 +148,28 @@ public function withSshKnownHosts(?string $sshKnownHosts): self $this->cloneDir, $this->recreateOnFolderChange, $this->credentialId, - $sshKnownHosts + $sshKnownHosts, + $this->autoDeploy + ); + } + + /** + * The same settings with another choice for automatic deploys. + * + * @throws InvalidArgumentException if it is not one of AUTO_DEPLOY_CHOICES + */ + public function withAutoDeploy(string $autoDeploy): self + { + return self::fromValues( + $this->url, + $this->branch, + $this->composePath, + $this->cloneId, + $this->cloneDir, + $this->recreateOnFolderChange, + $this->credentialId, + $this->sshKnownHosts, + $autoDeploy ); } @@ -208,6 +243,10 @@ public static function load(string $stackDir): ?self if ($sshKnownHosts !== null && !is_string($sshKnownHosts)) { throw new RuntimeException("$file: 'sshKnownHosts' must be a string."); } + $autoDeploy = $data['autoDeploy'] ?? self::AUTO_DEPLOY_OFF; + if (!is_string($autoDeploy)) { + throw new RuntimeException("$file: 'autoDeploy' must be a string."); + } try { return self::fromValues( @@ -218,7 +257,8 @@ public static function load(string $stackDir): ?self $data['cloneDir'], $data['recreateOnFolderChange'], $credentialId, - $sshKnownHosts + $sshKnownHosts, + $autoDeploy ); } catch (InvalidArgumentException $error) { throw new RuntimeException("$file: " . $error->getMessage(), 0, $error); @@ -278,6 +318,9 @@ public function save(string $stackDir): void if ($this->sshKnownHosts !== null) { $data['sshKnownHosts'] = $this->sshKnownHosts; } + if ($this->autoDeploy !== self::AUTO_DEPLOY_OFF) { + $data['autoDeploy'] = $this->autoDeploy; + } $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Could not encode the git settings.'); @@ -541,7 +584,8 @@ private static function fromValues( string $cloneDir, bool $recreateOnFolderChange, ?string $credentialId = null, - ?string $sshKnownHosts = null + ?string $sshKnownHosts = null, + string $autoDeploy = self::AUTO_DEPLOY_OFF ): self { self::validateUrl($url); self::validateBranch($branch); @@ -569,6 +613,12 @@ private static function fromValues( self::validateKnownHosts($sshKnownHosts); } - return new self($url, $branch, $composePath, $cloneId, $cloneDir, $recreateOnFolderChange, $credentialId, $sshKnownHosts); + if (!in_array($autoDeploy, self::AUTO_DEPLOY_CHOICES, true)) { + throw new InvalidArgumentException( + "Automatic deploys must be one of " . implode(', ', self::AUTO_DEPLOY_CHOICES) . ": $autoDeploy" + ); + } + + return new self($url, $branch, $composePath, $cloneId, $cloneDir, $recreateOnFolderChange, $credentialId, $sshKnownHosts, $autoDeploy); } } diff --git a/source/compose.manager/scripts/compose_git.php b/source/compose.manager/scripts/compose_git.php index c33c120d..9d9fe34f 100644 --- a/source/compose.manager/scripts/compose_git.php +++ b/source/compose.manager/scripts/compose_git.php @@ -34,6 +34,7 @@ final class ComposeGitUsageError extends InvalidArgumentException compose-git add --url --path [--branch ] [--clones-root ] [--description ] [--credential ] compose-git convert --url --path [--branch ] [--clones-root ] [--credential ] compose-git credential |--none + compose-git auto-deploy off|notify|deploy compose-git deploy-key compose-git trust-host compose-git check |--all @@ -53,6 +54,8 @@ final class ComposeGitUsageError extends InvalidArgumentException trust-host pins them again after the server's keys change. deploy waits for healthy containers when the stack's wait-for-healthy setting says so; --wait and --no-wait override it. +auto-deploy sets what the scheduled check does when a new commit changes the stack: nothing +(off, the default), send a notification (notify), or deploy it (deploy). Exit status: 0 success, 1 failed, 2 usage error; for check, 3 a newer commit changes the stack's folder, 4 a newer commit changes only files outside it. See 'compose-git check --help'. @@ -493,6 +496,7 @@ function compose_git_print_status(array $status): void if ($status['credential'] !== null) { echo ' credential: ' . $status['credential'] . "\n"; } + echo ' automatic: ' . $status['autoDeploy'] . "\n"; echo ' deployed: ' . $short($status['deployedCommit']) . "\n"; if ($status['failedCommit'] !== null) { echo ' failed: ' . $short($status['failedCommit']) . " (fix it and deploy again)\n"; @@ -571,6 +575,14 @@ function compose_git_main(array $argv, string $composeRoot, $errors = null): int } return COMPOSE_GIT_EXIT_OK; + case 'auto-deploy': + [$positional] = compose_git_parse($args, [], []); + if (count($positional) !== 2) { + throw new ComposeGitUsageError('auto-deploy needs a stack and one of: ' . implode(', ', GitStackSettings::AUTO_DEPLOY_CHOICES) . '.'); + } + $manager->setAutoDeploy($positional[0], $positional[1]); + return COMPOSE_GIT_EXIT_OK; + case 'deploy-key': [$positional] = compose_git_parse($args, [], []); echo $manager->deployKey(compose_git_one_stack($positional)) . "\n"; diff --git a/tests/unit/GitStackManagerTest.php b/tests/unit/GitStackManagerTest.php index e3deaa65..2c413ae0 100644 --- a/tests/unit/GitStackManagerTest.php +++ b/tests/unit/GitStackManagerTest.php @@ -1015,6 +1015,23 @@ public function testDeployKeepsTheRunningProfilesUnlessTheCommandLineNamesSome() $this->assertSame(['debug'], gitDeployProfiles($stack(), ['debug'])); } + public function testCommandLineAutoDeployChangesTheStacksSetting(): void + { + $folder = $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); + $stackDir = $this->composeRoot . '/' . $folder; + + $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'deploy'])); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_DEPLOY, GitStackSettings::load($stackDir)->autoDeploy); + $this->assertSame('deploy', $this->manager->status($folder)['autoDeploy']); + + $this->assertSame(1, $this->runCli(['compose-git', 'auto-deploy', $folder, 'always'])); + $this->assertSame(2, $this->runCli(['compose-git', 'auto-deploy', $folder])); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_DEPLOY, GitStackSettings::load($stackDir)->autoDeploy); + + $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'off'])); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_OFF, GitStackSettings::load($stackDir)->autoDeploy); + } + public function testCommandLineStatusAsJson(): void { $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); diff --git a/tests/unit/GitStackSettingsTest.php b/tests/unit/GitStackSettingsTest.php index 0d074abb..b055e110 100644 --- a/tests/unit/GitStackSettingsTest.php +++ b/tests/unit/GitStackSettingsTest.php @@ -269,6 +269,38 @@ public function testNoCredentialByDefaultAndNoneWrittenToTheFile(): void $this->assertNull(GitStackSettings::load($this->stackDir)?->credentialId); } + public function testAutomaticDeploysAreOffByDefaultAndNotWrittenToTheFile(): void + { + $settings = $this->makeSettings(); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_OFF, $settings->autoDeploy); + + $settings->save($this->stackDir); + + // Left out, so a version from before scheduled checks still loads the file. + $data = json_decode((string) file_get_contents($this->stackDir . '/git.json'), true); + $this->assertArrayNotHasKey('autoDeploy', $data); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_OFF, GitStackSettings::load($this->stackDir)?->autoDeploy); + } + + public function testAutomaticDeploysAreSavedAndLoadedAndKeptByOtherChanges(): void + { + $this->makeSettings()->withAutoDeploy(GitStackSettings::AUTO_DEPLOY_DEPLOY)->save($this->stackDir); + + $loaded = GitStackSettings::load($this->stackDir); + + $this->assertNotNull($loaded); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_DEPLOY, $loaded->autoDeploy); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_DEPLOY, $loaded->withRecreateOnFolderChange(false)->autoDeploy); + $this->assertSame(GitStackSettings::AUTO_DEPLOY_NOTIFY, $loaded->withAutoDeploy('notify')->autoDeploy); + } + + public function testAutomaticDeploysTakeOnlyTheKnownChoices(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Automatic deploys must be one of'); + $this->makeSettings()->withAutoDeploy('always'); + } + public function testCredentialIsSavedAndLoaded(): void { $credentialId = str_repeat('ab', 16); @@ -409,6 +441,14 @@ public static function brokenFiles(): array $data['credentialId'] = 7; return (string) json_encode($data); }], + 'unknown automatic deploy choice' => [static function (array $data): string { + $data['autoDeploy'] = 'sometimes'; + return (string) json_encode($data); + }], + 'automatic deploys not a string' => [static function (array $data): string { + $data['autoDeploy'] = true; + return (string) json_encode($data); + }], 'string instead of true or false' => [static function (array $data): string { $data['recreateOnFolderChange'] = 'yes'; return (string) json_encode($data); @@ -491,11 +531,11 @@ public function testSettingFromANewerVersionIsRefusedByName(): void $this->makeSettings()->save($this->stackDir); $file = $this->stackDir . '/git.json'; $data = json_decode((string) file_get_contents($file), true); - $data['autoDeploy'] = 'off'; + $data['deployWindow'] = 'nights'; file_put_contents($file, (string) json_encode($data)); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('does not know: autoDeploy'); + $this->expectExceptionMessage('does not know: deployWindow'); GitStackSettings::load($this->stackDir); } From 222090386c82a3318682683c708ee2daed89ac66 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 21:07:44 -0400 Subject: [PATCH 02/18] feat(git): resume a git stack after a failed deploy A failed deploy pauses a git stack: the scheduled check (next commits) leaves it alone until a deploy works. compose-git resume clears the failure for someone who fixed the stack another way, by hand or with the web UI's Update, keeping the deployed commit as it was. Co-Authored-By: Claude Opus 5.5 --- .../include/GitStackManager.php | 29 +++++++++++++++++++ .../compose.manager/scripts/compose_git.php | 9 +++++- tests/unit/GitStackManagerTest.php | 15 ++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index b2d994bf..bc4db3a4 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -536,6 +536,35 @@ public function setAutoDeploy(string $folder, string $autoDeploy): void }); } + /** + * Clear a git stack's failed deploy, so the scheduled check deploys it again. + * + * After a failed deploy the stack is paused: the scheduled check leaves it + * alone until a deploy works. Someone who fixed it another way (by hand, or + * with the web UI's Update) clears the failure with this. The deployed + * commit stays as it was. + * + * @return bool false if the stack had no failed deploy to clear + * @throws RuntimeException naming why nothing was changed + */ + public function resume(string $folder): bool + { + $this->assertArrayStarted(); + $stack = $this->stack($folder); + $this->settings($stack); + + return $this->withStackLock($stack, function () use ($stack, $folder): bool { + $state = GitStackState::load($stack->path); + if ($state->failedCommit === null) { + ($this->say)("'$folder' has no failed deploy, so there is nothing to resume."); + return false; + } + (new GitStackState($state->deployedCommit, null))->save($stack->path); + ($this->say)("Cleared the failed deploy of " . substr($state->failedCommit, 0, 12) . ". Automatic deploys of '$folder' resume."); + return true; + }); + } + /** * The id of a git repository credential, given its exact name or its id. * diff --git a/source/compose.manager/scripts/compose_git.php b/source/compose.manager/scripts/compose_git.php index 9d9fe34f..0a4fcc63 100644 --- a/source/compose.manager/scripts/compose_git.php +++ b/source/compose.manager/scripts/compose_git.php @@ -35,6 +35,7 @@ final class ComposeGitUsageError extends InvalidArgumentException compose-git convert --url --path [--branch ] [--clones-root ] [--credential ] compose-git credential |--none compose-git auto-deploy off|notify|deploy + compose-git resume compose-git deploy-key compose-git trust-host compose-git check |--all @@ -55,7 +56,8 @@ final class ComposeGitUsageError extends InvalidArgumentException deploy waits for healthy containers when the stack's wait-for-healthy setting says so; --wait and --no-wait override it. auto-deploy sets what the scheduled check does when a new commit changes the stack: nothing -(off, the default), send a notification (notify), or deploy it (deploy). +(off, the default), send a notification (notify), or deploy it (deploy). After a failed deploy +the scheduled check leaves the stack alone until a deploy works; resume clears the failure. Exit status: 0 success, 1 failed, 2 usage error; for check, 3 a newer commit changes the stack's folder, 4 a newer commit changes only files outside it. See 'compose-git check --help'. @@ -583,6 +585,11 @@ function compose_git_main(array $argv, string $composeRoot, $errors = null): int $manager->setAutoDeploy($positional[0], $positional[1]); return COMPOSE_GIT_EXIT_OK; + case 'resume': + [$positional] = compose_git_parse($args, [], []); + $manager->resume(compose_git_one_stack($positional)); + return COMPOSE_GIT_EXIT_OK; + case 'deploy-key': [$positional] = compose_git_parse($args, [], []); echo $manager->deployKey(compose_git_one_stack($positional)) . "\n"; diff --git a/tests/unit/GitStackManagerTest.php b/tests/unit/GitStackManagerTest.php index 2c413ae0..3ab29532 100644 --- a/tests/unit/GitStackManagerTest.php +++ b/tests/unit/GitStackManagerTest.php @@ -1032,6 +1032,21 @@ public function testCommandLineAutoDeployChangesTheStacksSetting(): void $this->assertSame(GitStackSettings::AUTO_DEPLOY_OFF, GitStackSettings::load($stackDir)->autoDeploy); } + public function testResumeClearsAFailedDeployAndKeepsTheDeployedCommit(): void + { + $folder = $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); + $stackDir = $this->composeRoot . '/' . $folder; + $deployed = str_repeat('a', 40); + (new GitStackState($deployed, str_repeat('b', 40)))->save($stackDir); + + $this->assertSame(0, $this->runCli(['compose-git', 'resume', $folder])); + + $state = GitStackState::load($stackDir); + $this->assertSame($deployed, $state->deployedCommit); + $this->assertNull($state->failedCommit); + $this->assertFalse($this->manager->resume($folder)); + } + public function testCommandLineStatusAsJson(): void { $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); From 099f12f2c1d6742537960cca1f3b97c7ef914d2e Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 21:12:08 -0400 Subject: [PATCH 03/18] refactor(git): share a git deploy's --wait arguments The web UI's deploy turns the stack's wait-for-healthy settings into compose.sh's --wait arguments inline; that moves into gitDeployWaitArguments() in Helpers.php, so the scheduled deploy (next commit) waits the same way. compose-git deploy's --wait arguments end in the same function, so the three cannot drift. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Helpers.php | 29 +++++++++++++------ .../compose.manager/scripts/compose_git.php | 11 ++----- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php index ebe0e290..178bba3b 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -470,6 +470,25 @@ function buildGitDeployCommand(StackInfo $stack, ?string $commit, bool $saveLoca return $command; } +/** + * compose.sh's --wait arguments for a git deploy, from the stack's + * wait-for-healthy settings (resolveStackWaitSettings()). A timeout is passed + * on only when it is a plain number of seconds. + * + * @param array{enabled: bool, timeout: string} $waitSettings + * @return string[] + */ +function gitDeployWaitArguments(array $waitSettings): array +{ + if (empty($waitSettings['enabled'])) { + return []; + } + if (preg_match('/^[1-9][0-9]{0,5}$/', (string) $waitSettings['timeout']) !== 1) { + return ['--wait']; + } + return ['--wait', '--wait-timeout', (string) $waitSettings['timeout']]; +} + /** * The profiles a git deploy from the web UI runs with. When the person chose * them in the profile dialog (profileChosen=1), exactly those: an empty choice @@ -552,15 +571,7 @@ function echoGitDeployCommand(array $options = []): void $profiles = gitDeployProfilesFromRequest($stack, $_POST); // Waits for healthy containers as the stack's up does. - $waitArguments = []; - $waitSettings = resolveStackWaitSettings($stack->path, $cfg); - if (!empty($waitSettings['enabled'])) { - $waitArguments[] = '--wait'; - if (preg_match('/^[1-9][0-9]{0,5}$/', (string) $waitSettings['timeout']) === 1) { - $waitArguments[] = '--wait-timeout'; - $waitArguments[] = (string) $waitSettings['timeout']; - } - } + $waitArguments = gitDeployWaitArguments(resolveStackWaitSettings($stack->path, $cfg)); $composeCommand = buildGitDeployCommand($stack, $commit === '' ? null : $commit, $saveLocalChanges, $profiles, $waitArguments); if ($debug) { diff --git a/source/compose.manager/scripts/compose_git.php b/source/compose.manager/scripts/compose_git.php index 0a4fcc63..5120afe9 100644 --- a/source/compose.manager/scripts/compose_git.php +++ b/source/compose.manager/scripts/compose_git.php @@ -367,14 +367,9 @@ function compose_git_wait_arguments(array $waitSettings, array $options): array throw new ComposeGitUsageError('--wait-timeout must be a number of seconds.'); } } - if (!$wait) { - return []; - } - // A timeout read from the settings files is passed on only when it is a plain number. - if (preg_match('/^[1-9][0-9]{0,5}$/', $timeout) !== 1) { - return ['--wait']; - } - return ['--wait', '--wait-timeout', $timeout]; + // As the web UI's and the scheduled deploy wait: a timeout read from the settings + // files is passed on only when it is a plain number. + return gitDeployWaitArguments(['enabled' => $wait, 'timeout' => $timeout]); } /** From 080c5850bd61f56f959fb66fa07ca674a30d99ba Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Tue, 13 Oct 2026 13:29:15 -0400 Subject: [PATCH 04/18] refactor(git): take a stack's lock without waiting, for callers that do not throw withStackLock() throws when another operation holds the stack's lock. tryStackLock() is the part that takes the lock, returning null instead, for a caller that treats a busy stack as "try again later" rather than as an error: the scheduled check in the next commit. withStackLock() now uses it; nothing changes for its callers. Co-Authored-By: Claude Opus 5.5 --- .../include/GitStackManager.php | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index bc4db3a4..d3b81dc2 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -1154,6 +1154,27 @@ private function hasContent(string $file): bool * @return T */ private function withStackLock(StackInfo $stack, callable $work): mixed + { + $handle = self::tryStackLock($stack); + if ($handle === null) { + throw new RuntimeException("Another operation is in progress for '{$stack->projectFolder}'. Try again when it has finished."); + } + try { + return $work(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * Take compose.sh's lock for the stack without waiting. The caller releases it + * with flock($handle, LOCK_UN) and fclose($handle). + * + * @return resource|null null if another operation holds the lock + * @throws RuntimeException if the lock file cannot be opened + */ + public static function tryStackLock(StackInfo $stack) { if (!is_dir(COMPOSE_LOCK_DIR)) { @mkdir(COMPOSE_LOCK_DIR, 0755, true); @@ -1163,14 +1184,10 @@ private function withStackLock(StackInfo $stack, callable $work): mixed if ($handle === false) { throw new RuntimeException("Could not open the lock file $lockFile."); } - try { - if (!flock($handle, LOCK_EX | LOCK_NB)) { - throw new RuntimeException("Another operation is in progress for '{$stack->projectFolder}'. Try again when it has finished."); - } - return $work(); - } finally { - flock($handle, LOCK_UN); + if (!flock($handle, LOCK_EX | LOCK_NB)) { fclose($handle); + return null; } + return $handle; } } From 0eb4295b920468b64955937a6e10d5fec192f579 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Tue, 13 Oct 2026 13:29:16 -0400 Subject: [PATCH 05/18] feat(git): check git stacks for new commits, and deploy them, on request compose-git scheduled-check makes one pass over the git stacks whose auto-deploy is not off (the next commit runs it from cron): - It asks the remote for the branch's latest commit, once per repository, branch and credential in a pass, and fetches only when that is new. - "Behind" means the new commit changes the stack's own folder in the repository. In a repository shared by several stacks, a commit to another stack's folder changes nothing here. The comparison is with the deployed commit or, after a failed deploy, with the commit that failed: that is what the containers were last started from. - Each new commit that changes a stack is handled once: "notify" sends a notification; "deploy" runs compose.sh gitdeploy through compose_background.sh, which logs to the stack's last_cmd.log and notifies whether it worked. - A commit is not deployed, and one notification says why and what to do, when the stack's last deploy failed, its clone has local changes or a commit made by hand, or it is not running (stopped, or never deployed: as the image auto-update does, a commit never starts a stopped stack). Once that is cleared (or a notify stack is switched to deploy) the waiting commit is handled again. A stack whose project name is not settled is skipped, as the image auto-update skips it. - A scheduled deploy that fails before up (an image pull, say) is recorded as failed and not tried again: compose-git status shows it, and compose-git resume now clears it as well as a failed up, so the next pass tries again. - A stack another operation holds is tried again on the next pass, also when nothing needs fetching: what the stack runs from is read under its lock, so a deploy or rollback that is running is never acted on half done. An unreachable repository does not stop the other stacks being checked. What a pass learned is kept in git_check.json in the stack folder, written only when it changes, so a pass with nothing new writes nothing to the flash. Co-Authored-By: Claude Opus 5.5 --- .../include/GitScheduledCheck.php | 521 ++++++++++++++ .../include/GitStackManager.php | 43 +- .../compose.manager/scripts/compose_git.php | 17 + tests/unit/GitScheduledCheckTest.php | 655 ++++++++++++++++++ tests/unit/GitStackManagerTest.php | 18 + 5 files changed, 1245 insertions(+), 9 deletions(-) create mode 100644 source/compose.manager/include/GitScheduledCheck.php create mode 100644 tests/unit/GitScheduledCheckTest.php diff --git a/source/compose.manager/include/GitScheduledCheck.php b/source/compose.manager/include/GitScheduledCheck.php new file mode 100644 index 00000000..6d027151 --- /dev/null +++ b/source/compose.manager/include/GitScheduledCheck.php @@ -0,0 +1,521 @@ +notify = $notify ?? static function (string $subject, string $message, string $icon): void { + composeSendNotification($subject, $message, $icon); + }; + $this->deploy = $deploy ?? static function (StackInfo $stack): int { + return self::deployInBackgroundRunner($stack); + }; + $this->say = $say ?? static function (string $message): void { + }; + $this->isRunning = $isRunning ?? static function (StackInfo $stack): bool { + return self::hasRunningContainers($stack); + }; + } + + /** + * One pass over every git stack. Does nothing while the array is stopped, + * or while another pass is still running. + * + * @return int How many stacks were checked + */ + public function run(): int + { + if (!GitPathGuard::isArrayStarted()) { + ($this->say)('The array is not started, so nothing was checked.'); + return 0; + } + if (!is_dir(COMPOSE_LOCK_DIR)) { + @mkdir(COMPOSE_LOCK_DIR, 0755, true); + } + $lockFile = COMPOSE_LOCK_DIR . '/' . self::LOCK_NAME; + $lock = @fopen($lockFile, 'c'); + if ($lock === false) { + ($this->say)("Could not open the lock file $lockFile, so nothing was checked."); + composeLogger("Scheduled git check skipped: could not open the lock file $lockFile.", null, 'daemon', 'warning', 'git'); + return 0; + } + if (!flock($lock, LOCK_EX | LOCK_NB)) { + ($this->say)('Another scheduled check is still running.'); + // To the syslog too: cron drops what is said, and a pass stuck in a deploy + // would otherwise stop every stack's checks with nobody told. + composeLogger("Scheduled git check skipped: the last one is still running ($lockFile).", null, 'daemon', 'warning', 'git'); + fclose($lock); + return 0; + } + + try { + $checked = 0; + /** @var array $remoteHeads */ + $remoteHeads = []; + foreach ((new GitStackManager($this->composeRoot))->listGitStacks() as $folder) { + try { + if ($this->checkStack($folder, $remoteHeads)) { + $checked++; + } + } catch (Throwable $error) { + ($this->say)("$folder: " . $error->getMessage()); + composeLogger("Scheduled git check of '$folder' failed: " . $error->getMessage(), null, 'daemon', 'warning', 'git'); + } + } + return $checked; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + /** + * Check one stack. + * + * @param array $remoteHeads Remote heads already asked for in this pass + * @return bool false if the stack is not checked on a schedule + */ + private function checkStack(string $folder, array &$remoteHeads): bool + { + StackInfo::clearCache(); + $stack = StackInfo::fromProject($this->composeRoot, $folder); + $settings = GitStackSettings::load($stack->path); + if ($settings === null || $settings->autoDeploy === GitStackSettings::AUTO_DEPLOY_OFF) { + return false; + } + // As the image auto-update does: a stack whose project name is not settled is left alone, + // so a deploy cannot start it under the wrong name. + if (!$stack->hasResolvedIdentity()) { + ($this->say)("$folder: skipped, its compose project identity is unresolved."); + composeLogger("Skipped scheduled git check of '$folder': compose project identity is unresolved", null, 'daemon', 'warning', 'git'); + return false; + } + $clone = new GitClone($settings); + + // 1. The branch's latest commit, asked once per repository, branch and credential. + $key = $settings->url . "\n" . $settings->branch . "\n" . ($settings->credentialId ?? ''); + if (!isset($remoteHeads[$key])) { + try { + $remoteHeads[$key] = $clone->remoteBranchCommit(); + } catch (RuntimeException $error) { + $remoteHeads[$key] = $error; + } + } + if ($remoteHeads[$key] instanceof RuntimeException) { + throw $remoteHeads[$key]; + } + $remote = $remoteHeads[$key]; + + $record = self::loadRecord($stack->path); + $newRecord = $this->compare($stack, $clone, $remote, $record); + if ($newRecord === null) { + // Another operation holds the stack; the next pass tries again. + ($this->say)("$folder: busy, checked next time."); + return true; + } + + // 3. Handle a new commit that changes the stack, once. + $sameAsTheHandledCommit = $this->changesNothingSinceTheHandledCommit($newRecord, $clone, $remote); + $waitsForResume = $sameAsTheHandledCommit && $newRecord['handledBy'] === self::HANDLED_FAILED; + if ($sameAsTheHandledCommit && !$waitsForResume) { + $newRecord['handledCommit'] = $remote; + } + // After a failed deploy, the record keeps the commit that failed, so the list and + // status name it; a newer commit that changes nothing more waits for Resume with it. + if ($newRecord['stackChanged'] && !$waitsForResume && !$this->alreadyHandled($newRecord, $remote, $settings, $stack, $clone)) { + $newRecord['handledBy'] = $this->handleNewCommit($stack, $settings, $clone, $remote); + $newRecord['handledCommit'] = $remote; + // A deploy moves what the containers run from: record that, so a later change to it + // (a rollback by hand, Resume) is seen as one. + $state = GitStackState::load($stack->path); + $newRecord['baseCommit'] = $state->failedCommit ?? $state->deployedCommit; + } + if ($newRecord !== $record) { + self::saveRecord($stack->path, $newRecord); + } + ($this->say)("$folder: " . ($newRecord['stackChanged'] ? 'behind ' : 'up to date with ') . substr($remote, 0, 12)); + return true; + } + + /** + * Whether the remote commit changes nothing in the stack's folder since the + * commit already handled. While a stack is behind, a commit to another + * stack in the same repository then counts as handled too, so it brings no + * second notification. + * + * @param array{remoteCommit: ?string, stackChanged: bool, handledCommit: ?string, handledBy: ?string, baseCommit: ?string} $record + */ + private function changesNothingSinceTheHandledCommit(array $record, GitClone $clone, string $remote): bool + { + $handled = $record['handledCommit']; + if (!$record['stackChanged'] || $handled === null || $handled === $remote) { + return false; + } + return $clone->hasCommit($handled) && !$clone->stackFilesChanged($handled, $remote); + } + + /** + * Whether a commit was handled already and needs nothing more. A + * notification is never sent twice for a commit, but a commit that was only + * notified about is deployed once the stack is switched to "deploy", and one + * held back by a failed deploy or local changes is deployed once that is + * cleared. + * + * @param array{remoteCommit: ?string, stackChanged: bool, handledCommit: ?string, handledBy: ?string, baseCommit: ?string} $record + */ + private function alreadyHandled(array $record, string $remote, GitStackSettings $settings, StackInfo $stack, GitClone $clone): bool + { + if ($record['handledCommit'] !== $remote) { + return false; + } + $deploying = $settings->autoDeploy === GitStackSettings::AUTO_DEPLOY_DEPLOY; + return match ($record['handledBy']) { + self::HANDLED_NOTIFIED => !$deploying, + self::HANDLED_PAUSED => !$deploying || GitStackState::load($stack->path)->failedCommit !== null, + self::HANDLED_LOCAL_CHANGES => !$deploying || self::localChanges($clone, $stack) !== [], + self::HANDLED_NOT_RUNNING => !$deploying || !($this->isRunning)($stack), + default => true, + }; + } + + /** + * 2. Whether the remote commit changes the stack, fetching it when it is new. + * + * What the containers run from is read under the stack's lock, so the stack counts as + * busy while another operation on it runs, also when nothing needs fetching: a deploy + * or rollback that is running may be about to change it. + * + * @param array{remoteCommit: ?string, stackChanged: bool, handledCommit: ?string, handledBy: ?string, baseCommit: ?string} $record + * @return array{remoteCommit: ?string, stackChanged: bool, handledCommit: ?string, handledBy: ?string, baseCommit: ?string}|null + * null if another operation holds the stack's lock + */ + private function compare(StackInfo $stack, GitClone $clone, string $remote, array $record): ?array + { + $lock = GitStackManager::tryStackLock($stack); + if ($lock === null) { + return null; + } + try { + $state = GitStackState::load($stack->path); + $base = $state->failedCommit ?? $state->deployedCommit; + if ($remote === $base) { + return ['remoteCommit' => $remote, 'stackChanged' => false, 'handledCommit' => $remote, 'handledBy' => self::HANDLED_DEPLOYED, 'baseCommit' => $base]; + } + if ($remote === $record['remoteCommit'] && $base === $record['baseCommit']) { + return $record; + } + + // A new commit: fetch it while holding the lock, so no deploy runs at the same time. + $clone->fetch(); + // A base the clone no longer has (after a reclone, or a hand-edited git_state.json) counts as a change. + $changed = $base === null || !$clone->hasCommit($base) || $clone->stackFilesChanged($base, $remote); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + if (!$changed) { + // The stack's folder is as the base has it (a commit to another stack, or one that + // undoes the change): nothing is waiting, so what an earlier commit left in the + // record (a failed automatic deploy, say) no longer applies. + return ['remoteCommit' => $remote, 'stackChanged' => false, 'handledCommit' => $remote, 'handledBy' => self::HANDLED_DEPLOYED, 'baseCommit' => $base]; + } + if ($base !== $record['baseCommit']) { + // What the containers run from changed since the record was written (a deploy of + // another commit by hand, a rollback, Resume after a failed up): how the commit was + // handled then says nothing about now, so it is handled again. + return ['remoteCommit' => $remote, 'stackChanged' => true, 'handledCommit' => null, 'handledBy' => null, 'baseCommit' => $base]; + } + return ['remoteCommit' => $remote, 'stackChanged' => true, 'handledCommit' => $record['handledCommit'], 'handledBy' => $record['handledBy'], 'baseCommit' => $base]; + } + + /** + * @return string How it was handled (one of the HANDLED_ constants) + */ + private function handleNewCommit(StackInfo $stack, GitStackSettings $settings, GitClone $clone, string $remote): string + { + $name = $stack->projectFolder; + $short = substr($remote, 0, 12); + + if ($settings->autoDeploy === GitStackSettings::AUTO_DEPLOY_NOTIFY) { + ($this->notify)( + "Git stack update: $name", + "Commit $short on {$settings->branch} changes '$name'. Deploy it with Pull and Redeploy, or compose-git deploy $name.", + '' + ); + return self::HANDLED_NOTIFIED; + } + + $state = GitStackState::load($stack->path); + if ($state->failedCommit !== null) { + ($this->notify)( + "Git stack not deployed: $name", + "Commit $short on {$settings->branch} was not deployed: the deploy of " . substr($state->failedCommit, 0, 12) + . " failed, so automatic deploys of '$name' are paused. Fix it and deploy it, or resume automatic deploys" + . " (stack menu, or compose-git resume $name).", + 'warning' + ); + return self::HANDLED_PAUSED; + } + $localChanges = self::localChanges($clone, $stack); + if ($localChanges !== []) { + ($this->notify)( + "Git stack not deployed: $name", + "Commit $short on {$settings->branch} was not deployed: files in the clone of '$name' were changed by hand (" + . implode(', ', array_slice($localChanges, 0, 5)) . (count($localChanges) > 5 ? ', ...' : '') + . '). Deploy it with Pull and Redeploy, which offers to save them as a patch first.', + 'warning' + ); + return self::HANDLED_LOCAL_CHANGES; + } + // As the image auto-update does: a stack someone stopped, or never started, is not + // started by a commit. It is deployed at the first check after it runs again. + if (!($this->isRunning)($stack)) { + ($this->notify)( + "Git stack not deployed: $name", + "Commit $short on {$settings->branch} was not deployed: '$name' is not running. Deploy it with Pull and " + . 'Redeploy, or start the stack and the next check deploys it. To stop these notifications for a stack' + . ' stopped on purpose, set its automatic deploys to Off.', + '' + ); + return self::HANDLED_NOT_RUNNING; + } + + ($this->say)("$name: deploying $short..."); + composeLogger("Scheduled git deploy of '$name' at $short", null, 'daemon', 'info', 'git'); + // The background runner logs to the stack's last_cmd.log and notifies whether it worked. + $exitStatus = ($this->deploy)($stack); + if ($exitStatus !== 0) { + // Tried once, like every commit, but recorded as failed: the stack list shows it, + // and Resume Automatic Deploys clears it so the next pass tries again. A failure + // during up is recorded by compose.sh itself, as failedCommit, and pauses the stack. + composeLogger("Scheduled git deploy of '$name' at $short failed (exit status $exitStatus)", null, 'daemon', 'warning', 'git'); + return self::HANDLED_FAILED; + } + return self::HANDLED_DEPLOYED; + } + + /** + * The commit whose scheduled deploy failed before up, if the last pass + * recorded one and neither it nor the newer commit the last pass saw on the + * branch has been deployed since. + * + * A scheduled deploy that failed during up is recorded the same way by the + * pass, but compose.sh has recorded it as the stack's failed commit too; + * that one is the failure to show, so this returns null for it. + */ + public static function failedAutomaticDeploy(string $stackDir): ?string + { + $record = self::loadRecord($stackDir); + if ($record['handledBy'] !== self::HANDLED_FAILED || $record['handledCommit'] === null) { + return null; + } + $state = GitStackState::load($stackDir); + if ($state->deployedCommit === $record['handledCommit'] || $state->deployedCommit === $record['remoteCommit']) { + return null; + } + if ($state->failedCommit === $record['handledCommit']) { + return null; + } + return $record['handledCommit']; + } + + /** + * Forget a scheduled deploy that failed before up, so the next pass + * deploys the branch's latest commit again (compose-git resume). + * + * @return bool false if there was none + */ + public static function clearFailedAutomaticDeploy(string $stackDir): bool + { + if (self::failedAutomaticDeploy($stackDir) === null) { + return false; + } + $record = self::loadRecord($stackDir); + $record['handledCommit'] = null; + $record['handledBy'] = null; + self::saveRecord($stackDir, $record); + return true; + } + + /** + * What in the clone a deploy would stop on without the option to save it: + * the tracked files changed by hand, and a commit made in the clone by hand + * (GitClone::isMadeByHand(), the test the deploy itself uses). + * + * @return string[] Changed files, and a line naming a hand-made commit + */ + private static function localChanges(GitClone $clone, StackInfo $stack): array + { + $changes = $clone->locallyChangedFiles(); + $state = GitStackState::load($stack->path); + $checkedOut = $clone->checkedOutCommit(); + if ($clone->isMadeByHand($checkedOut, $state->deployedCommit, $state->failedCommit)) { + $changes[] = 'a commit made in the clone (' . substr($checkedOut, 0, 12) . ')'; + } + return $changes; + } + + /** + * Whether any of the stack's containers is running, as docker reports it. A + * container that is restarting (crash-looping, say) or paused counts as running, + * as it does for the image auto-update: docker ps without -a lists those too, and + * a crash-looping stack is the one whose fix should be deployed. + * + * @throws RuntimeException when docker cannot be asked: not knowing is not "stopped", + * so the pass leaves the stack and asks again next time + */ + private static function hasRunningContainers(StackInfo $stack): bool + { + $result = DockerCommand::run([ + 'ps', '-q', '--filter', 'label=com.docker.compose.project=' . $stack->projectName, + ]); + if (!$result->succeeded()) { + throw new RuntimeException("Could not ask docker whether '{$stack->projectFolder}' is running: " . $result->errorSummary()); + } + return trim($result->stdout) !== ''; + } + + /** + * Deploy a stack the way the web UI's background deploy does: compose.sh + * gitdeploy through compose_background.sh, waiting for it to finish. + */ + private static function deployInBackgroundRunner(StackInfo $stack): int + { + require_once '/usr/local/emhttp/plugins/compose.manager/include/Helpers.php'; + $waitSettings = resolveStackWaitSettings($stack->path, parse_plugin_cfg('compose.manager')); + // The profiles the stack runs with, as Pull and Redeploy uses them: without them, profile + // services would stay on the old commit while the deploy reports success. + $command = buildGitDeployCommand($stack, null, false, gitDeployProfiles($stack, []), gitDeployWaitArguments($waitSettings)); + // compose_background.sh takes compose.sh's arguments, without compose.sh itself. It is + // run with sh, as AutoUpdateRunner runs its script, so it needs no execute bit. + array_shift($command); + array_unshift($command, '/bin/sh', dirname(__DIR__) . '/scripts/compose_background.sh'); + + $process = proc_open($command, [0 => ['file', '/dev/null', 'r'], 1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); + if (!is_resource($process)) { + return 1; + } + return proc_close($process); + } + + /** + * What earlier passes learned about a stack. A missing or unreadable file + * means nothing is known yet: the next pass works it out again. + * + * @return array{remoteCommit: ?string, stackChanged: bool, handledCommit: ?string, handledBy: ?string, baseCommit: ?string} + */ + public static function loadRecord(string $stackDir): array + { + $empty = ['remoteCommit' => null, 'stackChanged' => false, 'handledCommit' => null, 'handledBy' => null, 'baseCommit' => null]; + $data = json_decode((string) @file_get_contents(rtrim($stackDir, '/') . '/' . self::RECORD_FILE), true); + if (!is_array($data) || ($data['version'] ?? null) !== self::RECORD_VERSION) { + return $empty; + } + $commitOrNull = static fn(mixed $value): ?string => is_string($value) && GitClone::isCommitId($value) ? $value : null; + return [ + 'remoteCommit' => $commitOrNull($data['remoteCommit'] ?? null), + 'stackChanged' => ($data['stackChanged'] ?? false) === true, + 'handledCommit' => $commitOrNull($data['handledCommit'] ?? null), + 'handledBy' => in_array($data['handledBy'] ?? null, [self::HANDLED_NOTIFIED, self::HANDLED_DEPLOYED, self::HANDLED_PAUSED, self::HANDLED_LOCAL_CHANGES, self::HANDLED_FAILED, self::HANDLED_NOT_RUNNING], true) + ? $data['handledBy'] + : null, + 'baseCommit' => $commitOrNull($data['baseCommit'] ?? null), + ]; + } + + /** + * @param array{remoteCommit: ?string, stackChanged: bool, handledCommit: ?string, handledBy: ?string, baseCommit: ?string} $record + */ + private static function saveRecord(string $stackDir, array $record): void + { + $stackDir = rtrim($stackDir, '/'); + if (str_starts_with($stackDir, rtrim(COMPOSE_GIT_MNT_DIR, '/') . '/')) { + GitPathGuard::assertSafeToWrite($stackDir); + } + $json = json_encode(['version' => self::RECORD_VERSION] + $record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + $file = $stackDir . '/' . self::RECORD_FILE; + $tmpFile = $file . '.tmp-' . bin2hex(random_bytes(4)); + if (file_put_contents($tmpFile, $json) !== strlen($json) || !rename($tmpFile, $file)) { + @unlink($tmpFile); + throw new RuntimeException("Could not write $file."); + } + } +} diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index d3b81dc2..9524403e 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -343,7 +343,7 @@ static function () use ($stack, $clone, $remote): array { /** * What is known about a git stack locally, without asking the remote. * - * @return array{stack: string, url: string, branch: string, composePath: string, cloneDir: string, recreateOnFolderChange: bool, autoDeploy: string, credential: ?string, deployedCommit: ?string, failedCommit: ?string, checkedOutCommit: ?string, localChanges: list, commitMadeByHand: bool, problem: ?string} + * @return array{stack: string, url: string, branch: string, composePath: string, cloneDir: string, recreateOnFolderChange: bool, autoDeploy: string, credential: ?string, deployedCommit: ?string, failedCommit: ?string, checkedOutCommit: ?string, localChanges: list, commitMadeByHand: bool, failedAutomaticCommit: ?string, problem: ?string} */ public function status(string $folder): array { @@ -376,10 +376,20 @@ public function status(string $folder): array 'checkedOutCommit' => $checkedOut, 'localChanges' => $changes, 'commitMadeByHand' => $madeByHand, + 'failedAutomaticCommit' => self::failedAutomaticCommit($stack->path), 'problem' => $problem, ]; } + /** + * The commit whose scheduled deploy failed before up, if any (see GitScheduledCheck). + */ + private static function failedAutomaticCommit(string $stackDir): ?string + { + require_once '/usr/local/emhttp/plugins/compose.manager/include/GitScheduledCheck.php'; + return GitScheduledCheck::failedAutomaticDeploy($stackDir); + } + /** * Change the credential a git stack uses to reach its repository, or remove it. * @@ -542,7 +552,8 @@ public function setAutoDeploy(string $folder, string $autoDeploy): void * After a failed deploy the stack is paused: the scheduled check leaves it * alone until a deploy works. Someone who fixed it another way (by hand, or * with the web UI's Update) clears the failure with this. The deployed - * commit stays as it was. + * commit stays as it was. It also clears a scheduled deploy that failed + * before any container was started, which is otherwise not tried again. * * @return bool false if the stack had no failed deploy to clear * @throws RuntimeException naming why nothing was changed @@ -551,17 +562,31 @@ public function resume(string $folder): bool { $this->assertArrayStarted(); $stack = $this->stack($folder); - $this->settings($stack); + $checked = $this->settings($stack)->autoDeploy !== GitStackSettings::AUTO_DEPLOY_OFF; - return $this->withStackLock($stack, function () use ($stack, $folder): bool { + return $this->withStackLock($stack, function () use ($stack, $folder, $checked): bool { + $cleared = false; $state = GitStackState::load($stack->path); - if ($state->failedCommit === null) { + if ($state->failedCommit !== null) { + (new GitStackState($state->deployedCommit, null))->save($stack->path); + ($this->say)("Cleared the failed deploy of " . substr($state->failedCommit, 0, 12) . '.' + . ($checked ? " Automatic deploys of '$folder' resume." : '')); + $cleared = true; + } + // A scheduled deploy that failed before up (a pull, say) is kept in the + // scheduled check's record instead; clearing it lets the next check try again. + require_once '/usr/local/emhttp/plugins/compose.manager/include/GitScheduledCheck.php'; + $failedAutomatic = GitScheduledCheck::failedAutomaticDeploy($stack->path); + if ($failedAutomatic !== null) { + GitScheduledCheck::clearFailedAutomaticDeploy($stack->path); + ($this->say)("Cleared the failed automatic deploy of " . substr($failedAutomatic, 0, 12) . '.' + . ($checked ? " The next scheduled check of '$folder' tries again." : '')); + $cleared = true; + } + if (!$cleared) { ($this->say)("'$folder' has no failed deploy, so there is nothing to resume."); - return false; } - (new GitStackState($state->deployedCommit, null))->save($stack->path); - ($this->say)("Cleared the failed deploy of " . substr($state->failedCommit, 0, 12) . ". Automatic deploys of '$folder' resume."); - return true; + return $cleared; }); } diff --git a/source/compose.manager/scripts/compose_git.php b/source/compose.manager/scripts/compose_git.php index 5120afe9..d08807d5 100644 --- a/source/compose.manager/scripts/compose_git.php +++ b/source/compose.manager/scripts/compose_git.php @@ -16,6 +16,7 @@ declare(strict_types=1); require_once '/usr/local/emhttp/plugins/compose.manager/include/GitStackManager.php'; +require_once '/usr/local/emhttp/plugins/compose.manager/include/GitScheduledCheck.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/Helpers.php'; const COMPOSE_GIT_EXIT_OK = 0; @@ -42,6 +43,7 @@ final class ComposeGitUsageError extends InvalidArgumentException compose-git deploy [--commit ] [--save-local-changes] [--wait|--no-wait] [--wait-timeout ] [--profile ]... compose-git reclone compose-git status [|--all] [--json] + compose-git scheduled-check is an https address (no user name or password in it), an ssh address (ssh://git@host/path or git@host:path), or the path of a repository under /mnt. @@ -58,6 +60,8 @@ final class ComposeGitUsageError extends InvalidArgumentException auto-deploy sets what the scheduled check does when a new commit changes the stack: nothing (off, the default), send a notification (notify), or deploy it (deploy). After a failed deploy the scheduled check leaves the stack alone until a deploy works; resume clears the failure. +scheduled-check runs the scheduled check now (cron runs it every 15 minutes while any stack +asks for it). Exit status: 0 success, 1 failed, 2 usage error; for check, 3 a newer commit changes the stack's folder, 4 a newer commit changes only files outside it. See 'compose-git check --help'. @@ -471,6 +475,7 @@ function compose_git_unreadable_status(string $folder, string $problem): array 'checkedOutCommit' => null, 'localChanges' => [], 'commitMadeByHand' => false, + 'failedAutomaticCommit' => null, 'problem' => $problem, ]; } @@ -498,6 +503,10 @@ function compose_git_print_status(array $status): void if ($status['failedCommit'] !== null) { echo ' failed: ' . $short($status['failedCommit']) . " (fix it and deploy again)\n"; } + if ($status['failedAutomaticCommit'] !== null) { + echo ' failed: ' . $short($status['failedAutomaticCommit']) + . " (the automatic deploy, before up; compose-git resume tries it again)\n"; + } echo ' checked out: ' . $short($status['checkedOutCommit']) . "\n"; if ($status['localChanges'] !== []) { echo ' local changes: ' . implode(', ', $status['localChanges']) . "\n"; @@ -677,6 +686,14 @@ function compose_git_main(array $argv, string $composeRoot, $errors = null): int } return COMPOSE_GIT_EXIT_OK; + case 'scheduled-check': + [$positional] = compose_git_parse($args, [], []); + if ($positional !== []) { + throw new ComposeGitUsageError('scheduled-check checks every git stack whose auto-deploy is not off; name no stack.'); + } + (new GitScheduledCheck($composeRoot, null, null, $say))->run(); + return COMPOSE_GIT_EXIT_OK; + case 'help': // 'compose-git help ' is the same as 'compose-git --help'. if (isset($args[0])) { diff --git a/tests/unit/GitScheduledCheckTest.php b/tests/unit/GitScheduledCheckTest.php new file mode 100644 index 00000000..16155c00 --- /dev/null +++ b/tests/unit/GitScheduledCheckTest.php @@ -0,0 +1,655 @@ + */ + private array $notifications = []; + + /** @var list */ + private array $deployed = []; + + /** The exit status the stand-in deploy returns. */ + private int $deployExitStatus = 0; + + /** Whether the stand-in docker says the stack runs; null when it cannot be asked. */ + private ?bool $running = true; + + /** What the stand-in deploy does besides returning its status, as compose.sh would. */ + private ?\Closure $onDeploy = null; + + protected function setUp(): void + { + parent::setUp(); + \StackInfo::clearCache(); + $this->mnt = COMPOSE_GIT_MNT_DIR; + FakeDocker::removeTree($this->mnt); + mkdir($this->mnt . '/user/appdata', 0755, true); + mkdir($this->mnt . '/user/repos', 0755, true); + file_put_contents(COMPOSE_UNRAID_VAR_INI, "mdState=\"STARTED\"\n"); + file_put_contents(COMPOSE_MOUNTS_FILE, "rootfs {$this->mnt} rootfs rw 0 0\nshfs {$this->mnt}/user fuse.shfs rw 0 0\n"); + + $this->upstream = $this->mnt . '/user/repos/stacks.git'; + $this->author = sys_get_temp_dir() . '/compose_git_schedule_author'; + FakeDocker::removeTree($this->author); + $this->git(['init', '-q', '--bare', '-b', 'main', $this->upstream], null); + $this->git(['clone', '-q', $this->upstream, $this->author], null); + $this->git(['checkout', '-q', '-b', 'main'], $this->author); + $this->writeAndPush([ + 'whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami\n", + 'other/compose.yaml' => "services:\n other:\n image: nginx\n", + ], 'first'); + + $this->composeRoot = sys_get_temp_dir() . '/compose_git_schedule_projects'; + FakeDocker::removeTree($this->composeRoot); + mkdir($this->composeRoot); + FakeDocker::removeTree(COMPOSE_LOCK_DIR); + $this->clonesRoot = $this->mnt . '/user/appdata/git'; + $this->manager = new GitStackManager($this->composeRoot); + $this->notifications = []; + $this->deployed = []; + $this->deployExitStatus = 0; + $this->onDeploy = null; + $this->running = true; + } + + protected function tearDown(): void + { + FakeDocker::removeTree($this->mnt); + FakeDocker::removeTree($this->author); + FakeDocker::removeTree($this->composeRoot); + FakeDocker::removeTree(COMPOSE_LOCK_DIR); + parent::tearDown(); + } + + public function testAStackWithAutomaticDeploysOffIsNotChecked(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_OFF); + $this->writeAndPush(['whoami/compose.yaml' => "services: {}\n"], 'change'); + + $this->assertSame(0, $this->check()->run()); + + $this->assertSame([], $this->notifications); + $this->assertFileDoesNotExist($this->composeRoot . '/' . $folder . '/' . GitScheduledCheck::RECORD_FILE); + } + + public function testAnUpToDateStackIsLeftAlone(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + + $this->assertSame(1, $this->check()->run()); + + $this->assertSame([], $this->notifications); + $this->assertSame([], $this->deployed); + $this->assertFalse(GitScheduledCheck::loadRecord($this->composeRoot . '/' . $folder)['stackChanged']); + } + + public function testNotifyTellsOnceAboutACommitThatChangesTheStack(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + + $this->check()->run(); + $this->check()->run(); + + $this->assertCount(1, $this->notifications); + $this->assertSame("Git stack update: $folder", $this->notifications[0][0]); + $this->assertStringContainsString(substr($this->upstreamHead(), 0, 12), $this->notifications[0][1]); + $this->assertSame([], $this->deployed); + $record = GitScheduledCheck::loadRecord($this->composeRoot . '/' . $folder); + $this->assertTrue($record['stackChanged']); + $this->assertSame($this->upstreamHead(), $record['remoteCommit']); + } + + public function testWhileBehindACommitToAnotherStacksFolderBringsNoSecondNotification(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->check()->run(); + $this->writeAndPush(['other/compose.yaml' => "services: {}\n"], 'other stack'); + $this->check()->run(); + $this->assertCount(1, $this->notifications); + $this->assertTrue(GitScheduledCheck::loadRecord($this->composeRoot . '/' . $folder)['stackChanged']); + + // A further change to the stack's own folder is news again. + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.11\n"], 'bump'); + $this->check()->run(); + $this->assertCount(2, $this->notifications); + } + + public function testACommitToAnotherStacksFolderChangesNothing(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['other/compose.yaml' => "services: {}\n"], 'other stack'); + + $this->check()->run(); + + $this->assertSame([], $this->notifications); + $this->assertSame([], $this->deployed); + $record = GitScheduledCheck::loadRecord($this->composeRoot . '/' . $folder); + $this->assertFalse($record['stackChanged']); + $this->assertSame($this->upstreamHead(), $record['remoteCommit']); + } + + public function testDeployDeploysEachNewCommitOnce(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + + $this->check()->run(); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.11\n"], 'bump'); + $this->check()->run(); + $this->assertSame([$folder, $folder], $this->deployed); + } + + public function testAStackWhoseLastDeployFailedIsNotDeployedAndSaysWhy(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $state = GitStackState::load($stackDir); + (new GitStackState($state->deployedCommit, str_repeat('b', 40)))->save($stackDir); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + + $this->check()->run(); + $this->check()->run(); + + $this->assertSame([], $this->deployed); + $this->assertCount(1, $this->notifications); + $this->assertStringContainsString('paused', $this->notifications[0][1]); + $this->assertStringContainsString('compose-git resume', $this->notifications[0][1]); + $this->assertSame('warning', $this->notifications[0][2]); + + // Once the failure is cleared, the waiting commit is deployed. + $this->manager->resume($folder); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + $this->assertCount(1, $this->notifications); + } + + public function testADeployThatFailsBeforeUpIsRecordedAndResumeTriesItAgain(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + // A pull that fails, say: compose.sh exits 1 and records nothing in git_state.json. + $this->deployExitStatus = 1; + + $this->check()->run(); + $this->check()->run(); + + // Tried once, and recorded as failed rather than deployed. + $this->assertSame([$folder], $this->deployed); + $this->assertSame($this->upstreamHead(), GitScheduledCheck::failedAutomaticDeploy($stackDir)); + $this->assertNull(GitStackState::load($stackDir)->failedCommit); + + // Resume clears it, and the next check tries the commit again. + $this->deployExitStatus = 0; + $this->assertTrue($this->manager->resume($folder)); + $this->assertNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + $this->check()->run(); + $this->assertSame([$folder, $folder], $this->deployed); + $this->assertFalse($this->manager->resume($folder)); + } + + public function testAFailedAutomaticDeployIsForgottenOnceACommitUndoesTheChange(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->deployExitStatus = 1; + $this->check()->run(); + $this->assertNotNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + + // The folder back as deployed: nothing to deploy, and nothing failed any more. + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami\n"], 'revert'); + $this->check()->run(); + + $this->assertSame([$folder], $this->deployed); + $this->assertNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + $this->assertFalse(GitScheduledCheck::loadRecord($stackDir)['stackChanged']); + } + + public function testAFailedAutomaticDeployIsForgottenOnceTheCommitIsDeployed(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->deployExitStatus = 1; + $this->check()->run(); + $this->assertNotNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + + // Deployed by hand, with Pull and Redeploy. + (new GitStackState($this->upstreamHead(), null))->save($stackDir); + $this->assertNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + } + + public function testAfterAFailedAutomaticDeployACommitToAnotherStackKeepsTheFailedCommitNamed(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $failed = $this->upstreamHead(); + $this->deployExitStatus = 1; + $this->check()->run(); + + // A commit to another stack's folder: not deployed, and the commit that failed is still the one named. + $this->writeAndPush(['other/compose.yaml' => "services:\n other:\n image: traefik/whoami\n"], 'other'); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + $this->assertSame($failed, GitScheduledCheck::failedAutomaticDeploy($stackDir)); + + // Resume: the next check deploys the branch's latest commit. + $this->deployExitStatus = 0; + $this->assertTrue($this->manager->resume($folder)); + $this->check()->run(); + $this->assertSame([$folder, $folder], $this->deployed); + } + + public function testAFailedAutomaticDeployIsForgottenOnceANewerCommitIsDeployedByHand(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->deployExitStatus = 1; + $this->check()->run(); + $this->writeAndPush(['other/compose.yaml' => "services:\n other:\n image: traefik/whoami\n"], 'other'); + $this->check()->run(); + $this->assertNotNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + + // The branch's latest commit, deployed with Pull and Redeploy. + (new GitStackState($this->upstreamHead(), null))->save($stackDir); + $this->assertNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + } + + public function testAStackThatIsNotRunningIsNotDeployedUntilItRuns(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->running = false; + + $this->check()->run(); + $this->check()->run(); + + // Stopped (or never deployed): told once, not started. + $this->assertSame([], $this->deployed); + $this->assertCount(1, $this->notifications); + $this->assertStringContainsString('is not running', $this->notifications[0][1]); + + // Started again: the waiting commit is deployed at the next check. + $this->running = true; + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + $this->assertCount(1, $this->notifications); + } + + public function testWhenDockerCannotBeAskedNothingIsDeployedAndTheNextPassAsksAgain(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->running = null; + + $this->check()->run(); + + // Not knowing is not "stopped": no deploy, no notification, nothing recorded as handled. + $this->assertSame([], $this->deployed); + $this->assertSame([], $this->notifications); + $this->running = true; + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + } + + public function testAStackWhoseProjectIdentityIsUnresolvedIsLeftAlone(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + // An older project name and docker that cannot be asked: the identity fails closed. + @unlink($stackDir . '/' . \ProjectIdentity::METADATA_FILE); + file_put_contents($stackDir . '/name', 'older_name'); + \ProjectIdentity::setProbe(static fn() => null); + try { + $this->assertSame(0, $this->check()->run()); + } finally { + \ProjectIdentity::setProbe(static fn(): array => []); + } + + $this->assertSame([], $this->deployed); + $this->assertSame([], $this->notifications); + } + + public function testAFailedUpThenResumeDeploysTheCommitAgainAtTheNextCheck(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $deployed = $this->upstreamHead(); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + // The deploy fails during up: compose.sh records the commit as failed, and exits 1. + $this->deployExitStatus = 1; + $this->onDeploy = function () use ($stackDir, $deployed): void { + (new GitStackState($deployed, $this->upstreamHead()))->save($stackDir); + }; + $this->check()->run(); + // The failure is the stack's failed commit; it is not shown a second time as an automatic + // deploy that failed before up. + $this->assertSame($this->upstreamHead(), GitStackState::load($stackDir)->failedCommit); + $this->assertNull(GitScheduledCheck::failedAutomaticDeploy($stackDir)); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + + // Resumed after the next pass, and fixed outside git: the next check tries the commit again. + $this->deployExitStatus = 0; + $this->onDeploy = null; + $this->manager->resume($folder); + $this->check()->run(); + $this->assertSame([$folder, $folder], $this->deployed); + } + + public function testAStackTakenBackToAnOlderCommitIsBroughtForward(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $older = $this->upstreamHead(); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->onDeploy = function () use ($stackDir): void { + (new GitStackState($this->upstreamHead(), null))->save($stackDir); + }; + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + + // Deploy Commit back to the older commit, by hand. + (new GitStackState($older, null))->save($stackDir); + $this->check()->run(); + + $this->assertSame([$folder, $folder], $this->deployed); + } + + public function testACommitMadeByHandInTheCloneHoldsTheDeployBack(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $cloneDir = GitStackSettings::load($this->composeRoot . '/' . $folder)->cloneDir; + file_put_contents($cloneDir . '/whoami/compose.yaml', "services:\n whoami:\n image: traefik/whoami:by-hand\n"); + $this->git(['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-q', '-am', 'by hand'], $cloneDir); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + + $this->check()->run(); + + $this->assertSame([], $this->deployed); + $this->assertCount(1, $this->notifications); + $this->assertStringContainsString('a commit made in the clone', $this->notifications[0][1]); + } + + public function testStatusShowsAScheduledDeployThatFailedBeforeUp(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->deployExitStatus = 1; + $this->check()->run(); + + $this->assertSame($this->upstreamHead(), $this->manager->status($folder)['failedAutomaticCommit']); + } + + public function testAfterAFailedDeployACommitIsComparedWithTheCommitThatFailed(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $stackDir = $this->composeRoot . '/' . $folder; + $deployed = $this->upstreamHead(); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:broken\n"], 'break'); + (new GitStackState($deployed, $this->upstreamHead()))->save($stackDir); + // The fix puts the folder back exactly as the deployed commit had it. + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami\n"], 'fix'); + + $this->check()->run(); + + // Still a change from what the containers were last started from: paused, so it says so. + $this->assertTrue(GitScheduledCheck::loadRecord($stackDir)['stackChanged']); + $this->assertCount(1, $this->notifications); + $this->assertStringContainsString('paused', $this->notifications[0][1]); + + $this->manager->resume($folder); + $this->check()->run(); + // Compared with the deployed commit again: nothing changed, so nothing is deployed. + $this->assertFalse(GitScheduledCheck::loadRecord($stackDir)['stackChanged']); + $this->assertSame([], $this->deployed); + } + + public function testACommitOnlyNotifiedAboutIsDeployedOnceTheStackIsSwitchedToDeploy(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->check()->run(); + $this->assertCount(1, $this->notifications); + + GitStackSettings::load($stackDir)->withAutoDeploy(GitStackSettings::AUTO_DEPLOY_DEPLOY)->save($stackDir); + $this->check()->run(); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + + // Switching back to notify after a deploy sends nothing for that commit. + GitStackSettings::load($stackDir)->withAutoDeploy(GitStackSettings::AUTO_DEPLOY_NOTIFY)->save($stackDir); + $this->check()->run(); + $this->assertCount(1, $this->notifications); + } + + public function testAStackWithLocalChangesIsNotDeployedAndSaysWhy(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + file_put_contents(GitStackSettings::load($this->composeRoot . '/' . $folder)->composeFileInClone(), "services: {}\n"); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + + $this->check()->run(); + $this->check()->run(); + + $this->assertSame([], $this->deployed); + $this->assertCount(1, $this->notifications); + $this->assertStringContainsString('whoami/compose.yaml', $this->notifications[0][1]); + + // Once the local change is undone, the waiting commit is deployed. + $this->git(['checkout', '--', 'whoami/compose.yaml'], GitStackSettings::load($this->composeRoot . '/' . $folder)->cloneDir); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + } + + public function testABusyStackIsCheckedAgainNextTime(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $stack = \StackInfo::fromProject($this->composeRoot, $folder); + mkdir(COMPOSE_LOCK_DIR, 0755, true); + $lock = fopen(COMPOSE_LOCK_DIR . '/' . $stack->projectName . '.lock', 'c'); + $this->assertNotFalse($lock); + flock($lock, LOCK_EX); + + $this->check()->run(); + $this->assertSame([], $this->deployed); + + flock($lock, LOCK_UN); + fclose($lock); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + } + + public function testAStackIsBusyWhileAnotherOperationRunsAlsoWhenNothingNeedsFetching(): void + { + // A commit already fetched and notified about: the next pass needs no fetch to act on it. + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + $this->check()->run(); + GitStackSettings::load($stackDir)->withAutoDeploy(GitStackSettings::AUTO_DEPLOY_DEPLOY)->save($stackDir); + $stack = \StackInfo::fromProject($this->composeRoot, $folder); + mkdir(COMPOSE_LOCK_DIR, 0755, true); + $lock = fopen(COMPOSE_LOCK_DIR . '/' . $stack->projectName . '.lock', 'c'); + $this->assertNotFalse($lock); + flock($lock, LOCK_EX); + + // A deploy by hand holds the stack and may be changing what it runs: the pass leaves it. + $this->check()->run(); + $this->assertSame([], $this->deployed); + + flock($lock, LOCK_UN); + fclose($lock); + $this->check()->run(); + $this->assertSame([$folder], $this->deployed); + } + + public function testAPassWhileAnotherIsStillRunningChecksNothing(): void + { + $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_DEPLOY); + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + if (!is_dir(COMPOSE_LOCK_DIR)) { + mkdir(COMPOSE_LOCK_DIR, 0755, true); + } + $held = fopen(COMPOSE_LOCK_DIR . '/git-scheduled-check.lock', 'c'); + $this->assertNotFalse($held); + flock($held, LOCK_EX); + try { + $this->assertSame(0, $this->check()->run()); + $this->assertSame([], $this->deployed); + } finally { + flock($held, LOCK_UN); + fclose($held); + } + $this->assertSame(1, $this->check()->run()); + } + + public function testAMalformedRecordIsWorkedOutAgain(): void + { + $folder = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $stackDir = $this->composeRoot . '/' . $folder; + $this->writeAndPush(['whoami/compose.yaml' => "services:\n whoami:\n image: traefik/whoami:v1.10\n"], 'pin'); + file_put_contents($stackDir . '/' . GitScheduledCheck::RECORD_FILE, '{"version": 1, "remoteCommit": "not a commit", "stackChanged": "yes"'); + + $this->check()->run(); + + // Read as empty, so the commit is compared again and notified about once. + $this->assertCount(1, $this->notifications); + $this->assertTrue(GitScheduledCheck::loadRecord($stackDir)['stackChanged']); + } + + public function testNothingIsCheckedWhileTheArrayIsStopped(): void + { + $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $this->writeAndPush(['whoami/compose.yaml' => "services: {}\n"], 'change'); + file_put_contents(COMPOSE_UNRAID_VAR_INI, "mdState=\"STOPPED\"\n"); + + $this->assertSame(0, $this->check()->run()); + $this->assertSame([], $this->notifications); + } + + public function testAnUnreachableRepositoryIsSkippedAndTheOtherStacksAreStillChecked(): void + { + $broken = $this->deployedStack('whoami', GitStackSettings::AUTO_DEPLOY_NOTIFY); + $working = $this->deployedStack('other', GitStackSettings::AUTO_DEPLOY_NOTIFY, 'other/compose.yaml'); + rename($this->upstream, $this->upstream . '.moved'); + // Only the first stack's repository is gone: point the second at a copy. + $copy = $this->mnt . '/user/repos/copy.git'; + $this->git(['clone', '-q', '--bare', $this->upstream . '.moved', $copy], null); + $otherDir = $this->composeRoot . '/' . $working; + $settings = json_decode((string) file_get_contents($otherDir . '/git.json'), true); + $settings['url'] = $copy; + file_put_contents($otherDir . '/git.json', (string) json_encode($settings)); + + $this->assertSame(1, $this->check()->run()); + $this->assertFileDoesNotExist($this->composeRoot . '/' . $broken . '/' . GitScheduledCheck::RECORD_FILE); + } + + // ----- helpers ----- + + private function check(): GitScheduledCheck + { + return new GitScheduledCheck( + $this->composeRoot, + function (string $subject, string $message, string $icon): void { + $this->notifications[] = [$subject, $message, $icon]; + }, + function (\StackInfo $stack): int { + $this->deployed[] = $stack->projectFolder; + if ($this->onDeploy !== null) { + ($this->onDeploy)(); + } + return $this->deployExitStatus; + }, + null, + function (\StackInfo $stack): bool { + if ($this->running === null) { + throw new \RuntimeException('Cannot connect to the Docker daemon.'); + } + return $this->running; + } + ); + } + + /** + * A git stack that has deployed the branch's latest commit. + */ + private function deployedStack(string $name, string $autoDeploy, string $composePath = 'whoami/compose.yaml'): string + { + $folder = $this->manager->add($name, $this->upstream, 'main', $composePath, $this->clonesRoot); + $stackDir = $this->composeRoot . '/' . $folder; + GitStackSettings::load($stackDir)->withAutoDeploy($autoDeploy)->save($stackDir); + (new GitStackState($this->upstreamHead(), null))->save($stackDir); + return $folder; + } + + private function upstreamHead(): string + { + $path = file_exists($this->upstream) ? $this->upstream : $this->upstream . '.moved'; + $result = GitCommand::run(['rev-parse', 'refs/heads/main'], $path); + return trim($result->stdout); + } + + /** + * @param array $files + */ + private function writeAndPush(array $files, string $message): void + { + foreach ($files as $path => $content) { + $full = $this->author . '/' . $path; + if (!is_dir(dirname($full))) { + mkdir(dirname($full), 0755, true); + } + file_put_contents($full, $content); + $this->git(['add', '--', $path], $this->author); + } + $this->git(['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-q', '-m', $message], $this->author); + $this->git(['push', '-q', 'origin', 'main'], $this->author); + } + + /** + * @param string[] $args + */ + private function git(array $args, ?string $dir): void + { + $result = GitCommand::run($args, $dir); + $this->assertTrue($result->succeeded(), 'git ' . implode(' ', $args) . ': ' . $result->stderr); + } +} diff --git a/tests/unit/GitStackManagerTest.php b/tests/unit/GitStackManagerTest.php index 3ab29532..53f5df93 100644 --- a/tests/unit/GitStackManagerTest.php +++ b/tests/unit/GitStackManagerTest.php @@ -1047,6 +1047,24 @@ public function testResumeClearsAFailedDeployAndKeepsTheDeployedCommit(): void $this->assertFalse($this->manager->resume($folder)); } + public function testResumeSaysAutomaticDeploysResumeOnlyWhenTheyAreOn(): void + { + $folder = $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); + $stackDir = $this->composeRoot . '/' . $folder; + + (new GitStackState(str_repeat('a', 40), str_repeat('b', 40)))->save($stackDir); + [, $output, $said] = $this->runCliCapturingOutput(['compose-git', 'resume', $folder]); + $output .= $said; + $this->assertStringContainsString('Cleared the failed deploy of bbbbbbbbbbbb.', $output); + $this->assertStringNotContainsString('resume', $output); + + GitStackSettings::load($stackDir)->withAutoDeploy(GitStackSettings::AUTO_DEPLOY_NOTIFY)->save($stackDir); + (new GitStackState(str_repeat('a', 40), str_repeat('b', 40)))->save($stackDir); + [, $output, $said] = $this->runCliCapturingOutput(['compose-git', 'resume', $folder]); + $output .= $said; + $this->assertStringContainsString("Automatic deploys of '$folder' resume.", $output); + } + public function testCommandLineStatusAsJson(): void { $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); From ef2535141019ed7aaab7ec12bac2f1100559cb37 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 21:15:19 -0400 Subject: [PATCH 06/18] feat(cron): run the scheduled git check every 15 minutes while a stack asks for it The plugin's cron file gets a third line, marked #compose-gitcheck, that runs compose-git scheduled-check every 15 minutes. It is there while any git stack's auto-deploy is not off, and every rebuild of the file (image auto-update on or off, the backup schedule, compose-git auto-deploy) works that out again, so turning image auto-update off never removes it. The check runs on its own, not inside AutoUpdateRunner: that runner stops when no image auto-update is configured, and holds its own lock. Co-Authored-By: Claude Opus 5.5 --- .../compose.manager/include/CronManager.php | 48 ++++++++++++- .../compose.manager/scripts/compose_git.php | 33 +++++++++ tests/unit/CronManagerTest.php | 67 +++++++++++++++++++ tests/unit/GitStackManagerTest.php | 27 ++++++-- 4 files changed, 167 insertions(+), 8 deletions(-) diff --git a/source/compose.manager/include/CronManager.php b/source/compose.manager/include/CronManager.php index 66c60879..a0970461 100644 --- a/source/compose.manager/include/CronManager.php +++ b/source/compose.manager/include/CronManager.php @@ -22,8 +22,8 @@ function composeCronManager(): CronManager $syncDisabledEnv = getenv('COMPOSE_MANAGER_CRON_NOSYNC'); $syncEnabled = ($syncDisabledEnv === false || $syncDisabledEnv === ''); - global $plugin_root; - return new CronManager($cronFile, $plugin_root, $syncEnabled); + global $plugin_root, $compose_root; + return new CronManager($cronFile, $plugin_root, $syncEnabled, $compose_root); } class CronManager @@ -31,23 +31,29 @@ class CronManager private const DEFAULT_CRON_FILE = '/boot/config/plugins/compose.manager/compose.manager.cron'; private const MARKER_AUTOUPDATE = '#compose-autoupdate'; private const MARKER_BACKUP = '#compose-backup'; + private const MARKER_GITCHECK = '#compose-gitcheck'; private string $cronFile; private string $pluginRoot; private bool $syncEnabled; + private ?string $composeRoot; /** * @param string|null $cronFile Override cron file path (for testing) * @param string|null $pluginRoot Override plugin root path (for testing) * @param bool $syncEnabled Whether to call update_cron after writing (disable in tests) + * @param string|null $composeRoot The projects folder, to find git stacks that are checked on a + * schedule; null leaves the git check line out */ public function __construct( ?string $cronFile = null, ?string $pluginRoot = null, - bool $syncEnabled = true + bool $syncEnabled = true, + ?string $composeRoot = null ) { $this->cronFile = $cronFile ?? self::DEFAULT_CRON_FILE; $this->pluginRoot = rtrim($pluginRoot ?? '/usr/local/emhttp/plugins/compose.manager/', '/') . '/'; $this->syncEnabled = $syncEnabled; + $this->composeRoot = $composeRoot; } /** @@ -79,6 +85,7 @@ public function rebuild(?array $overrides = null): bool $lines[] = $backupLine; composeLogger('rebuild: backup cron line included', null, 'daemon', 'debug', 'cron'); } + $this->addGitCheckLineIfWanted($lines); composeLogger('rebuild: writing ' . count($lines) . ' cron line(s)', null, 'daemon', 'debug', 'cron'); return $this->writeCronFile($lines); @@ -99,6 +106,7 @@ public function enableAutoupdate(): bool if ($backupLine !== null) { $lines[] = $backupLine; } + $this->addGitCheckLineIfWanted($lines); return $this->writeCronFile($lines); } @@ -118,6 +126,7 @@ public function disableAutoupdate(): bool if ($backupLine !== null) { $lines[] = $backupLine; } + $this->addGitCheckLineIfWanted($lines); return $this->writeCronFile($lines); } @@ -130,6 +139,14 @@ public function isAutoupdateInstalled(): bool return $this->hasMarker(self::MARKER_AUTOUPDATE); } + /** + * Check if the scheduled git check's cron line is currently installed. + */ + public function isGitCheckInstalled(): bool + { + return $this->hasMarker(self::MARKER_GITCHECK); + } + /** * Check if backup cron is currently installed. */ @@ -210,6 +227,31 @@ private function buildAutoupdateLine(): string . escapeshellarg($runner) . " >/dev/null 2>&1 " . self::MARKER_AUTOUPDATE; } + /** + * Add the scheduled git check's line (every 15 minutes) when any git stack + * asks for it: a stack whose git.json has autoDeploy set to anything but "off". + * + * @param string[] $lines + */ + private function addGitCheckLineIfWanted(array &$lines): void + { + if ($this->composeRoot === null || $this->composeRoot === '') { + return; + } + foreach (glob(rtrim($this->composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) { + // Read as plain JSON: the scheduled check itself validates each stack's settings. + $settings = json_decode((string) @file_get_contents($gitSettingsFile), true); + if (is_array($settings) && is_string($settings['autoDeploy'] ?? null) && $settings['autoDeploy'] !== 'off') { + $command = $this->pluginRoot . 'scripts/compose-git'; + // Every 15 minutes, 7 minutes off the image auto-update's quarter hours, so a git + // deploy does not wait on the stack lock of that stack's own auto-update. + $lines[] = "7-59/15 * * * * " . escapeshellarg($command) . " scheduled-check >/dev/null 2>&1 " . self::MARKER_GITCHECK; + composeLogger('git check cron line included', null, 'daemon', 'debug', 'cron'); + return; + } + } + } + /** * Build the backup cron line based on config. Returns null if backup schedule is disabled. */ diff --git a/source/compose.manager/scripts/compose_git.php b/source/compose.manager/scripts/compose_git.php index d08807d5..4afe66eb 100644 --- a/source/compose.manager/scripts/compose_git.php +++ b/source/compose.manager/scripts/compose_git.php @@ -17,6 +17,7 @@ require_once '/usr/local/emhttp/plugins/compose.manager/include/GitStackManager.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/GitScheduledCheck.php'; +require_once '/usr/local/emhttp/plugins/compose.manager/include/CronManager.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/Helpers.php'; const COMPOSE_GIT_EXIT_OK = 0; @@ -274,6 +275,33 @@ function compose_git_command_help(string $command): ?string --all Show every git stack. This is what happens when no stack is named, too. A stack whose git settings cannot be read is shown with the reason. --json Print the same as JSON, for scripts. +TEXT, + 'auto-deploy' => <<<'TEXT' +Usage: compose-git auto-deploy off|notify|deploy + +Choose what the scheduled check (every 15 minutes) does when a new commit on the stack's +branch changes the stack, that is, anything in the stack's folder in the repository: + off nothing is checked (the default) + notify send an Unraid notification, once per commit + deploy deploy the commit, as 'compose-git deploy' does with no options +Each commit is tried once. A stack that is not running (stopped, or never deployed) is not +deployed until it runs again, and one taken back to an older commit is brought forward to +the newest. The plugin's cron line is added while any stack's setting is not off. +TEXT, + 'resume' => <<<'TEXT' +Usage: compose-git resume + +Clear a stack's failed deploy, so the scheduled check deploys it again. After a deploy fails +during up, automatic deploys of the stack are paused until a deploy works; after a scheduled +deploy fails before up (an image pull, say), that commit is not tried again. Use this once +you have fixed the cause another way. The deployed commit and the containers are not changed. +TEXT, + 'scheduled-check' => <<<'TEXT' +Usage: compose-git scheduled-check + +Run the scheduled check now, for every git stack whose auto-deploy setting is not off. Cron +runs it every 15 minutes while any stack asks for it. Does nothing while the array is +stopped, or while another check is still running. TEXT, default => null, }; @@ -587,6 +615,11 @@ function compose_git_main(array $argv, string $composeRoot, $errors = null): int throw new ComposeGitUsageError('auto-deploy needs a stack and one of: ' . implode(', ', GitStackSettings::AUTO_DEPLOY_CHOICES) . '.'); } $manager->setAutoDeploy($positional[0], $positional[1]); + // Installs the scheduled check's cron line while any stack asks for it, and removes it after. + if (!composeCronManager()->rebuild()) { + fwrite($errors, "The setting was saved, but the plugin's cron file could not be updated.\n"); + return COMPOSE_GIT_EXIT_FAILED; + } return COMPOSE_GIT_EXIT_OK; case 'resume': diff --git a/tests/unit/CronManagerTest.php b/tests/unit/CronManagerTest.php index 165de27a..4341a1d0 100644 --- a/tests/unit/CronManagerTest.php +++ b/tests/unit/CronManagerTest.php @@ -264,6 +264,73 @@ public function testRebuildWithOverrides(): void // Factory function // --------------------------------------------------------------- + // --------------------------------------------------------------- + // Scheduled git check + // --------------------------------------------------------------- + + /** + * A projects folder with one git stack whose autoDeploy is $autoDeploy (null: not set). + */ + private function projectsWithGitStack(?string $autoDeploy): string + { + $root = sys_get_temp_dir() . '/cron_manager_projects_' . getmypid(); + @mkdir($root . '/whoami', 0755, true); + $settings = ['version' => 1, 'url' => 'https://example.com/r.git']; + if ($autoDeploy !== null) { + $settings['autoDeploy'] = $autoDeploy; + } + file_put_contents($root . '/whoami/git.json', (string) json_encode($settings)); + return $root; + } + + private function removeProjects(string $root): void + { + @unlink($root . '/whoami/git.json'); + @rmdir($root . '/whoami'); + @rmdir($root); + } + + public function testGitCheckLineIsAddedWhileAGitStackAsksForIt(): void + { + $root = $this->projectsWithGitStack('notify'); + try { + $cron = new CronManager($this->cronFile, '/usr/local/emhttp/plugins/compose.manager/', false, $root); + + $this->assertTrue($cron->rebuild()); + $content = (string) file_get_contents($this->cronFile); + $this->assertStringContainsString("7-59/15 * * * * '/usr/local/emhttp/plugins/compose.manager/scripts/compose-git' scheduled-check", $content); + $this->assertStringContainsString('#compose-gitcheck', $content); + $this->assertTrue($cron->isGitCheckInstalled()); + + // Turning image auto-update on and off keeps it. + $cron->enableAutoupdate(); + $this->assertTrue($cron->isGitCheckInstalled()); + $this->assertTrue($cron->isAutoupdateInstalled()); + $cron->disableAutoupdate(); + $this->assertTrue($cron->isGitCheckInstalled()); + $this->assertFalse($cron->isAutoupdateInstalled()); + } finally { + $this->removeProjects($root); + } + } + + public function testNoGitCheckLineWhenNoGitStackAsksForIt(): void + { + foreach (['off', null] as $autoDeploy) { + $root = $this->projectsWithGitStack($autoDeploy); + try { + $cron = new CronManager($this->cronFile, '/usr/local/emhttp/plugins/compose.manager/', false, $root); + $cron->rebuild(); + $this->assertFalse($cron->isGitCheckInstalled()); + } finally { + $this->removeProjects($root); + } + } + // Without a projects folder there is nothing to look at. + $this->cron->rebuild(); + $this->assertFalse($this->cron->isGitCheckInstalled()); + } + public function testFactoryFunctionRespectsEnvVars(): void { putenv('COMPOSE_MANAGER_CRON_FILE=' . $this->cronFile); diff --git a/tests/unit/GitStackManagerTest.php b/tests/unit/GitStackManagerTest.php index 53f5df93..7d0b5696 100644 --- a/tests/unit/GitStackManagerTest.php +++ b/tests/unit/GitStackManagerTest.php @@ -1019,17 +1019,31 @@ public function testCommandLineAutoDeployChangesTheStacksSetting(): void { $folder = $this->manager->add('whoami', $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); $stackDir = $this->composeRoot . '/' . $folder; - - $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'deploy'])); + $cronFile = sys_get_temp_dir() . '/compose_git_manager_test.cron'; + @unlink($cronFile); + putenv('COMPOSE_MANAGER_CRON_FILE=' . $cronFile); + putenv('COMPOSE_MANAGER_CRON_NOSYNC=1'); + global $compose_root; + $savedComposeRoot = $compose_root; + $compose_root = $this->composeRoot; + try { + $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'deploy'])); + $this->assertStringContainsString('scheduled-check', (string) @file_get_contents($cronFile)); + $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'off'])); + $this->assertFileDoesNotExist($cronFile); + $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'deploy'])); + } finally { + putenv('COMPOSE_MANAGER_CRON_FILE'); + putenv('COMPOSE_MANAGER_CRON_NOSYNC'); + $compose_root = $savedComposeRoot; + @unlink($cronFile); + } $this->assertSame(GitStackSettings::AUTO_DEPLOY_DEPLOY, GitStackSettings::load($stackDir)->autoDeploy); $this->assertSame('deploy', $this->manager->status($folder)['autoDeploy']); $this->assertSame(1, $this->runCli(['compose-git', 'auto-deploy', $folder, 'always'])); $this->assertSame(2, $this->runCli(['compose-git', 'auto-deploy', $folder])); $this->assertSame(GitStackSettings::AUTO_DEPLOY_DEPLOY, GitStackSettings::load($stackDir)->autoDeploy); - - $this->assertSame(0, $this->runCli(['compose-git', 'auto-deploy', $folder, 'off'])); - $this->assertSame(GitStackSettings::AUTO_DEPLOY_OFF, GitStackSettings::load($stackDir)->autoDeploy); } public function testResumeClearsAFailedDeployAndKeepsTheDeployedCommit(): void @@ -1115,6 +1129,9 @@ public static function commandsAndTheirOptions(): array 'deploy' => ['deploy', ['--commit', '--save-local-changes', '--wait', '--no-wait', '--wait-timeout', '--profile']], 'reclone' => ['reclone', []], 'status' => ['status', ['--all', '--json']], + 'auto-deploy' => ['auto-deploy', ['off', 'notify', 'deploy']], + 'resume' => ['resume', []], + 'scheduled-check' => ['scheduled-check', []], ]; } From 4977e10995b968f575477bdace44f38e6b25551b Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 21:18:10 -0400 Subject: [PATCH 07/18] feat(git): choose automatic deploys in the web UI, and see when a stack is behind - The Sources tab of a git stack gets "Automatic deploys": Off, notify me when a new commit changes the stack, or deploy it. It is saved at once, through the same code as compose-git auto-deploy, cron line included. - After a failed deploy, the stack menu offers "Resume Automatic Deploys", as compose-git resume does. - The stack list shows "behind" next to a git stack's branch and commit when the scheduled check last saw a newer commit that changes it. Co-Authored-By: Claude Opus 5.5 --- .../compose.manager/include/ComposeList.php | 15 +++- .../include/ComposeManager.php | 11 +++ source/compose.manager/include/Exec.php | 16 ++++ .../include/GitStackWebActions.php | 60 ++++++++++++- .../javascript/composeManagerMain.js | 68 +++++++++++++++ tests/unit/GitStackWebActionsTest.php | 84 ++++++++++++++++++- 6 files changed, 248 insertions(+), 6 deletions(-) diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index 1c5c3417..bee4cf04 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -351,8 +351,11 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str // Check if stack has build configurations (needs rebuild on update) $hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0'; + // A git stack's branch, deployed commit and state, read from its own files + $git = $stackInfo->isGitStack() ? GitStackWebActions::listSummary($stackInfo->path) : null; + // Main row - Docker tab structure with expand arrow on left - $o .= ""; + $o .= ""; // Arrow column $o .= ""; @@ -393,8 +396,7 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str $o .= "Project: $projectHtml"; $o .= ""; // A git stack's branch and deployed commit - if ($stackInfo->isGitStack()) { - $git = GitStackWebActions::listSummary($stackInfo->path); + if ($git !== null) { $o .= "
"; if ($git['problem'] !== null) { $o .= "git settings cannot be read"; @@ -405,9 +407,16 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str : "" . htmlspecialchars(substr($git['deployedCommit'], 0, 7), ENT_QUOTES, 'UTF-8') . ''; $o .= ''; + if ($git['behind']) { + $o .= " behind"; + } if ($git['failedCommit'] !== null) { $failedTitle = 'The deploy of commit ' . substr($git['failedCommit'], 0, 12) . ' failed. Fix it and deploy again.'; $o .= " failed"; + } elseif ($git['failedAutomaticCommit'] !== null) { + $failedTitle = 'The automatic deploy of commit ' . substr($git['failedAutomaticCommit'], 0, 12) + . ' failed before any container was started. Fix it and deploy again, or resume automatic deploys to try again.'; + $o .= " failed"; } } $o .= "
"; diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index e937cae2..0e103288 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -640,9 +640,20 @@ function compose_manager_cpu_spec_count($cpuSpec) Credential Deployed commit Failed commit + Failed automatic deploy Checked out +
+ + + +
Checked every 15 minutes. Saved as soon as it is changed. A failed deploy pauses automatic deploys until a deploy works, or Resume Automatic Deploys in the stack menu.
+