diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 199a81102..4696ac69e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,27 @@ jobs: DIFF_HEAD: ${{ github.sha }} run: python3 scripts/ci/changes.py >> "$GITHUB_OUTPUT" + # Every workflow, shellchecked `run:` blocks included. Not behind `changes`: it takes seconds, + # and a workflow edit is exactly what the selector would skip. The pin moves with the Makefile's + # `actions-lint` (CONTRIBUTING). The release archive is checked against its pinned sha256 before + # it runs, so a replaced asset or tag fails here instead of running. + workflows: + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + steps: + - uses: actions/checkout@v5 + + - name: Lint the workflows + run: | + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/$archive" + echo "$ACTIONLINT_SHA256 $archive" | sha256sum --check --strict + tar -xzf "$archive" actionlint + ./actionlint -color + cli: needs: changes if: always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') diff --git a/.github/workflows/host-image.yml b/.github/workflows/host-image.yml new file mode 100644 index 000000000..ae26ac141 --- /dev/null +++ b/.github/workflows/host-image.yml @@ -0,0 +1,120 @@ +name: Host image + +# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly +# workflow, which pins the app it builds to the digest this outputs. Release joins it with #260. +# +# Each architecture is built once, smoke-tested as that exact image (host-image-test.sh with +# WR_HOST_IMAGE), and only then pushed by digest: a second build for the push could resolve other +# Debian packages than the ones tested. The two digests are then joined into one multi-arch manifest +# under `tag`, and the manifest's digest is the output. +# +# The package must be public for an app to pull it without signing in to ghcr: set once, by hand, +# in the package's settings after its first push. + +on: + workflow_call: + inputs: + tag: + description: Tag the manifest is published under (nightly, or the release version). + required: true + type: string + outputs: + image: + description: The published manifest, by digest (ghcr.io//workroom-host@sha256:…). + value: ${{ jobs.manifest.outputs.image }} + +jobs: + build: + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + packages: write + outputs: + # One output per leg; a matrix job's outputs are last-writer-wins otherwise. + digest-amd64: ${{ steps.push.outputs.digest-amd64 }} + digest-arm64: ${{ steps.push.outputs.digest-arm64 }} + steps: + - uses: actions/checkout@v5 + + - name: Build + run: docker build --tag workroom-host-candidate vcs/scripts/ssh-fixture + + - name: Smoke-test that image + env: + WR_HOST_IMAGE: workroom-host-candidate + run: vcs/scripts/ssh-fixture/host-image-test.sh + + - name: Log in to ghcr + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + run: echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + + # Pushed under a per-run, per-arch tag only so `docker push` has something to push; the + # manifest below references it by digest, which is what the app pins. + - name: Push + id: push + run: | + repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/workroom-host" + tag="$repo:build-${{ github.run_id }}-${{ matrix.arch }}" + docker tag workroom-host-candidate "$tag" + digest="$(docker push "$tag" | sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p')" + test -n "$digest" || { echo "::error::no digest from docker push"; exit 1; } + echo "digest-${{ matrix.arch }}=$digest" >> "$GITHUB_OUTPUT" + + manifest: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.create.outputs.image }} + steps: + - name: Log in to ghcr + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + run: echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + + - name: Create the multi-arch manifest + id: create + env: + AMD64: ${{ needs.build.outputs.digest-amd64 }} + ARM64: ${{ needs.build.outputs.digest-arm64 }} + TAG: ${{ inputs.tag }} + run: | + repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/workroom-host" + docker buildx imagetools create --tag "$repo:$TAG" "$repo@$AMD64" "$repo@$ARM64" + digest="$(docker buildx imagetools inspect "$repo:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')" + case "$digest" in sha256:*) ;; *) echo "::error::no manifest digest: $digest"; exit 1 ;; esac + echo "image=$repo@$digest" >> "$GITHUB_OUTPUT" + echo "Published $repo@$digest ($TAG)" >> "$GITHUB_STEP_SUMMARY" + + # The app pulls without signing in to ghcr, so a package left private would ship a digest + # every create fails to pull. Asked as a stranger: an anonymous token, then the manifest. + - name: Check the image can be pulled anonymously + env: + IMAGE: ${{ steps.create.outputs.image }} + run: | + path="${IMAGE#ghcr.io/}"; name="${path%@*}"; digest="${IMAGE#*@}" + # A private package refuses the token itself, which the manifest's refusal below reports. + token="$(curl -sS "https://ghcr.io/token?scope=repository:$name:pull" | jq -r '.token // empty')" + status="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/$name/manifests/$digest")" + if [ "$status" != 200 ]; then + echo "::error::$IMAGE can't be pulled anonymously (HTTP $status): make the package public in its settings" + exit 1 + fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 11f0e40d8..d2fbda7be 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -121,9 +121,21 @@ jobs: # Build + sign + notarize + staple the DMG alongside the CLI. The publish job below # uploads it, updates the appcast, then stamps the successful version as the skip marker. - macapp: + # The `workroom-host` image tonight's local container workrooms run (#309), built, tested and + # published per architecture; its digest is pinned into the app below. + host-image: needs: check if: needs.check.outputs.should_build == 'true' + uses: ./.github/workflows/host-image.yml + permissions: + contents: read + packages: write + with: + tag: nightly + + macapp: + needs: [check, host-image] + if: needs.check.outputs.should_build == 'true' runs-on: macos-15 env: VERSION: ${{ needs.check.outputs.version }} @@ -240,6 +252,8 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + # The host image this app runs, by digest (release.sh → WorkroomHostImage). + WORKROOM_HOST_IMAGE: ${{ needs.host-image.outputs.image }} # release.sh derives MARKETING_VERSION = ${VERSION#v} and CURRENT_PROJECT_VERSION = commit # count; the nightly version has no leading v, so it flows through unchanged. run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b776ffc..e40e0a9b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,9 @@ jobs: # NOTARY_KEY / NOTARY_KEY_ID / NOTARY_ISSUER_ID. # Optional: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT enable Sentry dSYM upload so release # crashes symbolicate; release.sh skips that step when any of them (or sentry-cli) is absent. + # No `workroom-host` image (#309) yet: container workrooms are Nightly-only until #260, so a + # release has nothing to pin, and an image build that failed on tag day would hold the DMG back + # after the CLI has shipped. Add the `host-image` job (as nightly.yml has it) with #260. macapp: runs-on: macos-15 steps: diff --git a/AGENTS.md b/AGENTS.md index c47c9ca59..52525d357 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Run commands from the repository root; `make` lists available targets. - `make cli-build`, `make cli-test`: build the CLI and run `go test ./...`. - `make cli-lint`: run golangci-lint v2 and check gofmt/goimports formatting. +- `make actions-lint`: lint `.github/workflows` with actionlint, shellcheck included. - `make app-build`: build Rust dependencies, generate the Xcode project, and build Debug. - `make app-run`: rebuild and relaunch this checkout's Workroom Dev; stops its persisted session helpers. - `make app-test`: run app unit/integration tests; safe alongside other workrooms' runs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6600e9f91..695b408ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ Workroom *is* and how to use it, see [`README.md`](README.md). Before opening a PR: -- Run the linters on everything you touched: `make cli-lint` (Go) and `make app-lint` (Swift). +- Run the linters on everything you touched: `make cli-lint` (Go), `make app-lint` (Swift) and + `make actions-lint` (GitHub workflows). - Run the relevant tests: `make cli-test` and/or `make app-test`. - Keep the `--json` contract stable — it's the boundary between the CLI and the app. Breaking changes bump `schema_version` (`cmd/json.go`). See @@ -68,6 +69,11 @@ older Go cannot read the current toolchain's export data. v1 is not an option he `go/types` caps out at export-data version 2, so it cannot analyze a Go 1.27 stdlib, and it rejects the v2-format `.golangci.yml`. +**To change a GitHub workflow:** **`actionlint` 1.7.12** for `make actions-lint` +(`go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12`), with `shellcheck` on `PATH` so +`run:` blocks are checked too. CI's `workflows` job runs the same version, checked by the +release archive's sha256: bump the version and the hash there together. + Runtime requirements for *using* Workroom are in [the README](README.md#requirements). --- diff --git a/Makefile b/Makefile index cd16aadd8..d5f1d591e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ # # App recipes run inside macapp/ and need its toolchain on PATH (xcodegen via Homebrew). The # Xcode build also runs project.yml phases — a non-fatal swift-format lint and embedding the Go -# helper (macapp/Scripts/build-helper.sh). `cli-lint` needs golangci-lint installed (see AGENTS.md). +# helper (macapp/Scripts/build-helper.sh). `cli-lint` needs golangci-lint installed (see AGENTS.md), +# `actions-lint` actionlint (CONTRIBUTING). export PATH := /opt/homebrew/bin:/usr/local/bin:$(PATH) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) @@ -11,7 +12,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) .PHONY: help \ cli-build cli-test cli-install cli-lint cli-clean \ app-run app-build app-test app-uitest app-identity app-test-supervisor app-test-scripts app-generate app-format app-lint app-release app-icon app-tool-logos app-clean \ - remote-host-image remote-host-image-test + remote-host-image remote-host-image-test actions-lint help: ## List available targets @grep -hE '^[a-z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) \ @@ -39,6 +40,11 @@ cli-lint: ## Lint Go with golangci-lint (analyzers + formatters) cli-clean: ## Remove the built binary rm -f workroom +# --- GitHub Actions --- + +actions-lint: ## Lint .github/workflows with actionlint (shellcheck included) + actionlint + # --- macOS app (macapp/) --- # The Debug product is "Workroom Dev" (distinct bundle id + name) so it runs alongside the diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 92225f83f..1641cb10f 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -1630,6 +1630,76 @@ these are the subsystems that actually gate "a remote workroom is a real workroo instead of a fresh shell: its terminals as panes on its host, each attaching with `--no-create`. A held session whose workroom is deleted is dropped at the next save. - **Not done.** A held session is restored only in the window that claimed it. +- **As built (#309, local container workrooms on Docker and Apple's runtime).** A workroom can + be created in a container on the Mac, on Docker (Desktop, OrbStack, Colima) or on Apple's + `container`. These are not remote workrooms: they stop when the Mac sleeps. They ship with the + remote targets, behind the same preview and Nightly gate, until #260. + - **Choosing.** With the preview on, every way in asks where: a project's New Workroom is a + submenu, its row's "+" opens the same menu, and File ▸ New Workroom… (⌘N, ⌥⌘N, the tab bar's + "+") asks once its project is picked. The places are This Mac, then Docker and Apple Container + under "Local containers". It replaced New Remote Workroom. An entry that + can't be used says why in its title (`RemoteWorkrooms.unavailability`): Apple's runtime needs + Apple silicon and macOS 26, either may not be installed, and a create needs a Codaset or `gh` sign-in. + - **Drivers.** `RemoteHosts` keeps a `ContainerHostDriver` per `DriverKey`: runtime, and for + Docker the context. A descriptor's `driver` names the runtime; `container` stays Docker, so + every record before #309 is unchanged, and `apple-container` is Apple's. Each driver's sweep + keeps every recorded host and image, since two keys can reach one daemon. + - **Docker contexts.** A new Docker base is pinned to `docker context show` (never `default`, + which is whatever `DOCKER_HOST` says), recorded in its container record, and every command for + it names `--context`, which outranks `DOCKER_HOST` and `DOCKER_CONTEXT` (Docker 29, measured). + A record with no context runs with no `--context`, as before. + - **Bases.** A project keeps a base per runtime and context, in its descriptor's `bases` list + once it has more than one; a descriptor without the list is one base, as before. A workroom + derives from the base where it is asked for, made there first when there is none; a base made + before #309 (no context) still serves Docker. A delete builds an environment per key. + - **Apple's runtime (1.5.0, measured; its docs on `main` describe an unreleased CLI).** + `ContainerHostDriver.Dialect.apple` covers what differs. There is no `commit`: a derive stops + the base if running, exports its disk, starts it again in a task of its own, builds the disk + back into an image `FROM scratch` with the host image's own entrypoint and environment + (`AppleContainerCLI.dockerfile`, `$` escaped), runs it, and removes the image, since a + container's disk outlives it. `build --quiet` never returns. The builder is one VM for the whole + Mac and is left running. Apple removes an image a container still uses, so its sweep keeps any + image a container was run from. There is no `--filter` or template output, so it reads JSON; + no `--restart`, so a stopped container is started when its workroom is opened (below); + published ports survive a restart. Images are pulled `--arch arm64`. Each container gets + explicit CPUs and memory (half the cores, a quarter of memory, 2-8 GB; hidden + `containerCPUs`/`containerMemory` override), since Apple's default is a 1 GB VM. + - **Credentials.** A base decides how its workrooms reach GitHub, once, and records it + (`credentials: relay`): the broker when signed in to Codaset, else, or when the broker refuses + with `app_not_installed`, the Mac's own `gh`. A relayed base clones and a relayed workroom + fetches and branches with `gh auth token` as git's header (`cloneEnvironment`); the workroom + never enrols. Later git in it asks `wr-agent credential`, which, unenrolled, sends a per-launch + secret and the request to a port on its loopback (`relay.json`, set by `wr-agent credential + relay` on every connect); `ReverseForwardRegistry`, lifted out of the Debug broker route, + carries it to `CredentialRelay`'s listener on the Mac's loopback, which answers github.com over + https only, from `gh auth git-credential get`. Accepted (D9): anything in the workroom can ask, + so it has the user's `gh` access, as a workroom on the Mac does. It can ask only while the app + is connected (git is told to open the workroom when it isn't), but a token it was answered with + works until the user revokes it. An enrolled workroom never uses the relay. + - **Starting.** Selecting a container workroom marks its host opened (`RemoteHosts.activate`), and + its next connect starts a stopped container first, once: one the user stops afterwards stays + stopped until its workroom is selected again. Background status probes reach every reachable + remote workroom, so they never start one; an opened connect that finds one under way goes + after it rather than joining it. + - **The image.** Nightly (and Release, once #260 lifts the gate) builds the `FIXTURE=0` image + once per architecture, runs `host-image-test.sh` against that exact image (`WR_HOST_IMAGE`), + pushes it by digest and joins the two into one manifest on ghcr (`host-image.yml`). The + manifest's digest is pinned into the app (`WorkroomHostImage`); the hidden `remoteHostImage` + overrides it, and a local build runs `workroom-host`. A missing image is pulled before `run`, which never pulls (`--pull=never`). + The ghcr package must be made public once, by hand, after its first push: the workflow checks + the digest can be pulled anonymously, and `release.sh` won't build a Nightly without a pin. + - **Tests.** `RemoteHostsTests` drive both dialects through a stub CLI. The Docker fixture suites + (`run.sh`) cover the Docker path; `AppleContainerIntegrationTests` run the whole lifecycle on + the real Apple runtime, opt-in (`TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`), since CI's macOS + runners can't run its VMs. + - **Pull progress.** The first create in a project pulls the host image, a couple of hundred MB. + The pull's output is read as it arrives (`HostStream.communicate`'s `onOutput`) and turned + into a fraction (`ImagePullProgress`): Docker prints only each layer's state without a + terminal, so it is the layers done of those seen; Apple's `[step/steps] … N%` lines give its + step and percent. It reaches the create through a task-local (`pullProgress`), and the + project row's spinner fills as a circle with the percentage as its tooltip. + - **Not done.** A development environment in the container (toolchains, agent CLIs) is #308; + Docker on another machine is #307. - **Cross-machine session enumeration** (from Phase 1's S4 note): agent-side session naming, a pane→tab mapping on the wire, and workroom UI state stored with the workroom. - **OSC 7 and cmd-click (C7).** `SessionDaemon.swift:395-401` deliberately emits diff --git a/macapp/Scripts/release.sh b/macapp/Scripts/release.sh index 5786ac79b..31d8c79b7 100755 --- a/macapp/Scripts/release.sh +++ b/macapp/Scripts/release.sh @@ -95,6 +95,17 @@ BUILD_NUMBER="$(git -C "$MACAPP_DIR/.." rev-list --count HEAD 2>/dev/null || ech # Go helper — with our Developer ID, hardened runtime, and a secure timestamp. A plain `build` # leaves nested helpers without a timestamp (and injects get-task-allow), which notarization # rejects. Archiving also produces a distribution build with get-task-allow omitted. +# The `workroom-host` image the app's local container workrooms run, by digest (#309): CI's +# host-image workflow publishes it and passes it here. Without it the app falls back to a locally +# built `workroom-host` (`make remote-host-image`), which a user won't have, so a Nightly, the only +# build that runs container workrooms until #260, refuses to build without one. +case "$CONFIGURATION:${WORKROOM_HOST_IMAGE:-}" in + Nightly:*@sha256:*) ;; + Nightly:*) + echo "error: a Nightly needs WORKROOM_HOST_IMAGE, the workroom-host image by digest" \ + "(@sha256:...), got '${WORKROOM_HOST_IMAGE:-}'." >&2 + exit 1 ;; +esac echo "==> Archiving $CONFIGURATION ($APP_NAME) $SHORT_VERSION ($BUILD_NUMBER)" rm -rf "$ARCHIVE" "$EXPORT_DIR" xcodebuild -project "$PROJ" -scheme WorkroomApp -configuration "$CONFIGURATION" \ @@ -102,6 +113,7 @@ xcodebuild -project "$PROJ" -scheme WorkroomApp -configuration "$CONFIGURATION" -clonedSourcePackagesDirPath "$BUILD/SourcePackages" \ -archivePath "$ARCHIVE" \ MARKETING_VERSION="$SHORT_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + WORKROOM_HOST_IMAGE="${WORKROOM_HOST_IMAGE:-}" \ archive # Upload dSYMs to Sentry so release crashes symbolicate. The archive bundles them in diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 8b05f4ef9..b668b681b 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -208,6 +208,8 @@ final class AppStore: ObservableObject { // instead of lingering on the previous workroom's commits until the panel's `.task` catches // up. `focus` is idempotent, so the panel's later re-focus to the same root no-ops. if selectedTargetID != oldValue { + // Opening a container workroom starts its container if it was stopped (#309). + if let host = selectedWorkroom?.reachableHost { RemoteHosts.shared.activate(.remote(host)) } focusHistoryIfShown() focusRemoteStateIfShown() // A confirmation asking about the OLD workroom's dirty tree is meaningless once the selection @@ -1456,7 +1458,7 @@ final class AppStore: ObservableObject { let location = detachedLocation(for: target) detachedPanes.open( tabID: tabID, title: tab.title, project: location.project, workroom: location.workroom, - origin: origin, frame: frame, + glyph: target.workroomGlyph, origin: origin, frame: frame, onCloseTab: { [weak self] in self?.requestCloseTerminalTab(tabID, for: target) }, onDock: { [weak self] in self?.dockPane(tabID, for: target) }, content: { @@ -1467,7 +1469,8 @@ final class AppStore: ObservableObject { guard let self else { return } let location = self.detachedLocation(for: target) self.detachedPanes.setTitle( - title, project: location.project, workroom: location.workroom, for: tabID) + title, project: location.project, workroom: location.workroom, + glyph: target.workroomGlyph, for: tabID) } ) }) @@ -2923,6 +2926,11 @@ final class AppStore: ObservableObject { private func loadFixture() { let fixtures = applyingDeletionTombstones(UITestFixture.projects()) projects = fixtures + // A create mid-download, for the row's progress (#309). + if let pulled = UITestFixture.imagePull, let project = fixtures.first { + beginBusy(project.path) + imagePulls[project.path] = pulled + } // Fixture mode never reaches `apply`, so restore has to be released here or saves would stay // suspended for the whole run (issue #46). Fixture projects live in a temp directory that a real // saved session can't address anyway. @@ -3087,7 +3095,14 @@ final class AppStore: ObservableObject { acceptedPaths.contains($0.localSourcePath ?? $0.location.path) }) RepositoryRouter.shared.replaceRemote(RemoteWorkrooms.registrations(fresh)) + let resolved = selectedWorkroom?.reachableHost projects = fresh + // A selection restored before its projects were loaded opened nothing then (#309). Only then: + // every reload passes here, and opening again on each would restart a container the user + // stopped. + if resolved == nil, let host = selectedWorkroom?.reachableHost { + RemoteHosts.shared.activate(.remote(host)) + } // Prune shared caches only when publishing an accepted snapshot. let liveIDs = Set(fresh.map(\.id)) rootRefs = rootRefs.filter { liveIDs.contains($0.key) } @@ -3509,21 +3524,54 @@ final class AppStore: ObservableObject { }) } - /// Whether New Remote Workroom is on for `project`: not while another create holds it busy, since - /// a second create would build a second base. + /// How far each project's container workroom create has got downloading its host image, 0 to 1 + /// (#309): the first create in a project pulls it, a couple of hundred MB. The row's spinner + /// shows it. + @Published var imagePulls: [String: Double] = [:] + + /// Why no workroom can be created in `project` right now, as the New Workroom menu shows it beside + /// an entry (#309), or nil when one can: another create holds the project, or it is being deleted. + func createBlockedReason(in project: Project) -> String? { + if deletingProjects.contains(project.path) { return "the project is being deleted" } + if isBusyProject(project.path) { return "a workroom is already being created" } + return nil + } + + /// Whether a create must say that its new base fell back to the Mac's own GitHub token (#309, D9): + /// signed in to Codaset, yet the base it made relays, which only an uninstalled Codaset App does. + /// Said once, when the base is made; signed out, the relay is the only way, and expected. + nonisolated static func fellBackToGitHub( + signedIn: Bool, hadBase: Bool, made: HostDescriptor? + ) -> Bool { + signedIn && !hadBase && made?.isRelayed == true + } + + /// Whether creating a container workroom is on for `project`: not while another create holds it + /// busy, since a second create would build a second base. func canCreateRemoteWorkroom(in project: Project) -> Bool { RemoteWorkrooms.isEnabled && !isBusyProject(project.path) && !deletingProjects.contains(project.path) } - /// Creates a remote workroom for `project` (#253), derived from the project's base machine - /// (built first if it has none), then selects it, which opens its first pane on the far side. - func createRemoteWorkroom(in project: Project) async { + /// Creates a container workroom for `project` on `runtime` (#253, #309), derived from the + /// project's base machine (built first if it has none), then selects it, which opens its first + /// pane on the far side. `splitAnchor` lands it beside that workroom instead, as `createWorkroom`. + func createRemoteWorkroom( + in project: Project, runtime: RemoteWorkrooms.Runtime = .docker, splitAnchor: SidebarID? = nil + ) async { + // As it is now, not as a menu or the picker caught it: a create that finished meanwhile may + // have recorded the project's first base, which this one must derive from, not overwrite. + let project = projects.first { $0.path == project.path } ?? project guard canCreateRemoteWorkroom(in: project) else { return } beginBusy(project.path) defer { endBusy(project.path) } do { - let (driver, environment) = try RemoteHosts.shared.environment() + // A project keeps a base per runtime and Docker context (#309): the workroom derives from the + // one where it is asked for, made there first if there is none. + let wanted = try await RemoteHosts.shared.key(for: runtime) + let base = RemoteWorkrooms.base(in: project.host, for: wanted) + let key = base.flatMap(RemoteHosts.DriverKey.init) ?? wanted + let (driver, environment) = try RemoteHosts.shared.environment(key) let resolution = await WorkroomStatusResolver().resolveRepository(in: project.path) guard case .found(let repository) = resolution else { throw RemoteWorkrooms.Failure.notOnGitHub("Couldn't find its GitHub repository with gh.") @@ -3531,15 +3579,45 @@ final class AppStore: ObservableObject { guard repository.host == "github.com" else { throw RemoteWorkrooms.Failure.notOnGitHub("Its origin is on \(repository.host).") } - let created = try await RemoteWorkrooms.create( - repository: repository, cloneURL: RemoteWorkrooms.cloneURL(for: repository), - base: project.host, driver: driver, environment: environment, - recorder: remoteRecorder(project: project.path)) + let path = project.path + defer { imagePulls[path] = nil } + let report: @Sendable (Double?) -> Void = { fraction in + Task { @MainActor [weak self] in + // A report that arrives after the create ended shows nothing. + guard let self, self.isBusyProject(path) else { return } + self.imagePulls[path] = fraction + } + } + let created = try await ContainerHostDriver.$pullProgress.withValue(report) { + try await RemoteWorkrooms.create( + repository: repository, cloneURL: RemoteWorkrooms.cloneURL(for: repository), + base: base, project: project.host, runtime: runtime, driver: driver, + environment: environment, + recorder: remoteRecorder(project: project.path)) + } // Its panes attach through their own ssh; Changes and the rest connect for themselves. await created.instance.connection.close() await reload() selectedProjectID = project.id - selectedTargetID = .workroom(project: project.path, name: created.name) + let sid = SidebarID.workroom(project: project.path, name: created.name) + let landedInSplit = + splitAnchor.map { + insertWorkroomSplit( + sid, beside: $0, edge: .right, destinationRect: workroomPaneRect(for: $0)) + } ?? false + if !landedInSplit { selectedTargetID = sid } + let made = RemoteWorkrooms.base( + in: projects.first { $0.path == project.path }?.host, for: wanted) + if Self.fellBackToGitHub( + signedIn: environment.client != nil, hadBase: base != nil, made: made) + { + errorTitle = + "\(runtime.displayName) workrooms of \(project.displayName) use your own GitHub access" + errorMessage = + "The Codaset App isn't installed for \(repository.owner), so git in this project's " + + "\(runtime.displayName) workrooms asks your gh sign-in on this Mac, with all of its " + + "access, as a workroom on this Mac does. They keep doing so after the App is installed." + } } catch { await reload() present(error) @@ -3730,7 +3808,9 @@ final class AppStore: ObservableObject { // optimistic removal below forgets its label. var remote: RemoteProvisioning.Environment? if let host = workroom.host { - do { remote = try RemoteHosts.shared.environment(toDelete: [host]) } catch { + do { + remote = try RemoteHosts.shared.environment(toDelete: [host])?.environment(for: host) + } catch { present(error) errorTitle = "Can't delete \(workroom.displayName)" return @@ -4029,10 +4109,11 @@ final class AppStore: ObservableObject { // What that needs is checked here, before the local cleanup below kills this project's shells // and forgets its labels. let remoteWorkrooms = project.workrooms.filter(\.isRemote) - let remote: RemoteProvisioning.Environment? + let bases = project.host?.allBases ?? [] + let remote: RemoteHosts.Deletion? do { remote = try RemoteHosts.shared.environment( - toDelete: remoteWorkrooms.compactMap(\.host) + [project.host].compactMap { $0 }) + toDelete: remoteWorkrooms.compactMap(\.host) + bases, bases: Set(bases.compactMap(\.id))) } catch { present(error) errorTitle = "Can't delete \(project.displayName)" @@ -4069,13 +4150,26 @@ final class AppStore: ObservableObject { for workroom in remoteWorkrooms { guard let host = workroom.host else { continue } try await RemoteWorkrooms.delete( - workroom.name, host: host, environment: remote, recorder: recorder) + workroom.name, host: host, environment: remote?.environment(for: host), + recorder: recorder) } - if let base = project.host { - let cli = self.cli - try await RemoteWorkrooms.deleteBase(base, environment: remote) { - try await cli.setHost(project: project.path, workroom: nil, descriptor: nil) + // Each base on its own runtime and context (#309). Config keeps the ones still to go, so + // a failure part-way leaves what is live recorded for the next delete. + let cli = self.cli + var left = project.host + for base in bases { + // A base without an id can't be told apart, so the record stays as it is. + let rest = base.id.map { RemoteWorkrooms.removing($0, from: left) } ?? left + try await RemoteWorkrooms.deleteBase(base, environment: remote?.environment(for: base)) + { + try await cli.setHost( + project: project.path, workroom: nil, + descriptor: try rest.map { try JSONEncoder().encode($0) }) } + left = rest + } + if left != nil { + try await cli.setHost(project: project.path, workroom: nil, descriptor: nil) } let trashPaths = try await self.cli.deleteProject( project.path, diff --git a/macapp/WorkroomApp/Core/AppleContainerCLI.swift b/macapp/WorkroomApp/Core/AppleContainerCLI.swift new file mode 100644 index 000000000..cc6f74866 --- /dev/null +++ b/macapp/WorkroomApp/Core/AppleContainerCLI.swift @@ -0,0 +1,120 @@ +import Foundation + +/// What `ContainerHostDriver` reads from, and writes for, Apple's `container` CLI (#309): its +/// `--format json` output, which is the only format it has besides tables (no Go templates, no +/// `--filter`), and the Dockerfile a derive builds its snapshot with, since 1.5.0 has no `commit`. +/// Measured against `container` 1.5.0. +enum AppleContainerCLI { + /// A JSON array of objects, as `list`, `inspect`, `image list` and `image inspect` print. + static func objects(_ text: String) throws -> [[String: Any]] { + guard let objects = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[String: Any]] + else { + throw HostDriverError.provisioning("container printed something other than a JSON list") + } + return objects + } + + /// A container's ID (its name, as the driver runs it with `--name`). + static func id(of container: [String: Any]) -> String? { container["id"] as? String } + + /// `running`, `stopped`, … + static func state(of container: [String: Any]) -> String? { + (container["status"] as? [String: Any])?["state"] as? String + } + + static func labels(of container: [String: Any]) -> [String: String] { + (configuration(container)["labels"] as? [String: String]) ?? [:] + } + + /// The image reference the container was run from. + static func imageReference(of container: [String: Any]) -> String? { + (configuration(container)["image"] as? [String: Any])?["reference"] as? String + } + + /// An image's reference, as `image list` names it. + static func name(ofImage image: [String: Any]) -> String? { + configuration(image)["name"] as? String + } + + /// An image's labels, from whichever of its platforms carries them. + static func labels(ofImage image: [String: Any]) -> [String: String] { + variants(image).reduce(into: [:]) { labels, variant in + labels.merge(config(variant)["Labels"] as? [String: String] ?? [:]) { first, _ in first } + } + } + + /// How an image's process starts, for its `architecture` (`arm64`: the runtime is Apple + /// silicon only). Read from the image rather than from a container run from it, whose + /// environment holds what `run --env` added, such as the client key. + struct ProcessConfig: Equatable { + var entrypoint: [String]? = nil + var cmd: [String]? = nil + var env: [String] = [] + var workingDir: String? = nil + var user: String? = nil + } + + static func processConfig(ofImage image: [String: Any], architecture: String) -> ProcessConfig? { + guard + let variant = variants(image).first(where: { + ($0["platform"] as? [String: Any])?["architecture"] as? String == architecture + }) + else { return nil } + let config = config(variant) + return ProcessConfig( + entrypoint: config["Entrypoint"] as? [String], cmd: config["Cmd"] as? [String], + env: config["Env"] as? [String] ?? [], workingDir: config["WorkingDir"] as? String, + user: config["User"] as? String) + } + + /// The Dockerfile that turns an exported container disk (`rootfs.tar`, beside it) back into an + /// image that starts as `process` did. `ADD` unpacks a local tar. Exec form throughout, so + /// nothing passes through a shell; a value with a newline is refused, since no Dockerfile line + /// can hold one. + static func dockerfile(_ process: ProcessConfig) throws -> String { + func json(_ words: [String]) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + return String(decoding: try encoder.encode(words), as: UTF8.self) + } + let values = + process.env + (process.entrypoint ?? []) + (process.cmd ?? []) + + [process.workingDir, process.user].compactMap { $0 } + guard !values.contains(where: { $0.contains(where: \.isNewline) }) else { + throw HostDriverError.invalidConfiguration("the host image's config has a newline in it") + } + // A builder expands `$` in ENV, WORKDIR and USER; the image holds the values already expanded. + // Quotes only matter inside ENV's quoted value. + func literal(_ text: some StringProtocol, quoted: Bool = false) -> String { + let escaped = text.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "$", with: "\\$") + return quoted ? escaped.replacingOccurrences(of: "\"", with: "\\\"") : escaped + } + var lines = ["FROM scratch", "ADD rootfs.tar /"] + for variable in process.env { + guard let equals = variable.firstIndex(of: "=") else { continue } + lines.append( + "ENV \(variable[.. [String: Any] { + object["configuration"] as? [String: Any] ?? [:] + } + + private static func variants(_ image: [String: Any]) -> [[String: Any]] { + image["variants"] as? [[String: Any]] ?? [] + } + + private static func config(_ variant: [String: Any]) -> [String: Any] { + ((variant["config"] as? [String: Any])?["config"] as? [String: Any]) ?? [:] + } +} diff --git a/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift b/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift index aac66b608..7f6eaf614 100644 --- a/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift +++ b/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift @@ -27,54 +27,20 @@ final class BrokerReverseForwards { static let shared = BrokerReverseForwards() - /// How the registry reaches a host's connection, so a test can hand it a real agent without - /// `HostConnectionManager`: the shape `PortForwardingModel.Transport` set. - struct Transport { - var forwarding: - @Sendable (HostID) async throws -> (HostConnectionManager.Lease, AgentForwardService) - var updates: @Sendable (HostID) async -> AsyncStream - - static let live = Transport( - forwarding: { try await HostConnectionManager.shared.forwarding(host: $0) }, - updates: { await HostConnectionManager.shared.updates(for: $0) }) - } - - /// How long the agent may take to answer a `listen`. - static let listenTimeout: Duration = .seconds(5) - /// A bind that lost the race with the old connection's teardown is retried this often, this - /// many times: the agent notices a departed connection within its 200 ms accept poll. - static let bindRetries = 10 - static let bindRetryDelay: Duration = .milliseconds(200) - /// How many times a connection's listener is tried, `reopenRetry` apart, before the registry - /// waits for the next connection: five minutes at the default. - static let reopenAttempts = 60 - - private let transport: Transport - /// The Puma port on this Mac each connection is carried to, asked again for each listener. - private let target: @Sendable () async -> UInt16 - private let reopenRetry: Duration - private let log = Logger(subsystem: "com.developwithstyle.workroom", category: "broker") - - private struct Entry { - let host: HostID - /// The connection the listener is open on, once it is. - var lease: HostConnectionManager.Lease? - var forward: ReverseForward? - var watch: Task? - /// An open in flight, which a second caller waits on rather than opening a second listener. - /// The id is how the task, finishing, clears only itself. - var opening: (id: UUID, task: Task)? - } - private var entries: [UUID: Entry] = [:] + typealias Transport = ReverseForwardRegistry.Transport + private let registry: ReverseForwardRegistry init( transport: Transport = .live, target: @escaping @Sendable () async -> UInt16 = { await DevelopmentCodaset.port() }, reopenRetry: Duration = .seconds(5) ) { - self.transport = transport - self.target = target - self.reopenRetry = reopenRetry + registry = ReverseForwardRegistry( + transport: transport, port: Self.port(for:), target: target, + failure: { + BrokerError.agent("The remote agent could not reach this Mac's Codaset: \($0)") + }, + reopenRetry: reopenRetry) } /// The agent's port for `workroom`: the same on every connection and every launch, so the URL in @@ -96,188 +62,16 @@ /// Open the listener for `workroom` on `host`, keep it open on every later connection, and return /// the URL to give the agent. Throws when the agent does not listen now. func open(workroom: UUID, host: HostID) async throws -> URL { - if entries[workroom] == nil { - entries[workroom] = Entry(host: host) - watch(workroom) - } - try await listen(workroom) + try await registry.open(workroom: workroom, host: host) return Self.agentURL(for: workroom) } /// Stop carrying `workroom`'s connections: its enrolment failed, or the workroom is gone. - func close(workroom: UUID) { - guard let entry = entries.removeValue(forKey: workroom) else { return } - entry.watch?.cancel() - entry.opening?.task.cancel() - entry.forward?.stop() - } + func close(workroom: UUID) { registry.close(workroom: workroom) } /// Whether `workroom`'s listener is open on `lease`, for the tests. func isOpen(_ workroom: UUID, on lease: HostConnectionManager.Lease) -> Bool { - entries[workroom]?.lease == lease && entries[workroom]?.forward != nil - } - - private func listen(_ workroom: UUID) async throws { - // An open in flight is waited out, then this caller runs its own: the one in flight may be - // for an earlier connection than the caller's, and `reopen` does nothing when the current - // connection already has a listener. - while let opening = entries[workroom]?.opening { _ = try? await opening.task.value } - guard entries[workroom] != nil else { throw CancellationError() } - let id = UUID() - // The task clears its own entry as its last step, on this actor, BEFORE it completes: a - // waiter above resumes only after that, so it never sees a finished open still recorded - // (awaiting a finished task does not suspend, and it would spin on this actor forever). - let task = Task { - defer { - if self.entries[workroom]?.opening?.id == id { self.entries[workroom]?.opening = nil } - } - try await self.reopen(workroom) - } - entries[workroom]?.opening = (id, task) - try await task.value - } - - /// A listener on the host's current connection, unless one is already open there. - private func reopen(_ workroom: UUID) async throws { - guard let host = entries[workroom]?.host else { return } - let (lease, service) = try await transport.forwarding(host) - guard var entry = entries[workroom] else { return } - if entry.lease == lease, entry.forward != nil { return } - entry.forward?.stop() - entry.forward = nil - entries[workroom] = entry - - // A bind can lose a race on a reconnect: the agent releases the old connection's port only - // once it notices that connection ended, which can be just after the new one asks. The - // port is this workroom's alone, so a bind failure is retried for a moment, not reported. - var attempt = 0 - var outcome = await listen(service, port: Self.port(for: workroom), workroom, lease) - while case .failure(let refusal) = outcome, refusal.detail.hasPrefix("bind:"), - attempt < Self.bindRetries - { - attempt += 1 - try await Task.sleep(for: Self.bindRetryDelay) - outcome = await listen(service, port: Self.port(for: workroom), workroom, lease) - } - let forward: ReverseForward - switch outcome { - case .success(let listening): forward = listening - case .failure(let refusal): - throw BrokerError.agent( - "The remote agent could not reach this Mac's Codaset: \(refusal.detail)") - } - // Closed meanwhile, or another connection took over: this listener is nobody's. - guard entries[workroom] != nil else { - forward.stop() - throw CancellationError() - } - entries[workroom]?.lease = lease - entries[workroom]?.forward = forward - } - - /// One `listen`, answered or timed out. A refused listener is stopped before it is returned. - private func listen( - _ service: AgentForwardService, port: UInt16, _ workroom: UUID, - _ lease: HostConnectionManager.Lease - ) async -> Result { - let ready = ListenOutcome() - let forward = service.reverse(remotePort: port, target: await target()) { - [weak self, log] event in - switch event { - case .listening: - ready.resolve(nil) - case .failed(let detail): - log.info("broker reverse forward: \(detail, privacy: .public)") - case .stopped(let detail): - ready.resolve(detail) - Task { @MainActor in self?.lost(workroom, on: lease) } - } - } - forward.start() - if let refusal = await ready.wait(Self.listenTimeout) { - forward.stop() - return .failure(.init(refusal)) - } - return .success(forward) - } - - /// The listener ended. With its connection, the next connection opens a new one; on its own - /// (the agent's listener broke while the connection lives), no new connection is coming, so it - /// is reopened here. - private func lost(_ workroom: UUID, on lease: HostConnectionManager.Lease) { - guard entries[workroom]?.lease == lease else { return } - entries[workroom]?.lease = nil - entries[workroom]?.forward = nil - Task { await keepTrying(workroom) } - } - - /// Listen until it works, the workroom is closed, or the attempts run out. A link lost without - /// a goodbye leaves the old connection's listener holding the port until the agent notices it - /// has gone, which can take as long as ssh's keepalives, so a bind that keeps failing is tried - /// again every `reopenRetry` rather than given up on until the next connection. - private func keepTrying(_ workroom: UUID) async { - for attempt in 1...Self.reopenAttempts { - guard entries[workroom] != nil, !Task.isCancelled else { return } - do { - try await listen(workroom) - return - } catch { - if attempt == 1 || attempt == Self.reopenAttempts { - log.error( - "broker reverse forward did not reopen (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" - ) - } - } - try? await Task.sleep(for: reopenRetry) - } - } - - /// Reopen on every new connection to the host. - private func watch(_ workroom: UUID) { - guard let host = entries[workroom]?.host else { return } - entries[workroom]?.watch = Task { [weak self, transport] in - for await snapshot in await transport.updates(host) { - guard let self, !Task.isCancelled else { return } - guard snapshot.status == .connected, let lease = snapshot.lease, - self.entries[workroom]?.lease != lease - else { continue } - await self.keepTrying(workroom) - } - } - } - } - - /// The first answer to a `listen`, waited on with a deadline: nil once the agent listens, the - /// reason when it will not. - private final class ListenOutcome: @unchecked Sendable { - private let lock = NSLock() - private var outcome: String?? - private var waiter: CheckedContinuation? - - func resolve(_ refusal: String?) { - let waiter: CheckedContinuation? = lock.withLock { - guard outcome == nil else { return nil } - outcome = .some(refusal) - defer { self.waiter = nil } - return self.waiter - } - waiter?.resume(returning: refusal) - } - - func wait(_ timeout: Duration) async -> String? { - let timer = Task { [weak self] in - try? await Task.sleep(for: timeout) - self?.resolve("the agent did not answer the listen request") - } - defer { timer.cancel() } - return await withCheckedContinuation { continuation in - let settled: String?? = lock.withLock { - if let outcome { return outcome } - waiter = continuation - return nil - } - if let settled { continuation.resume(returning: settled) } - } + registry.isOpen(workroom, on: lease) } } diff --git a/macapp/WorkroomApp/Core/Broker/BrokerSession.swift b/macapp/WorkroomApp/Core/Broker/BrokerSession.swift index 06562c4fa..9765e7267 100644 --- a/macapp/WorkroomApp/Core/Broker/BrokerSession.swift +++ b/macapp/WorkroomApp/Core/Broker/BrokerSession.swift @@ -13,6 +13,11 @@ final class BrokerSession: ObservableObject { } @Published private(set) var state: State = .signedOut + + var isSignedIn: Bool { + if case .signedIn = state { return true } + return false + } @Published var error: String? private let credentials: BrokerCredentials diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index 13058572d..3bf738d2f 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -45,13 +45,35 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// `key=value` labels on every container and image made, so whoever started the fixture can /// sweep up what a crashed test left (run.sh does). let labels: [String] - } - - /// A host this driver made: its container, and for a derived one the image it was run from, - /// which only it uses. + /// The Docker context every command names (`--context`, #309), or nil for whichever daemon + /// the environment and the CLI's current context pick. Docker Desktop, OrbStack and Colima + /// are each a context behind the one `docker`, so without it a host made on one is looked for + /// on whichever the user switched to since. + var context: String? = nil + /// Which runtime's CLI `runtime` is: Docker's, or Apple's `container` (#309). + var dialect: Dialect = .docker + /// CPUs and memory (`4G`) each container gets, or nil for the runtime's own default. Apple's is + /// 1 GB per container VM, too little for a compiler or an agent; Docker's containers share its + /// VM's. + var cpus: Int? = nil + var memory: String? = nil + } + + /// The two container CLIs a driver can speak (#309). Apple's `container` (1.5.0) runs each + /// container in a VM of its own and has no `commit`, no `--restart`, no `--filter` and no Go + /// templates (`AppleContainerCLI`). + enum Dialect: Sendable { + case docker + case apple + } + + /// A host this driver made: its container, whether it is a base (the one kind a workroom is + /// derived from), and for a derived one the image it was run from, which only it uses. An Apple + /// instance has no image: its own disk outlives the image it was run from, which is removed. private struct Provisioned { let container: String let image: String? + let isBase: Bool } /// What a host this driver made needs to be reached and destroyed again by a driver in a later @@ -65,9 +87,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { let hostKey: String /// The image a derived instance was run from, which only it uses; nil for a base. let image: String? + /// The Docker context it runs in (`Provisioning.context`). nil, as every record made before + /// #309 has it, follows the environment and the CLI's current context, as those always did. + var context: String? = nil enum CodingKeys: String, CodingKey { - case address, port, user, image + case address, port, user, image, context case hostKey = "host_key" } } @@ -109,7 +134,79 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { func create() async throws -> HostID { guard let provisioning else { throw HostDriverError.notImplemented("Creating a base") } - return try await run(provisioning.image, image: nil) + try await ensureImage(provisioning.image) + return try await run(provisioning.image, image: nil, isBase: true) + } + + /// Told how far a host image's pull has got, 0 to 1, then nil once it is done, for a create run + /// inside `$pullProgress.withValue` (#309): a task-local, so `HostDriver.create` keeps its shape + /// and two creates at once each hear only their own. + @TaskLocal static var pullProgress: (@Sendable (Double?) -> Void)? + + /// Pulls `image` unless the runtime already has it (#309). `run` itself never pulls + /// (`--pull=never`): left to it, a missing `workroom-host` was looked for on Docker Hub as + /// `library/workroom-host`, and failed with a registry error that said nothing about why. + private func ensureImage(_ image: String) async throws { + let apple = provisioning?.dialect == .apple + let inspect = + apple ? ["image", "inspect", image] : ["image", "inspect", "--format", "{{.Id}}", image] + do { + _ = try await runtime(inspect) + return + } catch { + // Missing, unless it's the runtime itself that didn't answer. + if let down = Self.runtimeDown(error.localizedDescription, apple: apple) { throw down } + } + do { + // Silence-bounded, and a pull reports its progress as it goes. Apple's unpacks every + // platform of a multi-arch image unless told which, and it runs on Apple silicon only. + let report = Self.pullProgress + let progress = PullReader() + _ = try await runtime( + apple ? ["image", "pull", "--arch", "arm64", image] : ["pull", image], timeout: 900, + onOutput: report.map { report in + { data, stderr in + if let fraction = progress.read(data, stderr: stderr) { report(fraction) } + } + }) + // The rest of the create (a clone, a derive) can take minutes, and isn't a download. + report?(nil) + } catch { + let said = error.localizedDescription + if let down = Self.runtimeDown(said, apple: apple) { throw down } + if ["denied", "unauthorized", "forbidden", "manifest unknown"].contains(where: { + said.lowercased().contains($0) + }) { + throw HostDriverError.provisioning( + "the registry refused the workroom host image \(image): it isn't public, or this build " + + "names one that doesn't exist. (\(said))") + } + throw HostDriverError.provisioning( + "couldn't download the workroom host image \(image). Check your network connection, " + + "then try again. (\(said))") + } + } + + /// The error for a runtime that didn't answer at all, by what it printed, or nil when it did + /// (#309): every create's first command would otherwise blame the image or the network. + static func runtimeDown(_ said: String, apple: Bool) -> Error? { + if apple { + guard said.contains("container system start") || said.contains("XPC connection") else { + return nil + } + return HostDriverError.provisioning( + "Apple's container runtime isn't running. Run `container system start`, then try again.") + } + // "Cannot connect to…", and a socket the user can't open ("permission denied while trying to + // connect to the Docker daemon socket"), which the registry check would read as a refusal. + let lowered = said.lowercased() + guard + ["connect to the docker daemon", "error during connect", "unable to resolve docker"] + .contains(where: lowered.contains) + else { return nil } + return HostDriverError.provisioning( + "Docker didn't answer. Start it (Docker Desktop, OrbStack or Colima), or check that the " + + "Docker context is one on this Mac, then try again.") } /// A snapshot of `base`'s disk, run as a container of its own. The base keeps running: `commit` @@ -125,9 +222,10 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { } // An instance has enrolled, and its key and credential helper are on its disk: a copy of it // would start with them. - guard source.image == nil else { + guard source.isBase else { throw HostDriverError.invalidConfiguration("a workroom instance cannot be derived from") } + if provisioning.dialect == .apple { return try await appleDerive(from: source) } // Labelled with an ID chosen here, because the runtime's own is known only from its output: // a commit that outlives its CLI (killed by the silence bound, or a cancelled derive) leaves // an image nothing would otherwise find. @@ -141,7 +239,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { + [source.container], // `commit` prints nothing until it is done, and copying a base's disk takes a while. timeout: 900) - return try await run(image, image: image) + return try await run(image, image: image, isBase: false) } catch { let removals = await Task { () -> [String] in do { @@ -166,6 +264,110 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { } } + /// Apple's derive (#309). `container` 1.5.0 has no `commit`, so the base's disk is exported (with + /// the base stopped, so the copy is consistent, and started again whatever happens), built back + /// into an image from scratch with the host image's own entrypoint and environment, and run. + /// The image is then removed: Apple's runtime gives each container a disk of its own, which + /// outlives the image it came from, and a derived image is the size of the base's whole disk. + /// Apple also removes an image a container still uses, so nothing would protect one later. + private func appleDerive(from source: Provisioned) async throws -> HostID { + guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } + let tag = Self.deriveDirectoryPrefix + UUID().uuidString.lowercased() + let work = FileManager.default.temporaryDirectory.appendingPathComponent(tag) + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: work) } + + try AppleContainerCLI.dockerfile(try await appleProcess(of: source, provisioning)).write( + to: work.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8) + + let rootfs = work.appendingPathComponent("rootfs.tar") + // Stopped only if running, and started again whatever happens, even if this derive is + // cancelled: a cancelled `runtime` call ends its CLI, so the start runs in a task of its own. + // A base found stopped (an earlier derive cut off mid-export) is exported as it is and left + // stopped: nothing needs it running, since a derive reads its disk, not its processes. + let wasRunning = + try AppleContainerCLI.objects( + try await runtime(["inspect", source.container], allLines: true) + ).first.flatMap(AppleContainerCLI.state) == "running" + if wasRunning { + do { _ = try await runtime(["stop", source.container]) } catch { + // Cut off mid-stop (a cancelled create ends its CLI), it may have stopped all the same: + // started again, as after the export, so a failed create never leaves the base down. + _ = try? await Task { _ = try await self.runtime(["start", source.container]) }.value + throw error + } + } + let exported: Error? + do { + // ponytail: `export` prints nothing until the whole disk is written, so this silence bound + // is a total one, an hour; upgrade path: export to stdout and stream it to the file. + _ = try await runtime(["export", "--output", rootfs.path, source.container], timeout: 3600) + exported = nil + } catch { exported = error } + if wasRunning { + try await Task { _ = try await self.runtime(["start", source.container]) }.value + } + if let exported { throw exported } + + // Never `--quiet`: in 1.5.0 a quiet build never returns. The builder is one VM for the whole + // Mac, which other builds may be using, so it is left running. + do { + _ = try await runtime( + ["build"] + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } + + ["--tag", tag, work.path], timeout: 900) + // The image holds the disk now; the tar is as big again. + try? FileManager.default.removeItem(at: rootfs) + let host = try await run(tag, image: nil, isBase: false) + _ = try? await runtime(["image", "delete", tag]) + return host + } catch { + _ = await Task { try? await self.runtime(["image", "delete", "--force", tag]) }.value + throw error + } + } + + /// How the image `source` was run from starts its process, for the derived image to start the + /// same way; the host image's own when that image is gone (Apple removes images still in use, so + /// a prune or a newer pinned image can take it). + private func appleProcess(of source: Provisioned, _ provisioning: Provisioning) async throws + -> AppleContainerCLI.ProcessConfig + { + let base = try AppleContainerCLI.objects( + try await runtime(["inspect", source.container], allLines: true)) + for reference in [base.first.flatMap(AppleContainerCLI.imageReference), provisioning.image] + .compactMap({ $0 }) + { + guard let text = try? await runtime(["image", "inspect", reference], allLines: true), + let image = try? AppleContainerCLI.objects(text).first, + let process = AppleContainerCLI.processConfig(ofImage: image, architecture: "arm64") + else { continue } + return process + } + throw HostDriverError.provisioning("couldn't read the base's image to derive from") + } + + /// A pull's output, read as it arrives from two streams at once. + private final class PullReader: @unchecked Sendable { + private let lock = NSLock() + private var progress = ImagePullProgress() + private var last: Double? + + /// The fraction now, when it moved by a whole percent or more since the last one told. + func read(_ data: Data, stderr: Bool) -> Double? { + lock.withLock { + progress.read(String(decoding: data, as: UTF8.self), stderr: stderr) + guard let fraction = progress.fraction, + last.map({ abs(fraction - $0) >= 0.01 }) ?? true + else { return nil } + last = fraction + return fraction + } + } + } + + /// Where an Apple derive stages a base's exported disk, and the derived image's name. + static let deriveDirectoryPrefix = "workroom-derive-" + /// Removes the container and the image it alone was run from. A host handed in rather than /// made here is not this driver's to remove. func destroy(_ host: HostID) async throws { @@ -176,8 +378,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { } throw HostDriverError.unknownHost(host) } - _ = try await runtime(["rm", "--force", "--volumes", made.container]) - if let image = made.image { _ = try await runtime(["rmi", "--force", image]) } + if provisioning?.dialect == .apple { + _ = try await runtime(["delete", "--force", made.container]) + } else { + _ = try await runtime(["rm", "--force", "--volumes", made.container]) + if let image = made.image { _ = try await runtime(["rmi", "--force", image]) } + } lock.withLock { hosts[id] = nil provisioned[id] = nil @@ -186,6 +392,28 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { try? FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString)) } + /// Starts `host`'s container if it is stopped, and waits until it can be logged in to (#309): a + /// container someone stopped stays stopped, and a runtime with no restart policy (Apple's) leaves + /// every one stopped after a reboot. Only for a workroom the user opens (`RemoteHosts.activate`), + /// never for a background probe, which would start every container the app knows. + func startIfStopped(_ host: HostID) async throws { + guard case .remote(let id) = host, let made = lock.withLock({ provisioned[id] }) else { + throw HostDriverError.unknownHost(host) + } + let stopped: Bool + if provisioning?.dialect == .apple { + let containers = try AppleContainerCLI.objects( + try await runtime(["inspect", made.container], allLines: true)) + stopped = containers.first.flatMap(AppleContainerCLI.state) == "stopped" + } else { + stopped = + try await runtime(["inspect", "--format", "{{.State.Running}}", made.container]) == "false" + } + guard stopped else { return } + _ = try await runtime(["start", made.container]) + try await awaitLogin(host) + } + /// What a later launch's driver needs to `adopt` `host`, or nil for a host this driver did not /// make. func record(of host: HostID) -> Record? { @@ -194,26 +422,36 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { guard let target = hosts[id], let made = provisioned[id] else { return nil } return Record( address: target.address, port: target.port, user: target.user, hostKey: target.hostKey, - image: made.image) + image: made.image, context: provisioning?.context) } } /// Takes a host an earlier launch made back on (#253), as its `record` describes it, so it can /// be reached, derived from and destroyed as if this driver had made it. Whether its container - /// is still there is found out by using it. - func adopt(_ id: UUID, _ record: Record) throws { + /// is still there is found out by using it. `isBase` says whether it is a project's base; left + /// out, a Docker host with no image is one, as only a base has none. An Apple instance has none + /// either, so it is a base only when the caller says so. + func adopt(_ id: UUID, _ record: Record, isBase: Bool? = nil) throws { guard let provisioning else { throw HostDriverError.notImplemented("Adopting a host") } // `destroy` removes it by this, with `--force`: a config edited by hand must not name an image // that is not a commit's. if let image = record.image, !Self.isImageID(image) { throw HostDriverError.invalidConfiguration("image \(image) is not an image ID") } + // Its container is on that context's daemon, which this driver's commands don't reach. + guard record.context == provisioning.context else { + throw HostDriverError.invalidConfiguration( + "host is in Docker context \(record.context ?? "(current)"), not " + + (provisioning.context ?? "(current)")) + } lock.withLock { hosts[id] = Host( address: record.address, port: record.port, user: record.user, identityFile: provisioning.identityFile, hostKey: record.hostKey, agentSocket: provisioning.agentSocket) - provisioned[id] = Provisioned(container: Self.containerName(id), image: record.image) + provisioned[id] = Provisioned( + container: Self.containerName(id), image: record.image, + isBase: isBase ?? (provisioning.dialect == .docker && record.image == nil)) destroyed.remove(id) } } @@ -224,8 +462,15 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// since it may belong to a create or derive still under way, in this app or another sharing /// the daemon. Returns what it could not remove. /// ponytail: a resource made before `createdLabel` existed has no age and counts as old. - func sweep(keeping known: Set, grace: TimeInterval = 20 * 60) async -> [String] { + /// `images` are images other drivers' hosts were run from, kept too: a driver for another Docker + /// context can reach the same daemon (#309). + func sweep( + keeping known: Set, images: Set = [], grace: TimeInterval = 20 * 60 + ) async -> [String] { guard let provisioning, !provisioning.labels.isEmpty else { return [] } + if provisioning.dialect == .apple { + return await appleSweep(keeping: known, images: images, grace: grace) + } let filters = provisioning.labels.flatMap { ["--filter", "label=\($0)"] } let cutoff = Date().timeIntervalSince1970 - grace func old(_ created: Substring) -> Bool { (TimeInterval(created) ?? 0) <= cutoff } @@ -233,7 +478,8 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { let keptContainers = Set(known.map(Self.containerName)) let keptImages = Set( - lock.withLock { known.compactMap { provisioned[$0]?.image } }.map(Self.shortImageID)) + (lock.withLock { known.compactMap { provisioned[$0]?.image } } + images).map( + Self.shortImageID)) do { for line in try await runtime( ["ps", "-a"] + filters + ["--format", "{{.Names}}\t{{.Label \"\(Self.createdLabel)\"}}"], @@ -273,6 +519,81 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { return failed } + /// Apple's sweep (#309): the same rules as Docker's, read from JSON, since its CLI has no + /// `--filter` and no templates. An image is kept while any container at all was run from it: + /// Apple removes an image a container still uses, where Docker refuses. + private func appleSweep(keeping known: Set, images kept: Set, grace: TimeInterval) + async -> [String] + { + guard let provisioning else { return [] } + let cutoff = Date().timeIntervalSince1970 - grace + func ours(_ labels: [String: String]) -> Bool { + provisioning.labels.allSatisfy { label in + let parts = label.split(separator: "=", maxSplits: 1).map(String.init) + return parts.count == 2 && labels[parts[0]] == parts[1] + } + } + func old(_ labels: [String: String]) -> Bool { + (labels[Self.createdLabel].flatMap(TimeInterval.init) ?? 0) <= cutoff + } + var failed: [String] = [] + // A derive a crash cut short leaves its staged disk behind, the size of a base's whole disk. One + // still under way is writing to it, which keeps its newest time recent. + let temp = FileManager.default.temporaryDirectory + for name in (try? FileManager.default.contentsOfDirectory(atPath: temp.path)) ?? [] + where name.hasPrefix(Self.deriveDirectoryPrefix) { + let directory = temp.appendingPathComponent(name) + let times = + ([directory] + + ((try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.contentModificationDateKey])) ?? [])) + .compactMap { try? $0.resourceValues(forKeys: [.contentModificationDateKey]) } + .compactMap(\.contentModificationDate) + if let newest = times.max(), newest.timeIntervalSince1970 <= cutoff { + try? FileManager.default.removeItem(at: directory) + } + } + let keptContainers = Set(known.map(Self.containerName)) + var inUse = kept + do { + for container in try AppleContainerCLI.objects( + try await runtime(["list", "--all", "--format", "json"], allLines: true)) + { + // A derive's snapshot is the exception: its container runs without it (1.5.0, measured), + // and one whose delete failed would otherwise hold a whole disk for the workroom's life. + if let image = AppleContainerCLI.imageReference(of: container), + !image.contains(Self.deriveDirectoryPrefix) + { + inUse.insert(image) + } + let labels = AppleContainerCLI.labels(of: container) + guard let id = AppleContainerCLI.id(of: container), ours(labels), old(labels), + !keptContainers.contains(id) + else { continue } + do { _ = try await runtime(["delete", "--force", id]) } catch { + failed.append("container \(id): \(error.localizedDescription)") + } + } + } catch { + // Without the list, no image is known to be unused. + return ["listing containers: \(error.localizedDescription)"] + } + do { + for image in try AppleContainerCLI.objects( + try await runtime(["image", "list", "--format", "json"], allLines: true)) + { + let labels = AppleContainerCLI.labels(ofImage: image) + guard let name = AppleContainerCLI.name(ofImage: image), ours(labels), old(labels), + !inUse.contains(name) + else { continue } + do { _ = try await runtime(["image", "delete", name]) } catch { + failed.append("image \(name): \(error.localizedDescription)") + } + } + } catch { failed.append("listing images: \(error.localizedDescription)") } + return failed + } + static func containerName(_ id: UUID) -> String { "workroom-\(id.uuidString.lowercased())" } private static func created() -> String { @@ -294,7 +615,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// Runs a container from `source` and waits until it can be reached: its identity minted, its /// host key pinned, and an ssh login through this driver's own configuration answering. A /// container that gets no further is removed. - private func run(_ source: String, image: String?) async throws -> HostID { + private func run(_ source: String, image: String?, isBase: Bool) async throws -> HostID { guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } // A port of its own rather than an ephemeral one (`127.0.0.1::22`): a restart keeps it, so the // host's address outlives a reboot, as a provider's box keeps its address. @@ -309,21 +630,27 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { // outlives its CLI still leaves a container this name removes. let container = Self.containerName(id) do { - _ = try await runtime( - // `unless-stopped`: a Docker or Mac restart brings it back, on the port its record names. - [ - "run", "--detach", "--init", "--restart", "unless-stopped", "--name", container, - "--publish", "127.0.0.1:\(port):22", - ] - + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } - + ["--env", "AUTHORIZED_KEY=\(provisioning.publicKey)", source]) + // Docker's `unless-stopped`: a Docker or Mac restart brings it back, on the port its record + // names. Apple's runtime has no restart policy; opening the workroom starts it + // (`startIfStopped`), on the port it keeps. + // In steps, typed: as one expression, CI's Swift gave up type-checking it. + var arguments: [String] = + provisioning.dialect == .apple + ? ["run", "--detach", "--init", "--arch", "arm64"] + : ["run", "--pull=never", "--detach", "--init", "--restart", "unless-stopped"] + if let cpus = provisioning.cpus { arguments += ["--cpus", String(cpus)] } + if let memory = provisioning.memory { arguments += ["--memory", memory] } + arguments += ["--name", container, "--publish", "127.0.0.1:\(port):22"] + for label in provisioning.labels + [Self.created()] { arguments += ["--label", label] } + arguments += ["--env", "AUTHORIZED_KEY=\(provisioning.publicKey)", source] + _ = try await runtime(arguments) let hostKey = try await identity(of: container) lock.withLock { hosts[id] = Host( address: "127.0.0.1", port: Int(port), user: provisioning.user, identityFile: provisioning.identityFile, hostKey: hostKey, agentSocket: provisioning.agentSocket) - provisioned[id] = Provisioned(container: container, image: image) + provisioned[id] = Provisioned(container: container, image: image, isBase: isBase) } try await awaitLogin(.remote(id)) return .remote(id) @@ -332,9 +659,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { hosts[id] = nil provisioned[id] = nil } + let remove = + provisioning.dialect == .apple + ? ["delete", "--force", container] : ["rm", "--force", "--volumes", container] let removal = await Task { () -> String? in do { - _ = try await self.runtime(["rm", "--force", "--volumes", container]) + _ = try await self.runtime(remove) return nil } catch { return "container \(container): \(error.localizedDescription)" } }.value @@ -376,24 +706,46 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// or with `allLines` all of it. Its environment is the few variables that say which daemon to /// talk to, never the app's whole one. private func runtime( - _ arguments: [String], timeout: TimeInterval = 120, allLines: Bool = false + _ arguments: [String], timeout: TimeInterval = 120, allLines: Bool = false, + onOutput: (@Sendable (Data, _ stderr: Bool) -> Void)? = nil ) async throws -> String { guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } let environment = ProcessInfo.processInfo.environment.filter { ["HOME", "PATH", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG"].contains($0.key) } let (status, output) = try await HostStream.spawn( - provisioning.runtime, arguments, environment: environment, handshakeTimeout: 20, - purpose: .exchange - ).communicate(nil, timeout: timeout) + provisioning.runtime, Self.runtimeArguments(arguments, context: provisioning.context), + environment: environment, handshakeTimeout: 20, purpose: .exchange + ).communicate(nil, timeout: timeout, onOutput: onOutput) let said = output.trimmingCharacters(in: .whitespacesAndNewlines) guard status == 0 else { + if let context = provisioning.context, said.contains("context not found") { + throw HostDriverError.provisioning( + "the Docker context \(context) this host was made in no longer exists. Create it " + + "again (`docker context create \(context)`), or delete the workroom.") + } throw HostDriverError.provisioning( "\(provisioning.runtime.lastPathComponent) \(arguments[0]) exited \(status): \(said)") } return allLines ? said : said.split(separator: "\n").first.map(String.init) ?? "" } + /// `arguments` for the runtime, naming `context` when there is one. `--context` is global, so it + /// goes before the command (after it, Docker refuses it), and it outranks both `DOCKER_HOST` and + /// `DOCKER_CONTEXT` (Docker 29, measured). + static func runtimeArguments(_ arguments: [String], context: String?) -> [String] { + guard let context else { return arguments } + return ["--context", context] + arguments + } + + /// The Docker context the CLI would use now, to pin a new host to (#309), or nil for `default`: + /// that one is whatever `DOCKER_HOST` says, so naming it pins nothing. Asks a driver whose own + /// context is nil, so the answer is the environment's and the CLI's. + func currentContext() async throws -> String? { + let name = try await runtime(["context", "show"]) + return name.isEmpty || name == "default" ? nil : name + } + func openStream(to host: HostID) async throws -> HostStream { let (id, target) = try target(host) return try Self.exec( diff --git a/macapp/WorkroomApp/Core/CredentialRelay.swift b/macapp/WorkroomApp/Core/CredentialRelay.swift new file mode 100644 index 000000000..d5602be4d --- /dev/null +++ b/macapp/WorkroomApp/Core/CredentialRelay.swift @@ -0,0 +1,374 @@ +import Foundation +import os + +/// The Mac's end of the git credential relay for a local container workroom that never enrolled +/// with the Codaset broker (#309): the user is signed in to `gh`, not to Codaset, or the repository's +/// owner hasn't installed the Codaset App. +/// +/// Its agent's `wr-agent credential` dials a port on its own box's loopback (`port(for:)`), which +/// `ReverseForwardRegistry` carries over the app's connection to a listener here, on this Mac's +/// loopback. A connection sends the workroom's secret, then git's request; it is answered from +/// `gh auth git-credential get`, for `https://github.com` only. The secret is what keeps another +/// user of this Mac, who can also reach the listener, from being answered; it is made per launch +/// and installed on every connect (`install`). +/// +/// **Accepted exposure (D9, #309).** Anything in the workroom can read the secret and ask, so such +/// a workroom has the GitHub access the user's own `gh` has, as a workroom on the Mac itself does. +/// It can ask only while the app is connected, but a token it was answered with works until the +/// user revokes it. A remote workroom never uses this: it always enrols. +final class CredentialRelay: @unchecked Sendable { + static let shared = CredentialRelay() + + private let lock = NSLock() + private var listener: (descriptor: Int32, port: UInt16)? + /// Each relayed host's secret. + private var secrets: [UUID: String] = [:] + private let answer: @Sendable (_ request: String) throws -> String + /// How the registry reaches a host's connection: the app's own, or a test's. + private let transport: ReverseForwardRegistry.Transport + /// The listeners on relayed hosts, made on the main actor the first time one is installed. + private var forwards: ReverseForwardRegistry? + private static let logger = Logger( + subsystem: "com.developwithstyle.workroom", category: "CredentialRelay") + /// The most a request may be: git's credential protocol is a handful of short lines. + static let maxRequest = 16 * 1024 + + init( + answer: @escaping @Sendable (_ request: String) throws -> String = CredentialRelay.gh, + transport: ReverseForwardRegistry.Transport = .live + ) { + self.answer = answer + self.transport = transport + } + + /// The agent's port for workroom host `id`: the same on every connection and every launch, so + /// the `relay.json` it was given stays right. 30000-31999, below Linux's ephemeral ports (32768 + /// up) and apart from the Debug broker route's (`BrokerReverseForwards`). + static func port(for id: UUID) -> UInt16 { + let bytes = id.uuid + return 30_000 + (UInt16(bytes.2) << 8 | UInt16(bytes.3)) % 2_000 + } + + /// The secret host `id`'s agent sends, made the first time it is asked for this launch. Throws + /// rather than make one the system's random source didn't fill: a guessable secret would answer + /// anyone on this Mac. + func secret(for id: UUID) throws -> String { + try lock.withLock { + if let secret = secrets[id] { return secret } + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + throw HostDriverError.provisioning("couldn't make a secret for git's credentials") + } + let secret = bytes.map { String(format: "%02x", $0) }.joined() + secrets[id] = secret + return secret + } + } + + /// Connections read at once, before anything is known of them; one more is turned away. A read + /// takes a thread for up to `requestDeadline`, so this bounds what a flood can hold. + static let maxReading = 32 + /// Requests answered at once, each a gh run of up to 15 s. Only a request with a known secret + /// gets here, so connections that never say one, from anyone who can reach the listener, can't + /// keep a workroom from being answered. + static let maxConcurrent = 8 + /// The most one request may take to arrive, whatever pace its bytes come at. The agent writes it + /// whole, at once (`relay` in `broker.rs`), so a second is plenty. + static let requestDeadline: TimeInterval = 2 + private let reading = DispatchSemaphore(value: maxReading) + private let serving = DispatchSemaphore(value: maxConcurrent) + private let queue = DispatchQueue( + label: "com.developwithstyle.workroom.credential-relay", qos: .userInitiated, + attributes: .concurrent) + + /// The listener on this Mac's loopback, started the first time it is needed. + func localPort() throws -> UInt16 { + try lock.withLock { + if let listener { return listener.port } + guard let made = LoopbackSocket.listen(backlog: 8) else { + throw HostDriverError.provisioning("couldn't listen for git credentials (errno \(errno))") + } + listener = made + Thread.detachNewThread { [weak self] in self?.accept(on: made.descriptor) } + return made.port + } + } + + private func accept(on descriptor: Int32) { + while true { + let connection = Darwin.accept(descriptor, nil, nil) + if connection < 0 { + switch errno { + case EINTR, ECONNABORTED: continue + case EMFILE, ENFILE, ENOBUFS, ENOMEM: + // Out of descriptors or memory for now: wait it out rather than stop relaying. + Thread.sleep(forTimeInterval: 0.1) + continue + default: + // Forgotten, so the next install listens again. + Self.logger.error("credential relay stopped accepting: errno \(errno)") + lock.withLock { if listener?.descriptor == descriptor { listener = nil } } + Darwin.close(descriptor) + return + } + } + guard reading.wait(timeout: .now()) == .success else { + Darwin.close(connection) + continue + } + queue.async { [weak self] in + defer { Darwin.close(connection) } + self?.serve(connection) + } + } + } + + /// One request: a known secret, then `protocol=https` and `host=github.com`, or nothing back. + private func serve(_ connection: Int32) { + // A peer that resets before the reply would otherwise raise SIGPIPE, which ends the app. + var on: Int32 = 1 + guard + setsockopt(connection, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + == 0 + else { return } + var timeout = timeval(tv_sec: Int(Self.requestDeadline), tv_usec: 0) + setsockopt( + connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + let request = { () -> String? in + defer { reading.signal() } + return Self.readRequest(connection) + }() + guard let request else { return } + // Only a known secret waits its turn for an answer; anything else is answered at once. + let reply: String + if knows(request) { + guard serving.wait(timeout: .now() + 5) == .success else { return } + defer { serving.signal() } + reply = respond(to: request) + } else { + reply = respond(to: request) + } + _ = reply.withCString { send(connection, $0, strlen($0), 0) } + } + + /// Whether `request` starts with a secret this launch made. + private func knows(_ request: String) -> Bool { + guard let secret = request.split(separator: "\n", omittingEmptySubsequences: false).first, + !secret.isEmpty + else { return false } + return lock.withLock { secrets.values.contains { Self.same($0, String(secret)) } } + } + + /// Reads up to the blank line that ends git's request, or nil past `maxRequest` or + /// `requestDeadline`, which bounds the whole request however slowly its bytes come. + static func readRequest(_ connection: Int32) -> String? { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + let deadline = Date().addingTimeInterval(requestDeadline) + while data.count < maxRequest { + let left = deadline.timeIntervalSinceNow + guard left > 0 else { return nil } + var timeout = timeval( + tv_sec: Int(left), tv_usec: Int32(left.truncatingRemainder(dividingBy: 1) * 1_000_000)) + setsockopt( + connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + let count = recv(connection, &buffer, buffer.count, 0) + guard count > 0 else { return nil } + data.append(buffer, count: count) + if data.range(of: Data("\n\n".utf8)) != nil { return String(data: data, encoding: .utf8) } + } + return nil + } + + /// The reply to `request` (the secret's line, then git's), as `username`/`password` lines, or an + /// `error=` line the agent passes on to git. + func respond(to request: String) -> String { + let lines = request.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let secret = lines.first, !secret.isEmpty, + lock.withLock({ secrets.values.contains { Self.same($0, secret) } }) + else { return "error=this workroom's credential relay isn't known to the Workroom app\n" } + var fields: [String: String] = [:] + for line in lines.dropFirst() where !line.isEmpty { + guard let equals = line.firstIndex(of: "=") else { continue } + fields[String(line[.. Bool { + let (a, b) = (Array(one.utf8), Array(other.utf8)) + guard a.count == b.count else { return false } + return zip(a, b).reduce(0) { $0 | ($1.0 ^ $1.1) } == 0 + } + + // MARK: gh + + /// `gh auth git-credential get` with `request` on stdin: gh's own answer to git, for the account + /// it has signed in on github.com. + @Sendable static func gh(_ request: String) throws -> String { + try runGH(["auth", "git-credential", "get"], input: request) + } + + /// The token `gh` holds for github.com, for git commands the app runs on a relayed workroom's + /// host while provisioning it (`RemoteProvisioning.cloneEnvironment`). + @Sendable static func gitHubToken() async throws -> String { + try await runBlocking { + let token = try runGH(["auth", "token", "--hostname", "github.com"], input: nil) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { throw RemoteWorkrooms.Failure.signedOut } + return token + } + } + + /// Whether `gh` has a sign-in for github.com, read from its config without running it: for the + /// New Workroom menu, which can't wait. A create checks for real (`gitHubToken`). Found as the gh + /// this app runs finds it, in this app's environment: a token there, else `hosts.yml` in + /// `GH_CONFIG_DIR`, `$XDG_CONFIG_HOME/gh` or `~/.config/gh`, in gh's order. + static func hasGitHubSignIn( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> Bool { + let set = { (name: String) in environment[name].flatMap { $0.isEmpty ? nil : $0 } } + if set("GH_TOKEN") != nil || set("GITHUB_TOKEN") != nil { return true } + let directory = + set("GH_CONFIG_DIR").map { URL(fileURLWithPath: $0) } + ?? set("XDG_CONFIG_HOME").map { URL(fileURLWithPath: $0).appendingPathComponent("gh") } + ?? home.appendingPathComponent(".config/gh") + guard + let text = try? String( + contentsOf: directory.appendingPathComponent("hosts.yml"), encoding: .utf8) + else { return false } + return text.split(separator: "\n").contains { $0.hasPrefix("github.com:") } + } + + private static func runGH(_ arguments: [String], input: String?) throws -> String { + // A Finder-launched app's PATH has no Homebrew; gh is wherever the user's shell finds it. + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = ShellEnvironment.path() + environment["GH_PROMPT_DISABLED"] = "1" + let (status, output) = try run( + URL(fileURLWithPath: "/usr/bin/env"), ["gh"] + arguments, input: input, + environment: environment, name: "gh") + guard status == 0 else { + throw HostDriverError.provisioning( + "gh has no GitHub sign-in on this Mac: run `gh auth login`") + } + return output + } + + /// Runs `executable`, bounded by `deadline` from start to the end of its output, whatever it or + /// anything it started does: a relay request holds a serving slot until this returns. Its output + /// is read here, on this thread, so a deadline leaves no reader behind: one that ignores SIGTERM + /// is killed, and a child of its that keeps its stdout open loses the pipe. + static func run( + _ executable: URL, _ arguments: [String], input: String?, environment: [String: String], + name: String, deadline seconds: TimeInterval = 15 + ) throws -> (status: Int32, output: String) { + let deadline = DispatchTime.now() + seconds + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.environment = environment + let stdin = Pipe() + let stdout = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + let exited = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in exited.signal() } + // A gh gone before its input is written would raise SIGPIPE, which ends the app. + _ = fcntl(stdin.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) + try process.run() + let out = stdout.fileHandleForReading + defer { try? out.close() } + let stop = { + process.terminate() + if exited.wait(timeout: .now() + 1) != .success { kill(process.processIdentifier, SIGKILL) } + } + if let input { try? stdin.fileHandleForWriting.write(contentsOf: Data(input.utf8)) } + try? stdin.fileHandleForWriting.close() + // To EOF or the deadline: a gh that hangs (a keychain prompt) is ended rather than waited on. + var output = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + var reader = pollfd(fd: out.fileDescriptor, events: Int16(POLLIN), revents: 0) + while true { + let left = Int( + (deadline.uptimeNanoseconds &- DispatchTime.now().uptimeNanoseconds) / 1_000_000) + guard deadline > .now(), left > 0 else { + stop() + throw HostDriverError.provisioning("\(name) didn't answer in time") + } + let ready = poll(&reader, 1, Int32(min(left, Int(Int32.max)))) + if ready < 0, errno == EINTR { continue } + guard ready > 0 else { continue } + let count = Darwin.read(out.fileDescriptor, &buffer, buffer.count) + if count < 0, errno == EINTR { continue } + guard count > 0 else { break } + output.append(buffer, count: count) + } + guard exited.wait(timeout: deadline) == .success else { + stop() + throw HostDriverError.provisioning("\(name) didn't answer in time") + } + return (process.terminationStatus, String(decoding: output, as: UTF8.self)) + } + + // MARK: Hosts + + /// Has host `id`'s agent ask this Mac for git's credentials: its listener carried here, and + /// `wr-agent credential relay` run there with this launch's secret. Run on every connect: the + /// listener goes with the connection, and the secret with the launch. + @MainActor + func install(on host: HostID, driver: any HostDriver, agentBinary: String) async throws { + guard case .remote(let id) = host else { return } + // Listening first, so a listener that can't be made fails here rather than in the registry. + _ = try localPort() + try await registry().open(workroom: id, host: host) + let command = + ContainerHostDriver.shellQuoted(agentBinary) + + " credential relay --port \(Self.port(for: id))" + let (status, output) = try await driver.exec(command, on: host).communicate( + Data((try secret(for: id) + "\n").utf8), timeout: 30) + guard status == 0 else { + throw HostDriverError.provisioning("setting up git's credentials failed: \(output)") + } + } + + /// Stops carrying host `id`'s relay: its workroom is gone. + @MainActor + func close(_ id: UUID) { + lock.withLock { forwards }?.close(workroom: id) + _ = lock.withLock { secrets.removeValue(forKey: id) } + } + + @MainActor + private func registry() -> ReverseForwardRegistry { + if let made = lock.withLock({ forwards }) { return made } + // Asked for each listener: a listener that stopped accepting is made again on a new port, and + // the next connect's install carries the forwards there. + let made = ReverseForwardRegistry( + transport: transport, port: { Self.port(for: $0) }, + target: { [weak self] in (try? self?.localPort()) ?? 0 }, + failure: { + HostDriverError.provisioning("the workroom's agent couldn't relay git's credentials: \($0)") + }) + lock.withLock { forwards = made } + return made + } +} diff --git a/macapp/WorkroomApp/Core/DefaultsKeys.swift b/macapp/WorkroomApp/Core/DefaultsKeys.swift index 1546206cd..a2f193da3 100644 --- a/macapp/WorkroomApp/Core/DefaultsKeys.swift +++ b/macapp/WorkroomApp/Core/DefaultsKeys.swift @@ -92,9 +92,15 @@ extension Defaults.Keys { /// `defaults write remoteWorkroomsPreview -bool YES`. static let remoteWorkroomsPreview = Key( "remoteWorkroomsPreview", default: false, suite: .app) - /// The Docker image a remote workroom's host runs (#253): `make remote-host-image` builds it. - /// Hidden; override with `defaults write remoteHostImage `. - static let remoteHostImage = Key("remoteHostImage", default: "workroom-host", suite: .app) + /// Overrides the image a container workroom's host runs (#253, #309), which is otherwise the one + /// this build pins (`RemoteWorkrooms.hostImage`). Hidden; set it with + /// `defaults write remoteHostImage `, e.g. `workroom-host` for the image + /// `make remote-host-image` builds. + static let remoteHostImage = Key("remoteHostImage", default: nil, suite: .app) + /// CPUs and memory (`8G`) each container workroom gets (#309), over the defaults + /// `RemoteHosts.resources` picks. Hidden: `defaults write containerCPUs -int 6`. + static let containerCPUs = Key("containerCPUs", default: nil, suite: .app) + static let containerMemory = Key("containerMemory", default: nil, suite: .app) /// The Workroom credential broker (Codaset). Empty means the build's own: codaset.dev, or /// `https://codaset.localhost` in a Debug build, which only ever reaches this Mac /// (`BrokerEndpoint`). Hidden; override with `defaults write brokerURL `. diff --git a/macapp/WorkroomApp/Core/DetachedPaneController.swift b/macapp/WorkroomApp/Core/DetachedPaneController.swift index 412756abc..d38029586 100644 --- a/macapp/WorkroomApp/Core/DetachedPaneController.swift +++ b/macapp/WorkroomApp/Core/DetachedPaneController.swift @@ -116,16 +116,19 @@ final class DetachedPaneWindows { /// `title` also stays on the window for the Window menu and Mission Control, which read it even /// though the title bar itself draws the toolbar's label. func setTitle( - _ title: String, project: String, workroom: String?, for tabID: TerminalTab.ID + _ title: String, project: String, workroom: String?, glyph: String = "cube", + for tabID: TerminalTab.ID ) { windows[tabID]?.title = title - toolbarDelegates[tabID]?.update(title: title, project: project, workroom: workroom) + toolbarDelegates[tabID]?.update( + title: title, project: project, workroom: workroom, glyph: glyph) } /// Open a window for a freshly-detached pane. `origin` is a SCREEN point (the cursor at drop time); /// the window is placed so its top-left lands there, then nudged onto a visible screen. func open( - tabID: TerminalTab.ID, title: String, project: String, workroom: String?, origin: CGPoint?, + tabID: TerminalTab.ID, title: String, project: String, workroom: String?, + glyph: String = "cube", origin: CGPoint?, frame: NSRect? = nil, onCloseTab: @escaping () -> Void, onDock: @escaping () -> Void, content: () -> Content ) { @@ -158,7 +161,7 @@ final class DetachedPaneWindows { // `.unifiedCompact` rather than `.unified`: the tall variant leaves a band of empty space under // the title, which on a window whose whole job is to show one pane is just less pane. let toolbarDelegate = ToolbarDelegate( - title: title, project: project, workroom: workroom, onDock: onDock) + title: title, project: project, workroom: workroom, glyph: glyph, onDock: onDock) let toolbar = NSToolbar(identifier: "detachedPane") toolbar.delegate = toolbarDelegate toolbar.showsBaselineSeparator = false @@ -231,27 +234,30 @@ final class DetachedPaneWindows { private let label = NSTextField(labelWithString: "") private let onDock: () -> Void - init(title: String, project: String, workroom: String?, onDock: @escaping () -> Void) { + init( + title: String, project: String, workroom: String?, glyph: String, + onDock: @escaping () -> Void + ) { self.onDock = onDock super.init() label.attributedStringValue = Self.attributed( - title: title, project: project, workroom: workroom) + title: title, project: project, workroom: workroom, glyph: glyph) label.lineBreakMode = .byTruncatingTail label.setAccessibilityIdentifier("detachedPane.title") } - func update(title: String, project: String, workroom: String?) { + func update(title: String, project: String, workroom: String?, glyph: String) { label.attributedStringValue = Self.attributed( - title: title, project: project, workroom: workroom) + title: title, project: project, workroom: workroom, glyph: glyph) } /// `Terminal 2 ⌂ my-project · ▣ my-workroom` — the pane's name, then where it lives, in /// smaller secondary type. The glyphs are the sidebar's own vocabulary (`ProjectSidebar`: `house` /// for a project root, `cube` for a workroom), so the window names the pane the same way the - /// sidebar does. - private static func attributed(title: String, project: String, workroom: String?) - -> NSAttributedString - { + /// sidebar does, and a workroom on a host takes the network (`TerminalTarget.workroomGlyph`). + private static func attributed( + title: String, project: String, workroom: String?, glyph: String + ) -> NSAttributedString { let small = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) let secondary: [NSAttributedString.Key: Any] = [ .font: small, .foregroundColor: NSColor.secondaryLabelColor, @@ -272,7 +278,7 @@ final class DetachedPaneWindows { result.append(NSAttributedString(string: project, attributes: secondary)) if let workroom { result.append(NSAttributedString(string: " · ", attributes: secondary)) - result.append(symbol("cube", font: small)) + result.append(symbol(glyph, font: small)) result.append(NSAttributedString(string: workroom, attributes: secondary)) } return result diff --git a/macapp/WorkroomApp/Core/HostDriver.swift b/macapp/WorkroomApp/Core/HostDriver.swift index 7db5159fe..44d2d4f21 100644 --- a/macapp/WorkroomApp/Core/HostDriver.swift +++ b/macapp/WorkroomApp/Core/HostDriver.swift @@ -142,6 +142,11 @@ final class HostStream: @unchecked Sendable { private var errors = Data() /// stderr reached EOF: everything the carrier said is in `errors`. private var errorsClosed = false + /// Told each piece of output as it arrives, stdout's and stderr's alike (`communicate`). + private var onOutput: (@Sendable (Data, _ stderr: Bool) -> Void)? + /// Told stderr spoke, so output there counts against `communicate`'s silence bound too: a pull or + /// build may report its progress there alone. + private var heard: (@Sendable () -> Void)? /// `end()` stopped a carrier that was still running, so its exit status is our own signal. private var endedByUs = false /// A connection took the descriptor and the process (`handOff`), so `deinit` leaves them alone. @@ -304,10 +309,18 @@ final class HostStream: @unchecked Sendable { /// /// The writes and reads block, on GCD (`runBlocking`), not the cooperative pool: an 11 MB /// binary over a slow link is exactly the kind of wait that starves other blocking work there. - func communicate(_ input: Data?, timeout: TimeInterval) async throws -> ( + /// + /// `onOutput` is told each piece of its output as it arrives, from stdout or stderr (which it is + /// told, since the two interleave), for a + /// command that reports progress (`ContainerHostDriver`'s image pull). + func communicate( + _ input: Data?, timeout: TimeInterval, + onOutput: (@Sendable (Data, _ stderr: Bool) -> Void)? = nil + ) async throws -> ( status: Int32, output: String ) { precondition(purpose == .exchange, "communicate needs a stream spawned for an exchange") + lock.withLock { self.onOutput = onOutput } let fd = descriptor // Claimed once: only this exchange closes it, after its last `send` on it. A second call, even // a concurrent one, traps rather than sending on a descriptor this one may have closed. @@ -330,7 +343,11 @@ final class HostStream: @unchecked Sendable { shutdown(fd, SHUT_RD) shutdown(fd, SHUT_WR) } - defer { watchdog.stop() } + lock.withLock { heard = { watchdog.heard() } } + defer { + lock.withLock { heard = nil } + watchdog.stop() + } // A cancelled caller (a pane closed, a host removed mid-push) ends the exchange the same way, // rather than leaving `runBlocking`, which cannot be cancelled, pushing 11 MB to no one. let (printed, exit): (Data, (status: Int32, reason: Process.TerminationReason)?) = @@ -368,6 +385,7 @@ final class HostStream: @unchecked Sendable { // and the scripts' report, which comes after whatever a shell startup printed, // survives even a startup that printed more than that. collected.append(chunk, count: count) + onOutput?(Data(chunk[0.. 1024 * 1024 { collected.removeFirst(collected.count - 1024 * 1024) } @@ -497,9 +515,12 @@ final class HostStream: @unchecked Sendable { /// Keeps the first 16 KiB of stderr: enough for any diagnosis, and bounded against a chatty one. private func collect(_ data: Data) { - lock.withLock { - guard errors.count < 16 * 1024 else { return } - errors.append(data.prefix(16 * 1024 - errors.count)) + let (listener, heard) = lock.withLock { + () -> ((@Sendable (Data, Bool) -> Void)?, (@Sendable () -> Void)?) in + if errors.count < 16 * 1024 { errors.append(data.prefix(16 * 1024 - errors.count)) } + return (onOutput, self.heard) } + heard?() + listener?(data, true) } } diff --git a/macapp/WorkroomApp/Core/ImagePullProgress.swift b/macapp/WorkroomApp/Core/ImagePullProgress.swift new file mode 100644 index 000000000..e03d5bd74 --- /dev/null +++ b/macapp/WorkroomApp/Core/ImagePullProgress.swift @@ -0,0 +1,58 @@ +import Foundation + +/// How far a container runtime's image pull has got (#309), read from what its CLI prints without +/// a terminal, as the app runs it. Measured: Docker (29) prints each layer's state on a line of its +/// own, `: Pulling fs layer`, `… Download complete`, `… Pull complete`, `… Already exists`, +/// and no byte counts, so its progress is the layers done of those seen. Apple's `container` (1.5.0) +/// prints `[/] % …`, so its progress is its step's share plus its +/// percent of that. +struct ImagePullProgress: Sendable { + private var layers: Set = [] + private var done: Set = [] + private var stepped: Double? + /// Each stream's line so far: stdout's and stderr's arrive interleaved, and a line of one must + /// never run on into the other's. + private var pending: [Bool: String] = [:] + + /// The fraction done, 0 to 1, or nil before anything says how far. + var fraction: Double? { + if let stepped { return stepped } + guard !layers.isEmpty else { return nil } + return Double(done.count) / Double(layers.count) + } + + /// Takes a piece of output from one stream, which may end mid-line. + mutating func read(_ text: String, stderr: Bool = false) { + let joined = pending[stderr, default: ""] + text + // `\r` too: a progress bar redraws its line that way. + var lines = joined.split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r" } + pending[stderr] = String(lines.removeLast()) + for line in lines { take(String(line)) } + } + + private mutating func take(_ line: String) { + if let step = Self.appleStep(line) { + stepped = step + return + } + guard let colon = line.firstIndex(of: ":") else { return } + let layer = String(line[..: Pulling from" line is not one. + guard layer.count == 12, layer.allSatisfy(\.isHexDigit) else { return } + let state = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) + layers.insert(layer) + if state == "Pull complete" || state == "Already exists" { done.insert(layer) } + } + + /// `[2/3] Unpacking image … 40% …` → (1 + 0.4) / 3. + private static func appleStep(_ line: String) -> Double? { + guard line.hasPrefix("["), let close = line.firstIndex(of: "]") else { return nil } + let parts = line[line.index(after: line.startIndex).. 0, + step >= 1, step <= steps + else { return nil } + let percent = line[close...].split(separator: " ").first { $0.hasSuffix("%") } + .flatMap { Double($0.dropLast()) } + return min(1, ((step - 1) + (percent ?? 0) / 100) / steps) + } +} diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 74a0900df..e15d3eaeb 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -35,13 +35,41 @@ struct HostDescriptor: Codable, Hashable { var repository: String? = nil var cloneURL: String? = nil var path: String? = nil - /// How `ContainerHostDriver` finds the host again. + /// How `ContainerHostDriver` finds the host again, including the Docker context it runs in. var container: ContainerHostDriver.Record? = nil + /// A project's bases when it has more than one (#309): one per container runtime and Docker + /// context, each a whole base descriptor. nil for a project with one base, whose descriptor is + /// the base itself, as every project before #309 has it. + var bases: [HostDescriptor]? = nil + /// `relay` for a local container base or workroom whose git credentials come from the Mac's + /// relay instead of the broker (#309); nil for the broker. + var credentials: String? = nil + + /// What kind of host this is, for the workroom's icon in the sidebar (#309): a container on this + /// Mac, on Docker (naming its context when it has one) or Apple's runtime, or a remote provider by + /// its driver's name. + var kindDescription: String { + switch driver.flatMap(RemoteWorkrooms.Runtime.init(rawValue:)) { + case .docker: + let context = container?.context.map { " (\($0))" } ?? "" + return "Docker container\(context) on this Mac" + case .apple: + return "Apple container on this Mac" + case nil: + return driver.map { "Remote workroom on \($0)" } ?? "Remote workroom" + } + } + + /// Whether git's credentials on this host come through the Mac's relay (#309). + var isRelayed: Bool { credentials == "relay" } + + /// The bases a project's descriptor records. + var allBases: [HostDescriptor] { bases ?? [self] } var isDestroyed: Bool { state == "destroyed" } enum CodingKeys: String, CodingKey { - case state, driver, provisioner, id, repository, path, container + case state, driver, provisioner, id, repository, path, container, bases, credentials case grantID = "grant_id" case workroomID = "workroom_id" case cloneURL = "clone_url" @@ -50,7 +78,8 @@ struct HostDescriptor: Codable, Hashable { init( state: String? = nil, driver: String? = nil, provisioner: String? = nil, id: UUID? = nil, grantID: String? = nil, workroomID: UUID? = nil, repository: String? = nil, - cloneURL: String? = nil, path: String? = nil, container: ContainerHostDriver.Record? = nil + cloneURL: String? = nil, path: String? = nil, container: ContainerHostDriver.Record? = nil, + bases: [HostDescriptor]? = nil, credentials: String? = nil ) { self.state = state self.driver = driver @@ -62,6 +91,8 @@ struct HostDescriptor: Codable, Hashable { self.cloneURL = cloneURL self.path = path self.container = container + self.bases = bases + self.credentials = credentials } init(from decoder: Decoder) throws { @@ -76,12 +107,16 @@ struct HostDescriptor: Codable, Hashable { cloneURL = try? fields?.decodeIfPresent(String.self, forKey: .cloneURL) path = try? fields?.decodeIfPresent(String.self, forKey: .path) container = try? fields?.decodeIfPresent(ContainerHostDriver.Record.self, forKey: .container) + bases = try? fields?.decodeIfPresent([HostDescriptor].self, forKey: .bases) + credentials = try? fields?.decodeIfPresent(String.self, forKey: .credentials) } /// A project's base, when the descriptor records a whole one. var base: RemoteProvisioning.Base? { guard let id, let repository, let cloneURL, let path else { return nil } - return RemoteProvisioning.Base(host: id, repository: repository, cloneURL: cloneURL, path: path) + return RemoteProvisioning.Base( + host: id, repository: repository, cloneURL: cloneURL, path: path, + relayed: isRelayed ? true : nil) } } @@ -180,9 +215,16 @@ struct TerminalTarget: Identifiable, Hashable { /// Why a remote workroom's panes don't open here, when that isn't simply this build /// (`Workroom.remoteNote`). var remoteNote: String? = nil + /// What kind of host a remote workroom is on (`HostDescriptor.kindDescription`), for its icon's + /// tooltip (#309). + var hostKind: String? = nil var isMissing: Bool { unavailability != nil } + /// The glyph that marks a workroom: the network for one on a host, a container on this Mac or a + /// remote provider (#309), and a cube for one on this Mac itself. A root takes a house instead. + var workroomGlyph: String { isRemoteWorkroom ? "network" : "cube" } + /// A remote workroom, reachable or not: its path names nothing on this Mac. var isRemoteWorkroom: Bool { remoteHost != nil || unavailability == .remote || unavailability == .hostDestroyed @@ -264,7 +306,7 @@ extension Workroom { TerminalTarget( id: TerminalTarget.workroomID(project: projectPath, name: name), title: displayName, path: path, unavailability: unavailability, - remoteHost: reachableHost, remoteNote: remoteNote) + remoteHost: reachableHost, remoteNote: remoteNote, hostKind: host?.kindDescription) } /// Why this remote workroom's panes don't open here, for one this build could otherwise reach: diff --git a/macapp/WorkroomApp/Core/RemoteProvisioning.swift b/macapp/WorkroomApp/Core/RemoteProvisioning.swift index 1b627bc14..ee44fa6a2 100644 --- a/macapp/WorkroomApp/Core/RemoteProvisioning.swift +++ b/macapp/WorkroomApp/Core/RemoteProvisioning.swift @@ -18,7 +18,11 @@ enum RemoteProvisioning { let driver: any HostDriver /// The agent's socket on each host of `driver`, which puts the agent's binary beside it. let agentSocket: String - let client: BrokerClient + /// The broker, when signed in to Codaset. Without it, only a relayed base can be made (#309). + let client: BrokerClient? + /// The Mac's own GitHub token (`gh`), for a base whose repository's git goes through the Mac's + /// credential relay rather than the broker (#309), or nil where there is none (a remote host). + var gitHubToken: (@Sendable () async throws -> String)? = nil var agentBroker: AgentEnrolment.AgentBroker = .standard /// A connection to the agent on a host, bootstrapping one there first. var connect: @Sendable (HostID) async throws -> AgentVCSConnection @@ -26,7 +30,8 @@ enum RemoteProvisioning { var revoke: @Sendable (BrokerClient.CloneToken) async -> Void = CloneToken.revoke init( - driver: any HostDriver, agentSocket: String, client: BrokerClient, + driver: any HostDriver, agentSocket: String, client: BrokerClient?, + gitHubToken: (@Sendable () async throws -> String)? = nil, agentBroker: AgentEnrolment.AgentBroker = .standard, connect: (@Sendable (HostID) async throws -> AgentVCSConnection)? = nil, revoke: @escaping @Sendable (BrokerClient.CloneToken) async -> Void = CloneToken.revoke @@ -34,6 +39,7 @@ enum RemoteProvisioning { self.driver = driver self.agentSocket = agentSocket self.client = client + self.gitHubToken = gitHubToken self.agentBroker = agentBroker self.connect = connect ?? { host in @@ -53,12 +59,17 @@ enum RemoteProvisioning { let cloneURL: String /// The clone on the host. let path: String + /// Whether its workrooms take git's credentials from the Mac's relay instead of enrolling with + /// the broker (#309): made when signed out of Codaset, or when the repository's owner hasn't + /// installed the Codaset App. nil, as every base before #309 has it, is the broker. + var relayed: Bool? = nil } /// A workroom derived from a base, serving requests over `connection`. struct Instance: Sendable { let host: HostID - let grantID: String + /// Its broker grant; nil for a relayed workroom, which never enrols. + let grantID: String? let path: String let branch: String let connection: AgentVCSConnection @@ -105,12 +116,14 @@ enum RemoteProvisioning { guard case .remote(let id) = host else { throw HostDriverError.unknownHost(host) } let connection = try await environment.connect(host) defer { Task { await connection.close() } } - try await withCloneToken(repository, in: environment) { header in + let relayed = try await withCloneToken(repository, relayed: nil, in: environment) { header in _ = try await git( ["clone", "--quiet", "--origin", "origin", "--", cloneURL, path], in: "/", environment: header, on: connection) } - let base = Base(host: id, repository: repository, cloneURL: cloneURL, path: path) + let base = Base( + host: id, repository: repository, cloneURL: cloneURL, path: path, + relayed: relayed ? true : nil) try await record(base) return base } catch { @@ -133,7 +146,8 @@ enum RemoteProvisioning { try await BaseLocks.shared.exclusively(on: base.host) { let connection = try await environment.connect(.remote(base.host)) defer { Task { await connection.close() } } - try await withCloneToken(base.repository, in: environment) { header in + try await withCloneToken(base.repository, relayed: base.relayed == true, in: environment) { + header in try await fetch(base.path, environment: header, on: connection) } } @@ -163,19 +177,51 @@ enum RemoteProvisioning { } /// Mints a clone token, hands `body` the exec environment carrying it, and revokes it after, - /// whatever `body` did. - private static func withCloneToken( - _ repository: String, in environment: Environment, + /// whatever `body` did. A relayed base (#309) uses the Mac's own GitHub token instead, which is + /// the user's and not revoked. `relayed` nil decides, for a new base: the broker when signed in, + /// unless the repository's owner hasn't installed the Codaset App; the Mac's token otherwise. + /// Returns whether it was relayed. + @discardableResult + static func withCloneToken( + _ repository: String, relayed: Bool?, in environment: Environment, _ body: ([String: String]) async throws -> Void - ) async throws { - let token = try await environment.client.baseCloneToken(repository: repository) - do { - try await body(cloneEnvironment(token: token.token)) - } catch { + ) async throws -> Bool { + if relayed != true, let client = environment.client { + let token: BrokerClient.CloneToken + do { + token = try await client.baseCloneToken(repository: repository) + } catch BrokerError.refused(let refusal) + where relayed == nil && refusal.code == "app_not_installed" + && environment.gitHubToken != nil + { + // Without `gh` either, the App's install is what the user needs to hear about. + guard let token = try? await relayToken(environment) else { + throw BrokerError.refused(refusal) + } + try await body(cloneEnvironment(token: token)) + return true + } + do { + try await body(cloneEnvironment(token: token.token)) + } catch { + await cleanUp { await environment.revoke(token) } + throw error + } await cleanUp { await environment.revoke(token) } - throw error + return false + } + // The broker's base needs the broker. + guard relayed != false else { throw RemoteWorkrooms.Failure.signedOut } + try await body(cloneEnvironment(token: try await relayToken(environment))) + return true + } + + /// The Mac's GitHub token, for a relayed base or workroom (#309). + private static func relayToken(_ environment: Environment) async throws -> String { + guard let gitHubToken = environment.gitHubToken else { + throw RemoteWorkrooms.Failure.signedOut } - await cleanUp { await environment.revoke(token) } + return try await gitHubToken() } /// git's configuration through its environment (`GIT_CONFIG_COUNT`), so the token is in no @@ -204,6 +250,11 @@ enum RemoteProvisioning { from base: Base, workroom: UUID, branch: String, in environment: Environment, checkpoint: @Sendable (_ host: HostID, _ grant: String?) async throws -> Void = { _, _ in } ) async throws -> Instance { + // What the workroom's git will need, checked before the copy, which can take many minutes: a + // relayed base the Mac's token, any other the broker. + let relayHeader = + base.relayed == true ? cloneEnvironment(token: try await relayToken(environment)) : nil + if relayHeader == nil, environment.client == nil { throw RemoteWorkrooms.Failure.signedOut } // The snapshot only: a commit taken while a refresh's fetch holds its ref locks would hand // every workroom derived from it those stale `.lock` files. let host = try await BaseLocks.shared.exclusively(on: base.host) { @@ -215,26 +266,35 @@ enum RemoteProvisioning { try await checkpoint(host, nil) let connected = try await environment.connect(host) connection = connected - let grantID: String - do { - grantID = try await AgentEnrolment.enrol( - client: environment.client, driver: environment.driver, host: host, - agentBinary: environment.agentBinary, workroomID: workroom, - repository: base.repository, agentBroker: environment.agentBroker) - } catch let live as AgentEnrolment.GrantStillLive { - // The enrolment's own cancel failed; the grant is still live, and only this knows it. - throw RollbackStart.grantLive(live.grantID, cause: live.cause, failure: live.cancelFailure) + // A relayed workroom never enrols (#309): these two git commands carry the Mac's token, + // and later ones ask the Mac through the relay its connect installs. + var header: [String: String] = [:] + if let relayHeader { + header = relayHeader + } else { + guard let client = environment.client else { throw RemoteWorkrooms.Failure.signedOut } + let grantID: String + do { + grantID = try await AgentEnrolment.enrol( + client: client, driver: environment.driver, host: host, + agentBinary: environment.agentBinary, workroomID: workroom, + repository: base.repository, agentBroker: environment.agentBroker) + } catch let live as AgentEnrolment.GrantStillLive { + // The enrolment's own cancel failed; the grant is still live, and only this knows it. + throw RollbackStart.grantLive( + live.grantID, cause: live.cause, failure: live.cancelFailure) + } + grant = grantID + try await checkpoint(host, grantID) } - grant = grantID - try await checkpoint(host, grantID) // With the instance's own credentials: `wr-agent enrol` made its helper git's. - try await fetch(base.path, on: connected) + try await fetch(base.path, environment: header, on: connected) // Fully qualified: a tag or branch named `origin/HEAD` would make the short form ambiguous. _ = try await git( ["switch", "--quiet", "--no-track", "--create", branch, "refs/remotes/origin/HEAD"], - in: base.path, on: connected) + in: base.path, environment: header, on: connected) return Instance( - host: host, grantID: grantID, path: base.path, branch: branch, connection: connected) + host: host, grantID: grant, path: base.path, branch: branch, connection: connected) } catch { var error = error var alreadyFailed: (grant: String, failure: String)? @@ -249,7 +309,7 @@ enum RemoteProvisioning { var grantLive: String? var hostLive: HostID? var failures: [String] = [] - if let made { + if let made, let client { do { try await client.cancelGrant(made) } catch { grantLive = made failures.append("cancelling grant \(made): \(error.localizedDescription)") @@ -296,7 +356,11 @@ enum RemoteProvisioning { var failures: [String] = [] var (grantLive, hostLive): (String?, HostID?) = (nil, nil) if let grantID { - do { try await environment.client.cancelGrant(grantID) } catch { + do { + // A grant needs the broker to cancel; signed out, it is left for a later delete. + guard let client = environment.client else { throw RemoteWorkrooms.Failure.signedOut } + try await client.cancelGrant(grantID) + } catch { grantLive = grantID failures.append("cancelling grant \(grantID): \(error.localizedDescription)") } diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index a1b464edd..05e344372 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -23,14 +23,73 @@ enum RemoteWorkrooms { /// defaults domain, and this is per process. nonisolated(unsafe) static var enabledForTesting: Bool? - /// The descriptor's `driver` for `ContainerHostDriver`, the only driver until boxd (#256). - static let containerDriver = "container" + /// Which container runtime makes a workroom's host (#309): Docker (Desktop, OrbStack, Colima) or + /// Apple's `container`. The raw value is the descriptor's `driver`; `container` predates Apple's + /// and keeps meaning Docker, so every record made before stays Docker's. + enum Runtime: String, CaseIterable, Sendable { + case docker = "container" + case apple = "apple-container" + + var displayName: String { self == .docker ? "Docker" : "Apple Container" } + /// One of its containers, in a sentence: "a Docker container", "an Apple container". + var containerPhrase: String { self == .docker ? "a Docker container" : "an Apple container" } + } + + /// The descriptor's `driver` for a Docker host. + static let containerDriver = Runtime.docker.rawValue + + /// Why a workroom can't be created on `runtime` now, as the New Workroom menu shows it beside the + /// entry, or nil when it can (#309). + @MainActor + static func unavailability(of runtime: Runtime) -> String? { + var arm64: Int32 = 0 + var size = MemoryLayout.size + let appleSilicon = sysctlbyname("hw.optional.arm64", &arm64, &size, nil, 0) == 0 && arm64 == 1 + return unavailability( + of: runtime, installed: RemoteHosts.executable(for: runtime) != nil, + appleSilicon: appleSilicon, + macOS26: ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)), + // The session's state, not `client()`: that reads the Keychain and rebuilds the device key, + // and this runs in menus' and the picker's bodies. A create checks for real. + signedIn: BrokerSession.shared.isSignedIn || CredentialRelay.hasGitHubSignIn()) + } + + static func unavailability( + of runtime: Runtime, installed: Bool, appleSilicon: Bool, macOS26: Bool, signedIn: Bool + ) -> String? { + if runtime == .apple { + // Apple's runtime runs each container in a VM of its own, on Apple silicon and macOS 26. + if !appleSilicon { return "needs Apple silicon" } + if !macOS26 { return "needs macOS 26" } + } + if !installed { return "not installed" } + // Codaset's broker, or the Mac's own `gh` through the relay (#309). + if !signedIn { return "sign in to Codaset or run gh auth login" } + return nil + } /// The descriptor's `provisioner`: this build, whose ssh key and Docker labels its hosts carry. static var provisioner: String { Bundle.main.bundleIdentifier ?? "com.developwithstyle.workroom" } /// Fixed by the host image's entrypoint (`vcs/scripts/ssh-fixture/entrypoint.sh`). static let agentSocket = "/run/workroom/agent.sock" static let user = "workroom" + /// The image a new base runs (#309): the hidden `remoteHostImage` override, else the published + /// image this build pins by digest (`WorkroomHostImage`, from CI), else a local `workroom-host`, + /// as `make remote-host-image` builds it for a Dev build. + static var hostImage: String { + hostImage( + override: Defaults[.remoteHostImage], + pinned: Bundle.main.object(forInfoDictionaryKey: "WorkroomHostImage") as? String) + } + + static func hostImage(override: String?, pinned: String?) -> String { + for image in [override, pinned] { + if let image = image?.trimmingCharacters(in: .whitespaces), !image.isEmpty { return image } + } + return "workroom-host" + } + /// The branch a remote workroom checks out, as a local workroom's is named. static func branch(for name: String) -> String { "workroom/\(name)" } @@ -48,6 +107,8 @@ enum RemoteWorkrooms { case signedOut case notOnGitHub(String) case noDocker + case noAppleContainer + case baseOnOtherRuntime(String) case anotherBuildsBase(String) case anotherBuildsHost(String) case baseRepositoryChanged(base: String, origin: String) @@ -56,8 +117,8 @@ enum RemoteWorkrooms { var errorDescription: String? { switch self { case .signedOut: - return "Sign in to Codaset in Settings → Remote workrooms first: it gives remote " - + "workrooms their GitHub access." + return "Sign in to Codaset in Settings → Remote workrooms, or to GitHub with " + + "`gh auth login`: one of them gives the workroom its GitHub access." case .notOnGitHub(let detail): return "A remote workroom needs a project whose origin is on github.com. \(detail)" case .anotherBuildsBase(let build): @@ -77,22 +138,55 @@ enum RemoteWorkrooms { + "building another would leave it running unrecorded. Delete the project (its remote " + "workrooms and base go with it) and add it again." case .noDocker: - return "Remote workrooms run on Docker on this Mac for now, and no docker command was " - + "found. Install Docker Desktop, then run `make remote-host-image`." + return "No docker command was found. Install Docker Desktop, OrbStack or Colima." + case .noAppleContainer: + return "Apple's container command wasn't found. Install it from " + + "github.com/apple/container, then run `container system start`." + case .baseOnOtherRuntime(let runtime): + return "This project's base machine runs on \(runtime), so its workrooms are created there " + + "too." } } } + /// The base a workroom on `key` derives from, in a project whose descriptor is `host` (#309): the + /// one made on that runtime and Docker context, else for Docker one made before #309, which names + /// no context and follows the current one, as `key` does. + static func base(in host: HostDescriptor?, for key: RemoteHosts.DriverKey) -> HostDescriptor? { + let bases = host?.allBases ?? [] + return bases.first { RemoteHosts.DriverKey($0) == key } + ?? bases.first { RemoteHosts.DriverKey($0) == RemoteHosts.DriverKey(runtime: key.runtime) } + } + + /// A project's descriptor `host` with `base` recorded in it, in place of any base on the same + /// runtime and context (#309). A project with one base keeps the one-base form. + static func recording(_ base: HostDescriptor, in host: HostDescriptor?) -> HostDescriptor { + let key = RemoteHosts.DriverKey(base) + let others = (host?.allBases ?? []).filter { $0.id != nil && RemoteHosts.DriverKey($0) != key } + return others.isEmpty ? base : HostDescriptor(bases: others + [base]) + } + + /// A project's descriptor `host` without the base `id`, or nil when it had no other. + static func removing(_ id: UUID, from host: HostDescriptor?) -> HostDescriptor? { + let others = (host?.allBases ?? []).filter { $0.id != nil && $0.id != id } + return others.count > 1 ? HostDescriptor(bases: others) : others.first + } + /// The router registrations of `projects`' reachable remote workrooms (#253). Each is its own - /// shared root on its host, as an independent clone is. Its GitHub identity is the base's, so its - /// PR and CI status read through `gh` here. + /// shared root on its host, as an independent clone is. Its GitHub identity is its own base's, so + /// its PR and CI status read through `gh` here: a project's bases on other runtimes may be clones + /// of another repository, made before its origin changed (#309). static func registrations(_ projects: [Project]) -> [RepositoryRouter.Registration] { projects.flatMap { project in - let github = project.host?.repository.flatMap(gitHubRepository) + let any = project.host?.allBases.lazy.compactMap(\.repository).first return project.workrooms.compactMap { workroom -> RepositoryRouter.Registration? in guard let host = workroom.reachableHost, let location = try? RepositoryLocation.remote(host: host, path: workroom.path) else { return nil } + let own = workroom.host.flatMap(RemoteHosts.DriverKey.init).flatMap { + base(in: project.host, for: $0)?.repository + } + let github = (own ?? any).flatMap(gitHubRepository) return try? RepositoryRouter.Registration( location: location, sharedLocation: location, github: github) } @@ -133,13 +227,21 @@ enum RemoteWorkrooms { /// the entry keeps what is still live (host, grant) so deleting it can finish the job. static func create( repository: GitHubRepository, cloneURL: String, base existing: HostDescriptor?, - driver: ContainerHostDriver, environment: RemoteProvisioning.Environment, recorder: Recorder + project projectHost: HostDescriptor? = nil, runtime: Runtime = .docker, + driver: ContainerHostDriver, + environment: RemoteProvisioning.Environment, recorder: Recorder ) async throws -> Created { let base: RemoteProvisioning.Base if let existing, existing.provisioner != provisioner { // Its key and labels are another build's, so this one can neither reach nor replace it. throw Failure.anotherBuildsBase(existing.provisioner ?? "an unknown build") } + // A workroom is derived from its base, on the base's runtime. + if let existing, existing.id != nil, + let other = Runtime(rawValue: existing.driver ?? Runtime.docker.rawValue), other != runtime + { + throw Failure.baseOnOtherRuntime(other.displayName) + } if let recorded = existing?.base { // Reused only for the repository it cloned: a changed origin would otherwise get workrooms of // the old one, with credentials for it. @@ -159,13 +261,17 @@ enum RemoteWorkrooms { cloneURL: cloneURL, path: clonePath(for: repository), in: environment ) { base in + // Beside the project's bases on other runtimes and contexts (#309). try await recorder.record( nil, - HostDescriptor( - driver: containerDriver, provisioner: provisioner, id: base.host, - repository: base.repository, - cloneURL: base.cloneURL, path: base.path, - container: driver.record(of: .remote(base.host)))) + recording( + HostDescriptor( + driver: runtime.rawValue, provisioner: provisioner, id: base.host, + repository: base.repository, + cloneURL: base.cloneURL, path: base.path, + container: driver.record(of: .remote(base.host)), + credentials: base.relayed == true ? "relay" : nil), + in: projectHost)) } } @@ -173,7 +279,7 @@ enum RemoteWorkrooms { let name = try await recorder.reserve( base.path, HostDescriptor( - state: "creating", driver: containerDriver, provisioner: provisioner, + state: "creating", driver: runtime.rawValue, provisioner: provisioner, workroomID: workroomID)) let instance: RemoteProvisioning.Instance do { @@ -184,7 +290,8 @@ enum RemoteWorkrooms { try await recorder.record( name, remaining( - state: "creating", workroomID: workroomID, host: host, grant: grant, driver: driver)) + state: "creating", workroomID: workroomID, host: host, grant: grant, runtime: runtime, + driver: driver)) } } catch RemoteProvisioning.Failure.rollbackIncomplete(let cause, let host, let grant, let left) { @@ -194,7 +301,7 @@ enum RemoteWorkrooms { name, remaining( state: host == nil && grant == nil ? "destroyed" : "failed", workroomID: workroomID, - host: host, grant: grant, driver: driver)) + host: host, grant: grant, runtime: runtime, driver: driver)) throw RemoteProvisioning.Failure.rollbackIncomplete( cause: cause, host: host, grantID: grant, cleanup: left) } catch { @@ -209,8 +316,9 @@ enum RemoteWorkrooms { try await recorder.record( name, HostDescriptor( - driver: containerDriver, provisioner: provisioner, id: id, grantID: instance.grantID, - workroomID: workroomID, container: driver.record(of: instance.host))) + driver: runtime.rawValue, provisioner: provisioner, id: id, grantID: instance.grantID, + workroomID: workroomID, container: driver.record(of: instance.host), + credentials: base.relayed == true ? "relay" : nil)) } catch { // Unrecorded, the instance would be found by nothing but the sweep, and its grant by nothing. do { @@ -220,7 +328,8 @@ enum RemoteWorkrooms { try? await recorder.record( name, remaining( - state: "failed", workroomID: workroomID, host: host, grant: grant, driver: driver)) + state: "failed", workroomID: workroomID, host: host, grant: grant, runtime: runtime, + driver: driver)) throw error } catch { // `destroy` throws nothing else: everything it made is gone. @@ -233,10 +342,11 @@ enum RemoteWorkrooms { /// The descriptor of a workroom whose undoing left `host` and `grant` live. private static func remaining( - state: String, workroomID: UUID, host: HostID?, grant: String?, driver: ContainerHostDriver + state: String, workroomID: UUID, host: HostID?, grant: String?, runtime: Runtime, + driver: ContainerHostDriver ) -> HostDescriptor { var descriptor = HostDescriptor( - state: state, driver: containerDriver, provisioner: provisioner, grantID: grant, + state: state, driver: runtime.rawValue, provisioner: provisioner, grantID: grant, workroomID: workroomID) if case .remote(let id) = host { descriptor.id = id @@ -293,6 +403,7 @@ enum RemoteWorkrooms { throw RemoteProvisioning.Failure.rollbackIncomplete( cause: cause, host: liveHost, grantID: grant, cleanup: left) } + if let id = host.id { await RemoteHosts.shared.forgetRelay(id) } try await recorder.forget(name) } @@ -309,33 +420,81 @@ enum RemoteWorkrooms { } } -/// The app's remote hosts (#253): one `ContainerHostDriver` on this Mac's Docker, the hosts config -/// records adopted into it at each reload, and one sweep per launch for what no record names. +/// The app's remote hosts (#253): a `ContainerHostDriver` per container runtime and Docker context +/// on this Mac (#309), the hosts config records adopted into theirs at each reload, and one sweep +/// per launch for what no record names. final class RemoteHosts: @unchecked Sendable { static let shared = RemoteHosts() private let lock = NSLock() - private var made: ContainerHostDriver? + /// Which driver a host is in: its runtime, and for Docker the context it names (nil names none). + struct DriverKey: Hashable, Sendable { + var runtime: RemoteWorkrooms.Runtime = .docker + var context: String? = nil + + /// The driver a recorded host is in, or nil for a descriptor of no container runtime. + init?(_ descriptor: HostDescriptor) { + guard let runtime = RemoteWorkrooms.Runtime(rawValue: descriptor.driver ?? "") else { + return nil + } + self.init(runtime: runtime, context: descriptor.container?.context) + } + + init(runtime: RemoteWorkrooms.Runtime = .docker, context: String? = nil) { + self.runtime = runtime + // Apple's runtime has no contexts. + self.context = runtime == .docker ? context : nil + } + } + + /// The drivers made so far. + private var made: [DriverKey: ContainerHostDriver] = [:] + /// The runtime each host config records is on, whether or not a driver holds it. + private var runtimes: [UUID: RemoteWorkrooms.Runtime] = [:] + /// Workroom hosts whose git credentials come through the Mac's relay (#309). + private var relayed: Set = [] private var swept = false /// A call reached the sweep while a delete was in flight and left it for later (#296). private var held = false - /// The connection attempt running for each host, which `ensureConnected` callers share. - private var connecting: [HostID: Task] = [:] + /// The connection attempt running for each host, which `ensureConnected` callers share, and + /// whether it starts a stopped container first. + private var connecting: [HostID: (task: Task, starts: Bool)] = [:] /// When each host's last attempt failed, which answers for it for `retryAfter`. private var failedAt: [HostID: ContinuousClock.Instant] = [:] + /// Hosts of workrooms the user has opened this launch (`activate`), whose connect starts a + /// stopped container first. + private var activated: Set = [] + /// Relayed hosts whose relay couldn't be set up on their connection, and when that was last + /// tried (#309): nothing else tries again before a reconnect, so a connection check does. + private var relayPending: [UUID: ContinuousClock.Instant] = [:] /// `ensureConnected`'s seams, nil in the app: the connect itself, whether a host is up, the clock. private let connectHost: (@Sendable (HostID) async throws -> Void)? + /// Sets up a relayed host's credential relay on its connection; nil in the app. + private let relayHost: (@Sendable (HostID) async throws -> Void)? private let isConnected: (@Sendable (HostID) async -> Bool)? + private let startHost: (@Sendable (HostID) async throws -> Void)? private let now: @Sendable () -> ContinuousClock.Instant + /// `adopt`'s seams, nil in the app: making a key's driver, and sweeping one. + private let makeDriver: (@Sendable (DriverKey) throws -> ContainerHostDriver)? + private let sweepDriver: + (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? init( connectHost: (@Sendable (HostID) async throws -> Void)? = nil, isConnected: (@Sendable (HostID) async -> Bool)? = nil, - now: @escaping @Sendable () -> ContinuousClock.Instant = { .now } + startHost: (@Sendable (HostID) async throws -> Void)? = nil, + now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, + makeDriver: (@Sendable (DriverKey) throws -> ContainerHostDriver)? = nil, + sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? = nil, + relayHost: (@Sendable (HostID) async throws -> Void)? = nil ) { self.connectHost = connectHost + self.relayHost = relayHost self.isConnected = isConnected + self.startHost = startHost self.now = now + self.makeDriver = makeDriver + self.sweepDriver = sweepDriver } /// How long a failed connect answers for its host before another is tried. The status sweep and @@ -346,14 +505,18 @@ final class RemoteHosts: @unchecked Sendable { private static let logger = Logger( subsystem: "com.developwithstyle.workroom", category: "RemoteWorkrooms") - /// The driver, made on first use: it needs Docker and this Mac's ssh key. - func driver() throws -> ContainerHostDriver { + /// The driver for `key`, made on first use: it needs that runtime's CLI and this Mac's ssh key. + /// The default key is Docker naming no context, as every driver did before #309. Each driver + /// writes its hosts' ssh files under their own IDs, so they share one directory. + func driver(_ key: DriverKey = DriverKey()) throws -> ContainerHostDriver { try lock.withLock { - if let made { return made } - let driver = ContainerHostDriver( - hosts: [:], directory: Self.directory.appendingPathComponent("hosts", isDirectory: true), - provisioning: try Self.provisioning()) - made = driver + if let made = made[key] { return made } + let driver = + try makeDriver?(key) + ?? ContainerHostDriver( + hosts: [:], directory: Self.directory.appendingPathComponent("hosts", isDirectory: true), + provisioning: try Self.provisioning(key)) + made[key] = driver return driver } } @@ -375,54 +538,97 @@ final class RemoteHosts: @unchecked Sendable { /// so a launch with nothing recorded never reads config again for one. var sweepHeld: Bool { lock.withLock { held && !swept } } - /// The driver if something has already made it, for a pane, which must not probe Docker. - var existingDriver: ContainerHostDriver? { lock.withLock { made } } + /// The driver that holds host `id`, for a pane, which must not probe Docker: nil when no driver + /// made so far has it. + func existingDriver(holding id: UUID) -> ContainerHostDriver? { + lock.withLock { made.values.first { $0.record(of: .remote(id)) != nil } } + } - /// Takes on every host `projects` record, so their panes and services reach them after a - /// relaunch, then sweeps once per launch what carries this app's labels and no record names. - /// Does nothing, and never touches Docker, when nothing is recorded and nothing has been made. - /// `sweep: false` holds the sweep for a later call: a list with a delete in flight leaves out - /// hosts config still records (#296). + /// Takes on every host `projects` record, each into the driver for its Docker context, so their + /// panes and services reach them after a relaunch, then sweeps once per launch what carries this + /// app's labels and no record names. Does nothing, and never touches Docker, when nothing is + /// recorded and nothing has been made. `sweep: false` holds the sweep for a later call: a list + /// with a delete in flight leaves out hosts config still records (#296). func adopt(_ projects: [Project], sweep: Bool = true) { let descriptors = - projects.compactMap(\.host) + projects.flatMap { $0.workrooms.compactMap(\.host) } + projects.flatMap { $0.host?.allBases ?? [] } + + projects.flatMap { $0.workrooms.compactMap(\.host) } // Another build's hosts take another key: adopted here, they would refuse every login. let recorded = descriptors.filter { - $0.driver == RemoteWorkrooms.containerDriver - && $0.provisioner == RemoteWorkrooms.provisioner + DriverKey($0) != nil && $0.provisioner == RemoteWorkrooms.provisioner } - guard !recorded.isEmpty || existingDriver != nil else { return } - let driver: ContainerHostDriver - do { driver = try self.driver() } catch { - Self.logger.error("remote hosts: \(error.localizedDescription, privacy: .public)") - return + // Only a base is derived from; on Apple nothing in its record says which a host is. + let bases = Set(projects.flatMap { $0.host?.allBases.compactMap(\.id) ?? [] }) + let already = lock.withLock { + for descriptor in recorded { + if let id = descriptor.id, let key = DriverKey(descriptor) { runtimes[id] = key.runtime } + // A base never has git ask for credentials; only its workrooms are relayed. + if let id = descriptor.id, descriptor.isRelayed, !bases.contains(id) { relayed.insert(id) } + } + return Set(made.keys) } - for descriptor in recorded where !descriptor.isDestroyed { - guard let id = descriptor.id, let record = descriptor.container, - driver.record(of: .remote(id)) == nil - else { continue } - do { try driver.adopt(id, record) } catch { - Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") + guard !recorded.isEmpty || !already.isEmpty else { return } + // A descriptor with no container record yet goes to its runtime's driver that names no + // context, where the one driver before #309 would have had it. + let keys = already.union(recorded.compactMap(DriverKey.init)) + var drivers: [ContainerHostDriver] = [] + for key in keys { + let driver: ContainerHostDriver + do { driver = try self.driver(key) } catch { + // That runtime isn't installed; another's may be. + Self.logger.error("remote hosts: \(error.localizedDescription, privacy: .public)") + continue + } + drivers.append(driver) + for descriptor in recorded where !descriptor.isDestroyed && DriverKey(descriptor) == key { + guard let id = descriptor.id, let record = descriptor.container, + driver.record(of: .remote(id)) == nil + else { continue } + do { try driver.adopt(id, record, isBase: bases.contains(id)) } catch { + Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") + } } } guard claimSweep(allowed: sweep) else { return } + // Every driver keeps every recorded host, not only its own: two contexts can name one daemon + // (the nil driver's current context and that context by name), and a sweep that kept only its + // own hosts would remove the other's, labelled as this build's and old enough. let known = Set(recorded.compactMap(\.id)) + let images = Set(recorded.compactMap { $0.container?.image }) + let sweep = sweepDriver ?? { await $0.sweep(keeping: $1, images: $2) } + // At once: a context whose daemon is slow to answer holds up no other context's sweep. Task.detached(priority: .utility) { - for failure in await driver.sweep(keeping: known) { - Self.logger.error("remote host sweep: \(failure, privacy: .public)") + await withTaskGroup(of: [String].self) { group in + for driver in drivers { group.addTask { await sweep(driver, known, images) } } + for await failures in group { + for failure in failures { + Self.logger.error("remote host sweep: \(failure, privacy: .public)") + } + } } } } - /// The sequence's environment over the driver, signed in as this Mac. + /// The driver key a new workroom on `runtime` wants (#309): Apple's runtime has one, and Docker's + /// is the context the CLI uses now, which a new base is pinned to. `RemoteWorkrooms.base(in:for:)` + /// then finds the project's base there, if it has one. + func key(for runtime: RemoteWorkrooms.Runtime) async throws -> DriverKey { + guard runtime == .docker else { return DriverKey(runtime: runtime) } + return DriverKey(runtime: .docker, context: try await driver().currentContext()) + } + + /// The sequence's environment over `key`'s driver, signed in as this Mac. @MainActor - func environment() throws -> (ContainerHostDriver, RemoteProvisioning.Environment) { - guard let client = BrokerSession.shared.client() else { - throw RemoteWorkrooms.Failure.signedOut - } - let driver = try driver() + func environment(_ key: DriverKey = DriverKey()) throws -> ( + ContainerHostDriver, RemoteProvisioning.Environment + ) { + // Signed out of Codaset, a local container workroom's git credentials come from the Mac's own + // `gh` instead (#309): every runtime here is a local one. + let client = BrokerSession.shared.client() + let driver = try driver(key) var environment = RemoteProvisioning.Environment( - driver: driver, agentSocket: RemoteWorkrooms.agentSocket, client: client) + driver: driver, agentSocket: RemoteWorkrooms.agentSocket, client: client, + gitHubToken: CredentialRelay.gitHubToken) #if DEBUG // A Debug agent reaches this Mac's Codaset through a listener on its host, which lives on // the host's service connection (`BrokerReverseForwards`), so that connection comes first. @@ -435,23 +641,38 @@ final class RemoteHosts: @unchecked Sendable { return (driver, environment) } - /// What deleting `hosts` needs, checked before anything is removed (#253): nil when none of them - /// is live, else the environment, which throws when signed out or without Docker. Refuses one - /// another build made (`RemoteWorkrooms.checkDeletable`). + /// What deleting `hosts` needs, checked before anything is removed (#253): an environment per + /// runtime and Docker context they are on (#309), or nil when none of them is live. Throws when + /// signed out or without a runtime they need. Refuses one another build made + /// (`RemoteWorkrooms.checkDeletable`). @MainActor - func environment(toDelete hosts: [HostDescriptor]) throws -> RemoteProvisioning.Environment? { + func environment(toDelete hosts: [HostDescriptor], bases: Set = []) throws -> Deletion? { guard try RemoteWorkrooms.checkDeletable(hosts) else { return nil } - let (driver, environment) = try environment() - // Taking a box down needs it on the driver, whatever a reload adopted: with previews off it - // adopted nothing, and the box would read as unknown. + var environments: [DriverKey: RemoteProvisioning.Environment] = [:] for host in hosts where RemoteWorkrooms.isLive(host) { + let key = DriverKey(host) ?? DriverKey() + if environments[key] == nil { environments[key] = try environment(key).1 } + // Taking a box down needs it on its driver, whatever a reload adopted: with previews off it + // adopted nothing, and the box would read as unknown. + let driver = try driver(key) guard let id = host.id, let record = host.container, driver.record(of: .remote(id)) == nil else { continue } - do { try driver.adopt(id, record) } catch { + // Whether it is a base sticks for the launch: a later create derives from it. + do { try driver.adopt(id, record, isBase: bases.contains(id)) } catch { Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") } } - return environment + return environments.isEmpty ? nil : Deletion(environments: environments) + } + + /// The environments a delete takes hosts down with, one per runtime and Docker context. + struct Deletion { + let environments: [DriverKey: RemoteProvisioning.Environment] + + /// The environment for `host`, or nil when it isn't live and needs none. + func environment(for host: HostDescriptor) -> RemoteProvisioning.Environment? { + environments[DriverKey(host) ?? DriverKey()] + } } /// The app's service connection to `host` (`HostConnectionManager`), with its agent bootstrapped @@ -461,8 +682,50 @@ final class RemoteHosts: @unchecked Sendable { try await AgentBootstrap.connect( host: host, driver: driver, socket: RemoteWorkrooms.agentSocket) } + // A relayed workroom's git asks this Mac for credentials (#309): its listener goes with the + // connection, and its secret with this launch, so it is set up again on every connect. A + // failure leaves the workroom usable except for git's remote commands, so it is logged. + guard case .remote(let id) = host, isRelayed(id) else { return } + // Tried a few times: a listen can lose a race with the last connection's teardown. + await installRelay(host, attempts: 3, driver: driver) } + /// Sets up `host`'s credential relay on its connection, up to `attempts` times. One that never + /// takes is left pending, for a later connection check to try again. + func installRelay( + _ host: HostID, attempts: Int, driver: ContainerHostDriver? = nil + ) async { + guard case .remote(let id) = host else { return } + for attempt in 1...attempts { + do { + if let relayHost { + try await relayHost(host) + } else { + try await CredentialRelay.shared.install( + on: host, driver: try driver ?? heldDriver(host), + agentBinary: AgentBootstrap.binary(besideSocket: RemoteWorkrooms.agentSocket)) + } + _ = lock.withLock { relayPending.removeValue(forKey: id) } + return + } catch { + Self.logger.error( + "credential relay for \(id, privacy: .public) (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" + ) + if attempt < attempts { try? await Task.sleep(for: .seconds(1)) } + } + } + lock.withLock { relayPending[id] = now() } + } + + /// A relayed workroom's host is gone: its relay with it (#309). + func forgetRelay(_ id: UUID) async { + guard lock.withLock({ relayed.remove(id) }) != nil else { return } + await CredentialRelay.shared.close(id) + } + + /// Whether host `id` is a workroom whose git credentials come through the Mac's relay (#309). + func isRelayed(_ id: UUID) -> Bool { lock.withLock { relayed.contains(id) } } + /// Connects `host`'s service connection unless it is up, for a read, write or listing that needs /// it (`RepositoryRouter`). One attempt per host at a time: the inspector's panels ask together /// when a remote workroom is selected, and every caller after the first waits for that attempt @@ -479,22 +742,52 @@ final class RemoteHosts: @unchecked Sendable { } else { up = await HostConnectionManager.shared.snapshot(for: host).status == .connected } - if up { return } - if let failed = lock.withLock({ failedAt[host] }), now() - failed < Self.retryAfter { + // An opened host is up already: there is nothing to start, and later probes mustn't start it. + if up { + _ = lock.withLock { activated.remove(host) } + // A relay that didn't take on this connection is tried again, at most once a `retryAfter`. + if case .remote(let id) = host, + let tried = lock.withLock({ relayPending[id] }), now() - tried >= Self.retryAfter + { + await installRelay(host, attempts: 1) + } + return + } + // An opened host is tried whatever a probe of it found: its connect starts it. + if !lock.withLock({ activated.contains(host) }), + let failed = lock.withLock({ failedAt[host] }), now() - failed < Self.retryAfter + { throw RepositoryRoutingError.unavailable(host) } do { let connect = - try connectHost ?? { [driver = try driver()] in + try connectHost ?? { [driver = try heldDriver(host)] in try await self.connect($0, driver: driver) } + // Only a workroom the user opened has its container started: the status sweep connects to + // every remote workroom, and would otherwise start them all. + let start = + lock.withLock({ activated.contains(host) }) + ? try startHost ?? { [driver = try heldDriver(host)] in try await driver.startIfStopped($0) + } + : nil let task = lock.withLock { () -> Task in - if let running = connecting[host] { return running } - let task = Task { try await connect(host) } - connecting[host] = task + if let running = connecting[host], running.starts || start == nil { return running.task } + // A probe that won't start the container is under way: joined, it would fail on a stopped + // one and hold the opened workroom back for `retryAfter`. This one goes after it instead. + let probe = connecting[host]?.task + let task = Task { + // Once, whatever comes of it: a container the user stops later, or one that won't start, + // is left to the status sweep's backoff until its workroom is opened again. + defer { if start != nil { _ = self.lock.withLock { self.activated.remove(host) } } } + if let probe, (try? await probe.value) != nil { return } + try await start?(host) + try await connect(host) + } + connecting[host] = (task, start != nil) return task } - defer { lock.withLock { if connecting[host] == task { connecting[host] = nil } } } + defer { lock.withLock { if connecting[host]?.task == task { connecting[host] = nil } } } try await task.value lock.withLock { failedAt[host] = nil } } catch is CancellationError { @@ -507,6 +800,34 @@ final class RemoteHosts: @unchecked Sendable { } } + /// The user opened a workroom on `host` (#309): its next connect starts its container if it is + /// stopped, and a failure from before, such as a status probe of the stopped container, no longer + /// holds that attempt back. + func activate(_ host: HostID) { + lock.withLock { + activated.insert(host) + failedAt[host] = nil + } + } + + /// Whether `host`'s runtime is missing from this Mac, so nothing can be running on it: the + /// runtime config records it on, or with none recorded, every runtime. + func runtimeIsMissing(for host: HostID) -> Bool { + guard case .remote(let id) = host else { return false } + let runtime = lock.withLock { runtimes[id] } + return (runtime.map { [$0] } ?? RemoteWorkrooms.Runtime.allCases).allSatisfy { + Self.executable(for: $0) == nil + } + } + + /// The driver holding `host`, which a reload adopted it into (`adopt`) or which made it. + private func heldDriver(_ host: HostID) throws -> ContainerHostDriver { + guard case .remote(let id) = host, let driver = existingDriver(holding: id) else { + throw HostDriverError.unknownHost(host) + } + return driver + } + /// `Application Support/Workroom//remote`. static var directory: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -522,22 +843,48 @@ final class RemoteHosts: @unchecked Sendable { "/usr/local/bin/docker", "/opt/homebrew/bin/docker", "/Applications/Docker.app/Contents/Resources/bin/docker", ] + /// Where Apple's `container` is: its installer's, or Homebrew's. + static let appleCandidates = ["/usr/local/bin/container", "/opt/homebrew/bin/container"] - private static func provisioning() throws -> ContainerHostDriver.Provisioning { - guard - let runtime = runtimeCandidates.first(where: { - FileManager.default.isExecutableFile(atPath: $0) - }) - else { throw RemoteWorkrooms.Failure.noDocker } + /// `runtime`'s CLI on this Mac, or nil when it isn't installed. + static func executable(for runtime: RemoteWorkrooms.Runtime) -> String? { + (runtime == .docker ? runtimeCandidates : appleCandidates).first { + FileManager.default.isExecutableFile(atPath: $0) + } + } + + /// What each container workroom on `runtime` gets (#309): the hidden settings, else for Apple's + /// runtime, whose own default is a 1 GB VM per container, half this Mac's cores and a quarter of + /// its memory, between 2 and 8 GB. Docker's containers share Docker's VM, which already bounds + /// them, so they get nothing unless set. + static func resources( + for runtime: RemoteWorkrooms.Runtime, cpus: Int? = Defaults[.containerCPUs], + memory: String? = Defaults[.containerMemory], + cores: Int = ProcessInfo.processInfo.activeProcessorCount, + physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory + ) -> (cpus: Int?, memory: String?) { + guard runtime == .apple else { return (cpus, memory) } + let gigabytes = min(max(Int(physicalMemory / 4 / 1_073_741_824), 2), 8) + return (cpus ?? max(2, cores / 2), memory ?? "\(gigabytes)G") + } + + private static func provisioning(_ target: DriverKey) throws -> ContainerHostDriver.Provisioning { + let resources = resources(for: target.runtime) + guard let runtime = executable(for: target.runtime) else { + throw target.runtime == .docker + ? RemoteWorkrooms.Failure.noDocker : RemoteWorkrooms.Failure.noAppleContainer + } let key = try clientKey() return ContainerHostDriver.Provisioning( - runtime: URL(fileURLWithPath: runtime), image: Defaults[.remoteHostImage], + runtime: URL(fileURLWithPath: runtime), image: RemoteWorkrooms.hostImage, user: RemoteWorkrooms.user, identityFile: key.path, publicKey: try String(contentsOf: key.appendingPathExtension("pub"), encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines), agentSocket: RemoteWorkrooms.agentSocket, // Per build, so a Dev app's sweep never takes a Nightly app's hosts, nor the other way. - labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"]) + labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"], context: target.context, + dialect: target.runtime == .docker ? .docker : .apple, cpus: resources.cpus, + memory: resources.memory) } /// This Mac's ssh key for its remote hosts, in `directory`, made the first time: ed25519, no diff --git a/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift b/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift new file mode 100644 index 000000000..0bad6743c --- /dev/null +++ b/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift @@ -0,0 +1,261 @@ +import Foundation +import os + +/// Listeners on remote hosts that carry each connection back to a port on this Mac, one per +/// workroom, kept open across reconnects (`ReverseForward`). For the Debug builds' route to this +/// Mac's development Codaset (`BrokerReverseForwards`), and for the git credential relay of a local +/// container workroom that never enrolled (`CredentialRelay`, #309). +/// +/// **The listener lives only as long as the connection to the host** (every forward does), so this +/// reopens it on each new connection, on the same port: what the agent saved (its broker URL, its +/// relay's port) must keep working after a reattach. +@MainActor +final class ReverseForwardRegistry { + + /// How the registry reaches a host's connection, so a test can hand it a real agent without + /// `HostConnectionManager`: the shape `PortForwardingModel.Transport` set. + struct Transport { + var forwarding: + @Sendable (HostID) async throws -> (HostConnectionManager.Lease, AgentForwardService) + var updates: @Sendable (HostID) async -> AsyncStream + + static let live = Transport( + forwarding: { try await HostConnectionManager.shared.forwarding(host: $0) }, + updates: { await HostConnectionManager.shared.updates(for: $0) }) + } + + /// How long the agent may take to answer a `listen`. + static let listenTimeout: Duration = .seconds(5) + /// A bind that lost the race with the old connection's teardown is retried this often, this + /// many times: the agent notices a departed connection within its 200 ms accept poll. + static let bindRetries = 10 + static let bindRetryDelay: Duration = .milliseconds(200) + /// How many times a connection's listener is tried, `reopenRetry` apart, before the registry + /// waits for the next connection: five minutes at the default. + static let reopenAttempts = 60 + + private let transport: Transport + /// The agent's port for a workroom: the same on every connection and every launch. + private let port: @Sendable (UUID) -> UInt16 + /// The port on this Mac each connection is carried to, asked again for each listener. + private let target: @Sendable () async -> UInt16 + /// The error a listener the agent refuses becomes, from the agent's reason. + private let failure: @Sendable (String) -> Error + private let reopenRetry: Duration + private let log = Logger(subsystem: "com.developwithstyle.workroom", category: "reverse-forward") + + private struct Entry { + let host: HostID + /// The connection the listener is open on, once it is. + var lease: HostConnectionManager.Lease? + var forward: ReverseForward? + /// The port on this Mac the listener carries to, which can move (the relay listening again). + var target: UInt16? + var watch: Task? + /// An open in flight, which a second caller waits on rather than opening a second listener. + /// The id is how the task, finishing, clears only itself. + var opening: (id: UUID, task: Task)? + } + private var entries: [UUID: Entry] = [:] + + init( + transport: Transport = .live, port: @escaping @Sendable (UUID) -> UInt16, + target: @escaping @Sendable () async -> UInt16, + failure: @escaping @Sendable (String) -> Error, reopenRetry: Duration = .seconds(5) + ) { + self.transport = transport + self.port = port + self.target = target + self.failure = failure + self.reopenRetry = reopenRetry + } + + /// Open the listener for `workroom` on `host` and keep it open on every later connection. Throws + /// when the agent does not listen now. + func open(workroom: UUID, host: HostID) async throws { + if entries[workroom] == nil { + entries[workroom] = Entry(host: host) + watch(workroom) + } + try await listen(workroom) + } + + /// Stop carrying `workroom`'s connections: the workroom is gone, or no longer needs them. + func close(workroom: UUID) { + guard let entry = entries.removeValue(forKey: workroom) else { return } + entry.watch?.cancel() + entry.opening?.task.cancel() + entry.forward?.stop() + } + + /// Whether `workroom`'s listener is open on `lease`, for the tests. + func isOpen(_ workroom: UUID, on lease: HostConnectionManager.Lease) -> Bool { + entries[workroom]?.lease == lease && entries[workroom]?.forward != nil + } + + private func listen(_ workroom: UUID) async throws { + // An open in flight is waited out, then this caller runs its own: the one in flight may be + // for an earlier connection than the caller's, and `reopen` does nothing when the current + // connection already has a listener. + while let opening = entries[workroom]?.opening { _ = try? await opening.task.value } + guard entries[workroom] != nil else { throw CancellationError() } + let id = UUID() + // The task clears its own entry as its last step, on this actor, BEFORE it completes: a + // waiter above resumes only after that, so it never sees a finished open still recorded + // (awaiting a finished task does not suspend, and it would spin on this actor forever). + let task = Task { + defer { + if self.entries[workroom]?.opening?.id == id { self.entries[workroom]?.opening = nil } + } + try await self.reopen(workroom) + } + entries[workroom]?.opening = (id, task) + try await task.value + } + + /// A listener on the host's current connection, unless one is already open there. + private func reopen(_ workroom: UUID) async throws { + guard let host = entries[workroom]?.host else { return } + let (lease, service) = try await transport.forwarding(host) + let target = await target() + // Nothing is listening on this Mac to carry to (the relay couldn't listen again). + guard target != 0 else { throw failure("nothing on this Mac is listening for it") } + guard var entry = entries[workroom] else { return } + if entry.lease == lease, entry.forward != nil, entry.target == target { return } + entry.forward?.stop() + entry.forward = nil + entries[workroom] = entry + + // A bind can lose a race on a reconnect: the agent releases the old connection's port only + // once it notices that connection ended, which can be just after the new one asks. The + // port is this workroom's alone, so a bind failure is retried for a moment, not reported. + var attempt = 0 + var outcome = await listen(service, port: port(workroom), to: target, workroom, lease) + while case .failure(let refusal) = outcome, refusal.detail.hasPrefix("bind:"), + attempt < Self.bindRetries + { + attempt += 1 + try await Task.sleep(for: Self.bindRetryDelay) + outcome = await listen(service, port: port(workroom), to: target, workroom, lease) + } + let forward: ReverseForward + switch outcome { + case .success(let listening): forward = listening + case .failure(let refusal): + throw failure(refusal.detail) + } + // Closed meanwhile, or another connection took over: this listener is nobody's. + guard entries[workroom] != nil else { + forward.stop() + throw CancellationError() + } + entries[workroom]?.lease = lease + entries[workroom]?.forward = forward + entries[workroom]?.target = target + } + + /// One `listen`, answered or timed out. A refused listener is stopped before it is returned. + private func listen( + _ service: AgentForwardService, port: UInt16, to target: UInt16, _ workroom: UUID, + _ lease: HostConnectionManager.Lease + ) async -> Result { + let ready = ListenOutcome() + let forward = service.reverse(remotePort: port, target: target) { + [weak self, log] event in + switch event { + case .listening: + ready.resolve(nil) + case .failed(let detail): + log.info("reverse forward: \(detail, privacy: .public)") + case .stopped(let detail): + ready.resolve(detail) + Task { @MainActor in self?.lost(workroom, on: lease) } + } + } + forward.start() + if let refusal = await ready.wait(Self.listenTimeout) { + forward.stop() + return .failure(.init(refusal)) + } + return .success(forward) + } + + /// The listener ended. With its connection, the next connection opens a new one; on its own + /// (the agent's listener broke while the connection lives), no new connection is coming, so it + /// is reopened here. + private func lost(_ workroom: UUID, on lease: HostConnectionManager.Lease) { + guard entries[workroom]?.lease == lease else { return } + entries[workroom]?.lease = nil + entries[workroom]?.forward = nil + Task { await keepTrying(workroom) } + } + + /// Listen until it works, the workroom is closed, or the attempts run out. A link lost without + /// a goodbye leaves the old connection's listener holding the port until the agent notices it + /// has gone, which can take as long as ssh's keepalives, so a bind that keeps failing is tried + /// again every `reopenRetry` rather than given up on until the next connection. + private func keepTrying(_ workroom: UUID) async { + for attempt in 1...Self.reopenAttempts { + guard entries[workroom] != nil, !Task.isCancelled else { return } + do { + try await listen(workroom) + return + } catch { + if attempt == 1 || attempt == Self.reopenAttempts { + log.error( + "reverse forward did not reopen (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" + ) + } + } + try? await Task.sleep(for: reopenRetry) + } + } + + /// Reopen on every new connection to the host. + private func watch(_ workroom: UUID) { + guard let host = entries[workroom]?.host else { return } + entries[workroom]?.watch = Task { [weak self, transport] in + for await snapshot in await transport.updates(host) { + guard let self, !Task.isCancelled else { return } + guard snapshot.status == .connected, let lease = snapshot.lease, + self.entries[workroom]?.lease != lease + else { continue } + await self.keepTrying(workroom) + } + } + } +} + +/// The first answer to a `listen`, waited on with a deadline: nil once the agent listens, the +/// reason when it will not. +private final class ListenOutcome: @unchecked Sendable { + private let lock = NSLock() + private var outcome: String?? + private var waiter: CheckedContinuation? + + func resolve(_ refusal: String?) { + let waiter: CheckedContinuation? = lock.withLock { + guard outcome == nil else { return nil } + outcome = .some(refusal) + defer { self.waiter = nil } + return self.waiter + } + waiter?.resume(returning: refusal) + } + + func wait(_ timeout: Duration) async -> String? { + let timer = Task { [weak self] in + try? await Task.sleep(for: timeout) + self?.resolve("the agent did not answer the listen request") + } + defer { timer.cancel() } + return await withCheckedContinuation { continuation in + let settled: String?? = lock.withLock { + if let outcome { return outcome } + waiter = continuation + return nil + } + if let settled { continuation.resume(returning: settled) } + } + } +} diff --git a/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift b/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift index 182722a8e..5d4a4511a 100644 --- a/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift +++ b/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift @@ -129,21 +129,12 @@ final class PersistentSessionService { self.ownershipOverride = nil self.now = { ProcessInfo.processInfo.systemUptime } self.endRemote = { session, host in - // No container runtime on this Mac: the app's remote hosts run in it (#253), so there is no - // host for the session to be running on, and nothing to stop. Reported as ended, rather - // than as a close that left something running. "No runtime" is no runtime CLI where the - // app looks for one, which a daemon still running behind a moved CLI would defeat. Off the - // main actor: making the driver for the first time can generate its ssh key. - let noRuntime = await Task.detached { () -> Bool in - do { - _ = try RemoteHosts.shared.driver() - return false - } catch RemoteWorkrooms.Failure.noDocker { - return true - } catch { - return false - } - }.value + // The container runtime the host is on, Docker's or Apple's, isn't on this Mac (#253, #309), + // so there is no host for the session to be running on, and nothing to stop. + // Reported as ended, rather than as a close that left something running. "No runtime" is no + // runtime CLI where the app looks for one, which a daemon still running behind a moved CLI + // would defeat. Off the main actor: it looks at the file system. + let noRuntime = await Task.detached { RemoteHosts.shared.runtimeIsMissing(for: host) }.value if noRuntime { return true } try await RemoteHosts.shared.ensureConnected(host) return try await HostConnectionManager.shared.endSession(session, on: host) diff --git a/macapp/WorkroomApp/Core/TerminalSessions.swift b/macapp/WorkroomApp/Core/TerminalSessions.swift index eeb068218..c83ad9a0c 100644 --- a/macapp/WorkroomApp/Core/TerminalSessions.swift +++ b/macapp/WorkroomApp/Core/TerminalSessions.swift @@ -2263,7 +2263,7 @@ final class TerminalSessions: ObservableObject { /// rather than opening that shell (`PersistentSessionService.attachCommand`). private func registerRemote(_ session: UUID, on host: UUID, workingDirectory: String) { let driver = - RemoteHosts.shared.existingDriver + RemoteHosts.shared.existingDriver(holding: host) ?? ContainerHostDriver(hosts: [:], directory: RemoteHosts.directory) sessionService.registerRemoteSession( session, on: .remote(host), via: driver, workingDirectory: workingDirectory) diff --git a/macapp/WorkroomApp/Core/UITestFixture.swift b/macapp/WorkroomApp/Core/UITestFixture.swift index c9ceff92e..e0736b3e3 100644 --- a/macapp/WorkroomApp/Core/UITestFixture.swift +++ b/macapp/WorkroomApp/Core/UITestFixture.swift @@ -622,6 +622,18 @@ enum UITestFixture { text("WorkroomUITestDimUnfocusedPanes").map { ($0 as NSString).boolValue } ?? true } + /// Whether a fixture launch has the remote preview on (`-WorkroomUITestRemotePreview 1`, #309): + /// New Workroom becomes a submenu of places. Off unless given, so no other test sees it; mirrored + /// into `Defaults` (`applyFixtureDefaults`), since the argument domain would shadow the key. + static var remotePreview: Bool { + text("WorkroomUITestRemotePreview").map { ($0 as NSString).boolValue } ?? false + } + + /// A container workroom create downloading its image, this far along (`-WorkroomUITestImagePull + /// 0.42`, #309), seeded on the fixture project so the row's progress can be seen without a real + /// pull. nil unless given. + static var imagePull: Double? { text("WorkroomUITestImagePull").flatMap(Double.init) } + /// Whether splits auto-even on add/remove in a fixture launch /// (`-WorkroomUITestAutoResizeSplitsEvenly 0` to turn it off). Defaults to the shipped `true`, and /// pinned for the same reason as `dimUnfocusedPanes`: it persists in the real Dev domain and it @@ -931,6 +943,8 @@ enum UITestFixture { // Auto-even splits: same persistence trap, and it decides where every divider in a split lands // (issue #126) — an unpinned value rebaselines any test or screenshot that measures panes. Defaults[.autoResizeSplitsEvenly] = autoResizeSplitsEvenly + // The remote preview PERSISTS too, and decides whether New Workroom is a submenu (#309). + Defaults[.remoteWorkroomsPreview] = remotePreview // Pinned "already onboarded" for the same reason as `themeFamily`/`diffViewMode` above: the flag // PERSISTS in the real Dev `Defaults` domain, so a fresh machine with zero registered projects // would otherwise pop the onboarding wizard (issue #151) over e.g. `NewWorkroomDialogUITests`' diff --git a/macapp/WorkroomApp/Info.plist b/macapp/WorkroomApp/Info.plist index 06a3292b1..c697a3094 100644 --- a/macapp/WorkroomApp/Info.plist +++ b/macapp/WorkroomApp/Info.plist @@ -38,6 +38,8 @@ JUdX5urBRS1a/heb5qKRuxdxQtHXS3WLGYd0+qI9P6k= SUScheduledCheckInterval 86400 + WorkroomHostImage + $(WORKROOM_HOST_IMAGE) WorkroomReleaseChannel $(WORKROOM_RELEASE_CHANNEL) diff --git a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift index 1229b4974..1baf59a83 100644 --- a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift +++ b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift @@ -12,6 +12,10 @@ import SwiftUI /// replacing it (issue #163); raised by ⌥⌘N the whole dialog is already in split mode, so a plain /// ⏎ splits — which is why the title and footer are derived from `PickerSplitIntent`, not fixed. /// +/// With the remote preview on (#309), picking a project doesn't create yet: the dialog then asks +/// where, This Mac or a local container, as the sidebar's New Workroom menu does, and the place +/// picked creates. +/// /// ┌─ "New Workroom [(split right)]" ─ Done ─┐ /// │ 🔍 [ filter… ] │ ← auto-focused; single-line, so ↑/↓/⏎ bubble up /// │ ┌─────────────────────────────────┐ │ @@ -34,6 +38,11 @@ struct NewWorkroomDialog: View { /// Index into `filtered` of the keyboard-highlighted row (↑/↓ move it, ⏎ / click pick it). @State private var highlighted = 0 @FocusState private var searchFocused: Bool + /// The project picked while the remote preview is on (#309), whose workroom's place is asked next. + @State private var placing: Project? + /// Where that workroom lands beside, taken when its project was picked. + @State private var placeAnchor: SidebarID? + @FocusState private var placesFocused: Bool private var filtered: [Project] { ProjectPickerModel.filtered(store.projects, query: query) @@ -42,79 +51,163 @@ struct NewWorkroomDialog: View { /// Pick a project: dismiss first, then kick off the (async) create+open. `createWorkroom` mounts /// and selects the new workroom, so the detail pane opens it — no extra wiring here. private func pick(_ project: Project, split: Bool = false) { - onClose() // Capture the anchor NOW, not at landing: the create is async and the user can select a // different workroom while a setup script runs. let anchor = (split || splitIntent) ? store.selectedTargetID : nil + if RemoteWorkrooms.isEnabled { + placing = project + placeAnchor = anchor + highlighted = WorkroomPlace.all.firstIndex { isUsable($0, in: project) } ?? 0 + return + } + onClose() Task { await store.createWorkroom(in: project, splitAnchor: anchor) } } + /// Creates `project`'s workroom at `place`, unless it can't go there now. + private func pick(_ place: WorkroomPlace, in project: Project, split: Bool = false) { + guard isUsable(place, in: project) else { return } + onClose() + let anchor = placeAnchor ?? (split ? store.selectedTargetID : nil) + Task { + switch place { + case .thisMac: await store.createWorkroom(in: project, splitAnchor: anchor) + case .container(let runtime): + await store.createRemoteWorkroom(in: project, runtime: runtime, splitAnchor: anchor) + } + } + } + + /// Whether a workroom of `project` can be created at `place` now. The same rules as the sidebar's + /// New Workroom menu. + private func isUsable(_ place: WorkroomPlace, in project: Project) -> Bool { + switch place { + case .thisMac: !store.isBusyProject(project.path) + case .container(let runtime): + RemoteWorkrooms.unavailability(of: runtime) == nil + && store.canCreateRemoteWorkroom(in: project) + } + } + + private var rowCount: Int { placing == nil ? filtered.count : WorkroomPlace.all.count } + + /// Where `project`'s workroom can go: the reason none can, said once, then each place, a + /// container one saying why it can't be used. + private func placesList(_ project: Project) -> some View { + VStack(alignment: .leading, spacing: 2) { + if let blocked = store.createBlockedReason(in: project) { + Text(blocked.prefix(1).uppercased() + blocked.dropFirst()) + .font(.footnote) + .foregroundStyle(theme.tokens.fgMuted) + .padding(.horizontal, 8) + .padding(.bottom, 6) + .accessibilityIdentifier("newWorkroom.placesBlocked") + } + ForEach(Array(WorkroomPlace.all.enumerated()), id: \.element) { index, place in + placeRow(place, in: project, isHighlighted: index == highlighted) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + // Nothing in here takes the keyboard, so the list itself does: ↑/↓/⏎ reach the handlers below. + .focusable() + .focusEffectDisabled() + .focused($placesFocused) + .onAppear { placesFocused = true } + } + + private func placeRow(_ place: WorkroomPlace, in project: Project, isHighlighted: Bool) + -> some View + { + let usable = isUsable(place, in: project) + let reason: String? = + if case .container(let runtime) = place { + RemoteWorkrooms.unavailability(of: runtime) + } else { + nil + } + return Button { + pick(place, in: project, split: PickerSplitIntent.requestedFromCurrentModifiers()) + } label: { + PickerRow( + icon: place.icon, title: place.name, detail: reason ?? place.detail, + help: reason.map { "\(place.name) can't be used: \($0)" } ?? place.detail, + isHighlighted: isHighlighted, dimmed: !usable) + } + .buttonStyle(.plain) + .disabled(!usable) + .accessibilityIdentifier("newWorkroom.place.\(place.id)") + } + private func projectRow(_ project: Project, isHighlighted: Bool) -> some View { - ProjectRow(project: project, isHighlighted: isHighlighted) - .contentShape(Rectangle()) - .onTapGesture { pick(project, split: PickerSplitIntent.requestedFromCurrentModifiers()) } - .accessibilityIdentifier("newWorkroom.project.\(project.displayName)") + PickerRow( + icon: "folder", title: project.displayName, detail: project.path, help: project.path, + detailTruncation: .head, isHighlighted: isHighlighted + ) + .contentShape(Rectangle()) + .onTapGesture { pick(project, split: PickerSplitIntent.requestedFromCurrentModifiers()) } + .accessibilityIdentifier("newWorkroom.project.\(project.displayName)") } var body: some View { VStack(spacing: 0) { HStack { - Text(PickerSplitIntent.title(open: false, split: splitIntent)).font(.headline) + if let placing { + Text("New Workroom in \(placing.displayName)").font(.headline) + } else { + Text(PickerSplitIntent.title(open: false, split: splitIntent)).font(.headline) + } Spacer() + if placing != nil { + Button("Back") { + placing = nil + placeAnchor = nil + highlighted = 0 + searchFocused = true + } + .accessibilityIdentifier("newWorkroom.back") + } Button("Cancel") { onClose() }.keyboardShortcut(.cancelAction) } .padding(12) Divider() - searchField - - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 2) { - if filtered.isEmpty { - Text("No projects match “\(query)”") - .font(.footnote) - .foregroundStyle(theme.tokens.fgMuted) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } else { - ForEach(Array(filtered.enumerated()), id: \.element.id) { index, project in - projectRow(project, isHighlighted: index == highlighted) - .id(project.id) - } - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - } - .onChange(of: highlighted) { _, new in - if filtered.indices.contains(new) { - withAnimation(.easeInOut(duration: 0.1)) { proxy.scrollTo(filtered[new].id) } - } - } + if let placing { + placesList(placing) + } else { + searchField + projectList } PickerHintFooter(open: false, split: splitIntent) } .frame(width: 420, height: 460) .onAppear { searchFocused = true } - // ↑/↓ move the highlight (no create); ⏎ picks the highlighted project. The single-line search + // ↑/↓ move the highlight (no create); ⏎ picks the highlighted row. The single-line search // field doesn't consume the arrow keys, so they bubble here (same as ThemePicker). ⏎ is wired // ONLY here — never on the field's `.onSubmit` — so a pick can't double-fire into a double-create. .onKeyPress(.upArrow) { - highlighted = ProjectPickerModel.move(highlight: highlighted, by: -1, count: filtered.count) + highlighted = ProjectPickerModel.move(highlight: highlighted, by: -1, count: rowCount) return .handled } .onKeyPress(.downArrow) { - highlighted = ProjectPickerModel.move(highlight: highlighted, by: 1, count: filtered.count) + highlighted = ProjectPickerModel.move(highlight: highlighted, by: 1, count: rowCount) return .handled } // ⌥⏎ splits, plain ⏎ creates (issue #163). The `keys:` overload is what carries the modifiers; // the plain `.onKeyPress(.return)` closure has none. Still wired ONLY here, never on the // field's `.onSubmit` — a double-fire would be a double *create*. .onKeyPress(keys: [.return]) { press in - if let project = ProjectPickerModel.selection(filtered: filtered, highlight: highlighted) { - pick(project, split: PickerSplitIntent.requested(press.modifiers)) + let split = PickerSplitIntent.requested(press.modifiers) + if let placing { + if WorkroomPlace.all.indices.contains(highlighted) { + pick(WorkroomPlace.all[highlighted], in: placing, split: split) + } + } else if let project = ProjectPickerModel.selection( + filtered: filtered, highlight: highlighted) + { + pick(project, split: split) } return .handled } @@ -122,6 +215,34 @@ struct NewWorkroomDialog: View { .onChange(of: query) { _, _ in highlighted = 0 } } + private var projectList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + if filtered.isEmpty { + Text("No projects match “\(query)”") + .font(.footnote) + .foregroundStyle(theme.tokens.fgMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else { + ForEach(Array(filtered.enumerated()), id: \.element.id) { index, project in + projectRow(project, isHighlighted: index == highlighted) + .id(project.id) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .onChange(of: highlighted) { _, new in + if filtered.indices.contains(new) { + withAnimation(.easeInOut(duration: 0.1)) { proxy.scrollTo(filtered[new].id) } + } + } + } + } + private var searchField: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass").foregroundStyle(theme.tokens.fgDim) @@ -191,30 +312,76 @@ struct NewWorkroomPresenter: ViewModifier { } } -/// One project row: the project's display name, with its full path dimmed beneath to disambiguate -/// same-named directories. Highlight + hover styling mirrors `ThemePicker`'s `FamilyRow`. -private struct ProjectRow: View { +/// Where a new workroom can go (#309): this Mac, or a local container on one of the runtimes. +enum WorkroomPlace: Hashable { + case thisMac + case container(RemoteWorkrooms.Runtime) + + static var all: [WorkroomPlace] { + [.thisMac] + RemoteWorkrooms.Runtime.allCases.map { .container($0) } + } + + var name: String { + switch self { + case .thisMac: "This Mac" + case .container(let runtime): runtime.displayName + } + } + + var id: String { + switch self { + case .thisMac: "thisMac" + case .container(let runtime): runtime.rawValue + } + } + + var icon: String { + switch self { + case .thisMac: "laptopcomputer" + case .container: "network" + } + } + + var detail: String { + switch self { + case .thisMac: "A workroom on this Mac" + case .container(let runtime): "A workroom in \(runtime.containerPhrase) on this Mac" + } + } +} + +/// One picker row: a title, with a dimmed detail beneath — a project's full path, to disambiguate +/// same-named directories, or what a place is. Highlight + hover styling mirrors `ThemePicker`'s +/// `FamilyRow`. +private struct PickerRow: View { private let theme = ThemeService.shared - let project: Project + let icon: String + let title: String + let detail: String + let help: String + var detailTruncation: Text.TruncationMode = .tail var isHighlighted = false + /// A row that can't be picked now. + var dimmed = false @State private var hovered = false var body: some View { HStack(spacing: 8) { - Image(systemName: "folder") + Image(systemName: icon) .font(.system(size: 12)) .foregroundStyle(theme.tokens.fgDim) + .frame(width: 16) VStack(alignment: .leading, spacing: 1) { - Text(project.displayName) + Text(title) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(theme.tokens.fg) + .foregroundStyle(dimmed ? theme.tokens.fgMuted : theme.tokens.fg) .lineLimit(1) .truncationMode(.tail) - Text(project.path) + Text(detail) .font(.system(size: 10)) .foregroundStyle(theme.tokens.fgMuted) .lineLimit(1) - .truncationMode(.head) + .truncationMode(detailTruncation) } Spacer(minLength: 0) } @@ -229,7 +396,8 @@ private struct ProjectRow: View { RoundedRectangle(cornerRadius: 6) .strokeBorder(isHighlighted ? theme.tokens.fgDim : .clear, lineWidth: 1.5) ) - .help(project.path) + .contentShape(Rectangle()) + .help(help) .onHover { hovered = $0 } } } diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index 71861b68b..80994736c 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -188,6 +188,56 @@ struct ProjectSidebar: View { // MARK: Rows + /// Where a new workroom of `project` can go (#309), for both doors to it: the row's "+" and its + /// context menu's New Workroom. + @ViewBuilder + private func newWorkroomPlaces(in project: Project) -> some View { + if let blocked = store.createBlockedReason(in: project) { + // Said once for every entry, rather than after each. + Text(blocked.prefix(1).uppercased() + blocked.dropFirst()) + Divider() + } + newLocalWorkroomButton(Text("This Mac"), in: project) + Section("Local containers") { + ForEach(RemoteWorkrooms.Runtime.allCases, id: \.self) { runtime in + newContainerWorkroomButton(on: runtime, in: project) + } + } + } + + /// New Workroom on this Mac. Disabled while a create is in flight, matching the row's own "+" + /// button (issue #167): ungated, the menu was one right-click away from N concurrent pre-name + /// creates, only one of which can own the loader slot. + private func newLocalWorkroomButton(_ label: some View, in project: Project) -> some View { + Button { + Task { await store.createWorkroom(in: project) } + } label: { + label + } + .help("A workroom of \(project.displayName) on this Mac") + .disabled(store.isBusyProject(project.path)) + } + + /// New Workroom in a local container on `runtime` (#309), cloned from the project's GitHub + /// origin. One that can't be used now says why in its title. + private func newContainerWorkroomButton( + on runtime: RemoteWorkrooms.Runtime, in project: Project + ) -> some View { + // The runtime's own reason only: one that holds the whole project (a create in flight) is said + // once, above the entries. + let reason = RemoteWorkrooms.unavailability(of: runtime) + return Button { + Task { await store.createRemoteWorkroom(in: project, runtime: runtime) } + } label: { + Text(reason.map { "\(runtime.displayName) — \($0)" } ?? runtime.displayName) + } + .help( + reason.map { "\(runtime.displayName) can't be used: \($0)" } + ?? "A workroom of \(project.displayName) in \(runtime.containerPhrase) on this Mac" + ) + .disabled(reason != nil || !store.canCreateRemoteWorkroom(in: project)) + } + @ViewBuilder private func projectRow(_ project: Project) -> some View { let id = SidebarID.project(project.path) @@ -235,13 +285,33 @@ struct ProjectSidebar: View { ) { store.pendingProjectSettings = PendingProjectSettings(project: project) } - CreateRowButton(help: "New workroom in \(project.displayName)") { - Task { await store.createWorkroom(in: project) } + Group { + // With the preview on, "+" asks where, as the context menu does (#309). + if RemoteWorkrooms.isEnabled { + CreateRowButton( + help: "New workroom in \(project.displayName), on this Mac or in a local container", + places: { newWorkroomPlaces(in: project) }) + } else { + CreateRowButton(help: "New workroom in \(project.displayName)") { + Task { await store.createWorkroom(in: project) } + } + } } .opacity(busy ? 0 : 1) .allowsHitTesting(!busy) .overlay { - if busy { ProgressView().controlSize(.small) } + if busy { + // A container workroom's first create downloads its host image, which can take a while: + // how far it has got, rather than a spinner that says nothing (#309). + if let pulled = store.imagePulls[project.path] { + ProgressView(value: pulled) + .progressViewStyle(.circular) + .controlSize(.small) + .help("Downloading the workroom image: \(Int(pulled * 100))%") + } else { + ProgressView().controlSize(.small) + } + } } } .frame(height: rowHeight) @@ -251,28 +321,18 @@ struct ProjectSidebar: View { if inside { hovered = id } else if hovered == id { hovered = nil } } .contextMenu { - Button { - Task { await store.createWorkroom(in: project) } - } label: { - Label("New Workroom", systemImage: "plus") - } - // Disabled while a create is in flight, matching the row's own "+" button above (issue #167) — - // ungated, this menu item was one right-click away from N concurrent pre-name creates, only one - // of which can own the loader slot. Re-read here rather than reusing the `busy` above: that one - // is scoped to the row's own `HStack` builder. - .disabled(store.isBusyProject(project.path)) - // Nightly and Dev, behind the preview setting (#253). The workroom belongs to this local - // project: its base machine is cloned from the project's GitHub origin. + // Nightly and Dev, behind the preview setting (#253, #309): New Workroom asks where, this Mac + // or a local container. Without the preview there is only this Mac, so no submenu. if RemoteWorkrooms.isEnabled { - Button { - Task { await store.createRemoteWorkroom(in: project) } + Menu { + newWorkroomPlaces(in: project) } label: { - Label("New Remote Workroom", systemImage: "network") + Label("New Workroom", systemImage: "plus") } - .help( - "A workroom of \(project.displayName) on a remote host, cloned from its GitHub origin" - ) - .disabled(!store.canCreateRemoteWorkroom(in: project)) + // No tooltip here: a submenu's parent shows it on top of the submenu, over This Mac. Each + // entry has its own. + } else { + newLocalWorkroomButton(Label("New Workroom", systemImage: "plus"), in: project) } Divider() Button { @@ -371,12 +431,6 @@ struct ProjectSidebar: View { .foregroundStyle(.yellow) .help(warning.message) } - // A destroyed host already shows its HostDestroyed warning above. - if target.unavailability == .remote { - Image(systemName: "network") - .foregroundStyle(.secondary) - .help("Remote workroom") - } // Spinner/delete slot: a progress spinner while the workroom's setup runs (issue #116) or a // command runs (issue #28), swapped for the delete button on hover — so a workroom stays // deletable even mid-run. The delete button is always laid out (it reveals via opacity), so it @@ -464,11 +518,14 @@ struct ProjectSidebar: View { store.toggleTerminals(for: target.id) } } else { - // The glyph's tint carries the VCS dirty signal (orange) in place of a separate status dot. - Image(systemName: root ? "house" : "cube") + // The glyph's tint carries the VCS dirty signal (orange) in place of a separate status dot. A + // workroom on a host wears the network, with the host's kind as its tooltip (#309). + Image(systemName: root ? "house" : target.workroomGlyph) .font(.system(size: 10)) .foregroundStyle(VCSStatusPresentation.iconTint(store.workroomStatuses[id] ?? .unresolved)) .frame(width: caretWidth, height: 18, alignment: .center) + .help(root ? "" : target.hostKind ?? "") + .accessibilityLabel(root ? "Project root" : target.hostKind ?? "Workroom") } } @@ -677,24 +734,48 @@ private struct SetupSpinner: View { /// The always-visible "new workroom" button on a project row. Its own hover paints a /// subtle neutral background to read as an actionable control. -private struct CreateRowButton: View { +private struct CreateRowButton: View { let help: String - let action: () -> Void + /// What a click does, or nil when it opens `places` instead (#309). + let action: (() -> Void)? + let places: (() -> Places)? @State private var hovering = false private let theme = ThemeService.shared + init(help: String, action: @escaping () -> Void) where Places == EmptyView { + (self.help, self.action, self.places) = (help, action, nil) + } + + init(help: String, @ViewBuilder places: @escaping () -> Places) { + (self.help, self.action, self.places) = (help, nil, places) + } + + private var glyph: some View { + Image(systemName: "plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(theme.tokens.hover.opacity(hovering ? 1 : 0)) + ) + } + var body: some View { - Button(action: action) { - Image(systemName: "plus") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .padding(4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(theme.tokens.hover.opacity(hovering ? 1 : 0)) - ) + Group { + if let places { + // A menu that looks exactly like the button it replaces: drawn by SwiftUI as a plain + // button, so the glyph keeps its size, weight and colour, with no arrow. + Menu(content: places) { glyph.accessibilityLabel(help) } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } else if let action { + Button(action: action) { glyph } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) .onHover { hovering = $0 } .help(help) .accessibilityLabel(help) diff --git a/macapp/WorkroomApp/Views/WorkroomSplitView.swift b/macapp/WorkroomApp/Views/WorkroomSplitView.swift index c532b7992..7f50e4d65 100644 --- a/macapp/WorkroomApp/Views/WorkroomSplitView.swift +++ b/macapp/WorkroomApp/Views/WorkroomSplitView.swift @@ -499,9 +499,10 @@ private struct WorkroomPaneTitleBar: View { HStack(spacing: 6) { // Leading glyph mirrors the tab chip's: a house for a project root, a cube for a workroom // (`workroomName` is nil only for a root). Accent on the focused pane, muted otherwise. - Image(systemName: workroomName == nil ? "house" : "cube") + Image(systemName: workroomName == nil ? "house" : target.workroomGlyph) .font(.system(size: 10)) .foregroundStyle(focused ? theme.tokens.accent : theme.tokens.fgMuted) + .help(workroomName == nil ? "" : target.hostKind ?? "") if let reason = target.terminalUnavailability { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) diff --git a/macapp/WorkroomApp/Views/WorkroomTabBar.swift b/macapp/WorkroomApp/Views/WorkroomTabBar.swift index 3d361fe91..71d98bb8c 100644 --- a/macapp/WorkroomApp/Views/WorkroomTabBar.swift +++ b/macapp/WorkroomApp/Views/WorkroomTabBar.swift @@ -534,9 +534,10 @@ private struct WorkroomTabChip: View { HStack(spacing: 6) { // Leading glyph: a house marks a project root, a cube an isolated workroom — set before the name. // Its tint carries the VCS dirty signal (orange) in place of a separate status dot. - Image(systemName: isRoot ? "house" : "cube") + Image(systemName: isRoot ? "house" : target.workroomGlyph) .font(.system(size: 10)) .foregroundStyle(VCSStatusPresentation.iconTint(store.workroomStatuses[sid] ?? .unresolved)) + .help(isRoot ? "" : target.hostKind ?? "") if let reason = target.terminalUnavailability { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) diff --git a/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift new file mode 100644 index 000000000..7d93665f8 --- /dev/null +++ b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift @@ -0,0 +1,152 @@ +import XCTest + +@testable import Workroom + +/// `ContainerHostDriver` on Apple's real `container` runtime (#309): a base, a workroom derived +/// from it, a restart, the sweep, and teardown. Opt-in: Apple's runtime needs Apple silicon and a +/// VM per container, which CI's macOS runners can't give it, so these run on a Mac by hand before +/// a release. Skipped unless `TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`, with +/// `TEST_RUNNER_WR_APPLE_HOST_IMAGE` naming a `workroom-host` image the runtime already has, e.g. +/// +/// docker build --platform linux/arm64 -t workroom-host vcs/scripts/ssh-fixture +/// docker save workroom-host -o /tmp/host.tar && container image load -i /tmp/host.tar +/// TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1 \ +/// TEST_RUNNER_WR_APPLE_HOST_IMAGE=docker.io/library/workroom-host:latest \ +/// make app-test APP_TEST_FLAGS=-only-testing:WorkroomAppTests/AppleContainerIntegrationTests +/// +/// with `container system start` run first. +final class AppleContainerIntegrationTests: XCTestCase { + private var directory: URL! + private var runtime: URL! + private var label: String! + + override func setUpWithError() throws { + let environment = ProcessInfo.processInfo.environment + guard environment["WR_APPLE_CONTAINER_TESTS"] == "1" else { + throw XCTSkip("set TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1 to run Apple container tests") + } + guard let found = RemoteHosts.executable(for: .apple) else { + throw XCTSkip("Apple's container CLI isn't installed") + } + runtime = URL(fileURLWithPath: found) + directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "wr-apple-\(UUID().uuidString)") + label = "workroom.provisioner=apple-test-\(UUID().uuidString.lowercased())" + } + + override func tearDown() async throws { + // Whatever a failed test left, by this run's label. + if runtime != nil { + let containers = (try? AppleContainerCLI.objects(cli(["list", "--all", "--format", "json"]))) + for container in containers ?? [] where carries(AppleContainerCLI.labels(of: container)) { + if let id = AppleContainerCLI.id(of: container) { _ = try? cli(["delete", "--force", id]) } + } + } + if let directory { try? FileManager.default.removeItem(at: directory) } + } + + /// This run's own, never another run's: two runs at once would otherwise delete each other's. + private func carries(_ labels: [String: String]) -> Bool { + labels["workroom.provisioner"] == label.split(separator: "=")[1].description + } + + /// The real CLI, run from here rather than through the driver, so a check is never the driver + /// checking itself. + @discardableResult + private func cli(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = runtime + process.arguments = arguments + let out = Pipe() + process.standardOutput = out + process.standardError = FileHandle.nullDevice + try process.run() + let data = out.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func ours() throws -> [String] { + try AppleContainerCLI.objects(cli(["list", "--all", "--format", "json"])).filter { + carries(AppleContainerCLI.labels(of: $0)) + }.compactMap(AppleContainerCLI.id) + } + + private func provisioning() throws -> ContainerHostDriver.Provisioning { + guard let image = ProcessInfo.processInfo.environment["WR_APPLE_HOST_IMAGE"] else { + throw XCTSkip("set TEST_RUNNER_WR_APPLE_HOST_IMAGE to a workroom-host image") + } + let key = try RemoteHosts.clientKey(in: directory) + return ContainerHostDriver.Provisioning( + runtime: runtime, image: image, user: RemoteWorkrooms.user, identityFile: key.path, + publicKey: try String(contentsOf: key.appendingPathExtension("pub"), encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + agentSocket: RemoteWorkrooms.agentSocket, labels: [label], dialect: .apple) + } + + private func onHost(_ driver: ContainerHostDriver, _ host: HostID, _ command: String) + async throws -> String + { + let (status, output) = try await driver.exec(command, on: host).communicate(nil, timeout: 20) + XCTAssertEqual(status, 0, "on the host, \(command) failed: \(output)") + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func testABaseDerivesARestartableWorkroomAndTeardownLeavesNothing() async throws { + let driver = ContainerHostDriver( + hosts: [:], directory: directory, provisioning: try provisioning()) + + let base = try await driver.create() + _ = try await onHost(driver, base, "echo carried > ~/marker") + let baseKey = try await onHost(driver, base, "cut -d' ' -f2 /etc/ssh/ssh_host_ed25519_key.pub") + + // The disk comes across, the identity doesn't, and the derived image is gone again. + let instance = try await driver.deriveFromBase(base) + let marker = try await onHost(driver, instance, "cat ~/marker") + XCTAssertEqual(marker, "carried") + let instanceKey = try await onHost( + driver, instance, "cut -d' ' -f2 /etc/ssh/ssh_host_ed25519_key.pub") + XCTAssertNotEqual(instanceKey, baseKey) + let images = try AppleContainerCLI.objects(cli(["image", "list", "--format", "json"])) + XCTAssertFalse( + images.contains { + AppleContainerCLI.name(ofImage: $0)?.contains("workroom-derive-") == true + && carries(AppleContainerCLI.labels(ofImage: $0)) + }, + "the derived image was left behind") + // The base was stopped for the export and started again. + let back = try await onHost(driver, base, "cat ~/marker") + XCTAssertEqual(back, "carried") + + // Apple keeps nothing running across a restart: opening starts it again, on its port. + guard case .remote(let id) = instance else { return XCTFail("\(instance)") } + try cli(["stop", ContainerHostDriver.containerName(id)]) + try await driver.startIfStopped(instance) + let up = try await onHost(driver, instance, "echo up") + XCTAssertEqual(up, "up") + + // A base left stopped (a reboot: Apple restarts nothing) is still derived from, and left + // stopped. + guard case .remote(let stoppedBase) = base else { return XCTFail("\(base)") } + try cli(["stop", ContainerHostDriver.containerName(stoppedBase)]) + let second = try await driver.deriveFromBase(base) + let carried = try await onHost(driver, second, "cat ~/marker") + XCTAssertEqual(carried, "carried") + let state = try AppleContainerCLI.objects( + cli(["inspect", ContainerHostDriver.containerName(stoppedBase)]) + ).first.flatMap(AppleContainerCLI.state) + XCTAssertEqual(state, "stopped") + try await driver.destroy(second) + try await driver.startIfStopped(base) + + // Both are recorded hosts: the sweep takes neither, however old. + guard case .remote(let baseID) = base else { return XCTFail("\(base)") } + let failures = await driver.sweep(keeping: [baseID, id], grace: 0) + XCTAssertEqual(failures, []) + XCTAssertEqual(try ours().count, 2) + + try await driver.destroy(instance) + try await driver.destroy(base) + XCTAssertEqual(try ours(), []) + } +} diff --git a/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift b/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift index c6107d98b..e2b2aa39d 100644 --- a/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift +++ b/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift @@ -203,6 +203,39 @@ final class ReverseForwardTests: XCTestCase { registry.close(workroom: workroom) } + /// The port on this Mac can move on the same connection (the credential relay listening again, + /// #309): the next open carries the workroom's listener to the new one rather than keeping the + /// old, which nothing answers any more. + @MainActor + func testAnOpenAfterTheTargetMovesCarriesToTheNewTarget() async throws { + let first = try echo() + let second = try echo() + let agent = try agent() + let host = HostID.remote(UUID()) + let current = Current( + lease: .init(host: host, generation: UUID()), connection: try await connection(to: agent)) + let target = Target(first.port) + let registry = ReverseForwardRegistry( + transport: .init( + forwarding: { _ in + let (lease, connection) = current.get() + return (lease, try connection.forwarding()) + }, + updates: { _ in AsyncStream { _ in } }), + port: { BrokerReverseForwards.port(for: $0) }, target: { target.get() }, + failure: { HostDriverError.provisioning($0) }) + let workroom = UUID() + let port = BrokerReverseForwards.port(for: workroom) + try await registry.open(workroom: workroom, host: host) + try roundTrip(port) + + target.set(second.port) + first.stop() + try await registry.open(workroom: workroom, host: host) + try roundTrip(port) + registry.close(workroom: workroom) + } + /// A link lost without a goodbye: the old connection's listener still holds the port when the new /// connection asks, for longer than a bind is retried. The registry keeps trying on the new /// connection, and gets the port once the old one lets it go. @@ -353,6 +386,14 @@ private final class Current: @unchecked Sendable { } } +private final class Target: @unchecked Sendable { + private let lock = NSLock() + private var port: UInt16 + init(_ port: UInt16) { self.port = port } + func get() -> UInt16 { lock.withLock { port } } + func set(_ port: UInt16) { lock.withLock { self.port = port } } +} + /// A reverse forward's events, with the first answer to its `listen` awaitable. final class ReverseEvents: @unchecked Sendable { private let lock = NSLock() diff --git a/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift b/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift index af86b70d0..cd4d38b33 100644 --- a/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift +++ b/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift @@ -124,6 +124,27 @@ final class ContainerProvisioningIntegrationTests: XCTestCase { } catch HostDriverError.unknownHost {} } + /// A stopped container is started again, and logged in to, when its workroom is opened (#309); a + /// running one is left as it is. + func testAStoppedHostIsStartedAndReachableAgain() async throws { + let provisioning = try provisioning() + let driver = ContainerHostDriver(hosts: [:], directory: directory, provisioning: provisioning) + let base = try await driver.create() + guard case .remote(let id) = base else { return XCTFail("\(base)") } + let container = ContainerHostDriver.containerName(id) + + try await driver.startIfStopped(base) + XCTAssertEqual(try docker(runtime, ["inspect", "-f", "{{.State.Running}}", container]), "true") + _ = try docker(runtime, ["stop", container]) + XCTAssertEqual( + try docker(runtime, ["inspect", "-f", "{{.State.Running}}", container]), "false") + + try await driver.startIfStopped(base) + let answer = try await onHost(driver, base, "echo up") + XCTAssertEqual(answer, "up") + try await driver.destroy(base) + } + func testInstancesDerivedFromOneBaseCarryItsDiskButMintTheirOwnIdentity() async throws { let provisioning = try provisioning() let driver = ContainerHostDriver(hosts: [:], directory: directory, provisioning: provisioning) diff --git a/macapp/WorkroomAppTests/CredentialRelayTests.swift b/macapp/WorkroomAppTests/CredentialRelayTests.swift new file mode 100644 index 000000000..e5438a2d7 --- /dev/null +++ b/macapp/WorkroomAppTests/CredentialRelayTests.swift @@ -0,0 +1,300 @@ +import XCTest + +@testable import Workroom + +/// The Mac's end of a local container workroom's git credential relay (#309): who is answered, for +/// what, and with what. +final class CredentialRelayTests: XCTestCase { + private final class Asked: @unchecked Sendable { + private let lock = NSLock() + private var requests: [String] = [] + var all: [String] { lock.withLock { requests } } + func add(_ request: String) { lock.withLock { requests.append(request) } } + } + + /// A known secret asking for github.com over https gets gh's username and password, and nothing + /// else gh said; gh is asked for github.com only, whatever else the request carried. + func testAKnownWorkroomIsAnsweredWithGhsCredentialOnly() throws { + let asked = Asked() + let relay = CredentialRelay(answer: { request in + asked.add(request) + return "protocol=https\nhost=github.com\nusername=joel\npassword=gho_x\nextra=1\n" + }) + let secret = try relay.secret(for: UUID()) + XCTAssertEqual( + relay.respond(to: "\(secret)\nprotocol=https\nhost=github.com\npath=o/r.git\n\n"), + "username=joel\npassword=gho_x\n") + XCTAssertEqual(asked.all, ["protocol=https\nhost=github.com\n\n"]) + } + + /// An unknown secret, another host, or plain http is answered with an error, and gh is never + /// asked: another user of this Mac can reach the listener too. + func testAnythingElseIsRefusedWithoutAskingGh() throws { + let asked = Asked() + let relay = CredentialRelay(answer: { request in + asked.add(request) + return "username=joel\npassword=gho_x\n" + }) + let secret = try relay.secret(for: UUID()) + for request in [ + "guess\nprotocol=https\nhost=github.com\n\n", + "\nprotocol=https\nhost=github.com\n\n", + "\(secret)\nprotocol=https\nhost=gitlab.com\n\n", + "\(secret)\nprotocol=http\nhost=github.com\n\n", + ] { + XCTAssertTrue(relay.respond(to: request).hasPrefix("error="), request) + } + XCTAssertEqual(asked.all, []) + } + + /// gh with no sign-in, or one that fails, says so in an `error=` line the agent hands git. + func testAGhWithoutASignInIsAnErrorLine() throws { + let empty = CredentialRelay(answer: { _ in "protocol=https\nhost=github.com\n" }) + let secret = try empty.secret(for: UUID()) + XCTAssertEqual( + empty.respond(to: "\(secret)\nprotocol=https\nhost=github.com\n\n"), + "error=gh has no GitHub sign-in on this Mac: run `gh auth login`\n") + let failing = CredentialRelay(answer: { _ in + throw HostDriverError.provisioning("gh\nbroke") + }) + let reply = failing.respond( + to: "\(try failing.secret(for: UUID()))\nprotocol=https\nhost=github.com\n\n") + XCTAssertTrue(reply.hasPrefix("error=") && !reply.dropLast().contains("\n"), reply) + } + + /// A workroom's secret is its own and stays the same for the launch; its port is the same on + /// every launch, below Linux's ephemeral range. + func testSecretsAreOwnAndStablePortsBelowTheEphemeralRange() throws { + let relay = CredentialRelay(answer: { _ in "" }) + let (one, two) = (UUID(), UUID()) + XCTAssertEqual(try relay.secret(for: one), try relay.secret(for: one)) + XCTAssertNotEqual(try relay.secret(for: one), try relay.secret(for: two)) + XCTAssertEqual(try relay.secret(for: one).count, 64) + for _ in 0..<200 { + let port = CredentialRelay.port(for: UUID()) + XCTAssertTrue((30_000..<32_000).contains(port), "\(port)") + } + } + + /// gh lists github.com in its hosts file when signed in there; the menu reads that, not gh. + func testAGhSignInIsReadFromItsHostsFile() throws { + let home = try directory() + let file = home.appendingPathComponent(".config/gh/hosts.yml") + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(environment: [:], home: home)) + try "github.example.com:\n user: x\n".write(to: file, atomically: true, encoding: .utf8) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(environment: [:], home: home)) + try "github.com:\n user: joel\n".write(to: file, atomically: true, encoding: .utf8) + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(environment: [:], home: home)) + } + + /// gh's sign-in is found where gh looks in this app's environment: a token, then + /// `GH_CONFIG_DIR`, then `XDG_CONFIG_HOME`, in gh's order. + func testAGhSignInIsFoundWhereGhLooks() throws { + let home = try directory() + let signedIn = "github.com:\n user: joel\n" + let config = home.appendingPathComponent("config-dir") + let xdg = home.appendingPathComponent("xdg") + for directory in [config, xdg.appendingPathComponent("gh")] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try signedIn.write( + to: directory.appendingPathComponent("hosts.yml"), atomically: true, encoding: .utf8) + } + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(environment: ["GH_TOKEN": "x"], home: home)) + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(environment: ["GITHUB_TOKEN": "x"], home: home)) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(environment: ["GH_TOKEN": ""], home: home)) + XCTAssertTrue( + CredentialRelay.hasGitHubSignIn(environment: ["GH_CONFIG_DIR": config.path], home: home)) + XCTAssertTrue( + CredentialRelay.hasGitHubSignIn(environment: ["XDG_CONFIG_HOME": xdg.path], home: home)) + // GH_CONFIG_DIR wins, even over a signed-in XDG config. + XCTAssertFalse( + CredentialRelay.hasGitHubSignIn( + environment: [ + "GH_CONFIG_DIR": home.appendingPathComponent("empty").path, + "XDG_CONFIG_HOME": xdg.path, + ], + home: home)) + } + + private func directory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "gh-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + return directory + } + + /// Secrets compare whole, and only equal ones match. + func testSecretsCompareWhole() { + XCTAssertTrue(CredentialRelay.same("abc", "abc")) + XCTAssertFalse(CredentialRelay.same("abc", "abd")) + XCTAssertFalse(CredentialRelay.same("abc", "ab")) + XCTAssertFalse(CredentialRelay.same("", "a")) + } + + /// A peer that sends junk and resets at once can't end the app (SIGPIPE), nor stop the listener: + /// a real request after a run of them is still answered, over the loopback socket itself. + func testResetPeersNeitherCrashNorStopTheListener() throws { + let relay = CredentialRelay(answer: { _ in "username=u\npassword=p\n" }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + for _ in 0..<30 { + // A burst like this can overrun the listen backlog for a moment, which refuses a connect; + // that's the kernel, not the relay, so it is tried again. The request below must still land. + var connected: Int32? + for _ in 0..<50 where connected == nil { + connected = LoopbackSocket.connect(port: port, timeout: 2) + if connected == nil { Thread.sleep(forTimeInterval: 0.01) } + } + guard let socket = connected else { return XCTFail("connect \(errno)") } + _ = "x\n\n".withCString { send(socket, $0, 3, 0) } + var linger = linger(l_onoff: 1, l_linger: 0) + setsockopt(socket, SOL_SOCKET, SO_LINGER, &linger, socklen_t(MemoryLayout.size)) + Darwin.close(socket) + } + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + defer { Darwin.close(socket) } + let request = "\(secret)\nprotocol=https\nhost=github.com\n\n" + _ = request.withCString { send(socket, $0, strlen($0), 0) } + var reply = Data() + var buffer = [UInt8](repeating: 0, count: 256) + while true { + let count = recv(socket, &buffer, buffer.count, 0) + guard count > 0 else { break } + reply.append(buffer, count: count) + } + XCTAssertEqual(String(decoding: reply, as: UTF8.self), "username=u\npassword=p\n") + } + + /// Everything `socket` is sent before it closes. + private func reply(_ socket: Int32) -> String { + var reply = Data() + var buffer = [UInt8](repeating: 0, count: 256) + while true { + let count = recv(socket, &buffer, buffer.count, 0) + guard count > 0 else { break } + reply.append(buffer, count: count) + } + return String(decoding: reply, as: UTF8.self) + } + + /// A request past `maxRequest` with no end in sight is dropped unanswered, and gh never asked. + func testAnOversizedRequestIsDroppedUnanswered() throws { + let asked = Asked() + let relay = CredentialRelay(answer: { request in + asked.add(request) + return "username=u\npassword=p\n" + }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + defer { Darwin.close(socket) } + // The relay may close before it has read all of this: an EPIPE, not a signal that ends the run. + var on: Int32 = 1 + setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + let request = + "\(secret)\nprotocol=https\nhost=github.com\n" + + String(repeating: "x", count: CredentialRelay.maxRequest) + "\n\n" + _ = request.withCString { send(socket, $0, strlen($0), 0) } + XCTAssertEqual(reply(socket), "") + XCTAssertEqual(asked.all, []) + } + + /// Idle connections that never say a secret, more than there are answering slots, don't keep a + /// workroom's request from being answered: anyone who can reach the listener could open them. + func testIdleStrangersDoNotBlockAKnownWorkroom() throws { + let relay = CredentialRelay(answer: { _ in "username=u\npassword=p\n" }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + var held: [Int32] = [] + defer { for socket in held { Darwin.close(socket) } } + for _ in 0..<(CredentialRelay.maxConcurrent + 4) { + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + held.append(socket) + } + Thread.sleep(forTimeInterval: 0.3) + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + defer { Darwin.close(socket) } + let request = "\(secret)\nprotocol=https\nhost=github.com\n\n" + _ = request.withCString { send(socket, $0, strlen($0), 0) } + XCTAssertEqual(reply(socket), "username=u\npassword=p\n") + } + + /// Past `maxReading` connections at once, one more is closed at once rather than read; the idle + /// ones are dropped after `requestDeadline`, and a real request is answered again. + func testConnectionsPastTheReadingCapAreTurnedAway() throws { + let relay = CredentialRelay(answer: { _ in "username=u\npassword=p\n" }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + var held: [Int32] = [] + defer { for socket in held { Darwin.close(socket) } } + for _ in 0..&2; sleep 0.3; done; echo done"], + environment: [:], handshakeTimeout: 5, purpose: .exchange) + let (status, output) = try await stream.communicate(nil, timeout: 1) + XCTAssertEqual(status, 0) + // stdout, then what stderr said. + XCTAssertTrue(output.hasPrefix("done\n"), output) + } + /// The binary is derived from the socket and run by path from any cwd, so a relative socket is /// refused before anything is written. func testARelativeAgentSocketIsRefused() { diff --git a/macapp/WorkroomAppTests/ImagePullProgressTests.swift b/macapp/WorkroomAppTests/ImagePullProgressTests.swift new file mode 100644 index 000000000..14ca17166 --- /dev/null +++ b/macapp/WorkroomAppTests/ImagePullProgressTests.swift @@ -0,0 +1,54 @@ +import XCTest + +@testable import Workroom + +/// Reading how far an image pull has got from what each runtime's CLI prints (#309). +final class ImagePullProgressTests: XCTestCase { + /// Docker: the layers done of those seen, counting one already present as done, and ignoring + /// lines that aren't a layer's. + func testDockerProgressIsLayersDoneOfThoseSeen() { + var progress = ImagePullProgress() + progress.read("1.36: Pulling from containerd/busybox\n") + XCTAssertNil(progress.fraction) + progress.read( + "f78e6840ded1: Pulling fs layer\naaaaaaaaaaaa: Already exists\nbbbbbbbbbbbb: Pulling fs layer\n" + + "cccccccccccc: Pulling fs layer\n") + XCTAssertEqual(progress.fraction, 0.25) + // A line split across two pieces of output. + progress.read("f78e6840ded1: Download complete\nf78e6840ded1: Pull ") + XCTAssertEqual(progress.fraction, 0.25) + progress.read("complete\n") + XCTAssertEqual(progress.fraction, 0.5) + progress.read( + "bbbbbbbbbbbb: Pull complete\ncccccccccccc: Pull complete\nDigest: sha256:7b3c\n" + + "Status: Downloaded newer image for x\n") + XCTAssertEqual(progress.fraction, 1) + } + + /// Apple: each step's share, plus its percent of that, as the lines `container` prints. + func testAppleProgressIsTheStepAndItsPercent() { + var progress = ImagePullProgress() + progress.read("[1/2] Fetching image 50% (10 of 20 blobs)\n") + XCTAssertEqual(progress.fraction, 0.25) + progress.read( + "[2/2] Unpacking image for platform linux/arm64 100% (440 of 440 entries, 3.9/3.9 MB) [3s]\r") + XCTAssertEqual(progress.fraction, 1) + var odd = ImagePullProgress() + odd.read("[3/2] nonsense 40%\n[x/y] 10%\n") + XCTAssertNil(odd.fraction) + } + + /// stdout and stderr interleave mid-line: each stream's line is its own, so neither garbles the + /// other and every layer's completion counts. + func testInterleavedStreamsKeepTheirOwnLines() { + var progress = ImagePullProgress() + progress.read("aaaaaaaaaaaa: Pulling fs layer\nbbbbbbbbbbbb: Pulling fs layer\n") + progress.read("aaaaaaaaaaaa: Pull com") + progress.read("some warning on std", stderr: true) + progress.read("plete\n") + progress.read("err\n", stderr: true) + XCTAssertEqual(progress.fraction, 0.5) + progress.read("bbbbbbbbbbbb: Already exists\n") + XCTAssertEqual(progress.fraction, 1) + } +} diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 478993f3d..c127a2f07 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -40,7 +40,7 @@ final class RemoteHostsTests: XCTestCase { connects.ask() return false }, - now: { connects.now }) + startHost: { _ in }, now: { connects.now }) } /// The inspector's panels ask together when a remote workroom is selected: one connect serves @@ -87,6 +87,187 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(connects.calls, 2) } + /// Only a host whose workroom the user opened has its container started before connecting: the + /// status sweep connects to every remote workroom, and must not start them all (#309). + func testOnlyAnOpenedHostIsStartedBeforeItsConnect() async throws { + let connects = Connects() + connects.hold(false) + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + }) + let (probed, opened) = (UUID(), UUID()) + + try await remote.ensureConnected(.remote(probed)) + XCTAssertTrue(started.calls.isEmpty, "a probe of an unopened host started it") + + remote.activate(.remote(opened)) + try await remote.ensureConnected(.remote(opened)) + XCTAssertEqual(started.calls.map(\.known), [[opened]]) + XCTAssertEqual(connects.calls, 2) + } + + /// Opening a workroom whose host just failed a probe tries it again at once, starting it: the + /// probe's failure was the container being stopped, which opening is about to fix. + func testOpeningAHostLiftsItsRetryWindow() async throws { + let connects = Connects() + connects.hold(false) + connects.fail(true) + let remote = hosts(connects) + let host = HostID.remote(UUID()) + do { try await remote.ensureConnected(host) } catch {} + connects.fail(false) + remote.activate(host) + try await remote.ensureConnected(host) + XCTAssertEqual(connects.calls, 2) + } + + /// Opening a workroom while a status probe of its stopped container is under way starts it: joined, + /// the probe would fail and hold the workroom back for `retryAfter`. + func testOpeningDuringAProbeStartsTheHost() async throws { + let connects = Connects() + connects.fail(true) + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, + isConnected: { _ in + connects.ask() + return false + }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + connects.fail(false) + }, now: { connects.now }) + let id = UUID() + + let probe = Task { try await remote.ensureConnected(.remote(id)) } + while connects.calls < 1 { try await Task.sleep(for: .milliseconds(1)) } + remote.activate(.remote(id)) + let opened = Task { try await remote.ensureConnected(.remote(id)) } + while connects.arrived < 2 { try await Task.sleep(for: .milliseconds(1)) } + try await Task.sleep(for: .milliseconds(20)) + connects.hold(false) + _ = try? await probe.value + try await opened.value + XCTAssertEqual(started.calls.map(\.known), [[id]], "the opened workroom's host wasn't started") + } + + /// Opening starts a container once: one the user stops afterwards is not started again by the + /// status sweep's next probe. + func testOpeningStartsTheHostOnce() async throws { + let connects = Connects() + connects.hold(false) + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + }) + let id = UUID() + remote.activate(.remote(id)) + try await remote.ensureConnected(.remote(id)) + try await remote.ensureConnected(.remote(id)) + XCTAssertEqual(started.calls.count, 1, "a later probe started the container again") + remote.activate(.remote(id)) + try await remote.ensureConnected(.remote(id)) + XCTAssertEqual(started.calls.count, 2, "opening it again didn't start it") + } + + /// An opening whose start fails is spent too: the status sweep's later probes neither start the + /// container again nor skip its backoff. + func testAFailedOpeningIsSpent() async throws { + let connects = Connects() + connects.hold(false) + let starts = Connects() + starts.hold(false) + starts.fail(true) + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { try await starts.connect($0) }, now: { connects.now }) + let host = HostID.remote(UUID()) + remote.activate(host) + do { try await remote.ensureConnected(host) } catch {} + XCTAssertEqual(starts.calls, 1) + do { try await remote.ensureConnected(host) } catch {} + XCTAssertEqual(starts.calls, 1, "a probe started the container again") + XCTAssertEqual(connects.calls, 0, "a probe skipped the backoff") + } + + /// A workroom opened while its container runs has nothing to start, and the opening is spent: + /// stopped later, the status sweep's probe doesn't start it. + func testOpeningARunningHostLeavesItStoppedLater() async throws { + let connects = Connects() + connects.hold(false) + let running = Connects() + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, + isConnected: { _ in + // Up the first time it's asked, stopped after. + defer { running.ask() } + return running.arrived == 0 + }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + }) + let id = UUID() + remote.activate(.remote(id)) + try await remote.ensureConnected(.remote(id)) + try await remote.ensureConnected(.remote(id)) + XCTAssertTrue(started.calls.isEmpty, "a probe started a container opened while it ran") + } + + /// A probe of the stopped container that fails after the workroom was opened doesn't hold it back: + /// the opened workroom's next connect starts it at once. + func testAProbeFailingAfterTheOpeningDoesNotHoldItBack() async throws { + let connects = Connects() + connects.fail(true) + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { _ in connects.fail(false) }, now: { connects.now }) + let host = HostID.remote(UUID()) + let probe = Task { try await remote.ensureConnected(host) } + while connects.calls < 1 { try await Task.sleep(for: .milliseconds(1)) } + remote.activate(host) + connects.hold(false) + _ = try? await probe.value + try await remote.ensureConnected(host) + XCTAssertEqual(connects.calls, 2) + } + + /// A relay that didn't take on a connection is tried again by a later connection check, at most + /// once a `retryAfter`, until it does; nothing else would try before a reconnect. + func testARelayThatDidNotTakeIsTriedAgain() async throws { + let connects = Connects() + connects.fail(true) + let relays = Connects() + relays.hold(false) + relays.fail(true) + let remote = RemoteHosts( + connectHost: { _ in }, isConnected: { _ in true }, startHost: { _ in }, + now: { connects.now }, relayHost: { try await relays.connect($0) }) + let host = HostID.remote(UUID()) + + await remote.installRelay(host, attempts: 1) + XCTAssertEqual(relays.calls, 1) + try await remote.ensureConnected(host) + XCTAssertEqual(relays.calls, 1, "tried again inside its window") + + relays.fail(false) + connects.advance(RemoteHosts.retryAfter) + try await remote.ensureConnected(host) + XCTAssertEqual(relays.calls, 2, "a later check didn't try again") + connects.advance(RemoteHosts.retryAfter) + try await remote.ensureConnected(host) + XCTAssertEqual(relays.calls, 2, "tried again once it had taken") + } + /// `destroy` removes a host's image with `--force`, so a record's image must be a commit's ID. func testOnlyAWholeLowercaseSHA256IsAnImageID() { let hex = String(repeating: "a1", count: 32) @@ -165,7 +346,7 @@ final class RemoteHostsTests: XCTestCase { XCTAssertFalse(remote.claimSweep(allowed: true)) } - /// New Remote Workroom is off while its project is busy: a second create would build a second + /// A container workroom's create is off while its project is busy: a second create would build a second /// base. @MainActor func testARemoteCreateIsOffWhileItsProjectIsBusy() { @@ -178,6 +359,55 @@ final class RemoteHostsTests: XCTestCase { XCTAssertFalse(store.canCreateRemoteWorkroom(in: project)) } + /// A workroom on a host, a container or a remote provider, wears the network glyph on its tab and + /// pane in place of the cube, with what kind of host it is as the tooltip; one on this Mac keeps + /// the cube (#309). + func testAWorkroomOnAHostWearsTheNetworkGlyph() { + let local = Workroom(name: "w", path: "/tmp/w", vcsName: "workroom/w", warnings: []) + .target(inProject: "/proj") + XCTAssertEqual(local.workroomGlyph, "cube") + XCTAssertNil(local.hostKind) + let docker = Workroom( + name: "d", path: "/home/workroom/r", vcsName: "workroom/d", warnings: [], + host: HostDescriptor( + driver: "container", provisioner: RemoteWorkrooms.provisioner, id: UUID(), + container: Self.record(context: "orbstack")) + ).target(inProject: "/proj") + XCTAssertEqual(docker.workroomGlyph, "network") + XCTAssertEqual(docker.hostKind, "Docker container (orbstack) on this Mac") + let destroyed = Workroom( + name: "x", path: "/home/workroom/r", vcsName: "workroom/x", warnings: [], + host: HostDescriptor(state: "destroyed", driver: "apple-container") + ).target(inProject: "/proj") + XCTAssertEqual(destroyed.workroomGlyph, "network") + } + + /// A workroom's icon says what kind of host it is on (#309). + func testAHostSaysWhatKindItIs() { + let host = { (driver: String?, context: String?) in + HostDescriptor(driver: driver, container: Self.record(context: context)) + } + XCTAssertEqual(host("container", nil).kindDescription, "Docker container on this Mac") + XCTAssertEqual( + host("container", "orbstack").kindDescription, "Docker container (orbstack) on this Mac") + XCTAssertEqual(host("apple-container", nil).kindDescription, "Apple container on this Mac") + XCTAssertEqual(host("boxd", nil).kindDescription, "Remote workroom on boxd") + XCTAssertEqual(host(nil, nil).kindDescription, "Remote workroom") + } + + /// The New Workroom menu says why its entries are off while a create holds the project, or a + /// delete is taking it (#309). + @MainActor + func testACreateBlockedProjectSaysWhy() { + let store = AppStore() + let project = Project(path: "/proj", vcs: "git", workrooms: []) + XCTAssertNil(store.createBlockedReason(in: project)) + store.busyProjects["/proj"] = 1 + XCTAssertEqual(store.createBlockedReason(in: project), "a workroom is already being created") + store.deletingProjects.insert("/proj") + XCTAssertEqual(store.createBlockedReason(in: project), "the project is being deleted") + } + /// No watcher refreshes a remote tree, so coming back to it reads it again; the same local path /// would be a no-op. @MainActor @@ -224,6 +454,727 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(files.state, .idle, "Files listed the remote path on this Mac") } + // MARK: Docker contexts (#309) + + /// A stand-in runtime CLI: a script that appends its arguments to `log`, one call per line, and + /// prints `output`. + private func stubRuntime(output: String = "") throws -> (runtime: URL, log: URL) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "wr-runtime-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let runtime = directory.appendingPathComponent("docker") + let log = directory.appendingPathComponent("calls") + try """ + #!/bin/sh + printf '%s\\n' "$*" >> \(ContainerHostDriver.shellQuoted(log.path)) + printf '%s' \(ContainerHostDriver.shellQuoted(output)) + """.write(to: runtime, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: runtime.path) + return (runtime, log) + } + + /// A stand-in runtime CLI that logs every call and fails `image inspect` (the image is missing), + /// `run`, and `pull` when `pullFails`, so `create` stops at its first container. + private func failingRuntime(pullFails: Bool, said: String = "denied") throws -> ( + runtime: URL, log: URL + ) { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try + (script + """ + + case "$1 $2" in "image inspect") exit 1 ;; esac + case "$1" in run) exit 1 ;; pull) \(pullFails ? "echo '\(said)' >&2; exit 1" : "exit 0") ;; esac + """).write(to: runtime, atomically: true, encoding: .utf8) + return (runtime, log) + } + + /// A missing host image is pulled, by itself, before the base's container runs, and `run` never + /// pulls: left to it, a missing image was looked for on Docker Hub (#309). + func testAMissingHostImageIsPulledBeforeRunAndRunNeverPulls() async throws { + let (runtime, log) = try failingRuntime(pullFails: false) + do { + _ = try await Self.driver(runtime: runtime, context: nil).create() + XCTFail("run was meant to fail") + } catch {} + let calls = try String(contentsOf: log, encoding: .utf8).split(separator: "\n").map { + $0.split(separator: " ").prefix(2).joined(separator: " ") + } + XCTAssertEqual( + Array(calls.prefix(3)), ["image inspect", "pull workroom-host", "run --pull=never"]) + } + + private final class Fractions: @unchecked Sendable { + private let lock = NSLock() + private var heard: [Double?] = [] + var all: [Double?] { lock.withLock { heard } } + func add(_ fraction: Double?) { lock.withLock { heard.append(fraction) } } + } + + /// A pull's progress reaches the create that asked for it as it goes, rising to the whole, then + /// says it is done (#309). How many steps arrive between depends on how the pipe's reads fall, so + /// only their order and ends are pinned (`ImagePullProgressTests` pins the parsing). + func testAPullsProgressReachesItsCreate() async throws { + let (runtime, _) = try scriptedRuntime( + """ + "image inspect --format {{.Id}} workroom-host") exit 1 ;; + "pull workroom-host") + printf 'aaaaaaaaaaaa: Pulling fs layer\nbbbbbbbbbbbb: Pulling fs layer\n' + sleep 0.2; printf 'aaaaaaaaaaaa: Pull complete\n' + sleep 0.2; printf 'bbbbbbbbbbbb: Pull complete\n' ;; + run*) exit 1 ;; + """) + let heard = Fractions() + let report: @Sendable (Double?) -> Void = { heard.add($0) } + // The scripted `run` fails, after the pull. + try? await ContainerHostDriver.$pullProgress.withValue(report) { + _ = try await Self.driver(runtime: runtime, context: nil).create() + } + let all = heard.all + XCTAssertEqual(all.last, .some(nil), "the pull never said it was done: \(all)") + let fractions = all.dropLast().compactMap { $0 } + XCTAssertEqual(fractions.count, all.count - 1, "done before the end: \(all)") + XCTAssertEqual(fractions.last, 1, "\(all)") + XCTAssertEqual(fractions, fractions.sorted(), "\(all)") + XCTAssertTrue(fractions.allSatisfy { (0...1).contains($0) }, "\(all)") + } + + /// A pull that fails says why, by cause, and runs nothing: a registry that refused the image, a + /// runtime that isn't running, or else the network. + func testAFailedPullSaysWhy() async throws { + for (said, expected) in [ + ("denied", "the registry refused the workroom host image"), + ( + "Cannot connect to the Docker daemon at unix:///x. Is the docker daemon running?", + "Docker didn't answer" + ), + ( + "Got permission denied while trying to connect to the Docker daemon socket at unix:///x", + "Docker didn't answer" + ), + ("manifest unknown", "the registry refused the workroom host image"), + ("read: connection reset by peer", "couldn't download the workroom host image"), + ] { + let (runtime, log) = try failingRuntime(pullFails: true, said: said) + do { + _ = try await Self.driver(runtime: runtime, context: nil).create() + XCTFail("a failed pull created a host") + } catch { + XCTAssertTrue(error.localizedDescription.contains(expected), error.localizedDescription) + } + XCTAssertFalse(try String(contentsOf: log, encoding: .utf8).contains("run ")) + } + } + + /// A runtime that isn't running says so at its first command, rather than pulling: its inspect + /// failing is not a missing image. + func testARuntimeThatIsNotRunningSaysSo() async throws { + for (dialect, said, expected) in [ + ( + ContainerHostDriver.Dialect.docker, + "Cannot connect to the Docker daemon at unix:///x. Is the docker daemon running?", + "Docker didn't answer" + ), + ( + .apple, + "Error: interrupted: \"XPC connection error: Connection invalid\"\nEnsure container " + + "system service has been started with `container system start`.", + "container system start" + ), + ] { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try (script + "\nprintf '%s\\n' \(ContainerHostDriver.shellQuoted(said)) >&2; exit 1\n") + .write(to: runtime, atomically: true, encoding: .utf8) + do { + _ = try await Self.driver(runtime: runtime, context: nil, dialect: dialect).create() + XCTFail("created without a runtime") + } catch { + XCTAssertTrue(error.localizedDescription.contains(expected), error.localizedDescription) + } + XCTAssertFalse(try String(contentsOf: log, encoding: .utf8).contains("pull"), "it pulled") + } + } + + /// The image a new base runs: the hidden override, else the build's pinned digest, else a local + /// `workroom-host`. Empty values, as an unset build setting leaves `WorkroomHostImage`, don't count. + func testTheHostImageIsTheOverrideThenThePinThenLocal() { + let pinned = "ghcr.io/joelmoss/workroom-host@sha256:" + String(repeating: "a", count: 64) + XCTAssertEqual(RemoteWorkrooms.hostImage(override: "mine", pinned: pinned), "mine") + XCTAssertEqual(RemoteWorkrooms.hostImage(override: nil, pinned: pinned), pinned) + XCTAssertEqual(RemoteWorkrooms.hostImage(override: " ", pinned: ""), "workroom-host") + XCTAssertEqual(RemoteWorkrooms.hostImage(override: nil, pinned: nil), "workroom-host") + } + + private static func driver( + runtime: URL, context: String?, dialect: ContainerHostDriver.Dialect = .docker + ) -> ContainerHostDriver { + ContainerHostDriver( + hosts: [:], directory: FileManager.default.temporaryDirectory, + provisioning: ContainerHostDriver.Provisioning( + runtime: runtime, image: "workroom-host", user: RemoteWorkrooms.user, + identityFile: "/dev/null", publicKey: "ssh-ed25519 AAAA", + agentSocket: RemoteWorkrooms.agentSocket, labels: ["workroom.provisioner=test"], + context: context, dialect: dialect)) + } + + private static func record(context: String?) -> ContainerHostDriver.Record { + ContainerHostDriver.Record( + address: "127.0.0.1", port: 2222, user: RemoteWorkrooms.user, hostKey: "ssh-ed25519 AAAA", + image: nil, context: context) + } + + /// A record written before #309 has no context: it reads as nil, which names no context on any + /// command, as every command did then. A pinned one keeps its context through config. + func testARecordWithoutAContextReadsAsTheUnpinnedOne() throws { + let old = Data( + #"{"address":"127.0.0.1","port":2222,"user":"workroom","host_key":"ssh-ed25519 AAAA"}"#.utf8) + let decoded = try JSONDecoder().decode(ContainerHostDriver.Record.self, from: old) + XCTAssertNil(decoded.context) + XCTAssertEqual(decoded, Self.record(context: nil)) + + // Written back with no `context` key at all, so an old record's bytes don't change. + let written = try JSONSerialization.jsonObject( + with: try JSONEncoder().encode(Self.record(context: nil))) + XCTAssertNil((written as? [String: Any])?["context"]) + + let pinned = Self.record(context: "orbstack") + XCTAssertEqual( + try JSONDecoder().decode( + ContainerHostDriver.Record.self, from: try JSONEncoder().encode(pinned)), pinned) + } + + /// Every runtime command names a pinned context, ahead of the command (Docker refuses it after), + /// and an unpinned driver's commands are exactly what they were before #309. + func testRuntimeCommandsNameTheContextAheadOfTheCommand() async throws { + for context in [nil, "orbstack"] as [String?] { + let (runtime, log) = try stubRuntime() + _ = await Self.driver(runtime: runtime, context: context).sweep(keeping: []) + let calls = try self.calls(log) + let prefix = context.map { "--context \($0) " } ?? "" + XCTAssertEqual( + calls, + [ + "\(prefix)ps -a --filter label=workroom.provisioner=test --format " + + "{{.Names}}\t{{.Label \"workroom.created\"}}", + "\(prefix)images -aq --filter label=workroom.provisioner=test", + ], "context \(context ?? "nil")") + } + } + + /// A new Docker workroom wants the context the CLI uses now, except `default`, which is whatever + /// `DOCKER_HOST` says and so pins nothing. Apple's runtime has no contexts and asks nothing. + func testANewWorkroomWantsTheCurrentDockerContext() async throws { + for (current, expected) in [("orbstack", "orbstack"), ("default", nil)] as [(String, String?)] { + let (runtime, log) = try stubRuntime(output: current + "\n") + let remote = RemoteHosts(makeDriver: { Self.driver(runtime: runtime, context: $0.context) }) + let key = try await remote.key(for: .docker) + XCTAssertEqual(key, RemoteHosts.DriverKey(runtime: .docker, context: expected), current) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "context show\n") + } + let remote = RemoteHosts(makeDriver: { _ in + XCTFail("Apple's key asked a runtime") + throw RemoteWorkrooms.Failure.noDocker + }) + let apple = try await remote.key(for: .apple) + XCTAssertEqual(apple, RemoteHosts.DriverKey(runtime: .apple)) + } + + /// A project keeps a base per runtime and Docker context (#309): a workroom derives from the one + /// where it is asked for, a base made before #309 (no context) still serves Docker, and recording + /// or removing one keeps the others. + func testAProjectKeepsABasePerRuntimeAndContext() throws { + let base = { (runtime: RemoteWorkrooms.Runtime, context: String?) in + HostDescriptor( + driver: runtime.rawValue, provisioner: RemoteWorkrooms.provisioner, id: UUID(), + container: Self.record(context: context)) + } + let old = base(.docker, nil) + let key = { (runtime: RemoteWorkrooms.Runtime, context: String?) in + RemoteHosts.DriverKey(runtime: runtime, context: context) + } + // The one-base form a project before #309 has serves Docker on any context, and not Apple. + XCTAssertEqual(RemoteWorkrooms.base(in: old, for: key(.docker, "orbstack"))?.id, old.id) + XCTAssertNil(RemoteWorkrooms.base(in: old, for: key(.apple, nil))) + + let apple = base(.apple, nil) + let both = RemoteWorkrooms.recording(apple, in: old) + XCTAssertEqual(both.allBases.map(\.id), [old.id, apple.id]) + XCTAssertEqual(RemoteWorkrooms.base(in: both, for: key(.apple, nil))?.id, apple.id) + // An exact match wins over the context-less one. + let pinned = base(.docker, "orbstack") + let three = RemoteWorkrooms.recording(pinned, in: both) + XCTAssertEqual(RemoteWorkrooms.base(in: three, for: key(.docker, "orbstack"))?.id, pinned.id) + XCTAssertEqual(RemoteWorkrooms.base(in: three, for: key(.docker, "desktop-linux"))?.id, old.id) + // Round-trips through config, and comes apart again base by base. + let decoded = try JSONDecoder().decode( + HostDescriptor.self, from: try JSONEncoder().encode(three)) + XCTAssertEqual(decoded, three) + let fewer = RemoteWorkrooms.removing(try XCTUnwrap(old.id), from: three) + XCTAssertEqual(fewer?.allBases.map(\.id), [apple.id, pinned.id]) + let one = RemoteWorkrooms.removing(try XCTUnwrap(apple.id), from: fewer) + XCTAssertEqual(one, pinned, "one base left goes back to the one-base form") + XCTAssertNil(RemoteWorkrooms.removing(try XCTUnwrap(pinned.id), from: one)) + } + + /// Each recorded host is adopted into its own context's driver, and every driver's sweep keeps + /// every recorded host: two contexts can name one daemon, and a sweep that kept only its own + /// driver's hosts would remove the other's. + func testHostsAreAdoptedByContextAndEverySweepKeepsThemAll() async throws { + let (runtime, _) = try stubRuntime() + let swept = Swept() + let remote = RemoteHosts( + makeDriver: { Self.driver(runtime: runtime, context: $0.context) }, + sweepDriver: { driver, known, _ in + swept.add(driver.provisioning?.context, known) + return [] + }) + let (base, pinned) = (UUID(), UUID()) + let descriptor = { (id: UUID, context: String?) in + HostDescriptor( + driver: RemoteWorkrooms.containerDriver, provisioner: RemoteWorkrooms.provisioner, id: id, + container: Self.record(context: context)) + } + let project = Project( + path: "/proj", vcs: "git", + workrooms: [ + Workroom( + name: "w", path: "/home/workroom/r", vcsName: "workroom/w", warnings: [], + host: descriptor(pinned, "orbstack")) + ], host: descriptor(base, nil)) + + remote.adopt([project]) + + XCTAssertNil(try XCTUnwrap(remote.existingDriver(holding: base)).provisioning?.context) + XCTAssertEqual(remote.existingDriver(holding: pinned)?.provisioning?.context, "orbstack") + for _ in 0..<500 where swept.calls.count < 2 { try await Task.sleep(for: .milliseconds(2)) } + XCTAssertEqual(Set(swept.calls.map(\.context)), [nil, "orbstack"]) + for call in swept.calls { XCTAssertEqual(call.known, [base, pinned], call.context ?? "nil") } + } + + private final class Swept: @unchecked Sendable { + private let lock = NSLock() + private var made: [(context: String?, known: Set)] = [] + var calls: [(context: String?, known: Set)] { lock.withLock { made } } + func add(_ context: String?, _ known: Set) { + lock.withLock { made.append((context, known)) } + } + } + + /// A sweep keeps an image another context's host was run from: both contexts can name one daemon, + /// where that image is labelled as this build's and old enough to go. + func testASweepKeepsImagesOtherContextsHostsRunFrom() async throws { + let image = "sha256:" + String(repeating: "b", count: 64) + for kept in [true, false] { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try + (script + """ + + case "$1 $2" in "images -aq") echo \(image.dropFirst(7).prefix(12)) ;; "image inspect") echo 0 ;; esac + """).write(to: runtime, atomically: true, encoding: .utf8) + _ = await Self.driver(runtime: runtime, context: nil).sweep( + keeping: [], images: kept ? [image] : []) + let removed = try String(contentsOf: log, encoding: .utf8).contains("rmi ") + XCTAssertEqual(removed, !kept, kept ? "a recorded image was removed" : "the control kept it") + } + } + + // MARK: Apple's container runtime (#309) + + /// A stand-in CLI that answers each command by the shell `cases` given (a `case "$*" in` body), + /// and logs every call. + private func scriptedRuntime(_ cases: String) throws -> (runtime: URL, log: URL) { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try (script + "\ncase \"$*\" in\n\(cases)\nesac\n").write( + to: runtime, atomically: true, encoding: .utf8) + return (runtime, log) + } + + private func calls(_ log: URL) throws -> [String] { + try String(contentsOf: log, encoding: .utf8).split(separator: "\n").map(String.init) + } + + /// Apple's runtime has no `--restart` or `--pull`, and pulls every platform of an image unless + /// told its architecture: a missing image is pulled for arm64, and run with neither flag. + func testAppleRunsAndPullsInItsOwnDialect() async throws { + let (runtime, log) = try scriptedRuntime( + """ + "image inspect workroom-host") exit 1 ;; + run*) exit 1 ;; + """) + do { + _ = try await Self.driver(runtime: runtime, context: nil, dialect: .apple).create() + XCTFail("run was meant to fail") + } catch {} + let made = try calls(log) + XCTAssertEqual( + Array(made.prefix(2)), + ["image inspect workroom-host", "image pull --arch arm64 workroom-host"]) + let run = try XCTUnwrap(made.first { $0.hasPrefix("run ") }) + XCTAssertTrue(run.hasPrefix("run --detach --init --arch arm64 --name workroom-"), run) + XCTAssertFalse(run.contains("--restart") || run.contains("--pull"), run) + XCTAssertTrue(made.contains { $0.hasPrefix("delete --force workroom-") }, "\(made)") + } + + /// Apple's sweep: containers and images labelled as this build's and old enough go, unless a + /// recorded host is theirs; an image any container was run from stays, since Apple removes an + /// image that is in use where Docker refuses. + func testAppleSweepsByJSONAndKeepsImagesInUse() async throws { + let (kept, swept) = (UUID(), UUID()) + func container(_ id: String, labels: [String: String], image: String) -> [String: Any] { + [ + "id": id, "status": ["state": "running"], + "configuration": ["labels": labels, "image": ["reference": image]], + ] + } + func image(_ name: String, labels: [String: String]) -> [String: Any] { + [ + "configuration": ["name": name], + "variants": [ + ["platform": ["architecture": "arm64"], "config": ["config": ["Labels": labels]]] + ], + ] + } + let ours = ["workroom.provisioner": "test", "workroom.created": "1"] + let list = [ + container(ContainerHostDriver.containerName(kept), labels: ours, image: "in-use:1"), + container(ContainerHostDriver.containerName(swept), labels: ours, image: "other:1"), + container( + "theirs", labels: ["workroom.provisioner": "other", "workroom.created": "1"], image: "x"), + container( + "fresh", + labels: [ + "workroom.provisioner": "test", + "workroom.created": "\(Int(Date().timeIntervalSince1970))", + ], image: "y"), + ] + let images = [ + image("in-use:1", labels: ours), image("leftover:1", labels: ours), + image("recorded:1", labels: ours), image("user:1", labels: [:]), + ] + let json = { (o: Any) in + String(decoding: try JSONSerialization.data(withJSONObject: o), as: UTF8.self) + } + let (runtime, log) = try scriptedRuntime( + """ + "list --all --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(list))) ;; + "image list --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(images))) ;; + """) + let failures = await Self.driver(runtime: runtime, context: nil, dialect: .apple).sweep( + keeping: [kept], images: ["recorded:1"]) + XCTAssertEqual(failures, []) + let removed = try calls(log).filter { $0.hasPrefix("delete ") || $0.hasPrefix("image delete ") } + XCTAssertEqual( + removed, + ["delete --force \(ContainerHostDriver.containerName(swept))", "image delete leftover:1"]) + } + + /// A derive's snapshot image whose delete failed goes with the next sweep, though its workroom's + /// container was run from it: Apple's container runs without its image, and the snapshot is a + /// whole disk. + func testAppleSweepRemovesALeftoverSnapshotItsContainerRanFrom() async throws { + let workroom = UUID() + let snapshot = ContainerHostDriver.deriveDirectoryPrefix + "abc:latest" + let ours = ["workroom.provisioner": "test", "workroom.created": "1"] + let list: [[String: Any]] = [ + [ + "id": ContainerHostDriver.containerName(workroom), "status": ["state": "running"], + "configuration": ["labels": ours, "image": ["reference": snapshot]], + ] + ] + let images: [[String: Any]] = [ + [ + "configuration": ["name": snapshot], + "variants": [ + ["platform": ["architecture": "arm64"], "config": ["config": ["Labels": ours]]] + ], + ] + ] + let json = { (o: Any) in + String(decoding: try JSONSerialization.data(withJSONObject: o), as: UTF8.self) + } + let (runtime, log) = try scriptedRuntime( + """ + "list --all --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(list))) ;; + "image list --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(images))) ;; + """) + let failures = await Self.driver(runtime: runtime, context: nil, dialect: .apple).sweep( + keeping: [workroom], images: []) + XCTAssertEqual(failures, []) + let removed = try calls(log).filter { $0.hasPrefix("delete ") || $0.hasPrefix("image delete ") } + XCTAssertEqual(removed, ["image delete \(snapshot)"]) + } + + /// A derived image is rebuilt from the exported disk with the host image's own process: its + /// entrypoint, command and environment, not the run-time ones a container carries. + func testAppleDerivesWithTheImagesOwnProcess() throws { + let image: [String: Any] = [ + "variants": [ + [ + "platform": ["architecture": "amd64"], + "config": ["config": ["Entrypoint": ["/wrong"]]], + ], + [ + "platform": ["architecture": "arm64"], + "config": [ + "config": [ + "Entrypoint": ["/usr/local/bin/entrypoint.sh"], "Cmd": ["serve", "a b"], + "Env": ["PATH=/usr/bin:/bin", #"QUOTED=say "hi" \ $HOME"#], "WorkingDir": "/srv/$x", + "User": "", + ] + ], + ], + ] + ] + let process = try XCTUnwrap( + AppleContainerCLI.processConfig(ofImage: image, architecture: "arm64")) + XCTAssertEqual( + try AppleContainerCLI.dockerfile(process), + """ + FROM scratch + ADD rootfs.tar / + ENV PATH="/usr/bin:/bin" + ENV QUOTED="say \\"hi\\" \\\\ \\$HOME" + WORKDIR /srv/\\$x + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + CMD ["serve","a b"] + + """) + var broken = process + broken.env.append("BAD=line\nbreak") + XCTAssertThrowsError(try AppleContainerCLI.dockerfile(broken)) + } + + /// What each container gets: Apple's own default is a 1 GB VM, too small for a compiler or an + /// agent, so it gets half the cores and a quarter of the memory, between 2 and 8 GB, unless set. + /// Docker's containers share Docker's VM and get nothing unless set. + func testContainerResourcesDefaultForAppleOnly() { + let gb: UInt64 = 1_073_741_824 + let apple = { (cores: Int, memory: UInt64) in + RemoteHosts.resources( + for: .apple, cpus: nil, memory: nil, cores: cores, physicalMemory: memory) + } + XCTAssertTrue(apple(10, 32 * gb) == (5, "8G")) + XCTAssertTrue(apple(8, 16 * gb) == (4, "4G")) + XCTAssertTrue(apple(2, 4 * gb) == (2, "2G")) + XCTAssertTrue( + RemoteHosts.resources( + for: .apple, cpus: 6, memory: "12G", cores: 8, physicalMemory: 16 * gb) == (6, "12G")) + XCTAssertTrue( + RemoteHosts.resources(for: .docker, cpus: nil, memory: nil, cores: 8, physicalMemory: 16 * gb) + == (nil, nil)) + } + + /// A derive a crash cut short leaves its staged disk in the temporary folder; the sweep removes + /// one gone quiet, and leaves one still being written. + func testAppleSweepRemovesStaleDeriveFolders() async throws { + let temp = FileManager.default.temporaryDirectory + let stale = temp.appendingPathComponent( + ContainerHostDriver.deriveDirectoryPrefix + "stale-\(UUID())") + let live = temp.appendingPathComponent( + ContainerHostDriver.deriveDirectoryPrefix + "live-\(UUID())") + for directory in [stale, live] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("disk".utf8).write(to: directory.appendingPathComponent("rootfs.tar")) + } + defer { for d in [stale, live] { try? FileManager.default.removeItem(at: d) } } + let old = Date(timeIntervalSinceNow: -3600) + for url in [stale, stale.appendingPathComponent("rootfs.tar"), live] { + try FileManager.default.setAttributes([.modificationDate: old], ofItemAtPath: url.path) + } + let (runtime, _) = try scriptedRuntime( + """ + "list --all --format json") printf '[]' ;; + "image list --format json") printf '[]' ;; + """) + _ = await Self.driver(runtime: runtime, context: nil, dialect: .apple).sweep(keeping: []) + XCTAssertFalse(FileManager.default.fileExists(atPath: stale.path), "a stale folder stayed") + XCTAssertTrue(FileManager.default.fileExists(atPath: live.path), "a live derive's was taken") + } + + /// A derive whose stop of the base fails, as a cancelled create's does, starts the base again: + /// the stop may have taken effect all the same, and the base must not be left down. + func testAnAppleDeriveCutOffMidStopStartsTheBaseAgain() async throws { + let base = UUID() + let container = ContainerHostDriver.containerName(base) + let inspected: [[String: Any]] = [ + [ + "id": container, "status": ["state": "running"], + "configuration": ["image": ["reference": "host:1"]], + ] + ] + let image: [[String: Any]] = [ + [ + "variants": [ + [ + "platform": ["architecture": "arm64"], + "config": ["config": ["Entrypoint": ["/usr/local/bin/entrypoint.sh"]]], + ] + ] + ] + ] + let json = { (o: Any) in + String(decoding: try JSONSerialization.data(withJSONObject: o), as: UTF8.self) + } + let (runtime, log) = try scriptedRuntime( + """ + "inspect \(container)") printf '%s' \(ContainerHostDriver.shellQuoted(try json(inspected))) ;; + "image inspect host:1") printf '%s' \(ContainerHostDriver.shellQuoted(try json(image))) ;; + "stop \(container)") exit 1 ;; + """) + let driver = Self.driver(runtime: runtime, context: nil, dialect: .apple) + try driver.adopt(base, Self.record(context: nil), isBase: true) + do { + _ = try await driver.deriveFromBase(.remote(base)) + XCTFail("derived through a failed stop") + } catch {} + let made = try calls(log) + XCTAssertTrue(made.contains("stop \(container)"), "\(made)") + XCTAssertTrue(made.contains("start \(container)"), "the base was left stopped: \(made)") + } + + /// Only a base is derived from. An Apple instance records no image, as a base doesn't, so a + /// host is a base only when its adopter says so; deriving from an instance would copy its + /// enrolment. + func testAnAppleInstanceIsNeverDerivedFrom() async throws { + let (runtime, log) = try stubRuntime() + let driver = Self.driver(runtime: runtime, context: nil, dialect: .apple) + let instance = UUID() + try driver.adopt(instance, Self.record(context: nil), isBase: false) + do { + _ = try await driver.deriveFromBase(.remote(instance)) + XCTFail("derived from an instance") + } catch HostDriverError.invalidConfiguration {} + XCTAssertFalse(FileManager.default.fileExists(atPath: log.path), "the instance was touched") + } + + /// A recorded Apple host is adopted into Apple's driver, and a Docker one beside it into + /// Docker's, by the descriptor's `driver`. + func testHostsAreAdoptedIntoTheirRuntimesDrivers() throws { + let (runtime, _) = try stubRuntime() + let remote = RemoteHosts( + makeDriver: { key in + Self.driver( + runtime: runtime, context: key.context, dialect: key.runtime == .apple ? .apple : .docker) + }, sweepDriver: { _, _, _ in [] }) + let (docker, apple) = (UUID(), UUID()) + let host = { (id: UUID, runtime: RemoteWorkrooms.Runtime) in + HostDescriptor( + driver: runtime.rawValue, provisioner: RemoteWorkrooms.provisioner, id: id, + container: Self.record(context: nil)) + } + let project = Project( + path: "/proj", vcs: "git", + workrooms: [ + Workroom( + name: "w", path: "/home/workroom/r", vcsName: "workroom/w", warnings: [], + host: host(apple, .apple)) + ], host: host(docker, .docker)) + remote.adopt([project]) + XCTAssertEqual(remote.existingDriver(holding: docker)?.provisioning?.dialect, .docker) + XCTAssertEqual(remote.existingDriver(holding: apple)?.provisioning?.dialect, .apple) + } + + /// A project's workrooms are derived from its base, so they go on the base's runtime: asking + /// for another is refused before anything is made. + func testAWorkroomOnAnotherRuntimeThanItsBaseIsRefused() async throws { + let (runtime, _) = try stubRuntime() + let driver = Self.driver(runtime: runtime, context: nil, dialect: .apple) + let environment = RemoteProvisioning.Environment( + driver: driver, agentSocket: RemoteWorkrooms.agentSocket, + client: BrokerClient( + baseURL: BrokerEndpoint.development, key: .software(P256.Signing.PrivateKey()))) + do { + _ = try await RemoteWorkrooms.create( + repository: try XCTUnwrap(GitHubRepository(host: "github.com", owner: "o", name: "r")), + cloneURL: "https://github.com/o/r.git", + base: HostDescriptor( + driver: RemoteWorkrooms.Runtime.docker.rawValue, provisioner: RemoteWorkrooms.provisioner, + id: UUID(), repository: "o/r", cloneURL: "https://github.com/o/r.git", + path: "/home/workroom/r"), + runtime: .apple, driver: driver, environment: environment, + recorder: RemoteWorkrooms.Recorder( + reserve: { _, _ in "x" }, record: { _, _ in XCTFail("recorded") }, + forget: { _ in })) + XCTFail("a workroom went on another runtime than its base") + } catch RemoteWorkrooms.Failure.baseOnOtherRuntime(let runtime) { + XCTAssertEqual(runtime, "Docker") + } + } + + /// Why a container runtime's New Workroom entry is off, in the order a user has to fix it: + /// Apple's needs Apple silicon and macOS 26 before installing it means anything (#309). + func testARuntimeEntrySaysWhyItIsOff() { + let ready = { (runtime: RemoteWorkrooms.Runtime) in + RemoteWorkrooms.unavailability( + of: runtime, installed: true, appleSilicon: true, macOS26: true, signedIn: true) + } + XCTAssertNil(ready(.docker)) + XCTAssertNil(ready(.apple)) + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .apple, installed: false, appleSilicon: false, macOS26: false, signedIn: false), + "needs Apple silicon") + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .apple, installed: false, appleSilicon: true, macOS26: false, signedIn: true), + "needs macOS 26") + // Docker runs on an Intel Mac and on macOS 15. + XCTAssertNil( + RemoteWorkrooms.unavailability( + of: .docker, installed: true, appleSilicon: false, macOS26: false, signedIn: true)) + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .docker, installed: false, appleSilicon: true, macOS26: true, signedIn: true), + "not installed") + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .docker, installed: true, appleSilicon: true, macOS26: true, signedIn: false), + "sign in to Codaset or run gh auth login") + } + + /// A driver takes on only a host of its own context: its commands would not reach another's. + func testADriverRefusesAHostOfAnotherContext() throws { + let (runtime, _) = try stubRuntime() + XCTAssertThrowsError( + try Self.driver(runtime: runtime, context: "orbstack").adopt( + UUID(), Self.record(context: nil))) + XCTAssertNoThrow( + try Self.driver(runtime: runtime, context: "orbstack").adopt( + UUID(), Self.record(context: "orbstack"))) + } + + /// A delete takes each host down with the environment of its own runtime and context (#309). + func testADeleteRoutesEachHostToItsRuntimesEnvironment() throws { + let (runtime, _) = try stubRuntime() + let environment = { (key: RemoteHosts.DriverKey) in + RemoteProvisioning.Environment( + driver: Self.driver( + runtime: runtime, context: key.context, dialect: key.runtime == .apple ? .apple : .docker), + agentSocket: RemoteWorkrooms.agentSocket, + client: BrokerClient( + baseURL: BrokerEndpoint.development, key: .software(P256.Signing.PrivateKey()))) + } + let keys = [ + RemoteHosts.DriverKey(runtime: .docker), + RemoteHosts.DriverKey(runtime: .docker, context: "orbstack"), + RemoteHosts.DriverKey(runtime: .apple), + ] + let deletion = RemoteHosts.Deletion( + environments: Dictionary(uniqueKeysWithValues: keys.map { ($0, environment($0)) })) + for key in keys { + let host = HostDescriptor( + driver: key.runtime.rawValue, provisioner: RemoteWorkrooms.provisioner, id: UUID(), + container: Self.record(context: key.context)) + let driver = try XCTUnwrap(deletion.environment(for: host)?.driver as? ContainerHostDriver) + XCTAssertEqual(driver.provisioning?.context, key.context) + XCTAssertEqual(driver.provisioning?.dialect, key.runtime == .apple ? .apple : .docker) + } + } + /// A base record that names a host but not enough to derive from is refused: building another /// would leave the recorded one running with nothing pointing at it. func testAnIncompleteBaseIsNotReplaced() async throws { diff --git a/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift b/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift index 75987b1ab..fcda93272 100644 --- a/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift +++ b/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift @@ -781,6 +781,86 @@ final class RemoteProvisioningIntegrationTests: XCTestCase { try onBox("git -C /srv/origin.git rev-parse refs/heads/wr-pushed"), "\(pushed)\nexit=0") } + /// Signed out of Codaset (#309), a local container's base clones with the Mac's own GitHub token, + /// its workroom never enrols, and git in the workroom gets credentials through the Mac's relay: + /// a push works while the app is connected and says so when it isn't. The fixture's GitHub takes + /// its clone token, which stands in for the Mac's `gh`. + @MainActor + func testARelayedWorkroomPushesThroughTheMacWithoutEnrolling() async throws { + let fixture = try fixture() + let driver = ContainerHostDriver( + hosts: [:], directory: directory, provisioning: fixture.provisioning) + let agent = fixture.agent + let socket = fixture.provisioning.agentSocket + let token = Self.token + let environment = RemoteProvisioning.Environment( + driver: driver, agentSocket: socket, client: nil, gitHubToken: { token }, + connect: { host in + try await AgentBootstrap.connect( + host: host, driver: driver, socket: socket, + agent: { $0 == "aarch64" || $0 == "x86_64" ? agent : nil }, handOff: false, + resources: nil) + }) + let base = try await RemoteProvisioning.buildBase( + repository: "o/r", cloneURL: "https://github.com/origin.git", path: Self.path, + in: environment, record: { _ in }) + cleanups.append { try? await driver.destroy(.remote(base.host)) } + XCTAssertEqual(base.relayed, true) + let instance = try await RemoteProvisioning.derive( + from: base, workroom: UUID(), branch: "wr-relayed", in: environment) + cleanups.append { try? await driver.destroy(instance.host) } + XCTAssertNil(instance.grantID, "a relayed workroom enrolled") + + // The relay, over a connection of its own as the app's registry holds one. + let generation = UUID() + let relay = CredentialRelay( + answer: { _ in "username=x-access-token\npassword=\(token)\n" }, + transport: .init( + forwarding: { host in + let connection = try await AgentVCSConnection.connect( + host: host, stream: try await driver.openStream(to: host)) + return (.init(host: host, generation: generation), try connection.forwarding()) + }, + updates: { host in + AsyncStream { + $0.yield(.init(lease: .init(host: host, generation: generation), status: .connected)) + } + })) + try await relay.install( + on: instance.host, driver: driver, agentBinary: AgentBootstrap.binary(besideSocket: socket)) + + guard case .remote(let id) = instance.host else { return XCTFail("not a remote host") } + let container = "workroom-\(id.uuidString.lowercased())" + func onBox(_ script: String) throws -> String { + try docker( + runtime, + [ + "exec", "--user", "workroom", "--env", "HOME=/home/workroom", "--workdir", Self.path, + container, "sh", "-c", script + "; echo exit=$?", + ]) + } + XCTAssertEqual( + try onBox("test -e \(socket.replacingOccurrences(of: "agent.sock", with: "broker.json"))"), + "exit=1", "a relayed workroom has a broker enrolment") + _ = try onBox( + "git -c user.name=W -c user.email=w@example.com commit -q --allow-empty -m relayed") + let push = try onBox("git push -q origin HEAD 2>&1") + XCTAssertTrue(push.hasSuffix("exit=0"), push) + XCTAssertTrue( + try onBox("git -C /srv/origin.git rev-parse --verify -q refs/heads/wr-relayed") + .hasSuffix("exit=0")) + + // With the relay gone, git is told why rather than asked for a password. + relay.close(id) + var failed = "" + for _ in 0..<50 { + failed = try onBox("git -c credential.interactive=false push -q origin HEAD:wr-later 2>&1") + if !failed.hasSuffix("exit=0") { break } + try await Task.sleep(for: .milliseconds(200)) + } + XCTAssertTrue(failed.contains("isn't connected to this workroom"), failed) + } + /// A workroom branches from the remote's default branch as it is now, not as it was when the /// base was cloned: `fetch` never moves `origin/HEAD`, and once `--prune` drops the old default /// it names nothing. diff --git a/macapp/WorkroomAppTests/RemoteProvisioningTests.swift b/macapp/WorkroomAppTests/RemoteProvisioningTests.swift index 8b7f1ce2b..44fa1a254 100644 --- a/macapp/WorkroomAppTests/RemoteProvisioningTests.swift +++ b/macapp/WorkroomAppTests/RemoteProvisioningTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import XCTest @testable import Workroom @@ -65,3 +66,177 @@ final class RemoteProvisioningTests: XCTestCase { XCTAssertEqual(probe.overlap, 2, "different bases did not run at once") } } + +/// Which credentials a base's clone and a workroom's derive take (#309): the broker's clone token, +/// or the Mac's own GitHub token through the relay. +final class RemoteProvisioningCredentialsTests: XCTestCase { + private let repository = "o/r" + + private func environment( + client: Bool, gitHubToken: (@Sendable () async throws -> String)?, + driver: any HostDriver = RefusingDriver(), revoked: Revoked = Revoked() + ) -> RemoteProvisioning.Environment { + RemoteProvisioning.Environment( + driver: driver, agentSocket: RemoteWorkrooms.agentSocket, + client: client + ? BrokerClient( + baseURL: URL(string: "https://codaset.localhost")!, + key: .software(P256.Signing.PrivateKey()), session: BrokerStub.session) + : nil, + gitHubToken: gitHubToken, revoke: { _ in revoked.add() }) + } + + private static let appNotInstalled = BrokerStub.Answer( + status: 409, body: #"{"error":"app_not_installed","message":"x"}"#) + + func testTheBrokersTokenClonesAndIsRevoked() async throws { + BrokerStub.reset([.init(body: #"{"token":"ghs_broker","expires_at":"x"}"#)]) + let revoked = Revoked() + let heard = Heard() + let relayed = try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, + in: environment(client: true, gitHubToken: { "gho_mac" }, revoked: revoked) + ) { heard.set($0) } + XCTAssertFalse(relayed) + XCTAssertEqual(heard.get(), RemoteProvisioning.cloneEnvironment(token: "ghs_broker")) + XCTAssertEqual(revoked.count, 1) + } + + /// The repository's owner hasn't installed the Codaset App: the Mac's own token clones, and the + /// base is relayed. It is the user's, so it is never revoked. + func testAnUninstalledAppFallsBackToTheMacsToken() async throws { + BrokerStub.reset([Self.appNotInstalled]) + let revoked = Revoked() + let heard = Heard() + let relayed = try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, + in: environment(client: true, gitHubToken: { "gho_mac" }, revoked: revoked) + ) { heard.set($0) } + XCTAssertTrue(relayed) + XCTAssertEqual(heard.get(), RemoteProvisioning.cloneEnvironment(token: "gho_mac")) + XCTAssertEqual(revoked.count, 0) + } + + /// Without `gh` either, the App's install is what the user needs to hear about. + func testAnUninstalledAppWithoutGhIsTheRefusal() async throws { + BrokerStub.reset([Self.appNotInstalled]) + do { + try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, + in: environment(client: true, gitHubToken: { throw RemoteWorkrooms.Failure.signedOut }) + ) { _ in XCTFail("cloned") } + XCTFail("expected the refusal") + } catch BrokerError.refused(let refusal) { + XCTAssertEqual(refusal.code, "app_not_installed") + } + } + + /// An existing broker base doesn't fall back: its workrooms enrol. + func testABrokerBaseKeepsItsRefusal() async throws { + BrokerStub.reset([Self.appNotInstalled]) + do { + try await RemoteProvisioning.withCloneToken( + repository, relayed: false, in: environment(client: true, gitHubToken: { "gho_mac" }) + ) { _ in XCTFail("cloned") } + XCTFail("expected the refusal") + } catch BrokerError.refused(let refusal) { + XCTAssertEqual(refusal.code, "app_not_installed") + } + } + + func testSignedOutOfCodasetANewBaseIsRelayed() async throws { + let heard = Heard() + let relayed = try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, in: environment(client: false, gitHubToken: { "gho_mac" }) + ) { heard.set($0) } + XCTAssertTrue(relayed) + XCTAssertEqual(heard.get(), RemoteProvisioning.cloneEnvironment(token: "gho_mac")) + } + + func testSignedOutOfCodasetABrokerBaseIsSignedOut() async throws { + do { + try await RemoteProvisioning.withCloneToken( + repository, relayed: false, in: environment(client: false, gitHubToken: { "gho_mac" }) + ) { _ in XCTFail("cloned") } + XCTFail("expected signedOut") + } catch RemoteWorkrooms.Failure.signedOut {} + } + + /// A workroom of a broker base needs the broker before its base is copied, which can take many + /// minutes, not after. + func testSignedOutOfCodasetADeriveFromABrokerBaseCopiesNothing() async throws { + let driver = RefusingDriver() + do { + _ = try await RemoteProvisioning.derive( + from: .init(host: UUID(), repository: repository, cloneURL: "u", path: "/p"), + workroom: UUID(), branch: "b", + in: environment(client: false, gitHubToken: { "gho_mac" }, driver: driver)) + XCTFail("expected signedOut") + } catch RemoteWorkrooms.Failure.signedOut {} + XCTAssertEqual(driver.derives, 0, "the base was copied first") + } + + /// A relayed base's workroom needs the Mac's token, also before the copy. + func testWithoutGhADeriveFromARelayedBaseCopiesNothing() async throws { + let driver = RefusingDriver() + do { + _ = try await RemoteProvisioning.derive( + from: .init(host: UUID(), repository: repository, cloneURL: "u", path: "/p", relayed: true), + workroom: UUID(), branch: "b", + in: environment( + client: true, gitHubToken: { throw RemoteWorkrooms.Failure.signedOut }, driver: driver)) + XCTFail("expected signedOut") + } catch RemoteWorkrooms.Failure.signedOut {} + XCTAssertEqual(driver.derives, 0, "the base was copied first") + } +} + +extension RemoteProvisioningCredentialsTests { + /// The fallback to the Mac's token is said once, to someone signed in, when the base is made. + func testOnlyASignedInFirstBaseThatRelaysSaysItFellBack() { + let relayed = HostDescriptor(id: UUID(), credentials: "relay") + let broker = HostDescriptor(id: UUID()) + XCTAssertTrue(AppStore.fellBackToGitHub(signedIn: true, hadBase: false, made: relayed)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: false, hadBase: false, made: relayed)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: true, hadBase: true, made: relayed)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: true, hadBase: false, made: broker)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: true, hadBase: false, made: nil)) + } +} + +private final class Revoked: @unchecked Sendable { + private let lock = NSLock() + private var revokes = 0 + func add() { lock.withLock { revokes += 1 } } + var count: Int { lock.withLock { revokes } } +} + +private final class Heard: @unchecked Sendable { + private let lock = NSLock() + private var environment: [String: String]? + func set(_ environment: [String: String]) { lock.withLock { self.environment = environment } } + func get() -> [String: String]? { lock.withLock { environment } } +} + +/// Counts derives, and refuses everything. +private final class RefusingDriver: HostDriver, @unchecked Sendable { + private let lock = NSLock() + private var derived = 0 + var derives: Int { lock.withLock { derived } } + let traits = HostDriverTraits( + transport: .sshStdio, deriveSpeed: nil, deriveCarriesLiveProcesses: false, + durableDisk: false, maxLifetime: nil) + + func create() async throws -> HostID { throw HostDriverError.notImplemented("create") } + func deriveFromBase(_ base: HostID) async throws -> HostID { + lock.withLock { derived += 1 } + throw HostDriverError.notImplemented("derive") + } + func destroy(_ host: HostID) async throws { throw HostDriverError.notImplemented("destroy") } + func openStream(to host: HostID) async throws -> HostStream { + throw HostDriverError.notImplemented("openStream") + } + func exec(_ command: String, on host: HostID) async throws -> HostStream { + throw HostDriverError.notImplemented("exec") + } +} diff --git a/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift b/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift index 316772ae5..b4ab8983a 100644 --- a/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift +++ b/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift @@ -57,6 +57,34 @@ final class RemoteWorkroomRoutingTests: XCTestCase { } } + /// A workroom carries its own base's GitHub identity: a base on another runtime, made after the + /// project's origin changed, is a clone of another repository (#309). + func testEachWorkroomCarriesItsOwnBasesRepository() throws { + let (docker, apple) = (UUID(), UUID()) + let workroom = { (name: String, host: UUID, driver: String) in + Workroom( + name: name, path: self.path, vcsName: "workroom/\(name)", warnings: [], + host: HostDescriptor(driver: driver, provisioner: RemoteWorkrooms.provisioner, id: host)) + } + let registrations = RemoteWorkrooms.registrations([ + Project( + path: "/proj", vcs: "git", + workrooms: [workroom("d", docker, "container"), workroom("a", apple, "apple-container")], + host: HostDescriptor(bases: [ + HostDescriptor( + driver: "container", provisioner: RemoteWorkrooms.provisioner, id: UUID(), + repository: "o/old"), + HostDescriptor( + driver: "apple-container", provisioner: RemoteWorkrooms.provisioner, id: UUID(), + repository: "o/new"), + ])) + ]) + let github = Dictionary( + uniqueKeysWithValues: registrations.map { ($0.location, $0.entry.github?.flag) }) + XCTAssertEqual(github[try .remote(host: docker, path: path)], "github.com/o/old") + XCTAssertEqual(github[try .remote(host: apple, path: path)], "github.com/o/new") + } + /// No base identity, no GitHub identity: its PR and CI stay off rather than guess. func testAProjectWithoutABaseRepositoryRegistersNoGitHubIdentity() { let registrations = RemoteWorkrooms.registrations([ diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift new file mode 100644 index 000000000..363c9a8bb --- /dev/null +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -0,0 +1,204 @@ +import XCTest + +/// New Workroom asks where once the remote preview is on (#309): This Mac, then Docker and Apple +/// Container under "Local containers", each disabled with its reason when it can't be used here. +/// Without the preview it stays one plain item. +final class NewWorkroomPlacesUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + private func launch(preview: Bool) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [ + "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", preview ? "1" : "0", + "-ApplePersistenceIgnoreState", "YES", + ] + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + let project = app.otherElements["sidebar.project.UITestProject"] + XCTAssertTrue(project.waitForExistence(timeout: 10), "fixture project row should exist") + project.rightClick() + return app + } + + func testNewWorkroomAsksWhereWithThePreviewOn() { + let app = launch(preview: true) + let newWorkroom = app.menuItems["New Workroom"] + XCTAssertTrue(newWorkroom.waitForExistence(timeout: 5)) + newWorkroom.hover() + + let thisMac = app.menuItems["This Mac"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "the submenu has no This Mac") + thisMac.hover() + XCTAssertTrue(thisMac.isEnabled) + // Each runtime is listed, usable or saying why not; which depends on this Mac. + for runtime in ["Docker", "Apple Container"] { + let entry = app.menuItems.matching( + NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") + ).firstMatch + XCTAssertTrue(entry.waitForExistence(timeout: 5), "\(runtime) isn't listed") + let title = entry.title + XCTAssertEqual( + entry.isEnabled, title == runtime, + "\(title): an entry is disabled exactly when its title gives a reason") + } + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "new-workroom-places" + shot.lifetime = .keepAlways + add(shot) + app.typeKey(.escape, modifierFlags: []) + } + + /// A create downloading its host image shows how far it has got in place of the row's spinner. + func testADownloadingImageShowsItsProgressOnTheRow() { + let app = XCUIApplication() + app.launchArguments += [ + "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", "1", + "-WorkroomUITestImagePull", "0.42", "-ApplePersistenceIgnoreState", "YES", + ] + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + // The row's own identifier is the indicator's too. + let progress = app.progressIndicators.matching(identifier: "sidebar.project.UITestProject") + .firstMatch + XCTAssertTrue(progress.waitForExistence(timeout: 10), "the row shows no download progress") + XCTAssertEqual(progress.value as? Double, 0.42) + attach(app, "image-pull-progress") + + // Meanwhile the project's New Workroom says once why its entries are off, and each is off. + app.otherElements["sidebar.project.UITestProject"].rightClick() + app.menuItems["New Workroom"].hover() + XCTAssertTrue( + app.menuItems["A workroom is already being created"].waitForExistence(timeout: 5), + "the submenu doesn't say why its entries are off") + let thisMac = app.menuItems["This Mac"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5)) + XCTAssertFalse(thisMac.isEnabled) + for runtime in ["Docker", "Apple Container"] { + let entry = app.menuItems.matching( + NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") + ).firstMatch + XCTAssertTrue(entry.waitForExistence(timeout: 5), "\(runtime) isn't listed") + XCTAssertFalse(entry.isEnabled, entry.title) + XCTAssertFalse( + entry.title.contains("already being created"), "the reason is repeated: \(entry.title)") + } + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "new-workroom-busy" + shot.lifetime = .keepAlways + add(shot) + app.typeKey(.escape, modifierFlags: []) + } + + private func attach(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.windows.firstMatch.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + /// The row's "+" asks where too, with the same places as the context menu. + func testTheRowsPlusAsksWhereWithThePreviewOn() { + let app = launch(preview: true) + app.typeKey(.escape, modifierFlags: []) // the context menu `launch` opened + let plus = app.menuButtons.matching( + NSPredicate(format: "title BEGINSWITH %@", "New workroom in UITestProject, on this Mac") + ).firstMatch + XCTAssertTrue(plus.waitForExistence(timeout: 5), "the row has no places +") + plus.hover() + let idle = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + idle.name = "plus-places-idle" + idle.lifetime = .keepAlways + add(idle) + plus.click() + XCTAssertTrue(app.menuItems["This Mac"].waitForExistence(timeout: 5), "+ didn't ask where") + for runtime in ["Docker", "Apple Container"] { + XCTAssertTrue( + app.menuItems.matching( + NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") + ).firstMatch.waitForExistence(timeout: 5), "\(runtime) isn't listed") + } + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "plus-places" + shot.lifetime = .keepAlways + add(shot) + app.typeKey(.escape, modifierFlags: []) + } + + /// File ▸ New Workroom… (⌘N, and the tab bar's +) asks where too: picking a project offers the + /// same places, rather than creating on this Mac. Nothing is picked, so nothing is created. + func testThePickerAsksWhereWithThePreviewOn() { + let app = openPicker(extraArgs: []) + XCTAssertFalse(app.textFields["newWorkroom.filter"].exists, "still listing projects") + let thisMac = app.buttons["newWorkroom.place.thisMac"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "the picker didn't ask where") + XCTAssertTrue(thisMac.isEnabled) + for runtime in ["container", "apple-container"] { + XCTAssertTrue(app.buttons["newWorkroom.place.\(runtime)"].exists, "\(runtime) isn't offered") + } + XCTAssertFalse(app.staticTexts["newWorkroom.placesBlocked"].exists) + attach(app, "picker-places") + + // Back lists the projects again. + app.buttons["newWorkroom.back"].click() + XCTAssertTrue(app.textFields["newWorkroom.filter"].waitForExistence(timeout: 5)) + app.typeKey(.escape, modifierFlags: []) + } + + /// While a create runs in the project, the picker says so once and offers no place. + func testThePickersPlacesAreOffWhileACreateRuns() { + let app = openPicker(extraArgs: ["-WorkroomUITestImagePull", "0.42"]) + let blocked = app.staticTexts["newWorkroom.placesBlocked"] + XCTAssertTrue(blocked.waitForExistence(timeout: 5), "the picker doesn't say why") + XCTAssertEqual(blocked.value as? String, "A workroom is already being created") + for place in ["thisMac", "container", "apple-container"] { + let button = app.buttons["newWorkroom.place.\(place)"] + XCTAssertTrue(button.exists, "\(place) isn't listed") + XCTAssertFalse(button.isEnabled, "\(place) can be picked while a create runs") + } + attach(app, "picker-places-busy") + app.typeKey(.escape, modifierFlags: []) + } + + /// Opens File ▸ New Workroom… with the preview on, and picks the fixture project. + private func openPicker(extraArgs: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [ + "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", "1", + "-ApplePersistenceIgnoreState", "YES", + ] + app.launchArguments += extraArgs + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + XCTAssertTrue( + app.otherElements["sidebar.project.UITestProject"].waitForExistence(timeout: 10), + "fixture project row should exist") + let file = app.menuBars.menuBarItems["File"] + file.click() + file.menuItems["New Workroom…"].click() + let project = app.descendants(matching: .any) + .matching(identifier: "newWorkroom.project.UITestProject").firstMatch + XCTAssertTrue(project.waitForExistence(timeout: 5), "the picker didn't list the project") + project.click() + return app + } + + func testNewWorkroomIsOneItemWithThePreviewOff() { + let app = launch(preview: false) + defer { + // The row's "+" as it looks without the preview, to compare with the places one. + app.typeKey(.escape, modifierFlags: []) + app.otherElements["sidebar.project.UITestProject"].hover() + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "plus-plain" + shot.lifetime = .keepAlways + add(shot) + } + XCTAssertTrue(app.menuItems["New Workroom"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.menuItems["This Mac"].exists, "a submenu appeared without the preview") + XCTAssertFalse(app.menuItems["New Remote Workroom"].exists) + app.typeKey(.escape, modifierFlags: []) + } +} diff --git a/macapp/project.yml b/macapp/project.yml index 448aad7fc..b66fae33b 100644 --- a/macapp/project.yml +++ b/macapp/project.yml @@ -274,6 +274,9 @@ targets: # selectable at runtime), "nightly" for the side-by-side Workroom Nightly build. Read by # ReleaseChannel.current → Updater.allowedChannels + CommandLineInstaller's binary name. WorkroomReleaseChannel: "$(WORKROOM_RELEASE_CHANNEL)" + # The `workroom-host` image local container workrooms run, by digest (#309): CI publishes + # it and release.sh passes it in. Empty for a local build, which runs `workroom-host`. + WorkroomHostImage: "$(WORKROOM_HOST_IMAGE)" LSMinimumSystemVersion: "15.0" NSHighResolutionCapable: true LSApplicationCategoryType: public.app-category.developer-tools @@ -328,6 +331,7 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Base identity is the main/release channel (empty marker). Debug + Nightly override below. WORKROOM_RELEASE_CHANNEL: "" + WORKROOM_HOST_IMAGE: "" # Sparkle feed filename, substituted into SUFeedURL above. Nightly overrides it; Debug # inherits the main feed (Dev builds can't self-update, so it is never fetched). WORKROOM_APPCAST: appcast.xml diff --git a/vcs/crates/wr-agent/src/broker.rs b/vcs/crates/wr-agent/src/broker.rs index 7e6a18c6b..29f78c18c 100644 --- a/vcs/crates/wr-agent/src/broker.rs +++ b/vcs/crates/wr-agent/src/broker.rs @@ -41,6 +41,11 @@ use serde_json::{json, Value}; const STATE_FILE: &str = "broker.json"; const TOKEN_FILE: &str = "broker-token.json"; +/// Where the Mac relays a workroom that never enrolled (#309): a local container workroom whose +/// user is signed in to `gh` but not to Codaset. See `relay`. +const RELAY_FILE: &str = "relay.json"; +/// The most a relayed answer may be: git's credential protocol is a handful of short lines. +const RELAY_MAX: u64 = 16 * 1024; /// A cached token with less than this left is replaced, so git never starts a long push with a /// token about to die. If the broker cannot be reached it is still used until it expires. const REFRESH_MARGIN: i64 = 300; @@ -295,7 +300,13 @@ pub fn credential( if action != "get" { return Ok(()); } - let token = token(dir)?; + // An enrolled workroom always mints its own; only one that never enrolled is relayed. + let token = match token(dir) { + Err(BrokerError::NotEnrolled) if load::(dir, RELAY_FILE)?.is_some() => { + return relay(dir, &mut output); + } + result => result?, + }; // `x-access-token` is the username GitHub documents for installation tokens over HTTPS. // `password_expiry_utc` (git 2.41+) stops git reusing it past its expiry; older gits ignore it. write!( @@ -307,6 +318,86 @@ pub fn credential( Ok(()) } +/// Where a workroom that never enrolled asks the Mac for git's credentials (#309): a port on this +/// box's loopback that the app carries back to itself over its own connection (a reverse +/// forward), and the secret that tells the app which workroom is asking. Anything in the workroom +/// can read it and ask, which is the accepted trade: such a workroom gets the GitHub access the +/// user's own `gh` has, as a workroom on the Mac itself does. +#[derive(Serialize, Deserialize)] +struct Relay { + port: u16, + secret: String, +} + +/// Records the relay for `credential get` (`wr-agent credential relay`). +pub fn install_relay(dir: &Path, port: u16, secret: &str) -> Result<(), BrokerError> { + let secret = secret.trim(); + if port == 0 || secret.is_empty() || secret.contains(char::is_whitespace) { + return Err(BrokerError::Invalid( + "a relay needs a port and a one-word secret".into(), + )); + } + save( + dir, + RELAY_FILE, + &Relay { + port, + secret: secret.to_string(), + }, + ) +} + +/// `get` through the Mac: sends the secret, then the request (only ever github.com over HTTPS, +/// which `credential` checked), and copies back the answer's `username` and `password`. Nothing +/// else in the answer reaches git. +fn relay(dir: &Path, output: &mut impl Write) -> Result<(), BrokerError> { + use std::io::Read; + use std::net::{Ipv4Addr, SocketAddr, TcpStream}; + let Some(relay) = load::(dir, RELAY_FILE)? else { + return Err(BrokerError::NotEnrolled); + }; + let unreachable = |e: io::Error| { + BrokerError::Transport(format!( + "the Workroom app isn't connected to this workroom, so git has no GitHub credentials. \ + Open the workroom in Workroom. ({e})" + )) + }; + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, relay.port)); + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT).map_err(unreachable)?; + stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; + write!( + stream, + "{}\nprotocol=https\nhost=github.com\n\n", + relay.secret + ) + .map_err(unreachable)?; + let mut answer = String::new(); + stream + .take(RELAY_MAX) + .read_to_string(&mut answer) + .map_err(unreachable)?; + let mut fields = answer.lines().filter_map(|line| line.split_once('=')); + let mut username = None; + let mut password = None; + for (key, value) in fields.by_ref() { + match key { + "username" => username = Some(value), + "password" => password = Some(value), + "error" => return Err(BrokerError::Invalid(value.to_string())), + _ => {} + } + } + let (Some(username), Some(password)) = (username, password) else { + return Err(BrokerError::Invalid( + "the Workroom app had no GitHub credentials to give: sign in with `gh auth login`" + .into(), + )); + }; + write!(output, "username={username}\npassword={password}\n")?; + Ok(()) +} + /// Makes `helper` git's only credential helper for `https://github.com`, in global config. /// /// The empty entry first resets the list inherited from system config, where boxd installs its diff --git a/vcs/crates/wr-agent/src/main.rs b/vcs/crates/wr-agent/src/main.rs index 114f9539b..50cc8e3b0 100644 --- a/vcs/crates/wr-agent/src/main.rs +++ b/vcs/crates/wr-agent/src/main.rs @@ -60,7 +60,12 @@ fn usage() -> &'static str { enrol this remote workroom with the Workroom credential broker, reading the one-time code from stdin, and make `wr-agent credential` git's helper for https://github.com wr-agent credential - git's credential helper: answers github.com with an installation token from the broker + git's credential helper: answers github.com with an installation token from the broker, + or for a workroom that never enrolled, with what the Mac relays + wr-agent credential relay --port + have a workroom that never enrolled ask the Mac for git's credentials through on + this box's loopback, reading the relay's secret from stdin, and make `wr-agent credential` + git's helper for https://github.com wr-agent protocol print the protocol version this build speaks " @@ -1129,10 +1134,46 @@ fn run_enrol(args: &[String]) -> ExitCode { } } +/// `wr-agent credential relay --port `, the secret on stdin (#309): has a workroom that never +/// enrolled ask the Mac for git's GitHub credentials, through `port` on this box's loopback, and +/// points git at `wr-agent credential`. Run on every connect, so it is idempotent. +fn run_credential_relay(args: &[String]) -> ExitCode { + let Some(port) = flag(args, "--port").and_then(|p| p.parse::().ok()) else { + eprintln!("error: credential relay needs --port , and the secret on stdin"); + return ExitCode::FAILURE; + }; + let mut secret = String::new(); + if std::io::stdin().read_line(&mut secret).is_err() { + eprintln!("error: could not read the relay's secret from stdin"); + return ExitCode::FAILURE; + } + let result = wr_agent::broker::directory() + .map_err(wr_agent::broker::BrokerError::from) + .and_then(|dir| wr_agent::broker::install_relay(&dir, port, &secret)) + .and_then(|()| { + let binary = std::env::current_exe()?; + let helper = wr_agent::broker::helper_command(&binary); + Ok(wr_agent::broker::configure_git( + || std::process::Command::new("git"), + &helper, + )?) + }); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + /// git's credential helper. A failure is reported on stderr, which git shows beside its own /// "Authentication failed", and answers nothing, so git falls through as it would for no helper. fn run_credential(args: &[String]) -> ExitCode { let action = args.get(1).map(String::as_str).unwrap_or(""); + if action == "relay" { + return run_credential_relay(args); + } let result = wr_agent::broker::directory() .map_err(wr_agent::broker::BrokerError::from) .and_then(|dir| { diff --git a/vcs/crates/wr-agent/tests/broker.rs b/vcs/crates/wr-agent/tests/broker.rs index 6bdab93c1..19f176643 100644 --- a/vcs/crates/wr-agent/tests/broker.rs +++ b/vcs/crates/wr-agent/tests/broker.rs @@ -738,3 +738,240 @@ fn an_unreadable_state_file_is_not_an_enrolment() { Err(BrokerError::NotEnrolled) )); } + +/// A stand-in for the Mac's end of a relay (#309): takes one connection, records what was sent, +/// and answers with `answer`. +fn relay_listener(answer: impl Into) -> (u16, std::thread::JoinHandle) { + let answer = answer.into(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let thread = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut sent = String::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line == "\n" { + break; + } + sent.push_str(&line); + } + let mut stream = stream; + stream.write_all(answer.as_bytes()).unwrap(); + sent + }); + (port, thread) +} + +#[test] +fn a_workroom_that_never_enrolled_is_answered_through_the_relay() { + let workspace = Workspace::new("broker-relay"); + let (port, mac) = relay_listener( + "protocol=https\nhost=github.com\nusername=joel\npassword=gho_mac\nextra=dropped\n", + ); + broker::install_relay(&workspace.dir, port, "s3cret\n").unwrap(); + + let mut output = Vec::new(); + broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\npath=joelmoss/workroom.git\n\n"[..], + &mut output, + ) + .unwrap(); + // Only the credential reaches git, and the Mac gets the secret and a github.com request only. + assert_eq!( + String::from_utf8(output).unwrap(), + "username=joel\npassword=gho_mac\n" + ); + assert_eq!( + mac.join().unwrap(), + "s3cret\nprotocol=https\nhost=github.com\n" + ); + + // Nothing but github.com over HTTPS is ever relayed, and `store`/`erase` ask nothing. + for (action, input) in [ + ("get", &b"protocol=https\nhost=gitlab.com\n\n"[..]), + ( + "store", + &b"protocol=https\nhost=github.com\npassword=x\n\n"[..], + ), + ("erase", &b"protocol=https\nhost=github.com\n\n"[..]), + ] { + let mut output = Vec::new(); + broker::credential(&workspace.dir, action, input, &mut output).unwrap(); + assert!(output.is_empty(), "{action}"); + } +} + +#[test] +fn an_enrolled_workroom_mints_its_own_and_never_asks_the_relay() { + let workspace = Workspace::new("broker-relay-enrolled"); + let broker = Broker::start(vec![ + enrolled_answer(), + ok(&format!( + r#"{{"token":"ghs_own","expires_at":"{}"}}"#, + iso8601(now() + 3600) + )), + ]); + enrolled(&workspace, &broker, "wr-1"); + // A port nothing listens on: a relay asked would fail the get. + let unused = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + broker::install_relay(&workspace.dir, unused, "s3cret").unwrap(); + + let mut output = Vec::new(); + broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + &mut output, + ) + .unwrap(); + assert!(String::from_utf8(output) + .unwrap() + .starts_with("username=x-access-token\npassword=ghs_own\n")); +} + +#[test] +fn a_relay_the_mac_is_not_listening_on_says_to_open_the_workroom() { + let workspace = Workspace::new("broker-relay-closed"); + let unused = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + broker::install_relay(&workspace.dir, unused, "s3cret").unwrap(); + let mut output = Vec::new(); + match broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + &mut output, + ) { + Err(BrokerError::Transport(message)) => { + assert!( + message.contains("isn't connected to this workroom"), + "{message}" + ) + } + other => panic!("expected a transport error, got {other:?}"), + } + assert!(output.is_empty()); + + // Neither is a workroom with no relay and no enrolment. + let bare = Workspace::new("broker-relay-none"); + assert!(matches!( + broker::credential( + &bare.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + Vec::new() + ), + Err(BrokerError::NotEnrolled) + )); +} + +#[test] +fn a_relay_needs_a_port_and_a_one_word_secret() { + let workspace = Workspace::new("broker-relay-install"); + for (port, secret) in [(0, "s"), (1, ""), (1, "two words"), (1, " \n")] { + assert!( + broker::install_relay(&workspace.dir, port, secret).is_err(), + "{port} {secret:?}" + ); + } +} + +/// `get` through the relay with `answer` from the Mac: what git is told, and what reached it. +fn relayed_get(name: &str, answer: impl Into) -> (Result<(), BrokerError>, Vec) { + let workspace = Workspace::new(name); + let (port, mac) = relay_listener(answer); + broker::install_relay(&workspace.dir, port, "s3cret").unwrap(); + let mut output = Vec::new(); + let result = broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + &mut output, + ); + mac.join().unwrap(); + (result, output) +} + +#[test] +fn a_relay_answer_with_an_error_is_the_error_and_gives_git_nothing() { + // The credential before the error: the error still wins, and none of it reaches git. + let (result, output) = relayed_get( + "broker-relay-error", + "username=joel\npassword=gho_mac\nerror=the Mac said no\n", + ); + match result { + Err(BrokerError::Invalid(message)) => assert_eq!(message, "the Mac said no"), + other => panic!("expected the Mac's error, got {other:?}"), + } + assert!(output.is_empty()); +} + +#[test] +fn a_relay_answer_without_a_username_and_password_says_to_sign_in_to_gh() { + for (name, answer) in [ + ("broker-relay-bare", "protocol=https\nhost=github.com\n"), + ("broker-relay-half", "username=joel\n"), + ("broker-relay-empty", ""), + ] { + let (result, output) = relayed_get(name, answer); + match result { + Err(BrokerError::Invalid(message)) => { + assert!(message.contains("gh auth login"), "{name}: {message}") + } + other => panic!("{name}: expected a sign-in error, got {other:?}"), + } + assert!(output.is_empty(), "{name}"); + } +} + +#[test] +fn a_relay_answer_is_read_only_up_to_the_cap() { + // 16 KiB is RELAY_MAX: the password and an error sit past it, so neither is ever seen. + let answer = format!( + "username=joel\npad={}\npassword=gho_beyond\nerror=beyond\n", + "a".repeat(16 * 1024) + ); + let (result, output) = relayed_get("broker-relay-oversized", answer); + match result { + Err(BrokerError::Invalid(message)) => { + assert!(message.contains("gh auth login"), "{message}") + } + other => panic!("expected a sign-in error, got {other:?}"), + } + assert!(output.is_empty()); +} + +/// `wr-agent credential relay` with a missing or unusable `--port` fails before it touches stdin, +/// the agent's directory or git config (a valid port would write beside this test binary's agent). +#[test] +fn credential_relay_without_a_usable_port_fails() { + for args in [ + &["credential", "relay"][..], + &["credential", "relay", "--port"], + &["credential", "relay", "--port", "abc"], + &["credential", "relay", "--port", "70000"], + &["credential", "relay", "--port", "-1"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_wr-agent")) + .args(args) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(!output.status.success(), "{args:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("credential relay needs --port"), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/vcs/scripts/ssh-fixture/entrypoint.sh b/vcs/scripts/ssh-fixture/entrypoint.sh index b977d6a4d..0e828e68f 100755 --- a/vcs/scripts/ssh-fixture/entrypoint.sh +++ b/vcs/scripts/ssh-fixture/entrypoint.sh @@ -26,6 +26,8 @@ if [ "$(cat "$IDENTITY" 2>/dev/null)" != "$(hostname)" ]; then tr -d '-' < /proc/sys/kernel/random/uuid > /etc/machine-id # With the agent's interrupted saves (`.broker.json.`), which can hold a key. rm -f /run/workroom/broker.json /run/workroom/broker-token.json /run/workroom/.broker* + # The Mac's relay (#309): its port and secret are the base's, and the app installs its own. + rm -f /run/workroom/relay.json /run/workroom/.relay* rm -rf /home/workroom/.local/state/workroom/screens hostname > "$IDENTITY" fi diff --git a/vcs/scripts/ssh-fixture/host-image-test.sh b/vcs/scripts/ssh-fixture/host-image-test.sh index 8104ae69f..e0d48ccf3 100755 --- a/vcs/scripts/ssh-fixture/host-image-test.sh +++ b/vcs/scripts/ssh-fixture/host-image-test.sh @@ -3,6 +3,7 @@ # Smoke-tests the `workroom-host` image (FIXTURE=0, `make remote-host-image`, #253, #288). # # vcs/scripts/ssh-fixture/host-image-test.sh +# WR_HOST_IMAGE= vcs/scripts/ssh-fixture/host-image-test.sh # test a built image (#309) # # run.sh only ever builds FIXTURE=1, so nothing else exercises the paths only this variant takes: # the `[ "$FIXTURE" = 1 ] || exit 0` steps, the `wr-agen[t]` COPY with no agent in the context, and @@ -50,7 +51,14 @@ cp "$HERE/Dockerfile" "$HERE/entrypoint.sh" "$HERE/identity.sh" "$HERE/fake-gith printf '#!/bin/sh\nexit 0\n' > "$STAGE/control/wr-agent" "$RUNTIME" build --quiet --tag "$CONTROL" --build-arg FIXTURE=1 \ ${CONTROL_BUILD_FLAGS[@]+"${CONTROL_BUILD_FLAGS[@]}"} "$STAGE/control" >/dev/null -"$RUNTIME" build --quiet --tag "$NAME" ${BUILD_FLAGS[@]+"${BUILD_FLAGS[@]}"} "$HERE" >/dev/null +# WR_HOST_IMAGE names an image already built, to test it rather than build one (#309): CI tests the +# exact image it then publishes, since a second build could resolve other packages. Tagged with this +# run's name, so cleanup's `rmi` only takes that tag off and the image stays for the push. +if [ -n "${WR_HOST_IMAGE:-}" ]; then + "$RUNTIME" tag "$WR_HOST_IMAGE" "$NAME" +else + "$RUNTIME" build --quiet --tag "$NAME" ${BUILD_FLAGS[@]+"${BUILD_FLAGS[@]}"} "$HERE" >/dev/null +fi ssh-keygen -q -t ed25519 -N '' -C wr-host-image-test -f "$STAGE/id_ed25519"