From c848a79f30d49c328237b0093b1cf6f22a84161f Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Thu, 8 Oct 2026 21:22:17 -0400 Subject: [PATCH 01/23] fix(credentials): stop calling every credential a registry credential The Credentials tab now also holds git repository tokens and git stacks' deploy keys, but its heading, its empty-table text and the save and delete log lines still said "registry credential". Deleting a deploy key logged "Deleted registry credential", which is the line someone would search the syslog for. Say "credential" in those four places. The save log line already names the provider. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/compose.manager.settings.page | 2 +- source/compose.manager/include/Exec.php | 4 ++-- source/compose.manager/javascript/credentialManager.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index f2033cf1..0f1a3c40 100755 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -2622,7 +2622,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js'
-
_(Registry Credentials)_
+
_(Credentials)_

Store pull credentials locally and assign them to individual stacks.

diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 98e05df0..789de281 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1045,7 +1045,7 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 'username' => trim((string) ($_POST['username'] ?? '')), 'secret' => trim((string) ($_POST['secret'] ?? '')), ]); - composeLogger('Saved registry credential', ['id' => $credential['id'], 'provider' => $credential['provider'], 'registry' => $credential['registry']], 'user', 'info', 'credentials'); + composeLogger('Saved credential', ['id' => $credential['id'], 'provider' => $credential['provider'], 'registry' => $credential['registry']], 'user', 'info', 'credentials'); echo json_encode(['result' => 'success', 'credential' => $credential]); } catch (\InvalidArgumentException $error) { echo json_encode(['result' => 'error', 'message' => $error->getMessage()]); @@ -1065,7 +1065,7 @@ function composeResolveContainerIcon(string $containerName, string $service, arr return (new CredentialVault())->deleteCredential($credentialId); }); if ($deleted) { - composeLogger('Deleted registry credential', ['id' => $credentialId], 'user', 'info', 'credential'); + composeLogger('Deleted credential', ['id' => $credentialId], 'user', 'info', 'credential'); } echo json_encode(['result' => $deleted ? 'success' : 'error', 'message' => $deleted ? '' : 'Credential not found.']); } catch (\Throwable $error) { diff --git a/source/compose.manager/javascript/credentialManager.js b/source/compose.manager/javascript/credentialManager.js index 4254a328..6e3a3d74 100644 --- a/source/compose.manager/javascript/credentialManager.js +++ b/source/compose.manager/javascript/credentialManager.js @@ -460,7 +460,7 @@ if (!$body.length) return; $body.empty(); if (!credentials.length) { - $body.append(''); + $body.append(''); return; } credentials.forEach(function(credential) { From f2b565382c145a8454089510b00f9f13062090c2 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Thu, 8 Oct 2026 21:22:30 -0400 Subject: [PATCH 02/23] test(credentials): name the useCredential test after what it checks The test's name said the secret goes to the callback only, but it checks the opposite: whatever the callback returns, the secret included, goes back to the caller, as useCredential's comment says. Co-Authored-By: Claude Opus 5.5 --- tests/unit/CredentialVaultTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/CredentialVaultTest.php b/tests/unit/CredentialVaultTest.php index a27c1036..75dfae60 100644 --- a/tests/unit/CredentialVaultTest.php +++ b/tests/unit/CredentialVaultTest.php @@ -287,7 +287,7 @@ public function testDeployKeyIsNeverWrittenOutAsADockerLogin(): void $vault->materializeDockerConfig($saved['id']); } - public function testUseCredentialHandsTheSecretToTheCallbackOnly(): void + public function testUseCredentialReturnsWhatTheCallbackReturns(): void { $vault = new CredentialVault(); $saved = $vault->saveCredential([ From d00c99901dc9248140764009d54a0648171dedef Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Thu, 8 Oct 2026 21:23:07 -0400 Subject: [PATCH 03/23] fix(git): let the Credentials tab's git Test wait 15 seconds, not 60 Testing a git credential asked the repository with the 60-second limit of a check or deploy, so a host that does not answer held the browser's request for a minute. The registry test waits at most 15 seconds; the git test now does too. The failure message also said "Could not reach" twice ("Could not reach (used by 'x'): Could not reach the repository: ..."). It now names the repository and the stack once, then gives the reason. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/GitClone.php | 5 +++-- source/compose.manager/include/GitStackManager.php | 10 ++++++++-- tests/unit/GitStackManagerTest.php | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/source/compose.manager/include/GitClone.php b/source/compose.manager/include/GitClone.php index ab409b1c..8df03099 100644 --- a/source/compose.manager/include/GitClone.php +++ b/source/compose.manager/include/GitClone.php @@ -65,15 +65,16 @@ public function exists(): bool /** * Ask the remote which commit the branch points at, without changing anything. * + * @param int $timeoutSeconds How long to wait for the remote * @throws RuntimeException if the remote cannot be reached or the branch does not exist */ - public function remoteBranchCommit(): string + public function remoteBranchCommit(int $timeoutSeconds = self::REMOTE_TIMEOUT_SECONDS): string { $ref = 'refs/heads/' . $this->settings->branch; $result = $this->git( ['ls-remote', '--heads', '--', $this->settings->url, $ref], null, - self::REMOTE_TIMEOUT_SECONDS + $timeoutSeconds ); if (!$result->succeeded()) { throw new RuntimeException('Could not reach the repository: ' . $result->errorSummary()); diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index f8f0493e..a00dc211 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -22,6 +22,12 @@ final class GitStackManager { public const DEFAULT_BRANCH = 'main'; + /** + * How long the Credentials tab's Test waits for a repository: the browser waits for the + * answer, and the registry test waits at most 15 seconds too. + */ + private const CREDENTIAL_TEST_TIMEOUT_SECONDS = 15; + /** @var callable(string): void */ private $say; @@ -375,10 +381,10 @@ public function testCredential(string $credentialId): ?array $summary = (new CredentialVault())->getCredentialSummary($credentialId); $result = ['id' => $credentialId, 'name' => $summary['name'], 'registry' => $summary['registry']]; try { - (new GitClone($settings))->remoteBranchCommit(); + (new GitClone($settings))->remoteBranchCommit(self::CREDENTIAL_TEST_TIMEOUT_SECONDS); return $result + ['valid' => true, 'message' => "Reached {$settings->url} (used by '$folder')."]; } catch (Throwable $error) { - return $result + ['valid' => false, 'message' => "Could not reach {$settings->url} (used by '$folder'): " . $error->getMessage()]; + return $result + ['valid' => false, 'message' => "{$settings->url} (used by '$folder'): " . $error->getMessage()]; } } return null; diff --git a/tests/unit/GitStackManagerTest.php b/tests/unit/GitStackManagerTest.php index ef985763..d0e83f38 100644 --- a/tests/unit/GitStackManagerTest.php +++ b/tests/unit/GitStackManagerTest.php @@ -436,7 +436,7 @@ public function testCredentialTestReachesTheRepositoryOfAStackThatUsesIt(): void $this->assertNotNull($test); $this->assertFalse($test['valid']); - $this->assertStringContainsString("Could not reach https://127.0.0.1:9/team/stacks.git (used by '$folder')", $test['message']); + $this->assertStringStartsWith("https://127.0.0.1:9/team/stacks.git (used by '$folder'): Could not reach the repository", $test['message']); } public function testOnlyAnSshStackHasADeployKeyOrPinnedHostKeys(): void From 1e3586ef1350a78a1e8d2808269d3ed06357be1c Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Thu, 8 Oct 2026 21:23:43 -0400 Subject: [PATCH 04/23] fix(git): say what to do when a reused deploy key is another repository's An ssh stack's deploy key is found again by the stack's name and host, so that a second attempt at adding the stack uses the key the user has just added to the repository. A key left behind by a deleted stack of the same name matches too, even when the new stack is for another repository, and GitHub refuses a deploy key that another repository already has. The vault does not record the repository, so rather than add a field to it, the message now names the reused key and says to delete it first when it belongs to another repository. The docs say the same next to the deploy key steps. Co-Authored-By: Claude Opus 5.5 --- docs/git-stacks.md | 4 ++++ source/compose.manager/include/GitStackManager.php | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/git-stacks.md b/docs/git-stacks.md index 11514aa7..2d4cb8cc 100644 --- a/docs/git-stacks.md +++ b/docs/git-stacks.md @@ -254,6 +254,10 @@ Deploy keys), then run the same command again. `compose-git deploy-key myapp` sh Until then the key is listed on the Credentials tab as used by no stack: leave it there, or the second run makes a new key, and the one you added to the repository no longer works. +Deleting a stack leaves its key on the Credentials tab. Delete the key there too before you add a new stack of +the same name for another repository on the same host: otherwise the new stack reuses the old key, and GitHub +refuses a deploy key that another repository already has. + When the stack is added, the server's ssh host keys are pinned and their fingerprints printed. Compare them with the ones your git host publishes (GitHub, GitLab and Codeberg list theirs). Every later connection must match them. If the server is rebuilt and its keys change, deploys stop until you run `compose-git trust-host myapp`, diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index a00dc211..df13a37a 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -473,6 +473,10 @@ private function newSettings( /** * The stack's deploy key: the one made for it by an earlier attempt, or a new one. * + * The key is found by the stack's name and host. The vault does not record the repository, + * so a key left behind by a deleted stack of the same name is reused too; the message says + * what to do when that key belongs to another repository. + * * @param array{user: string, host: string, port: int, path: string} $address */ private function deployKeyFor(string $folder, array $address): string @@ -481,7 +485,11 @@ private function deployKeyFor(string $folder, array $address): string $host = $address['host'] . ($address['port'] === 22 ? '' : ':' . $address['port']); foreach ((new CredentialVault())->listCredentials() as $credential) { if ($credential['name'] === $name && $credential['provider'] === 'git-ssh' && $credential['registry'] === $host) { - ($this->say)("Using the deploy key made for '$folder' earlier."); + ($this->say)( + "Using the deploy key made for '$folder' earlier ('$name' on the Credentials tab). If it was " + . "made for another repository, by a stack of the same name that has since been deleted, " + . "delete '$name' first: GitHub, for one, refuses a deploy key that another repository has." + ); return $credential['id']; } } From 11f9e2a35a99a265d7d58c1a0cb2314a6f9e4c36 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Thu, 8 Oct 2026 21:28:21 -0400 Subject: [PATCH 05/23] fix(git): say why ssh-keyscan got no host keys When a host gave no keys, the message asked "Is the address right?" whatever the reason, though a DNS failure or an unreachable host on this server looks the same to the user. It now gives ssh-keyscan's own error when it prints one (a name that does not resolve, for one; a refused or unreachable port prints nothing), or says that no key came back, and asks to check both the address and that this server can reach the host. The first test that runs ssh-keyscan checks the message against a port nothing listens on. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/GitSsh.php | 11 ++++++++++- tests/unit/GitSshTest.php | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/include/GitSsh.php b/source/compose.manager/include/GitSsh.php index bd936865..6b21d1cd 100644 --- a/source/compose.manager/include/GitSsh.php +++ b/source/compose.manager/include/GitSsh.php @@ -86,7 +86,16 @@ public static function scanHostKeys(string $host, int $port): string } sort($lines); if ($lines === []) { - throw new RuntimeException("Could not get the ssh host keys of $host (port $port). Is the address right?"); + // ssh-keyscan reports the server's banner as "#" lines on stderr; any other line is the error. + $errors = array_values(array_filter( + array_map('trim', explode("\n", $result->stderr)), + static fn(string $line): bool => $line !== '' && !str_starts_with($line, '#') + )); + $reason = $errors === [] ? 'no key came back' : $errors[count($errors) - 1]; + throw new RuntimeException( + "Could not get the ssh host keys of $host (port $port): $reason. " + . 'Check the address, and that this server can reach the host.' + ); } return implode("\n", $lines) . "\n"; } diff --git a/tests/unit/GitSshTest.php b/tests/unit/GitSshTest.php index 77c038ec..9a8d5d69 100644 --- a/tests/unit/GitSshTest.php +++ b/tests/unit/GitSshTest.php @@ -62,6 +62,18 @@ public function testHttpsTokenIsNotUsedAsADeployKey(): void GitSsh::publicKey($id); } + public function testHostThatGivesNoKeysIsRefusedWithAReason(): void + { + if (trim((string) shell_exec('command -v ssh-keyscan')) === '') { + $this->markTestSkipped('ssh-keyscan is not installed.'); + } + + // Nothing listens on port 9. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/^Could not get the ssh host keys of 127\.0\.0\.1 \(port 9\): .+\. Check the address/'); + GitSsh::scanHostKeys('127.0.0.1', 9); + } + public function testFingerprintsOfPinnedKeys(): void { $key = GitSsh::generateKey('host key'); From 7b97d32ac3f288cf4330dd432f96035085eb6bdf Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Fri, 9 Oct 2026 12:20:29 -0400 Subject: [PATCH 06/23] fix(git): deploy in the checks' folder and environment, whatever starts it compose-git deploy starts compose.sh with the checks' environment, in the compose file's folder, so up deploys what the checks saw. The web UI's deploy starts compose.sh too, from the web server's folder and environment, so ${PWD} in a compose file (or a variable that environment happens to carry, such as DOCKER_HOST) could still differ between the checks and up. compose.sh gitdeploy now does it itself, whatever starts it: it runs docker in the compose file's folder (git_stack.php compose-folder, the folder the checks use), with PATH, HOME, LC_ALL=C and PWD set to that folder, plus DOCKER_CONFIG (the registry credential) and TERM when set. That covers every docker call of the deploy: docker compose, the docker network calls of the missing-network step (create_missing_external_networks in common.sh takes the docker command to use) and the image cleanup. Nothing else of compose.sh's environment reaches docker. PATH is compose.sh's own, which is the fixed one for compose-git and the system's for the web UI. The Bats docker stand-in now reads its settings from a file, since the environment no longer reaches it, and records the environment of up, network create and rmi. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/GitDeploy.php | 14 +++++ source/compose.manager/scripts/common.sh | 7 ++- source/compose.manager/scripts/compose.sh | 31 +++++++++- source/compose.manager/scripts/git_stack.php | 4 ++ tests/unit/GitDeployTest.php | 17 ++++++ tests/unit/gitdeploy.bats | 64 +++++++++++++++++++- 6 files changed, 132 insertions(+), 5 deletions(-) diff --git a/source/compose.manager/include/GitDeploy.php b/source/compose.manager/include/GitDeploy.php index 6a9c352f..acfd8ca7 100644 --- a/source/compose.manager/include/GitDeploy.php +++ b/source/compose.manager/include/GitDeploy.php @@ -206,6 +206,20 @@ public function composeArgs(): array return $args; } + /** + * The folder docker compose runs in for the deploy: the compose file's + * folder, where the checks ran it too. + */ + public function composeFolder(): string + { + $settings = $this->loadSettings(); + $composeFile = realpath($settings->composeFileInClone()); + if ($composeFile === false) { + throw new RuntimeException("The compose file {$settings->composeFileInClone()} was not found."); + } + return dirname($composeFile); + } + /** * After a failed check or checkout, check the previous commit out again. * Returns the error to throw: the original problem, or, if putting the diff --git a/source/compose.manager/scripts/common.sh b/source/compose.manager/scripts/common.sh index f89efd54..24bbe080 100644 --- a/source/compose.manager/scripts/common.sh +++ b/source/compose.manager/scripts/common.sh @@ -136,6 +136,9 @@ foreach ($config["networks"] ?? [] as $key => $network) { # create_missing_external_networks docker compose -f compose.yaml -p mystack # A config that does not parse is skipped here; the compose command that follows reports it. # Returns 1 if any network could not be created (after trying them all, so each is named). +# The docker network calls run through network_docker_command, plain docker unless the +# caller sets it (compose.sh gitdeploy runs docker with its checks' environment). +network_docker_command=(docker) create_missing_external_networks() { local config_json network failed=0 if ! config_json=$("$@" config --format json 2>/dev/null); then @@ -143,10 +146,10 @@ create_missing_external_networks() { fi while IFS= read -r network; do [ -n "$network" ] || continue - if docker network inspect "$network" > /dev/null 2>&1; then + if "${network_docker_command[@]}" network inspect "$network" > /dev/null 2>&1; then continue fi - if docker network create "$network" > /dev/null; then + if "${network_docker_command[@]}" network create "$network" > /dev/null; then echo "✓ Created missing external network: $network" composeLogger "Created missing external network: $network" info compose else diff --git a/source/compose.manager/scripts/compose.sh b/source/compose.manager/scripts/compose.sh index 05090688..1f3324fe 100755 --- a/source/compose.manager/scripts/compose.sh +++ b/source/compose.manager/scripts/compose.sh @@ -570,7 +570,34 @@ case $command in fi mapfile -d '' -t git_compose_args < "$git_args_file" rm -f "$git_args_file" - git_compose=(docker compose "${git_compose_args[@]}" "${profile_args[@]}") + + # docker runs in the compose file's folder, with the environment the + # checks gave it, whatever started this deploy (compose-git, the web UI + # or anything else). Otherwise ${PWD} in a compose file, or a variable in + # the environment of whatever started compose.sh (which wins over the + # stack's .env, and DOCKER_HOST could point at another daemon), could make + # the deploy do something other than what was checked. + if ! git_compose_folder=$(git_stack compose-folder "$stack_path") || ! cd "$git_compose_folder"; then + save_result "failed" 1 "gitdeploy" + log_msg "ERROR" "Could not find the compose file's folder of git stack $name" + echo "" + echo "✗ Stack $name was not deployed." + put_back_previous_commit + exit 1 + fi + git_environment=(env -i "PATH=$PATH" "HOME=$HOME" "LC_ALL=C" "PWD=$git_compose_folder") + # DOCKER_CONFIG holds the registry credential chosen above; TERM only + # changes how Compose draws its progress. + for variable in DOCKER_CONFIG TERM; do + if [ -n "${!variable:-}" ]; then + git_environment+=("$variable=${!variable}") + fi + done + git_docker=("${git_environment[@]}" docker) + git_compose=("${git_docker[@]}" compose "${git_compose_args[@]}" "${profile_args[@]}") + # create_missing_external_networks (common.sh) runs its docker network calls with it too. + # shellcheck disable=SC2034 # read in common.sh + network_docker_command=("${git_docker[@]}") # A deploy always builds services that have a build section (the new # commit may change their Dockerfile), as a step of its own before up. @@ -693,7 +720,7 @@ case $command in echo "" echo "Cleaning up old images..." # Plain docker rmi leaves alone any image another container still uses. - docker rmi "${superseded_images[@]}" 2>/dev/null || true + "${git_docker[@]}" rmi "${superseded_images[@]}" 2>/dev/null || true fi if [ -n "$stack_path" ] && [ -d "$stack_path" ]; then diff --git a/source/compose.manager/scripts/git_stack.php b/source/compose.manager/scripts/git_stack.php index 104d2c7b..3c1377f1 100644 --- a/source/compose.manager/scripts/git_stack.php +++ b/source/compose.manager/scripts/git_stack.php @@ -66,6 +66,10 @@ } break; + case 'compose-folder': + echo $deploy->composeFolder() . "\n"; + break; + case 'up-arguments': foreach ($deploy->upArguments($args[2] ?? '') as $arg) { echo $arg . "\n"; diff --git a/tests/unit/GitDeployTest.php b/tests/unit/GitDeployTest.php index a07b6a36..001e6945 100644 --- a/tests/unit/GitDeployTest.php +++ b/tests/unit/GitDeployTest.php @@ -432,6 +432,23 @@ public function testComposeArgsUseTheCloneAndTheStackFoldersEnv(): void ); } + public function testComposeFolderIsTheFolderTheChecksRunIn(): void + { + $this->assertSame( + dirname((string) realpath($this->clone->settings()->composeFileInClone())), + $this->deploy()->composeFolder() + ); + } + + public function testComposeFolderRefusesAMissingComposeFile(): void + { + unlink($this->clone->settings()->composeFileInClone()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('was not found'); + $this->deploy()->composeFolder(); + } + public function testComposeFileReachedThroughASymlinkOutsideTheCloneIsRefused(): void { $outside = $this->mnt . '/user/appdata/outside.yaml'; diff --git a/tests/unit/gitdeploy.bats b/tests/unit/gitdeploy.bats index c292162a..3491cb74 100644 --- a/tests/unit/gitdeploy.bats +++ b/tests/unit/gitdeploy.bats @@ -22,7 +22,9 @@ test_setup() { : > "$CALLS" echo 0 > "$IMAGES_CALLS" export PREPARE_EXIT=0 PULL_EXIT=0 BUILD_EXIT=0 UP_EXIT=0 UP_ARGUMENTS_EXIT=0 UP_ARGUMENTS="" - export COMPOSE_ARGS_EXIT=0 RESTORE_EXIT=0 + export COMPOSE_ARGS_EXIT=0 RESTORE_EXIT=0 COMPOSE_FOLDER_EXIT=0 + export COMPOSE_FOLDER="$TEST_TEMP_DIR/clone/whoami" + mkdir -p "$COMPOSE_FOLDER" export PREPARE_OUTPUT="0000000000000000000000000000000000000001" # The plugin's settings: none, so Create Missing External Networks is off unless a test turns it on. export COMPOSE_MANAGER_CFG_FILE="$TEST_TEMP_DIR/compose.manager.cfg" @@ -41,6 +43,12 @@ if [ "$1" = "-d" ]; then shift 2 fi case "$1" in + */credential_config.php) + # The registry credential's DOCKER_CONFIG folder and its label. + echo "$CREDENTIAL_DOCKER_CONFIG" + echo "registry login" + exit 0 + ;; */git_stack.php) shift echo "git_stack $*" >> "$CALLS" @@ -53,6 +61,10 @@ case "$1" in [ "$COMPOSE_ARGS_EXIT" = 0 ] || { echo "✗ no compose file" >&2; exit "$COMPOSE_ARGS_EXIT"; } printf -- '-f\0/clone/whoami/compose.yaml\0--env-file\0/stack/.env\0' ;; + compose-folder) + [ "$COMPOSE_FOLDER_EXIT" = 0 ] || { echo "✗ no compose file" >&2; exit "$COMPOSE_FOLDER_EXIT"; } + echo "$COMPOSE_FOLDER" + ;; restore) exit "$RESTORE_EXIT" ;; @@ -73,6 +85,18 @@ SHIM # docker: record the call; images -q answers "before" then "after". cat > "$TEST_TEMP_DIR/bin/docker" <<'SHIM' #!/bin/bash +# compose.sh runs docker compose with only the checks' environment, so the +# settings above reach this script through a file (see run_gitdeploy). +here="$(dirname "$0")" +for arg in "$@"; do + case "$arg" in + up|rmi|create) + env > "$here/$arg-environment" + pwd > "$here/$arg-folder" + ;; + esac +done +. "$here/test-settings.sh" echo "docker $*" >> "$CALLS" for arg in "$@"; do case "$arg" in @@ -95,6 +119,8 @@ SHIM } run_gitdeploy() { + declare -p CALLS IMAGES_CALLS PULL_EXIT BUILD_EXIT UP_EXIT CONFIG_JSON NETWORK_INSPECT_EXIT NETWORK_CREATE_EXIT \ + > "$TEST_TEMP_DIR/bin/test-settings.sh" run bash "$COMPOSE_SCRIPT" -c gitdeploy -p whoami -s "$STACK" "$@" } @@ -177,6 +203,42 @@ calls_matching() { grep -q "git_stack restore $STACK 0000000000000000000000000000000000000001" "$CALLS" } +@test "gitdeploy stops before pulling when the compose file's folder cannot be found" { + export COMPOSE_FOLDER_EXIT=1 + run_gitdeploy + [ "$status" -eq 1 ] + [ "$(calls_matching 'docker')" -eq 0 ] + grep -q "git_stack restore $STACK 0000000000000000000000000000000000000001" "$CALLS" +} + +@test "gitdeploy runs docker in the compose file's folder with only the checks' environment" { + # As exported in the shell, web server or cron job that started the deploy. + export PORT=8080 DOCKER_HOST=tcp://elsewhere:2375 COMPOSE_PROFILES=debug TERM=xterm + # A missing network to create, so docker network create runs too; img-old is removed after up. + echo 'CREATE_MISSING_EXTERNAL_NETWORKS="true"' > "$COMPOSE_MANAGER_CFG_FILE" + export CONFIG_JSON='{"networks":{"proxy":{"name":"zz-proxy","external":true}}}' NETWORK_INSPECT_EXIT=1 + run_gitdeploy + [ "$status" -eq 0 ] + grep -q "docker network create zz-proxy$" "$CALLS" + grep -q "docker rmi img-old$" "$CALLS" + + for step in up create rmi; do + [ "$(cat "$TEST_TEMP_DIR/bin/$step-folder")" = "$COMPOSE_FOLDER" ] + grep -qx "PWD=$COMPOSE_FOLDER" "$TEST_TEMP_DIR/bin/$step-environment" + grep -qx "LC_ALL=C" "$TEST_TEMP_DIR/bin/$step-environment" + grep -qx "TERM=xterm" "$TEST_TEMP_DIR/bin/$step-environment" + run grep -E '^(PORT|DOCKER_HOST|COMPOSE_PROFILES)=' "$TEST_TEMP_DIR/bin/$step-environment" + [ "$status" -eq 1 ] + done +} + +@test "gitdeploy passes the registry credential's DOCKER_CONFIG on to docker" { + export CREDENTIAL_DOCKER_CONFIG="$TEST_TEMP_DIR/docker-config" + run_gitdeploy --credential-id registry-1 + [ "$status" -eq 0 ] + grep -qx "DOCKER_CONFIG=$CREDENTIAL_DOCKER_CONFIG" "$TEST_TEMP_DIR/bin/up-environment" +} + @test "gitdeploy records a failed up and does not roll back or remove images" { export UP_EXIT=3 run_gitdeploy From 193d3316ea61cbecaba2f7661be044e0919096d1 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 20:16:47 -0400 Subject: [PATCH 07/23] feat(git): show a git stack's repository on the editor's Sources tab For a git stack the Sources tab shows the repository, branch, compose file, clone, deployed and checked-out commits, any failed commit, files changed in the clone, and an ssh stack's deploy key, in place of the Compose Source choice. Saving the editor's settings no longer touches a git stack's indirect files: its compose file is the one in its clone, set by git.json, and a form that sent no compose source would have turned it back into a project-folder stack. Co-Authored-By: Claude Opus 5.5 --- .../include/ComposeManager.php | 28 +- source/compose.manager/include/Exec.php | 23 +- .../include/GitStackWebActions.php | 53 ++++ .../javascript/composeManagerMain.js | 80 ++++++ source/compose.manager/sheets/EditorModal.css | 22 ++ tests/unit/GitStackWebActionsTest.php | 248 ++++++++++++++++++ 6 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 source/compose.manager/include/GitStackWebActions.php create mode 100644 tests/unit/GitStackWebActionsTest.php diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index ae4f3f7a..eec3b01f 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -621,7 +621,33 @@ function compose_manager_cpu_spec_count($cpuSpec)
Compose Sources & Files
-
+ +
No registry credentials saved.
No credentials saved.
+ + + + + + + + + + + + + + +
+ +
Where does this stack's compose file live?
diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 789de281..a72679bf 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -5,6 +5,7 @@ require_once("/usr/local/emhttp/plugins/compose.manager/include/ColumnLayout.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/CredentialVault.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/GitHubDeviceAuth.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/GitStackWebActions.php"); require_once("/usr/local/emhttp/plugins/dynamix/include/Wrappers.php"); require_once('/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php'); @@ -1256,8 +1257,17 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 'projectPath' => "$compose_root/$script", 'projectOverridePath' => $stackInfo->overrideInfo->getProjectOverridePath(), 'effectiveOverridePath' => $stackInfo->getPreferredOverridePath(), + 'isGitStack' => $stackInfo->isGitStack(), ]); break; + case 'getGitStackStatus': + $script = getPostScript(); + if (!$script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); + break; + } + echo json_encode((new GitStackWebActions($compose_root))->status($script)); + break; case 'setLabelsViewMode': $script = getPostScript(); if (!$script) { @@ -1366,6 +1376,15 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $externalComposePath = rtrim($externalComposePath, '/'); $externalComposeFilePath = isset($_POST['externalComposeFilePath']) ? trim($_POST['externalComposeFilePath']) : ""; + // A git stack's compose file is the one in its clone, set by git.json. + // The Sources tab cannot change it, so the form's values are ignored + // and the stack's indirect files are left as they are. + $isGitStack = is_file("$compose_root/$script/git.json"); + if ($isGitStack) { + $externalComposePath = ''; + $externalComposeFilePath = ''; + } + if (!empty($externalComposePath) && !empty($externalComposeFilePath)) { echo json_encode(['result' => 'error', 'message' => 'Set either External Compose Path or External Compose File, not both.']); break; @@ -1585,7 +1604,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $indirectTarget = $externalComposePath; } - if ($indirectTarget === '') { + if ($isGitStack) { + // Left as they are (see above). + } elseif ($indirectTarget === '') { // Removing indirect: move compose file back to project folder if it only exists externally if (is_file($indirectFile)) { $oldIndirectPath = trim(file_get_contents($indirectFile)); diff --git a/source/compose.manager/include/GitStackWebActions.php b/source/compose.manager/include/GitStackWebActions.php new file mode 100644 index 00000000..73642114 --- /dev/null +++ b/source/compose.manager/include/GitStackWebActions.php @@ -0,0 +1,53 @@ + + */ + public function status(string $folder): array + { + $manager = new GitStackManager($this->composeRoot); + try { + $status = $manager->status($folder); + } catch (Throwable $error) { + return ['result' => 'error', 'message' => $error->getMessage()]; + } + + // An ssh stack's deploy key, so it can be added to the repository from here. + $deployKey = null; + try { + $deployKey = $manager->deployKey($folder); + } catch (Throwable $error) { + // Not an ssh stack: there is nothing to show. An ssh stack whose key cannot be read says why. + if (GitStackSettings::isSshUrl((string) $status['url']) && $status['problem'] === null) { + // GitSsh already starts its message this way when ssh-keygen refuses the key. + $message = $error->getMessage(); + if (!str_starts_with($message, 'Could not read the deploy key')) { + $message = 'Could not read the deploy key: ' . $message; + } + $status['problem'] = $message; + } + } + + return ['result' => 'success', 'git' => $status + ['deployKey' => $deployKey]]; + } +} diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index bc9aa7d3..316477a4 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -5990,6 +5990,8 @@ function openEditorModalByProject(project, projectName, initialTab) { $('#editor-validation-override').html(' Ready').removeClass('valid error warning'); $('#env-empty-state').hide(); $('#env-editor-wrap').show(); + // A git stack's Sources readout is shown again from getStackSettings. + showGitSourceForStack(project, false); // Set modal title $('#editor-modal-title').text('Editing: ' + projectName); @@ -6151,6 +6153,81 @@ function switchComposeFile(path) { } // Load settings data into the settings panel +// The first 12 characters of a commit id, as git shows it, or a dash for none. +function gitShortCommit(commit) { + return commit ? String(commit).substring(0, 12) : '-'; +} + +// On the Sources tab, show a git stack's repository and deploy state in place +// of the Compose Source choice: a git stack's compose file is in its clone, +// and is chosen by the stack's git settings, not here. +function showGitSourceForStack(project, isGitStack) { + $('#settings-compose-source-field').toggle(!isGitStack); + $('#settings-git-source').toggle(isGitStack); + if (!isGitStack) { + return; + } + + $('#settings-git-source-loading').show(); + $('#settings-git-source-error, #settings-git-source-table, #settings-git-local-changes, #settings-git-problem, #settings-git-deploy-key-wrap').hide(); + $.post(caURL, { + action: 'getGitStackStatus', + script: project + }).then(function(data) { + // The editor may have moved on to another stack while this loaded. + if (editorModal.currentProject !== project) { + return; + } + $('#settings-git-source-loading').hide(); + var response; + try { + response = JSON.parse(data); + } catch (e) { + response = { result: 'error', message: 'Unexpected response from server.' }; + } + if (response.result !== 'success') { + $('#settings-git-source-error').text(response.message || 'Could not read the git stack.').show(); + return; + } + renderGitSource(response.git); + }).fail(function() { + if (editorModal.currentProject !== project) { + return; + } + $('#settings-git-source-loading').hide(); + $('#settings-git-source-error').text('Could not read the git stack.').show(); + }); +} + +// Fill the Sources tab's git readout from getGitStackStatus. +function renderGitSource(git) { + $('#settings-git-url').text(git.url); + $('#settings-git-branch').text(git.branch); + $('#settings-git-compose-path').text(git.composePath); + $('#settings-git-clone-dir').text(git.cloneDir); + $('#settings-git-credential').text(git.credential || ''); + $('#settings-git-credential-row').toggle(!!git.credential); + $('#settings-git-deployed').text(git.deployedCommit ? gitShortCommit(git.deployedCommit) : 'Not deployed yet'); + $('#settings-git-failed').text(gitShortCommit(git.failedCommit) + ' (fix it and deploy again)'); + $('#settings-git-failed-row').toggle(!!git.failedCommit); + $('#settings-git-checked-out').text(gitShortCommit(git.checkedOutCommit)); + $('#settings-git-source-table').show(); + + var changes = git.localChanges || []; + if (changes.length > 0) { + $('#settings-git-local-changes') + .text('Files changed in the clone, which the next deploy will stop on: ' + changes.join(', ')) + .show(); + } + if (git.problem) { + $('#settings-git-problem').text(git.problem).show(); + } + if (git.deployKey) { + $('#settings-git-deploy-key').val(git.deployKey); + $('#settings-git-deploy-key-wrap').show(); + } +} + function loadSettingsData(project, projectName) { // Set the name from projectName (display name) $('#settings-name').val(projectName || ''); @@ -6297,6 +6374,9 @@ function loadSettingsData(project, projectName) { } } + // A git stack shows its repository instead of the Compose Source choice + showGitSourceForStack(project, response.isGitStack === true); + // Default profile var defaultProfile = response.defaultProfile || ''; $('#settings-default-profile').val(defaultProfile); diff --git a/source/compose.manager/sheets/EditorModal.css b/source/compose.manager/sheets/EditorModal.css index 1984db17..3c9cb11e 100644 --- a/source/compose.manager/sheets/EditorModal.css +++ b/source/compose.manager/sheets/EditorModal.css @@ -305,6 +305,28 @@ line-height: 1.4; } +/* A git stack's repository and deploy state on the Sources tab */ +.settings-git-source-table { + width: auto; + border-collapse: collapse; + font-size: 0.95rem; +} + +.settings-git-source-table th { + text-align: left; + font-weight: normal; + color: var(--alt-text-color); + width: 1%; + padding: 2px 16px 2px 0; + white-space: nowrap; + vertical-align: top; +} + +.settings-git-source-table td { + padding: 2px 0; + word-break: break-all; +} + .settings-effective-command-wrap { border: 1px solid var(--border-color, var(--dynamix-box-inner-div-border-color)); border-radius: 6px; diff --git a/tests/unit/GitStackWebActionsTest.php b/tests/unit/GitStackWebActionsTest.php new file mode 100644 index 00000000..89cc1394 --- /dev/null +++ b/tests/unit/GitStackWebActionsTest.php @@ -0,0 +1,248 @@ +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_web_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"], 'first'); + + $this->composeRoot = sys_get_temp_dir() . '/compose_git_web_projects'; + FakeDocker::removeTree($this->composeRoot); + mkdir($this->composeRoot); + FakeDocker::removeTree(COMPOSE_LOCK_DIR); + $this->clonesRoot = $this->mnt . '/user/appdata/git'; + + global $compose_root, $plugin_root, $sName; + $compose_root = $this->composeRoot; + $plugin_root = '/usr/local/emhttp/plugins/compose.manager'; + $sName = 'compose.manager'; + FunctionMocks::setPluginConfig('compose.manager', ['PROJECTS_FOLDER' => $this->composeRoot]); + } + + protected function tearDown(): void + { + FakeDocker::removeTree($this->mnt); + FakeDocker::removeTree($this->author); + FakeDocker::removeTree($this->composeRoot); + FakeDocker::removeTree(COMPOSE_LOCK_DIR); + $_POST = []; + parent::tearDown(); + } + + // ----- status ----- + + public function testStatusShowsTheRepositoryAndWhatIsDeployed(): void + { + $folder = $this->addGitStack('whoami'); + + $result = (new GitStackWebActions($this->composeRoot))->status($folder); + + $this->assertSame('success', $result['result']); + $this->assertSame($this->upstream, $result['git']['url']); + $this->assertSame('main', $result['git']['branch']); + $this->assertSame('whoami/compose.yaml', $result['git']['composePath']); + $this->assertNull($result['git']['deployedCommit']); + $this->assertSame([], $result['git']['localChanges']); + $this->assertNull($result['git']['deployKey']); + } + + public function testStatusSaysWhyAnSshStacksDeployKeyCannotBeShown(): void + { + if (trim((string) shell_exec('command -v ssh-keygen')) === '') { + $this->markTestSkipped('ssh-keygen is not installed.'); + } + $folder = $this->addGitStack('whoami'); + $stackDir = $this->composeRoot . '/' . $folder; + // An ssh stack whose deploy key in the vault is not a key ssh-keygen can read. + $credentialId = (new \CredentialVault())->saveCredential([ + 'name' => 'whoami deploy key', 'provider' => 'git-ssh', 'registry' => 'git.example.com', + 'username' => 'git', 'secret' => 'not a key', + ])['id']; + $hostKey = \GitSsh::generateKey('host key')['public']; + $url = 'git@git.example.com:team/stacks.git'; + $data = json_decode((string) file_get_contents($stackDir . '/git.json'), true); + $data['url'] = $url; + $data['credentialId'] = $credentialId; + $data['sshKnownHosts'] = 'git.example.com ' . implode(' ', array_slice(explode(' ', $hostKey), 0, 2)) . "\n"; + file_put_contents($stackDir . '/git.json', (string) json_encode($data)); + $this->git(['remote', 'set-url', 'origin', $url], GitStackSettings::load($stackDir)->cloneDir); + + $result = (new GitStackWebActions($this->composeRoot))->status($folder); + + $this->assertSame('success', $result['result']); + $this->assertNull($result['git']['deployKey']); + $this->assertStringStartsWith('Could not read the deploy key: ', (string) $result['git']['problem']); + $this->assertStringNotContainsString('Could not read the deploy key: Could not read', (string) $result['git']['problem']); + } + + public function testStatusListsFilesChangedInTheClone(): void + { + $folder = $this->addGitStack('whoami'); + $settings = GitStackSettings::load($this->composeRoot . '/' . $folder); + file_put_contents($settings->composeFileInClone(), "services: {}\n"); + + $result = (new GitStackWebActions($this->composeRoot))->status($folder); + + $this->assertSame(['whoami/compose.yaml'], $result['git']['localChanges']); + } + + public function testStatusOfAStackThatIsNotAGitStackIsAnError(): void + { + \StackInfo::createNew($this->composeRoot, 'plain'); + + $result = (new GitStackWebActions($this->composeRoot))->status('plain'); + + $this->assertSame('error', $result['result']); + $this->assertStringContainsString('not a git stack', $result['message']); + } + + public function testGetGitStackStatusAction(): void + { + $folder = $this->addGitStack('whoami'); + + $result = $this->executeAction('getGitStackStatus', ['script' => $folder]); + + $this->assertSame('success', $result['result']); + $this->assertSame('main', $result['git']['branch']); + } + + // ----- the editor's settings ----- + + public function testGetStackSettingsSaysWhetherTheStackIsAGitStack(): void + { + $folder = $this->addGitStack('whoami'); + \StackInfo::createNew($this->composeRoot, 'plain'); + + $this->assertTrue($this->executeAction('getStackSettings', ['script' => $folder])['isGitStack']); + $this->assertFalse($this->executeAction('getStackSettings', ['script' => 'plain'])['isGitStack']); + } + + public function testSavingSettingsNeverChangesAGitStacksComposeFile(): void + { + $folder = $this->addGitStack('whoami'); + $stackDir = $this->composeRoot . '/' . $folder; + $indirect = (string) file_get_contents($stackDir . '/indirect'); + + // A form that sends no compose source would otherwise turn it back into a project-folder stack. + $result = $this->executeAction('setStackSettings', ['script' => $folder, 'externalComposePath' => '', 'externalComposeFilePath' => '']); + + $this->assertSame('success', $result['result']); + $this->assertSame($indirect, file_get_contents($stackDir . '/indirect')); + $this->assertSame('file', file_get_contents($stackDir . '/indirect_mode')); + foreach (COMPOSE_FILE_NAMES as $composeFileName) { + $this->assertFileDoesNotExist($stackDir . '/' . $composeFileName); + } + } + + public function testSavingSettingsIgnoresAnotherComposeFileForAGitStack(): void + { + $folder = $this->addGitStack('whoami'); + $stackDir = $this->composeRoot . '/' . $folder; + $indirect = (string) file_get_contents($stackDir . '/indirect'); + $other = $this->mnt . '/user/appdata/other.compose.yaml'; + file_put_contents($other, "services: {}\n"); + + $result = $this->executeAction('setStackSettings', ['script' => $folder, 'externalComposeFilePath' => $other]); + + $this->assertSame('success', $result['result']); + $this->assertSame($indirect, file_get_contents($stackDir . '/indirect')); + } + + // ----- helpers ----- + + private function addGitStack(string $name): string + { + return (new GitStackManager($this->composeRoot))->add($name, $this->upstream, 'main', 'whoami/compose.yaml', $this->clonesRoot); + } + + /** + * Run an Exec.php action and decode its JSON answer. + * + * @param array $post + * @return array + */ + private function executeAction(string $action, array $post): array + { + global $compose_root, $plugin_root, $sName; + $compose_root = $this->composeRoot; + $plugin_root = '/usr/local/emhttp/plugins/compose.manager'; + $sName = 'compose.manager'; + \StackInfo::clearCache(); + + $_POST = array_merge(['action' => $action], $post); + ob_start(); + include '/usr/local/emhttp/plugins/compose.manager/include/Exec.php'; + $output = (string) ob_get_clean(); + $_POST = []; + + $decoded = json_decode($output, true); + $this->assertIsArray($decoded, "Exec.php $action answered: $output"); + return $decoded; + } + + /** + * @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); + } +} From 2625a097c75ad6d09da9e923b00a252eac25d8e8 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Mon, 5 Oct 2026 20:22:01 -0400 Subject: [PATCH 08/23] feat(git): add a git stack from the Add Stack dialog Add Stack gets a fourth compose source, "Git repository": the repository address, branch, the compose file's path in the repository, and for an https repository a git token from the Credentials tab. Creating the stack clones the repository through the same code as compose-git add, and deploys nothing. It then goes back to the stack list, saying the stack was added (with an ssh stack's host key fingerprints), rather than opening the editor: a git stack's compose file is changed in its repository. The dialog stays open while it clones, and shows any error in place. An ssh repository gets a deploy key of its own. When the server refuses the clone, which usually means it does not know the key yet, the dialog shows the key in a panel of its own, with a Copy key button, and the clone's error under it. For that, GitClone::create() throws GitCloneFailedException when git clone itself fails, keeping everything git said: only the lines before the "fatal:" line tell a refused key from an unreachable host. The other failures (the folder already exists, the compose file is not on the branch) are shown as the error they are. The latter says no clone was made. A refusal worded in a way not recognised still gets the key, inside the error as before. GitStackManager throws GitDeployKeyNotAddedException for the panel, so the web answer carries the key apart from the error; compose-git prints the same message as before. Co-Authored-By: Claude Opus 5.5 --- source/compose.manager/include/Exec.php | 11 +- source/compose.manager/include/GitClone.php | 41 ++- .../include/GitStackManager.php | 43 +++- .../include/GitStackWebActions.php | 73 ++++++ .../javascript/composeManagerMain.js | 234 +++++++++++++++++- .../javascript/credentialManager.js | 4 +- source/compose.manager/sheets/EditorModal.css | 38 +++ tests/bootstrap.php | 1 + tests/unit/GitCloneTest.php | 67 +++++ tests/unit/GitStackManagerTest.php | 15 ++ tests/unit/GitStackWebActionsTest.php | 72 ++++++ 11 files changed, 587 insertions(+), 12 deletions(-) diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index a72679bf..a2bc032c 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -486,6 +486,15 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 'overrideManagementAutomatic' => $overrideManagementAutomatic, ]); break; + case 'addGitStack': + $result = (new GitStackWebActions($compose_root))->add($_POST); + if ($result['result'] === 'success') { + composeLogger('Created git stack: ' . $result['project'], null, 'user', 'info', 'stack'); + } else { + composeLogger('Failed to create git stack: ' . $result['message'], null, 'user', 'error', 'stack'); + } + echo json_encode($result); + break; case 'deleteStack': $stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : ""; if (!$stackName) { @@ -1092,7 +1101,7 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $test = (new GitStackManager($compose_root))->testCredential($credentialId); if ($test === null) { echo json_encode(['result' => 'error', 'message' => 'No git stack uses this credential yet. ' - . 'It is tested by reaching a repository: give it to a stack with compose-git, then test it again.']); + . 'It is tested by reaching a repository: choose it for a git stack (when adding the stack, or with compose-git), then test it again.']); break; } } else { diff --git a/source/compose.manager/include/GitClone.php b/source/compose.manager/include/GitClone.php index 8df03099..db75f7d9 100644 --- a/source/compose.manager/include/GitClone.php +++ b/source/compose.manager/include/GitClone.php @@ -16,6 +16,40 @@ final class GitCloneNotOwnedException extends RuntimeException { } +/** + * Thrown when git clone itself failed, rather than a check before or after it. It keeps + * everything git said: a refused key and an unreachable host end in the same "fatal:" + * line, and only the lines before it tell them apart. + */ +final class GitCloneFailedException extends RuntimeException +{ + public function __construct(string $message, public readonly string $gitOutput) + { + parent::__construct($message); + } + + /** + * Whether the server answered and refused access, which over ssh usually means it + * does not know the key. False for anything else: an unreachable host, a wrong host + * key, a missing branch. Each host words a refusal its own way: + * - "Permission denied (publickey)": ssh itself, when no account has the key + * - "Repository not found": GitHub, when the key belongs to another repository + * - "you don't have permission to view it": GitLab + * - "User permission denied": Gitea and Forgejo + * - "repository access denied": Bitbucket + */ + public function serverRefusedAccess(): bool + { + $output = strtolower($this->gitOutput); + foreach (['permission denied', 'repository not found', "you don't have permission", 'access denied'] as $wording) { + if (str_contains($output, $wording)) { + return true; + } + } + return false; + } +} + /** * The plugin-owned git clone behind one git-backed stack. * @@ -96,7 +130,8 @@ public function remoteBranchCommit(int $timeoutSeconds = self::REMOTE_TIMEOUT_SE * created is removed again, and nothing else is touched. * * @return string the commit checked out - * @throws RuntimeException naming the problem + * @throws GitCloneFailedException when git clone itself failed + * @throws RuntimeException naming any other problem */ public function create(): string { @@ -124,7 +159,7 @@ public function create(): string $result = $this->git($args, null, self::CLONE_TIMEOUT_SECONDS); if (!$result->succeeded()) { - throw new RuntimeException('Could not clone the repository: ' . $result->errorSummary()); + throw new GitCloneFailedException('Could not clone the repository: ' . $result->errorSummary(), $result->stderr); } if (!is_dir($cloneDir . '/.git')) { throw new RuntimeException("git reported success but $cloneDir/.git is missing."); @@ -138,7 +173,7 @@ public function create(): string $commit = $this->resolveCommit('refs/remotes/origin/' . $this->settings->branch); if (!$this->composeFileExistsAt($commit)) { throw new RuntimeException( - "{$this->settings->composePath} does not exist on branch {$this->settings->branch}, so the clone was removed again." + "{$this->settings->composePath} does not exist on branch {$this->settings->branch}, so no clone was made." ); } $this->runOrThrow(['checkout', '--detach', $commit], 'Could not check out the files'); diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index df13a37a..0a2ed8a8 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -10,6 +10,32 @@ require_once '/usr/local/emhttp/plugins/compose.manager/include/GitClone.php'; require_once '/usr/local/emhttp/plugins/compose.manager/include/CredentialVault.php'; +/** + * The server refused a new ssh stack's clone, and the usual reason is that the repository + * does not know the stack's deploy key yet. The message, which compose-git prints, ends with + * the key; the web UI shows the clone's error and the key apart. + */ +final class GitDeployKeyNotAddedException extends RuntimeException +{ + public function __construct( + public readonly string $cloneError, + public readonly string $publicKey, + Throwable $previous + ) { + parent::__construct(self::messageWithKey($cloneError, $publicKey), 0, $previous); + } + + /** + * The clone's error, followed by the deploy key and what to do with it. + */ + public static function messageWithKey(string $cloneError, string $publicKey): string + { + return $cloneError . "\n\nIf the repository does not know this stack's deploy key yet, add it as a " + . "read-only deploy key in the repository's settings, then try again:\n" + . $publicKey; + } +} + /** * Creates and looks after git stacks: what the compose-git command does, * apart from deploying (compose.sh gitdeploy does that). @@ -63,7 +89,7 @@ public function add( } $stackDir = $this->composeRoot . '/' . $folder; if (file_exists($stackDir) || @readlink($stackDir) !== false) { - throw new RuntimeException("A stack folder named '$folder' already exists. Choose another name, or use convert for that stack."); + throw new RuntimeException("A stack folder named '$folder' already exists. Choose another name, or move that stack into git (compose-git convert, or the button on its Sources tab)."); } // Before the clone, so a projects folder whose mount is gone leaves nothing behind. $this->assertSafeToWriteStackFolder($stackDir); @@ -509,12 +535,16 @@ private function deployKeyFor(string $folder, array $address): string /** * Clone a new stack's repository. When an ssh clone fails, show the deploy * key: the usual reason is that it has not been added to the repository yet. + * + * Only a failure of git clone itself can be the key. A check before or after + * it (the folder already exists, the compose file is not on the branch) is + * passed on as it is. */ private function createClone(GitStackSettings $settings): void { try { (new GitClone($settings))->create(); - } catch (RuntimeException $error) { + } catch (GitCloneFailedException $error) { if (!$settings->isSsh() || $settings->credentialId === null) { throw $error; } @@ -524,10 +554,13 @@ private function createClone(GitStackSettings $settings): void // Not a deploy key at all (the wrong --credential): the clone's own message says so. throw $error; } + if ($error->serverRefusedAccess()) { + throw new GitDeployKeyNotAddedException($error->getMessage(), $publicKey, $error); + } + // Most likely something else (an unreachable host, a wrong branch), but a host may + // word a refusal in a way not recognised here, so the key still comes with the error. throw new RuntimeException( - $error->getMessage() . "\n\nIf the repository does not know this stack's deploy key yet, add it as a " - . "read-only deploy key in the repository's settings, then run the same command again:\n" - . $publicKey, + GitDeployKeyNotAddedException::messageWithKey($error->getMessage(), $publicKey), 0, $error ); diff --git a/source/compose.manager/include/GitStackWebActions.php b/source/compose.manager/include/GitStackWebActions.php index 73642114..768638ea 100644 --- a/source/compose.manager/include/GitStackWebActions.php +++ b/source/compose.manager/include/GitStackWebActions.php @@ -17,6 +17,58 @@ public function __construct(private readonly string $composeRoot) { } + /** + * Create a git stack from the Add Stack dialog: clone the repository and + * make the stack folder, as compose-git add does. Nothing is deployed. + * + * @param array $input The dialog's fields: stackName, stackDesc, + * gitUrl, gitBranch, gitComposePath, gitCredentialId, overrideManagementAutomatic + * @return array + */ + public function add(array $input): array + { + $messages = []; + $manager = new GitStackManager($this->composeRoot, static function (string $message) use (&$messages): void { + $messages[] = $message; + }); + + $branch = trim((string) ($input['gitBranch'] ?? '')); + $credentialId = trim((string) ($input['gitCredentialId'] ?? '')); + try { + $folder = $manager->add( + trim((string) ($input['stackName'] ?? '')), + trim((string) ($input['gitUrl'] ?? '')), + $branch === '' ? GitStackManager::DEFAULT_BRANCH : $branch, + trim((string) ($input['gitComposePath'] ?? '')), + null, + trim((string) ($input['stackDesc'] ?? '')), + // Only a git credential is accepted, never a registry login. + $credentialId === '' ? null : $manager->findGitCredential($credentialId) + ); + } catch (Throwable $error) { + return self::errorAnswer($error, $messages); + } + + // The same override choice as the dialog's other sources (see addStack in Exec.php). + $overrideManagementAutomatic = strtolower(trim((string) ($input['overrideManagementAutomatic'] ?? 'true'))) !== 'false'; + if (!$overrideManagementAutomatic) { + $labelsViewModeFile = $this->composeRoot . '/' . $folder . '/labels_view_mode'; + if (@file_put_contents($labelsViewModeFile, 'advanced') === false) { + // The stack is made; only its Labels tab opens in the basic view, where it can be switched. + composeLogger("Added git stack '$folder', but could not write $labelsViewModeFile", null, 'user', 'warning', 'git'); + } + } + + StackInfo::clearCache(); + $stack = StackInfo::fromProject($this->composeRoot, $folder); + return [ + 'result' => 'success', + 'project' => $folder, + 'projectName' => $stack->getName(), + 'messages' => $messages, + ]; + } + /** * A git stack's repository, deployed commit and local changes, for the * editor's Sources tab. Asks nothing of the remote. @@ -50,4 +102,25 @@ public function status(string $folder): array return ['result' => 'success', 'git' => $status + ['deployKey' => $deployKey]]; } + + /** + * The answer for a failed add or convert. When the repository did not know an ssh + * stack's deploy key, the key comes apart from the clone's error, so the dialog can + * show it as the next step rather than inside the error. + * + * @param list $messages What the manager said before it failed + * @return array + */ + private static function errorAnswer(Throwable $error, array $messages): array + { + if ($error instanceof GitDeployKeyNotAddedException) { + return [ + 'result' => 'error', + 'message' => $error->cloneError, + 'deployKey' => $error->publicKey, + 'messages' => $messages, + ]; + } + return ['result' => 'error', 'message' => $error->getMessage(), 'messages' => $messages]; + } } diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 316477a4..0f259f1f 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -164,9 +164,196 @@ function updateAddStackValidity() { $fileErr.hide().text(''); } + var $gitUrlErr = $('#compose-stack-git-url-error'); + var $gitPathErr = $('#compose-stack-git-compose-path-error'); + if (mode === 'git') { + // The server checks both properly; this only catches empty fields early. + var gitUrl = ($('#compose-stack-git-url').val() || '').trim(); + if (!gitUrl) { + errors.push('git-url'); + $gitUrlErr.text('Enter the repository address.').show(); + } else { + $gitUrlErr.hide().text(''); + } + var gitComposePath = ($('#compose-stack-git-compose-path').val() || '').trim(); + if (!gitComposePath) { + errors.push('git-compose-path'); + $gitPathErr.text('Enter the path of the compose file in the repository.').show(); + } else if (gitComposePath.charAt(0) === '/') { + errors.push('git-compose-path'); + $gitPathErr.text('Give the path from the top of the repository, without a leading /.').show(); + } else { + $gitPathErr.hide().text(''); + } + } else { + $gitUrlErr.hide().text(''); + $gitPathErr.hide().text(''); + } + $('#compose-stack-create-btn').prop('disabled', errors.length > 0); } +// Whether a repository address is an ssh one (ssh://... or user@host:path), as +// GitStackSettings::isSshUrl() decides on the server. +function isGitSshAddress(url) { + return /^ssh:\/\//i.test(url) || /^[^\/\s@]+@[^\/\s:]+:/.test(url); +} + +// In the Add Stack dialog, show the fields that apply to the chosen source. +// A git stack's compose file is in its clone and its .env in its stack +// folder, so the env path and discovery choices are hidden for it. An ssh +// repository gets a deploy key of its own, so the credential choice is for +// https only. +function updateAddStackFieldsForSource() { + var mode = ($('input[name="compose-stack-compose-source"]:checked').val()) || 'project'; + var isGit = mode === 'git'; + $('#compose-stack-env-path').closest('.settings-field').toggle(!isGit); + $('#compose-stack-discovery-mode-row').closest('.settings-field').toggle(!isGit); + + var isSsh = isGitSshAddress(($('#compose-stack-git-url').val() || '').trim()); + $('#compose-stack-git-credential-wrap').toggle(!isSsh); + $('#compose-stack-git-ssh-note').toggle(isSsh); +} + +// Fill a git credential choice with the git tokens from the credential vault +// (registry logins and deploy keys are left out). When the vault cannot be +// read, say so in the choice, so it does not look as if there were no tokens. +function fillGitCredentialSelect($select) { + var showUnreadable = function(message) { + $select.append($('
diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 065c5c3f..8212973d 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -253,7 +253,10 @@ function loadAddStackGitCredentials() { // to know the key before the stack can be made. What git said goes underneath, smaller. // buttonName is the dialog's button to select again once the key is added. function showGitDeployKeyPanel($panel, publicKey, details, buttonName) { - var $key = $(' diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index d8c96729..818953fd 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -1049,9 +1049,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr break; case 'saveCredential': try { - // Deploy keys are made by compose-git for one stack, never on the Credentials tab. + // Deploy keys are made by compose-git for a repository's ssh stacks, never on the Credentials tab. if (strtolower(trim((string) ($_POST['provider'] ?? ''))) === 'git-ssh') { - throw new \InvalidArgumentException('A deploy key is made by compose-git for its stack, not added here.'); + throw new \InvalidArgumentException('A deploy key is made by compose-git for the ssh stacks of a repository, not added here.'); } $credential = (new CredentialVault())->saveCredential([ 'id' => trim((string) ($_POST['id'] ?? '')), diff --git a/source/compose.manager/include/GitSsh.php b/source/compose.manager/include/GitSsh.php index 6b21d1cd..c6f127f6 100644 --- a/source/compose.manager/include/GitSsh.php +++ b/source/compose.manager/include/GitSsh.php @@ -8,7 +8,7 @@ require_once '/usr/local/emhttp/plugins/compose.manager/include/GitCredentials.php'; /** - * ssh for git stacks: a deploy key per stack, and the repository host's pinned keys. + * ssh for git stacks: a deploy key per repository, and the repository host's pinned keys. * * The private key is kept in the credential vault (a "git-ssh" entry) and * written to a root-only file under /var/tmp for each git run, like an HTTPS diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index 3af03d31..9d2aaef8 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -420,7 +420,7 @@ public function findGitCredential(string $nameOrId): string { $gitCredentials = array_values(array_filter( (new CredentialVault())->listCredentials(), - // HTTPS tokens only: a deploy key belongs to the one stack it was made for. + // HTTPS tokens only: a deploy key is chosen by the repository it reaches, never by name. static fn(array $credential): bool => ($credential['provider'] ?? '') === 'git' )); $matches = array_values(array_filter( @@ -519,7 +519,7 @@ public function trustHost(string $folder): void /** * Settings for a new git stack. For an ssh repository this also makes (or - * reuses) the stack's deploy key and pins the host's keys. + * reuses) the repository's deploy key and pins the host's keys. */ private function newSettings( string $url, @@ -539,7 +539,7 @@ private function newSettings( throw new InvalidArgumentException("The ssh repository address is not valid: $url"); } if ($credentialId !== null) { - throw new InvalidArgumentException('An ssh stack gets a deploy key of its own, so it cannot be given another credential.'); + throw new InvalidArgumentException('An ssh stack uses the deploy key the plugin keeps for its repository, so it cannot be given another credential.'); } // The host first: a mistyped host then fails before a deploy key is made for it. $knownHosts = GitSsh::scanHostKeys($address['host'], $address['port']); @@ -550,41 +550,209 @@ private function newSettings( } /** - * The stack's deploy key: the one made for it by an earlier attempt, or a new one. + * The deploy key for a new ssh stack. One key serves every stack of a repository: GitHub + * refuses one key on two repositories, but not one key for several stacks of the same one, + * and every such key would have the same read-only access anyway. * - * The key is found by the stack's name and host. The vault does not record the repository, - * so a key left behind by a deleted stack of the same name is reused too; the message says - * what to do when that key belongs to another repository. + * An existing key is used if there is one (see existingDeployKey()); otherwise a new key is + * made, named after the repository so that later stacks of the repository find it. Nothing + * locks this choice: two stacks of a new repository added at the same moment can each make a + * key. Both keys work; the Credentials tab then lists two keys of the same name. * * @param array{user: string, host: string, port: int, path: string} $address */ private function deployKeyFor(string $folder, array $address): string { - $name = "$folder deploy key"; - $host = $address['host'] . ($address['port'] === 22 ? '' : ':' . $address['port']); - foreach ((new CredentialVault())->listCredentials() as $credential) { - if ($credential['name'] === $name && $credential['provider'] === 'git-ssh' && $credential['registry'] === $host) { - ($this->say)( - "Using the deploy key made for '$folder' earlier ('$name' on the Credentials tab). If it was " - . "made for another repository, by a stack of the same name that has since been deleted, " - . "delete '$name' first: GitHub, for one, refuses a deploy key that another repository has." - ); - return $credential['id']; - } + $existing = $this->existingDeployKey($folder, $address); + if ($existing !== null) { + ($this->say)($existing['message']); + return $existing['id']; } - $key = GitSsh::generateKey("compose-manager $folder"); - ($this->say)("Made a new deploy key for '$folder'."); + $repository = self::sshRepositoryName($address); + $name = "deploy key for $repository"; + $key = GitSsh::generateKey("compose-manager $repository"); + ($this->say)("Made a new deploy key for $repository ('$name' on the Credentials tab). Other stacks of this repository will use it too."); $id = (new CredentialVault())->saveCredential([ 'name' => $name, 'provider' => 'git-ssh', - 'registry' => $host, + 'registry' => self::sshRegistry($address), 'username' => $address['user'], 'secret' => $key['private'], ])['id']; - composeLogger("Made a deploy key for git stack '$folder'", ['id' => $id, 'host' => $host], 'user', 'info', 'credentials'); + composeLogger( + "Made a deploy key for $repository, for git stack '$folder'", + ['id' => $id, 'host' => self::sshRegistry($address)], + 'user', + 'info', + 'credentials' + ); return $id; } + /** + * The deploy key a new ssh stack would use without making one, and the message that says + * why. In this order: + * 1. the key another ssh stack of the same repository uses: the repository knows it already; + * 2. a key made for this repository earlier, such as by an attempt whose clone failed + * because the key had not been added to the repository yet; + * 3. a key named after this stack by an earlier attempt of an older version of the plugin, + * as long as no git stack uses it: such a key may belong to another repository, and + * one that a stack uses could not be deleted to make way for a new one. + * The same repository means the same user, host, port and path in the address. + * + * @param array{user: string, host: string, port: int, path: string} $address + * @return array{id: string, message: string}|null + */ + public function existingDeployKey(string $folder, array $address): ?array + { + $deployKeys = $this->deployKeysOnHost($address); + + foreach ($this->stacksOfRepository($folder, $address) as $otherFolder => $credentialId) { + if (!isset($deployKeys[$credentialId])) { + continue; + } + $name = $deployKeys[$credentialId]; + return [ + 'id' => $credentialId, + 'message' => "Using the deploy key of '$otherFolder' ('$name' on the Credentials tab), which " + . 'reaches the same repository: the repository knows it already.', + ]; + } + + $repositoryKeyName = 'deploy key for ' . self::sshRepositoryName($address); + foreach ($deployKeys as $id => $name) { + if ($name === $repositoryKeyName) { + return [ + 'id' => $id, + 'message' => "Using the deploy key made for this repository earlier ('$name' on the Credentials tab).", + ]; + } + } + + $stackKeyName = "$folder deploy key"; + $keysInUse = array_values($this->gitStackCredentials()); + foreach ($deployKeys as $id => $name) { + if ($name === $stackKeyName && !in_array($id, $keysInUse, true)) { + return [ + 'id' => $id, + 'message' => "Using the deploy key made for '$folder' earlier ('$name' on the Credentials tab). If it was " + . 'made for another repository, by a stack of the same name that has since been deleted, ' + . "delete '$name' first: GitHub, for one, refuses a deploy key that another repository has.", + ]; + } + } + return null; + } + + /** + * The deploy keys in the credential vault for the host (and port) of an ssh address: names by id. + * + * @param array{user: string, host: string, port: int, path: string} $address + * @return array + */ + private function deployKeysOnHost(array $address): array + { + $keys = []; + foreach ((new CredentialVault())->listCredentials() as $credential) { + if ($credential['provider'] === 'git-ssh' && $credential['registry'] === self::sshRegistry($address)) { + $keys[$credential['id']] = $credential['name']; + } + } + return $keys; + } + + /** + * The other ssh stacks of a repository and the credential each uses: the git stacks, other + * than $exceptFolder, whose address has the same user, host, port and path. + * + * @param array{user: string, host: string, port: int, path: string} $address + * @return array Credential ids by stack folder + */ + private function stacksOfRepository(string $exceptFolder, array $address): array + { + $stacks = []; + foreach ($this->gitStackCredentials() as $folder => $credentialId) { + if ($folder === $exceptFolder) { + continue; + } + $url = $this->gitStackUrl($folder); + if ($url !== null && GitStackSettings::sshAddress($url) === $address) { + $stacks[$folder] = $credentialId; + } + } + return $stacks; + } + + /** + * The credential every git stack uses, by stack folder; a stack with none is left out. + * + * git.json is read as plain JSON, as the Credentials tab reads it: a stack whose settings fail + * a check still uses its credential. One that cannot be read at all is named in a message. + * + * @return array + */ + private function gitStackCredentials(): array + { + $credentials = []; + foreach ($this->listGitStacks() as $folder) { + $data = $this->readGitJson($folder); + if ($data === null) { + ($this->say)("Could not read the git settings of '$folder', so its deploy key is not looked at."); + continue; + } + $credentialId = $data['credentialId'] ?? null; + if (is_string($credentialId) && $credentialId !== '') { + $credentials[$folder] = $credentialId; + } + } + return $credentials; + } + + /** + * A git stack's repository address from its git.json, read as plain JSON; null if unreadable. + */ + private function gitStackUrl(string $folder): ?string + { + $url = ($this->readGitJson($folder) ?? [])['url'] ?? null; + return is_string($url) ? $url : null; + } + + /** + * A git stack's git.json as plain JSON, or null if it cannot be read or is not a JSON object. + * + * @return array|null + */ + private function readGitJson(string $folder): ?array + { + $data = json_decode((string) @file_get_contents($this->composeRoot . '/' . $folder . '/' . GitStackSettings::FILE_NAME), true); + return is_array($data) ? $data : null; + } + + /** + * An ssh repository's address as a deploy key's name shows it: user@host:path, or + * ssh://user@host:port/path for a port other than 22. + * + * @param array{user: string, host: string, port: int, path: string} $address + */ + private static function sshRepositoryName(array $address): string + { + if ($address['port'] === 22) { + return "{$address['user']}@{$address['host']}:{$address['path']}"; + } + return "ssh://{$address['user']}@{$address['host']}:{$address['port']}/{$address['path']}"; + } + + /** + * The host a deploy key is kept under in the credential vault: host, or host:port for a + * port other than 22. + * + * @param array{user: string, host: string, port: int, path: string} $address + */ + private static function sshRegistry(array $address): string + { + return $address['host'] . ($address['port'] === 22 ? '' : ':' . $address['port']); + } + /** * Clone a new stack's repository. When an ssh clone fails, show the deploy * key: the usual reason is that it has not been added to the repository yet. diff --git a/source/compose.manager/include/GitStackWebActions.php b/source/compose.manager/include/GitStackWebActions.php index b60a5446..035707d6 100644 --- a/source/compose.manager/include/GitStackWebActions.php +++ b/source/compose.manager/include/GitStackWebActions.php @@ -124,7 +124,8 @@ public static function leftBehindByDelete(string $stackDir): ?array } $note = "This is the stack's clone of its repository: delete it yourself once nothing in it is needed."; if ($settings->isSsh() && $settings->credentialId !== null) { - $note .= " Its ssh deploy key stays on the Credentials tab of the plugin settings, and in the repository's deploy keys."; + $note .= " Its ssh deploy key stays on the Credentials tab of the plugin settings, and in the repository's deploy keys:" + . ' other stacks of the repository may use it too.'; } return ['path' => $settings->cloneDir, 'note' => $note]; } diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 42dee027..03861ce3 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -202,8 +202,8 @@ function isGitSshAddress(url) { // In the Add Stack dialog, show the fields that apply to the chosen source. // A git stack's compose file is in its clone and its .env in its stack // folder, so the env path and discovery choices are hidden for it. An ssh -// repository gets a deploy key of its own, so the credential choice is for -// https only. +// repository gets a deploy key from the plugin, so the credential choice is +// for https only. function updateAddStackFieldsForSource() { var mode = ($('input[name="compose-stack-compose-source"]:checked').val()) || 'project'; var isGit = mode === 'git'; @@ -3711,7 +3711,7 @@ function addStack() {
An https:// address, an ssh address (git@host:me/stacks.git), or the path of a repository under /mnt/. No password or token in it.
- + @@ -3724,7 +3724,7 @@ function addStack() {
-
For a private https repository, first add a git token on the Credentials tab of the plugin's settings, then choose it here. For an ssh repository, no credential is needed: a deploy key is made for the stack.
+
For a private https repository, first add a git token on the Credentials tab of the plugin's settings, then choose it here. For an ssh repository, no credential is needed: the stack uses its repository's deploy key.
Create clones the repository but starts nothing. Deploy the stack from its menu when you are ready.
@@ -6777,7 +6777,7 @@ function openConvertToGitModal() {
An https:// address, an ssh address (git@host:me/stacks.git), or the path of a repository under /mnt/.
- +
@@ -6790,7 +6790,7 @@ function openConvertToGitModal() {
-
For a private https repository, first add a git token on the Credentials tab of the plugin's settings, then choose it here. For an ssh repository, no credential is needed: a deploy key is made for the stack.
+
For a private https repository, first add a git token on the Credentials tab of the plugin's settings, then choose it here. For an ssh repository, no credential is needed: the stack uses its repository's deploy key.