diff --git a/docs/git-stacks.md b/docs/git-stacks.md index 11514aa7..d61abe98 100644 --- a/docs/git-stacks.md +++ b/docs/git-stacks.md @@ -5,8 +5,9 @@ repository on your server, and deploying the stack means: fetch the latest commi images, and start the containers from it. To change the stack, you change the repository: commit, push, deploy. -For now git stacks are managed from the command line with `compose-git`. Stacks made this way show up in -the Compose page like any other stack, and Start, Stop and the rest work on them as usual. +Git stacks are created and deployed from the Compose page (see [In the web UI](#in-the-web-ui)) or from the +command line with `compose-git`; both run the same code. A git stack shows up in the Compose page like any +other stack, and Start, Stop and the rest work on it as usual. ```bash /usr/local/emhttp/plugins/compose.manager/scripts/compose-git @@ -21,7 +22,9 @@ to your shell profile. - [Quick start](#quick-start) - [Moving an existing stack into git](#moving-an-existing-stack-into-git) - [Day to day](#day-to-day) +- [In the web UI](#in-the-web-ui) - [How to lay out your repositories](#how-to-lay-out-your-repositories) +- [The stack's folder, and files outside it](#the-stacks-folder-and-files-outside-it) - [Private repositories](#private-repositories) - [Which .env is used](#which-env-is-used) - [Folders and files your containers need](#folders-and-files-your-containers-need) @@ -59,6 +62,12 @@ You need a repository with a compose file in it: a public one over `https://`, a [Private repositories](#private-repositories)), or one on this server under `/mnt` (see [step 2](#2-create-the-repository)). +In the web UI: **Add New Stack**, choose **Git repository** as the compose source, and fill in the +repository address and the compose file's path inside the repository. **Create** clones the repository; then +choose **Pull and Redeploy** from the new stack's menu. + +Or from the command line: + ```bash compose-git add whoami --url https://github.com/you/stacks.git --path whoami/compose.yaml compose-git deploy whoami @@ -154,6 +163,9 @@ git push ### 6. Convert the stack +In the web UI: **Edit Stack**, the **Sources** tab, **Move this stack into git...**. Or from the command +line: + ```bash compose-git convert myapp --url https://github.com/you/stacks.git --path stacks/myapp/compose.yaml ``` @@ -175,6 +187,8 @@ old compose file's location. ### 7. Deploy +**Pull and Redeploy** from the stack's menu, or: + ```bash compose-git deploy myapp ``` @@ -194,7 +208,8 @@ copy the backup folder's files back into it (they include the old `indirect` set 1. Change the compose file (or its config files) in the repository, commit and push. 2. See whether the server is behind: `compose-git check myapp` (or `--all`). Exit status 3 means a newer - commit is waiting. + commit changes the stack's folder; 4 means a newer commit changes only files outside it (see + [The stack's folder, and files outside it](#the-stacks-folder-and-files-outside-it)). 3. Deploy it: `compose-git deploy myapp`. To go back to an older version, deploy its commit: `compose-git deploy myapp --commit `. A @@ -202,6 +217,20 @@ later plain `deploy` brings the stack back to the branch's latest. `compose-git status` shows every git stack: what is deployed, any failed commit, and local changes. +## In the web UI + +| Where | What it does | Command line | +|---|---|---| +| Add New Stack, compose source **Git repository** | Clone a repository and make a new git stack. Not deployed yet. | `add` | +| Edit Stack, Sources tab, **Move this stack into git...** | Turn an existing stack into a git stack. | `convert` | +| Stack menu, **Check for Changes** | Compare the deployed commit with the branch on the remote. Says whether a newer commit changes the stack's folder, or only files outside it, and offers to deploy it either way (see [below](#the-stacks-folder-and-files-outside-it)). | `check` | +| Stack menu, **Pull and Redeploy** | Deploy the branch's latest commit. If files in the clone were changed, or a commit was made in it by hand, offers to save them as a patch and discard them first. Not offered while the clone has a problem (missing or unreadable): the dialog says what it is. | `deploy` (with `--save-local-changes`) | +| Stack menu, **Deploy Commit...** | Deploy a commit given by its full id, to go back to an older version. | `deploy --commit` | +| Edit Stack, Sources tab | Shows the repository, branch, compose file, clone, the deployed and checked-out commits, a failed commit, files changed in the clone (also shown above the file on the Compose tab), and an ssh stack's deploy key. | `status`, `deploy-key` | +| Stack list | Under the project name: the branch and deployed commit, and "failed" after a failed deploy. | `status` | + +Changing a stack's credential, pinning a changed ssh host key and recloning are done from the command line. + ## How to lay out your repositories **Every git stack keeps its own clone of the whole repository.** Ten stacks from one repository means ten @@ -219,7 +248,37 @@ big things: stacks. A change to another stack's folder in a shared repository is fetched by every stack, but only recreates the -containers of the stack whose folder changed (see [below](#config-files-and-recreating-containers)). +containers of the stack whose folder changed, and Check for Changes tells the two apart (see +[The stack's folder, and files outside it](#the-stacks-folder-and-files-outside-it)). + +## The stack's folder, and files outside it + +A git stack's **folder** is the folder holding its compose file in the repository: `myapp/` for +`myapp/compose.yaml`, or the whole repository when the compose file is at the top. Whether a new commit +changes that folder decides two things: + +- **Check for Changes** (and `compose-git check`) gives one of three answers: + - **up to date**: the deployed commit is the branch's latest (exit status 0); + - **a newer commit changes the stack's folder**: deploy it (exit status 3). This is also the answer + (and the dialog says why) when its changes cannot be identified: nothing is deployed yet, or the deployed commit is no longer + in the clone (it was made again after a force-push, say); + - **a newer commit changes only files outside the stack's folder**: in a repository that holds several + stacks, that is usually a commit to another stack, and this stack has nothing to do (exit status 4). + Deploying is still offered, as **Deploy anyway**. +- **A deploy recreates the stack's containers** only when it changes the stack's folder (see + [below](#config-files-and-recreating-containers)). + +**Files outside the folder are not counted.** A stack can use files from elsewhere in the repository: a +bind mount such as `../shared/certs:/certs`, a build context of `..`, an `env_file`, `extends` or `include` +in another folder. A commit that changes only those is reported as "outside the stack's folder", and a +deploy of it does not recreate the containers. If a stack uses files like that, either: + +- deploy it anyway when such a file changes (and restart the stack if the change is to a mounted file), or +- keep everything the stack uses inside its own folder (a copy per stack is fine for small files), or give + the stack a repository of its own. + +A script that should deploy whenever the branch moves, whatever the commit touches, treats exit status 4 +like 3. ## Private repositories @@ -242,7 +301,7 @@ offered only for that address, so a host that redirects a shorter address gets n One token can serve every stack on the same host that it can read. **Test** on the Credentials tab tries it against a stack that already uses it, so add the stack first; until then the tab says no git stack uses it. -**An SSH deploy key.** Use an ssh address, and the stack gets a key of its own: +**An SSH deploy key.** Use an ssh address, and the plugin makes a deploy key for the repository: ```bash compose-git add myapp --url git@github.com:you/stacks.git --path myapp/compose.yaml @@ -254,7 +313,23 @@ 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. -When the stack is added, the server's ssh host keys are pinned and their fingerprints printed. Compare them with +The key belongs to the repository, not to one stack. It is named after the repository on the Credentials tab +(`deploy key for git@github.com:you/stacks.git`), and every other stack you add from the same repository (a +repository that holds several stacks, one folder each) uses it too, so nothing more needs adding to the +repository. The same repository means the same address: the same user, host, port and path. + +Deleting a stack leaves the key on the Credentials tab, where other stacks of the repository may still use it. +An older version of the plugin named each key after its stack (`myapp deploy key`), and a new stack of that name +still reuses such a key. If the new stack is for another repository, delete the old key first: GitHub refuses a +deploy key that another repository already has. + +Stacks added before keys were shared each have a key of their own. To move one onto the key another stack of the +same repository uses: `compose-git credential myapp "deploy key for git@github.com:you/stacks.git"` (any deploy key +of the same repository works, by its name on the Credentials tab). The repository is reached with that key first. +The old key stays on the Credentials tab: once no stack uses it, delete it there and from the repository's deploy +keys. + +When the stack is added, the server's ssh host keys are pinned and their fingerprints shown. 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`, which shows the old and new fingerprints and pins the new ones. @@ -273,8 +348,9 @@ With no custom env file set for the stack, a git stack uses, in order: Only one of the two is used; they are not merged. Editing a `.env` that came from the repository changes the clone, so the next deploy stops with "local changes": make the change in the repository instead. -Variables exported in the shell you run `compose-git deploy` from are not passed on, and `${PWD}` in the compose -file is the compose file's folder in the clone: a deploy uses only what its checks saw. Put values in the `.env`. +Variables exported in the shell you run `compose-git deploy` from (or in the environment of whatever else starts +the deploy) are not passed on, and `${PWD}` in the compose file is the compose file's folder in the clone: a deploy +uses only what its checks saw. Put values in the `.env`. If the `.env` sets `COMPOSE_FILE` with relative paths, they are relative to the folder the `.env` is in. For the stack folder's `.env` that is the stack folder, not the clone, so use paths relative to the clone only in @@ -338,7 +414,7 @@ container too, even at the same commit: a container may still hold an edited fil stack folder holds a `git_discarded_changes` file that says so. Only the stack's own folder counts: a change to a shared folder elsewhere in the repository does not -recreate anything yet. To turn the behaviour off, set `"recreateOnFolderChange": false` in the stack's +recreate anything (see [The stack's folder, and files outside it](#the-stacks-folder-and-files-outside-it)). To turn the behaviour off, set `"recreateOnFolderChange": false` in the stack's `git.json`. ## Local changes on the server @@ -349,7 +425,8 @@ deploy stops and lists the files. Then either: - undo the change by hand, or - deploy with `--save-local-changes`: the changes are saved as a patch in the stack folder's `git-changes` folder, then discarded, and the deploy goes ahead. Apply the patch to your own copy of the repository with - `git apply ` if you want to keep it. + `git apply ` if you want to keep it. In the web UI, Pull and Redeploy lists the changed files and + offers this. Files that are not part of the repository, such as data a container wrote into the clone, are never removed or overwritten, whether `.gitignore` lists them or not. If a new commit would put a file where one of them @@ -409,10 +486,10 @@ otherwise make compose refuse the stack. The log names each one removed. |---|---| | `add --url --path [--branch ] [--clones-root ] [--description ] [--credential ]` | Clone a repository and make a new git stack. Not deployed yet. | | `convert --url --path [--branch ] [--clones-root ] [--credential ]` | Turn an existing stack into a git stack (see above). | -| `credential ` or `credential --none` | Change or remove the HTTPS credential a stack uses. | +| `credential ` or `credential --none` | Change or remove the HTTPS credential a stack uses, or move an ssh stack onto another deploy key of its repository. | | `deploy-key ` | Show an ssh stack's public deploy key. | | `trust-host ` | Pin an ssh stack's server host keys again, after they changed. | -| `check ` or `check --all` | Compare the deployed commit with the branch on the remote. Changes nothing. | +| `check ` or `check --all` | Compare the deployed commit with the branch on the remote, and say whether a newer commit changes the stack's folder or only files outside it. Fetches a newer commit into the clone; changes no container and no checked-out file. | | `deploy [--commit ] [--save-local-changes] [--wait\|--no-wait] [--wait-timeout ] [--profile

]...` | Deploy the branch's latest commit, or the given one. Waits for healthy containers when the stack's wait-for-healthy setting says so, unless `--wait` or `--no-wait` overrides it. | | `reclone ` | Move the clone aside and clone again at the deployed commit. | | `status [\|--all] [--json]` | Show what is deployed, failed and changed locally, without asking the remote. | @@ -422,15 +499,16 @@ otherwise make compose refuse the stack. The log names each one removed. `` is an `https://` address without a user name or password in it, an ssh address (`ssh://git@host[:port]/path` or `git@host:path`), or the path of a repository under `/mnt`. `--credential` is the name of a git credential (an HTTPS token) on the Credentials tab, for an `https://` address; an ssh -stack always uses the deploy key made for it. `` is the stack's exact folder name in the projects folder. `--clones-root` must be on a share, +stack uses its repository's deploy key. `` is the stack's exact folder name in the projects folder. `--clones-root` must be on a share, disk or pool, and the share must exist. Exit status: `0` success (for `check`: up to date), `1` failed, `2` the command line was not understood, `3` -(`check`) a newer commit is available. `check --all` exits `3` if any stack is behind, even when another could -not be checked. If another operation on the same stack is running (a Start or Update from the web UI, say), +(`check`) a newer commit changes the stack's folder, `4` (`check`) a newer commit changes only files outside +it. `check --all` exits `3` if any stack's folder changed, even when another could not be checked; otherwise +`1` if any could not be checked; otherwise `4` if any has a newer commit outside its folder. If another operation on the same stack is running (a Start or Update from the web UI, say), `deploy` waits up to 30 seconds (`COMPOSE_LOCK_TIMEOUT`) for it to finish, then fails without changing -anything. `convert` and `reclone` do not wait: they refuse to start while another operation on the stack is -running, so run them again once it has finished. +anything. `convert`, `reclone` and `check` (when it has a newer commit to fetch) do not wait: they refuse to +start while another operation on the stack is running, so run them again once it has finished. Without `--profile`, `deploy` uses the profiles the stack is running with, or else its default profiles, the same as Update in the web UI. @@ -445,17 +523,17 @@ deployed commit: only `compose-git deploy` does that. - **Delete** removes the stack folder and leaves the clone (see [Limitations](#limitations)). - **Backup** saves the stack folder, not the clone, as for any stack whose compose file lives elsewhere. The repository is the backup of the clone. -- **Settings:** do not change the stack's external compose path. The next deploy refuses, because it no longer - matches `git.json`. +- **Settings:** the Sources tab offers no compose path for a git stack. If the stack's `indirect` file is edited by + hand so it no longer names the compose file in the clone, the next deploy refuses, because it no longer matches + `git.json`. ## Limitations - **An `https` server needs a certificate this server trusts.** A self-hosted git server with a self-signed certificate is refused over `https`; reach it over ssh instead. -- **No web UI yet.** Git stacks are created and deployed from the command line; the Compose page shows and - runs them like other stacks. - **No automatic deploys yet.** Use `check` and `deploy` from your own schedule or a git hook. - No submodules and no Git LFS: the clone checks out plain files only. -- **The web UI's editor edits the file in the clone.** The next deploy treats that as a local change. Changes - that must survive deploys belong in the plugin's override or the stack folder's `.env`. -- **Deleting a git stack leaves its clone** under the clones folder. Remove it by hand. +- **The web UI's editor edits the file in the clone** (it says so above the file). The next deploy treats that + as a local change, and the editor lists it in a "Changed in the clone" box. Changes that must survive deploys belong in the plugin's override or the stack folder's + `.env`. +- **Deleting a git stack leaves its clone** under the clones folder, and names it. Remove it by hand. 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/ComposeList.php b/source/compose.manager/include/ComposeList.php index a41c4c6d..89385c76 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -8,6 +8,7 @@ require_once("/usr/local/emhttp/plugins/compose.manager/include/Defines.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/Util.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/ColumnLayout.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/GitStackWebActions.php"); $cfg = parse_plugin_cfg($sName); @@ -346,7 +347,7 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str $hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0'; // Main row - Docker tab structure with expand arrow on left - $o .= ""; + $o .= ""; // Arrow column $o .= ""; diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index ae4f3f7a..e937cae2 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -544,6 +544,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+ +
"; @@ -386,6 +387,26 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str $o .= "
"; $o .= "Project: $projectHtml"; $o .= "
"; + // A git stack's branch and deployed commit + if ($stackInfo->isGitStack()) { + $git = GitStackWebActions::listSummary($stackInfo->path); + $o .= "
"; + if ($git['problem'] !== null) { + $o .= "git settings cannot be read"; + } else { + $o .= "" . htmlspecialchars((string) $git['branch'], ENT_QUOTES, 'UTF-8') . ' @ '; + $o .= $git['deployedCommit'] === null + ? 'not deployed' + : "" + . htmlspecialchars(substr($git['deployedCommit'], 0, 7), ENT_QUOTES, 'UTF-8') . ''; + $o .= ''; + if ($git['failedCommit'] !== null) { + $failedTitle = 'The deploy of commit ' . substr($git['failedCommit'], 0, 12) . ' failed. Fix it and deploy again.'; + $o .= " failed"; + } + } + $o .= "
"; + } $o .= ""; $o .= "
+ + + + + + + + + + + + + + +
+ +
Where does this stack's compose file live?
@@ -656,6 +686,10 @@ function compose_manager_cpu_spec_count($cpuSpec) This stack uses an external compose source. The Compose editor tab loads and saves from that location.
+
+ +
Deploy this stack from a git repository from now on. Put its compose file in the repository first.
+
diff --git a/source/compose.manager/include/ComposeUtil.php b/source/compose.manager/include/ComposeUtil.php index e3965e06..992443e4 100644 --- a/source/compose.manager/include/ComposeUtil.php +++ b/source/compose.manager/include/ComposeUtil.php @@ -46,6 +46,11 @@ 'background' => $background ]); break; + case 'composeGitDeploy': + echoGitDeployCommand([ + 'background' => $background + ]); + break; case 'composePull': echoComposeCommand('pull', [ 'background' => $background diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 98e05df0..818953fd 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'); @@ -485,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) { @@ -497,6 +507,13 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $isInvalidIndirect = !$isIndirect && is_file("$folderName/indirect.invalid"); $filesRemain = $isIndirect ? file_get_contents("$folderName/indirect") : ($isInvalidIndirect ? file_get_contents("$folderName/indirect.invalid") : ""); + // A git stack's indirect file points into its clone: name the clone instead, and say what it is. + $filesRemainNote = ''; + $gitLeftBehind = GitStackWebActions::leftBehindByDelete($folderName); + if ($gitLeftBehind !== null) { + $filesRemain = $gitLeftBehind['path'] ?? $filesRemain; + $filesRemainNote = $gitLeftBehind['note']; + } composeLogger("Deleting stack: $stackName", [ 'folderName' => $folderName, 'isIndirect' => $isIndirect, @@ -537,14 +554,14 @@ function composeResolveContainerIcon(string $containerName, string $service, arr composeSendNotification("Stack delete complete: $stackName", $message); echo json_encode(['result' => 'success', 'message' => '', 'cachePurge' => $cachePurgeMeta]); } else { - $message = "Stack '$stackName' was deleted. Files remain on disk at '$filesRemain'."; + $message = "Stack '$stackName' was deleted. Files remain on disk at '$filesRemain'." . ($filesRemainNote !== '' ? " $filesRemainNote" : ''); composeLogger("Deleted stack: $stackName (indirect, external files remain at $filesRemain)", [ 'deleteMeta' => $deleteMeta, 'filesRemain' => $filesRemain, 'cachePurgeMeta' => $cachePurgeMeta, ], 'user', 'warning', 'stack'); composeSendNotification("Stack delete complete: $stackName", $message, 'warning'); - echo json_encode(['result' => 'warning', 'message' => $filesRemain, 'cachePurge' => $cachePurgeMeta]); + echo json_encode(['result' => 'warning', 'message' => $filesRemain . ($filesRemainNote !== '' ? ". $filesRemainNote" : ''), 'cachePurge' => $cachePurgeMeta]); } break; case 'changeName': @@ -1032,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'] ?? '')), @@ -1045,7 +1062,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 +1082,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) { @@ -1091,7 +1108,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 { @@ -1256,8 +1273,39 @@ 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 'convertToGitStack': + $script = getPostScript(); + if (!$script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); + break; + } + $result = (new GitStackWebActions($compose_root))->convert($script, $_POST); + if ($result['result'] === 'success') { + composeLogger("Converted stack to a git stack: $script", ['backupDir' => $result['backupDir']], 'user', 'info', 'stack'); + } else { + composeLogger("Failed to convert stack to a git stack: $script", ['error' => $result['message']], 'user', 'error', 'stack'); + } + echo json_encode($result); + break; + case 'checkGitStack': + $script = getPostScript(); + if (!$script) { + echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); + break; + } + echo json_encode((new GitStackWebActions($compose_root))->check($script)); + break; case 'setLabelsViewMode': $script = getPostScript(); if (!$script) { @@ -1366,6 +1414,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 +1642,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/GitClone.php b/source/compose.manager/include/GitClone.php index ab409b1c..86b38a41 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. * @@ -65,15 +99,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()); @@ -95,7 +130,8 @@ public function remoteBranchCommit(): string * 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 { @@ -123,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."); @@ -137,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'); @@ -561,6 +597,29 @@ public function isOnBranch(string $commit): bool return true; } + /** + * Whether the checked-out commit was committed in the clone by hand. + * + * A checked-out commit that is on the branch came from the repository: the + * deployed one, one whose "up" failed, or one left checked out when a + * deploy was interrupted. The deployed and failed commits also count when a + * rewritten branch no longer holds them. Anything else was committed in the + * clone by hand. The deploy and the stack's status both ask this, so the + * web UI offers to save such a commit when the deploy would refuse it. One + * rare case differs: the deploy asks after a fetch and the status without + * one, so a commit left by an interrupted deploy on a branch that was then + * force-pushed is fine to the status and made by hand to the deploy, which + * refuses it with its own message. + * + * @throws RuntimeException if git cannot tell + */ + public function isMadeByHand(string $checkedOut, ?string $deployedCommit, ?string $failedCommit): bool + { + return $checkedOut !== $deployedCommit + && $checkedOut !== $failedCommit + && !$this->isOnBranch($checkedOut); + } + /** * The last commit a commit shares with the stack's branch as last fetched, * or null when they share none. diff --git a/source/compose.manager/include/GitDeploy.php b/source/compose.manager/include/GitDeploy.php index 6a9c352f..92627a00 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 @@ -263,14 +277,7 @@ private function loadSettings(): GitStackSettings private function dealWithLocalChanges(GitClone $clone, GitStackState $state, string $checkedOut, bool $saveAndDiscard): void { $changedFiles = $clone->locallyChangedFiles(); - // A checked-out commit that is on the branch came from the repository: - // the deployed one, one whose "up" failed, or one left checked out when - // a deploy was interrupted. The deployed and failed commits also count - // when a rewritten branch no longer holds them. Anything else was - // committed in the clone by hand. - $madeByHand = $checkedOut !== $state->deployedCommit - && $checkedOut !== $state->failedCommit - && !$clone->isOnBranch($checkedOut); + $madeByHand = $clone->isMadeByHand($checkedOut, $state->deployedCommit, $state->failedCommit); if ($changedFiles === [] && !$madeByHand) { return; } diff --git a/source/compose.manager/include/GitSsh.php b/source/compose.manager/include/GitSsh.php index bd936865..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 @@ -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/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index f8f0493e..7ca05b9b 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). @@ -22,6 +48,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; @@ -57,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); @@ -237,31 +269,81 @@ private function foldersIn(string $folder): array } /** - * Compare the deployed commit with the branch on the remote, changing nothing. + * Compare the deployed commit with the branch on the remote. Changes no container and + * no file of the stack; when the branch has a newer commit, it is fetched into the + * clone (under the stack's lock, as a deploy would fetch it) to see what it changes. + * The deployed commit is read under that lock too, so the check is refused while + * another operation on the stack is running. + * + * changesStack is null when the stack is up to date. Otherwise it says whether the new + * commit changes anything in the stack's folder in the repository (the folder holding + * its compose file, or the whole repository for a compose file at the top). It is true + * when that cannot be identified: nothing deployed yet, or the clone no longer has the + * deployed commit (deployedCommitMissing says which, so the answer can say so). It is + * false when every change is outside the stack's folder, as a commit to another stack + * in a shared repository is. Files the stack uses from outside its folder (a ../shared + * bind mount, a build context of .., an env_file, extends or include in another folder) + * are not looked at, so false does not prove the new commit changes nothing the stack + * uses. + * + * stackFolder is that folder, relative to the top of the repository ('' for the top). * - * @return array{stack: string, branch: string, remoteCommit: string, deployedCommit: ?string, failedCommit: ?string, upToDate: bool} - * @throws RuntimeException if the stack or the remote cannot be read + * @param int|null $timeoutSeconds How long to wait for the remote; null for git's usual limit + * @return array{stack: string, branch: string, remoteCommit: string, deployedCommit: ?string, failedCommit: ?string, upToDate: bool, changesStack: ?bool, deployedCommitMissing: bool, stackFolder: string} + * @throws RuntimeException if the stack or the remote cannot be read, or another operation on the stack is running */ - public function check(string $folder): array + public function check(string $folder, ?int $timeoutSeconds = null): array { $stack = $this->stack($folder); $settings = $this->settings($stack); - $state = GitStackState::load($stack->path); - $remote = (new GitClone($settings))->remoteBranchCommit(); + $clone = new GitClone($settings); + // Asking the remote can take a while, so it is done before taking the lock: a deploy + // started meanwhile is not refused. + $remote = $timeoutSeconds === null ? $clone->remoteBranchCommit() : $clone->remoteBranchCommit($timeoutSeconds); + + // What is deployed is read under the lock, after the remote has answered. A deploy + // or rollback that finished while the remote was asked is then counted, and none + // can finish while the deployed commit is compared with the remote's. + [$state, $changesStack, $deployedCommitMissing] = $this->withStackLock( + $stack, + /** @return array{GitStackState, ?bool, bool} state, changesStack, deployedCommitMissing */ + static function () use ($stack, $clone, $remote): array { + $state = GitStackState::load($stack->path); + $deployed = $state->deployedCommit; + if ($deployed === null) { + // Nothing deployed yet: whatever the branch has is new to the stack. + return [$state, true, false]; + } + if ($deployed === $remote) { + return [$state, null, false]; + } + $clone->fetch(); + if (!$clone->hasCommit($deployed)) { + // Recloned since, after a force-push for one. Nothing to compare with. + return [$state, true, true]; + } + return [$state, $clone->stackFilesChanged($deployed, $remote), false]; + } + ); + $deployed = $state->deployedCommit; + return [ 'stack' => $folder, 'branch' => $settings->branch, 'remoteCommit' => $remote, - 'deployedCommit' => $state->deployedCommit, + 'deployedCommit' => $deployed, 'failedCommit' => $state->failedCommit, - 'upToDate' => $state->deployedCommit === $remote, + 'upToDate' => $deployed === $remote, + 'changesStack' => $changesStack, + 'deployedCommitMissing' => $deployedCommitMissing, + 'stackFolder' => dirname($settings->composePath) === '.' ? '' : dirname($settings->composePath), ]; } /** * 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, problem: ?string} + * @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} */ public function status(string $folder): array { @@ -270,11 +352,13 @@ public function status(string $folder): array $state = GitStackState::load($stack->path); $checkedOut = null; $changes = []; + $madeByHand = false; $problem = null; try { $clone = new GitClone($settings); $checkedOut = $clone->checkedOutCommit(); $changes = $clone->locallyChangedFiles(); + $madeByHand = $clone->isMadeByHand($checkedOut, $state->deployedCommit, $state->failedCommit); } catch (Throwable $error) { $problem = $error->getMessage(); } @@ -290,6 +374,7 @@ public function status(string $folder): array 'failedCommit' => $state->failedCommit, 'checkedOutCommit' => $checkedOut, 'localChanges' => $changes, + 'commitMadeByHand' => $madeByHand, 'problem' => $problem, ]; } @@ -297,6 +382,10 @@ public function status(string $folder): array /** * Change the credential a git stack uses to reach its repository, or remove it. * + * An ssh stack always keeps a deploy key, and can only be given another key of its own + * repository (see repositoryDeployKeys()): that is how stacks that each got a key of their + * own, before keys were shared, move onto one. + * * The repository is reached with the new setting first, and nothing is * saved unless that works. * @@ -307,11 +396,13 @@ public function setCredential(string $folder, ?string $credentialId): void $this->assertArrayStarted(); $stack = $this->stack($folder); $settings = $this->settings($stack); + $oldCredentialId = $settings->credentialId; if ($settings->isSsh()) { - throw new InvalidArgumentException( - "An ssh stack always uses a deploy key of its own, so the credential of '$folder' cannot be changed. " - . 'compose-git deploy-key shows its public key.' - ); + if ($credentialId !== null && $credentialId === $oldCredentialId) { + ($this->say)("'$folder' already uses the deploy key '" . $this->credentialName($credentialId) . "'. Nothing was changed."); + return; + } + $this->assertRepositoryDeployKey($folder, $settings, $credentialId); } $this->withStackLock($stack, function () use ($stack, $settings, $credentialId): void { @@ -324,6 +415,102 @@ public function setCredential(string $folder, ?string $credentialId): void ($this->say)($credentialId === null ? "'$folder' now reaches its repository without a credential." : "'$folder' now uses the credential '" . $this->credentialName($credentialId) . "'."); + if ($settings->isSsh() && $oldCredentialId !== null && $oldCredentialId !== $credentialId) { + ($this->say)( + "Its old deploy key '" . $this->credentialName($oldCredentialId) . "' stays on the Credentials tab, which " + . "shows whether another stack still uses it. If none does, delete it there and from the repository's deploy keys." + ); + } + } + + /** + * Refuse any credential for an ssh stack but a deploy key of its own repository. + * + * @throws InvalidArgumentException naming the keys it could be given + */ + private function assertRepositoryDeployKey(string $folder, GitStackSettings $settings, ?string $credentialId): void + { + $address = GitStackSettings::sshAddress($settings->url); + $keys = $address === null ? [] : $this->repositoryDeployKeys($folder, $address); + if ($credentialId !== null && isset($keys[$credentialId])) { + return; + } + throw $this->notARepositoryDeployKey($folder, $keys); + } + + /** + * Why an ssh stack cannot be given a credential, naming the deploy keys it could be given. + * + * @param array $keys The repository's deploy keys, by id + */ + private function notARepositoryDeployKey(string $folder, array $keys): InvalidArgumentException + { + $choices = array_map(static fn(string $name): string => "'$name'", array_values($keys)); + return new InvalidArgumentException( + "An ssh stack reaches its repository with a deploy key, so '$folder' can only be given a deploy key of the " + . 'same repository that another stack uses, or one made for the repository' + . ($choices === [] ? ', and there is none.' : ': ' . implode(', ', $choices) . '.') + . ' compose-git deploy-key shows the key it uses now.' + ); + } + + /** + * The deploy keys of an ssh stack's repository, other than its own: the keys other ssh stacks + * of the same repository use, and a key made for the repository, by id, with their names. + * + * @param array{user: string, host: string, port: int, path: string} $address + * @return array + */ + public function repositoryDeployKeys(string $folder, array $address): array + { + $deployKeys = $this->deployKeysOnHost($address); + $keys = []; + foreach ($this->stacksOfRepository($folder, $address) as $credentialId) { + if (isset($deployKeys[$credentialId])) { + $keys[$credentialId] = $deployKeys[$credentialId]; + } + } + $repositoryKeyName = 'deploy key for ' . self::sshRepositoryName($address); + foreach ($deployKeys as $id => $name) { + if ($name === $repositoryKeyName) { + $keys[$id] = $name; + } + } + return $keys; + } + + /** + * The id of the credential named $nameOrId (or with that id) that the stack $folder may be given: + * for an ssh stack a deploy key of its repository, otherwise an HTTPS token. + * + * @throws RuntimeException if there is none, or several with that name + */ + public function findCredentialFor(string $folder, string $nameOrId): string + { + $settings = $this->settings($this->stack($folder)); + $address = GitStackSettings::sshAddress($settings->url); + if ($address === null) { + return $this->findGitCredential($nameOrId); + } + // The key it uses now: setCredential() then says so and changes nothing. + $current = $settings->credentialId; + if ($current !== null && ($current === $nameOrId || $this->credentialName($current) === $nameOrId)) { + return $current; + } + $keys = $this->repositoryDeployKeys($folder, $address); + $matches = []; + foreach ($keys as $id => $name) { + if ($id === $nameOrId || $name === $nameOrId) { + $matches[] = $id; + } + } + if (count($matches) === 1) { + return $matches[0]; + } + if (count($matches) > 1) { + throw new RuntimeException("Several deploy keys are named '$nameOrId'. Use the key's id instead."); + } + throw $this->notARepositoryDeployKey($folder, $keys); } /** @@ -335,7 +522,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( @@ -375,10 +562,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; @@ -434,7 +621,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, @@ -454,7 +641,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']); @@ -465,42 +652,222 @@ 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. + * + * 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."); - 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. + * + * 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; } @@ -510,10 +877,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 new file mode 100644 index 00000000..035707d6 --- /dev/null +++ b/source/compose.manager/include/GitStackWebActions.php @@ -0,0 +1,243 @@ + $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, + ]; + } + + /** + * Ask the remote for the branch's latest commit and compare it with the + * deployed one, as compose-git check does. A newer commit is fetched into the + * clone, under the stack's lock; no container and no checked-out file changes. + * + * @return array + */ + public function check(string $folder): array + { + try { + return [ + 'result' => 'success', + 'check' => (new GitStackManager($this->composeRoot))->check($folder, self::CHECK_TIMEOUT_SECONDS), + ]; + } catch (Throwable $error) { + return ['result' => 'error', 'message' => $error->getMessage()]; + } + } + + /** + * What deleting a git stack leaves behind, to tell the person. + * + * Delete removes only the stack folder. The clone is not deleted (a + * container may keep data in it), nor moved: once git.json is gone the + * plugin cannot prove the clone is its own, so it is the person's to + * remove. An ssh stack's deploy key stays on the Credentials tab. + * + * @return array{path: ?string, note: string}|null null for a stack that is not + * a git stack. path is the clone, or null when git.json cannot be read; note + * says what it is and what else stays. + */ + public static function leftBehindByDelete(string $stackDir): ?array + { + if (!is_file($stackDir . '/' . GitStackSettings::FILE_NAME)) { + return null; + } + try { + $settings = GitStackSettings::load($stackDir); + } catch (Throwable) { + return ['path' => null, 'note' => "It is in the git stack's clone of its repository (its git.json could not be read)."]; + } + if ($settings === null) { + // git.json went away since the check above. + return null; + } + $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:" + . ' other stacks of the repository may use it too.'; + } + return ['path' => $settings->cloneDir, 'note' => $note]; + } + + /** + * What the stack list shows for a git stack. Read from the stack's own + * files only: no git command is run, so the list stays fast. + * + * @return array{branch: ?string, deployedCommit: ?string, failedCommit: ?string, problem: ?string} + */ + public static function listSummary(string $stackDir): array + { + try { + $settings = GitStackSettings::load($stackDir); + $state = GitStackState::load($stackDir); + } catch (Throwable $error) { + return ['branch' => null, 'deployedCommit' => null, 'failedCommit' => null, 'problem' => $error->getMessage()]; + } + return [ + 'branch' => $settings?->branch, + 'deployedCommit' => $state->deployedCommit, + 'failedCommit' => $state->failedCommit, + 'problem' => null, + ]; + } + + /** + * Turn an existing stack into a git stack, from the editor's Sources tab, + * as compose-git convert does. Its old compose file, when it is in the + * stack folder, goes into a dated backup folder there (an indirect stack's + * file stays where it is); its containers change at the next deploy. + * + * @param array $input gitUrl, gitBranch, gitComposePath, gitCredentialId + * @return array + */ + public function convert(string $folder, 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 { + $backupDir = $manager->convert( + $folder, + trim((string) ($input['gitUrl'] ?? '')), + $branch === '' ? GitStackManager::DEFAULT_BRANCH : $branch, + trim((string) ($input['gitComposePath'] ?? '')), + null, + // Only a git credential is accepted, never a registry login. + $credentialId === '' ? null : $manager->findGitCredential($credentialId) + ); + } catch (Throwable $error) { + return self::errorAnswer($error, $messages); + } + return ['result' => 'success', 'backupDir' => $backupDir, 'messages' => $messages]; + } + + /** + * A git stack's repository, deployed commit and local changes, for the + * editor's Sources tab. Asks nothing of the remote. + * + * @return array + */ + 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]]; + } + + /** + * 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/include/Helpers.php b/source/compose.manager/include/Helpers.php index 7b55ff1d..ebe0e290 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -414,6 +414,176 @@ function echoComposeCommand($action, array $options = []) } } +/** + * The compose profiles a git deploy runs with. + * + * The profiles asked for when there are any. Otherwise the profiles the stack + * is running with, else its default profiles, as the web UI's Update does: a + * deploy without profiles would leave the running profile services on the + * old commit, and compose.sh would forget the stack's running profiles. + * + * @param string[] $requested Profiles named for this deploy, often none + * @return string[] + */ +function gitDeployProfiles(StackInfo $stack, array $requested): array +{ + if ($requested !== []) { + return array_values($requested); + } + $running = $stack->getRunningProfiles(); + if ($running !== []) { + return array_values($running); + } + return array_values($stack->getDefaultProfiles()); +} + +/** + * compose.sh's command line for deploying a git stack (compose.sh gitdeploy). + * Used by both compose-git deploy and the web UI, so the two deploy alike. + * + * @param string|null $commit A full commit id to deploy, or null for the branch's latest + * @param string[] $profiles Compose profiles to enable + * @param string[] $waitArguments --wait and --wait-timeout, if the deploy waits for healthy containers + * @return string[] + */ +function buildGitDeployCommand(StackInfo $stack, ?string $commit, bool $saveLocalChanges, array $profiles, array $waitArguments): array +{ + $command = [dirname(__DIR__) . '/scripts/compose.sh', '-cgitdeploy', '-p' . $stack->projectName, '-s' . $stack->path]; + $credentialId = trim((string) ($stack->getCredentialId() ?? '')); + if ($credentialId !== '') { + $command[] = '--credential-id'; + $command[] = $credentialId; + } + foreach ($profiles as $profile) { + $command[] = '-g' . $profile; + } + if ($commit !== null) { + $command[] = '--git-commit'; + $command[] = $commit; + } + if ($saveLocalChanges) { + $command[] = '--save-local-changes'; + } + foreach ($waitArguments as $argument) { + $command[] = $argument; + } + return $command; +} + +/** + * 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 + * means the default services only. Otherwise the ones the stack runs with, as + * compose-git deploy does (gitDeployProfiles()). + * + * @param array $post The request: profile, profileChosen + * @return string[] + */ +function gitDeployProfilesFromRequest(StackInfo $stack, array $post): array +{ + if (($post['profileChosen'] ?? '') != '1') { + return gitDeployProfiles($stack, []); + } + $names = array_map('trim', explode(',', (string) ($post['profile'] ?? ''))); + return array_values(array_filter($names, static fn(string $name): bool => $name !== '')); +} + +/** + * Deploy a git stack from the web UI: the stack menu's "Pull and Redeploy" + * and "Deploy Commit...". Echoes what echoComposeCommand() does: a ttyd + * viewer URL, {"background":true}, or a JSON error. + * + * POST parameters: + * path: the stack folder (required) + * commit: a full commit id to deploy (optional; the branch's latest otherwise) + * saveLocalChanges: '1' to save files changed in the clone as a patch, then discard them + * profile: optional comma-separated list of profiles to enable + * profileChosen: '1' when profile is the person's choice (see gitDeployProfilesFromRequest()) + * + * @param array $options background + */ +function echoGitDeployCommand(array $options = []): void +{ + global $plugin_root; + global $sName; + global $compose_root; + $cfg = parse_plugin_cfg($sName); + $debug = ($cfg['DEBUG_TO_LOG'] ?? '') == "true"; + $path = isset($_POST['path']) ? trim($_POST['path']) : ''; + $commit = isset($_POST['commit']) ? strtolower(trim((string) $_POST['commit'])) : ''; + $saveLocalChanges = isset($_POST['saveLocalChanges']) && $_POST['saveLocalChanges'] == '1'; + $background = !empty($options['background']); + + $unRaidVars = parse_ini_file("/var/local/emhttp/var.ini"); + if ($unRaidVars['mdState'] != "STARTED") { + echo $plugin_root . "/scripts/arrayNotStarted.sh"; + return; + } + + try { + $stack = StackInfo::fromProject($compose_root, basename($path)); + } catch (\Throwable $e) { + composeLogger("Cannot deploy: invalid stack", ['path' => $path, 'error' => $e->getMessage()], 'user', 'warning', 'compose'); + echo json_encode(['error' => 'git', 'message' => 'There is no such stack.']); + return; + } + if (!$stack->isGitStack()) { + echo json_encode(['error' => 'git', 'message' => "'{$stack->projectFolder}' is not a git stack."]); + return; + } + if (!$stack->hasResolvedIdentity()) { + echo json_encode([ + 'error' => 'identity', + 'project' => $stack->projectFolder, + 'folderCandidate' => $stack->identity->folderCandidate, + 'legacyCandidate' => $stack->identity->legacyCandidate, + 'message' => $stack->getIdentityBlockReason(), + ]); + return; + } + // A full commit id only: the deploy refuses anything else too, but this + // way the person sees why at once, not in a terminal window. + require_once '/usr/local/emhttp/plugins/compose.manager/include/GitClone.php'; + if ($commit !== '' && !GitClone::isCommitId($commit)) { + echo json_encode(['error' => 'git', 'message' => 'Enter the full commit id (40 characters, or 64 in a SHA-256 repository), not a short one or a branch name.']); + return; + } + + $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']; + } + } + + $composeCommand = buildGitDeployCommand($stack, $commit === '' ? null : $commit, $saveLocalChanges, $profiles, $waitArguments); + if ($debug) { + $composeCommand[] = '--debug'; + } + + if ($background) { + // As echoComposeCommand(): output goes to last_cmd.log and a notification is sent at the end. + $bgCmd = escapeshellarg($plugin_root . "scripts/compose_background.sh"); + foreach ($composeCommand as $arg) { + $bgCmd .= ' ' . escapeshellarg($arg); + } + exec($bgCmd . ' > /dev/null 2>&1 &'); + composeLogger("Background command: " . $bgCmd, ['command' => $bgCmd], 'user', 'debug', 'compose'); + echo json_encode(['background' => true]); + return; + } + + $composeCommandString = implode(' ', array_map('escapeshellarg', $composeCommand)); + execComposeCommandInTTY($composeCommandString, $debug, getLastCmdLogFileForComposeAction('gitdeploy', $stack->path)); + echo "/plugins/compose.manager/include/ShowTtyd.php?done=1"; +} + /** * Build and echo a compose command for multiple stacks. * diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index bc9aa7d3..03861ce3 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -164,9 +164,199 @@ 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 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'; + $('#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($('