diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 57824351e..9771dd154 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -122,6 +122,15 @@ jobs: printf '%s\n' "$packages" | xargs go test -race -short + - name: test Compose UID expectation matrix + shell: bash + run: | + set -euo pipefail + go test ./e2e/tests/up-docker-compose -list '^TestExpectedUIDMapping$' \ + | grep -Fx 'TestExpectedUIDMapping' + go test -race -short -count=1 -v ./e2e/tests/up-docker-compose \ + -run '^TestExpectedUIDMapping$' + can-read-secret: name: Can Read Secret runs-on: ubuntu-latest @@ -1030,6 +1039,53 @@ jobs: private-key: ${{ secrets.DEVSY_GITHUB_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} + - name: test Compose UID mapping as existing non-root runner + if: runner.os == 'Linux' && matrix.label == 'up-docker-compose' + shell: bash + working-directory: ./e2e + env: + TEST_TIMEOUT: ${{ matrix.test-timeout || '1500s' }} + run: | + set -euo pipefail + id + test "$(id -u)" = 1001 + test "$(id -g)" = 1001 + getent passwd "$(id -u)" + getent group "$(id -g)" + test -d "$HOME" && test -w "$HOME" + test "$(stat -c %u "$HOME")" = "$(id -u)" + test -d "$RUNNER_TEMP" && test -w "$RUNNER_TEMP" + docker info + docker compose version + printf 'checkout=%s\n' "$(git rev-parse HEAD)" + sha256sum ./e2e.test ./bin/devsy-linux-amd64 + ./e2e.test -test.v -ginkgo.v -test.timeout "${TEST_TIMEOUT}" \ + -ginkgo.fail-on-empty -ginkgo.flake-attempts=1 \ + --ginkgo.label-filter=up-docker-compose \ + --ginkgo.focus='implements updateRemoteUserUID with root container user$' \ + --ginkgo.json-report="$RUNNER_TEMP/compose-uid-nonroot.json" + python3 - "$RUNNER_TEMP/compose-uid-nonroot.json" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as report: + suites = json.load(report) + assert isinstance(suites, list) and len(suites) == 1, "unexpected suite report shape" + suite = suites[0] + assert suite["SuiteConfig"]["DryRun"] is False, "dry-run is not execution" + assert suite["SuiteSucceeded"] is True, "suite failed" + selected = [ + spec for spec in suite["SpecReports"] + if spec.get("LeafNodeType") == "It" and spec.get("State") != "skipped" + ] + assert len(selected) == 1, f"expected one executed leaf, found {len(selected)}" + spec = selected[0] + assert spec["LeafNodeText"] == "implements updateRemoteUserUID with root container user", "wrong UID spec" + assert spec["State"] == "passed", "UID spec did not pass" + assert spec["NumAttempts"] == 1, "unexpected retries" + print("one www-data UID spec passed in one attempt") + PY + - name: run test if: matrix.requires-secret == false || needs.can-read-secret.outputs.secret-set == 'true' shell: bash diff --git a/e2e/tests/up-docker-compose/helper.go b/e2e/tests/up-docker-compose/helper.go index ac8c6faf6..428d7ad66 100644 --- a/e2e/tests/up-docker-compose/helper.go +++ b/e2e/tests/up-docker-compose/helper.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -281,30 +282,47 @@ func verifyContainerUser( To(gomega.Equal(expectedUser), fmt.Sprintf("remote container user should be %s", expectedUser)) } +type userIDs struct { + uid, gid int +} + +func expectedUIDMapping(goos string, host, defaults userIDs) (int, int) { + if goos == "linux" && host.uid != 0 { + return host.uid, host.gid + } + return defaults.uid, defaults.gid +} + func verifyUIDMapping( containerUID, containerGID, hostUID, hostGID, defaultUID, defaultGID int, username string, ) { - ginkgo.By( - fmt.Sprintf("container UID mapping: %s=%d, expected=%d", username, containerUID, hostUID), + expectedUID, expectedGID := expectedUIDMapping( + runtime.GOOS, + userIDs{uid: hostUID, gid: hostGID}, + userIDs{uid: defaultUID, gid: defaultGID}, ) - ginkgo.By( - fmt.Sprintf("container GID mapping: %s=%d, expected=%d", username, containerGID, hostGID), + ginkgo.By(fmt.Sprintf( + "container UID/GID mapping: user=%s os=%s host=%d:%d default=%d:%d actual=%d:%d expected=%d:%d", + username, + runtime.GOOS, + hostUID, + hostGID, + defaultUID, + defaultGID, + containerUID, + containerGID, + expectedUID, + expectedGID, + )) + gomega.Expect(containerUID).To( + gomega.Equal(expectedUID), + fmt.Sprintf("%s UID should match computed platform expectation", username), + ) + gomega.Expect(containerGID).To( + gomega.Equal(expectedGID), + fmt.Sprintf("%s GID should match computed platform expectation", username), ) - - if hostUID == 0 { - ginkgo.By("running as root user on host") - gomega.Expect(containerUID). - To(gomega.Equal(defaultUID), fmt.Sprintf("%s user UID should remain %d when host is root", username, defaultUID)) - gomega.Expect(containerGID). - To(gomega.Equal(defaultGID), fmt.Sprintf("%s user GID should remain %d when host is root", username, defaultGID)) - } else { - ginkgo.By("running as non-root user on host") - gomega.Expect(containerUID). - To(gomega.Equal(hostUID), fmt.Sprintf("%s user UID should match host user UID", username)) - gomega.Expect(containerGID). - To(gomega.Equal(hostGID), fmt.Sprintf("%s user GID should match host user GID", username)) - } } func verifyHostFileAccess(filePath, expectedContent string) { diff --git a/e2e/tests/up-docker-compose/helper_test.go b/e2e/tests/up-docker-compose/helper_test.go new file mode 100644 index 000000000..b26e7602a --- /dev/null +++ b/e2e/tests/up-docker-compose/helper_test.go @@ -0,0 +1,55 @@ +//go:build linux || darwin || unix + +package up + +import "testing" + +func TestExpectedUIDMapping(t *testing.T) { + const ( + linux = "linux" + darwin = "darwin" + ) + tests := []struct { + name string + goos string + hostUID, hostGID int + defaultUID, defaultGID int + wantUID, wantGID int + }{ + {"linux root www-data", linux, 0, 0, 33, 33, 33, 33}, + {"linux root vscode", linux, 0, 0, 1001, 1001, 1001, 1001}, + {"linux root nonzero GID www-data", linux, 0, 2002, 33, 33, 33, 33}, + {"linux root nonzero GID vscode", linux, 0, 2002, 1001, 1001, 1001, 1001}, + {"linux 501 www-data", linux, 501, 20, 33, 33, 501, 20}, + {"linux 501 vscode", linux, 501, 20, 1001, 1001, 501, 20}, + {"linux distinct IDs www-data", linux, 2000, 2002, 33, 33, 2000, 2002}, + {"linux distinct IDs vscode", linux, 2000, 2002, 1001, 1001, 2000, 2002}, + {"linux non-root zero GID www-data", linux, 2000, 0, 33, 33, 2000, 0}, + {"linux non-root zero GID vscode", linux, 2000, 0, 1001, 1001, 2000, 0}, + {"darwin user www-data", darwin, 501, 20, 33, 33, 33, 33}, + {"darwin user vscode", darwin, 501, 20, 1001, 1001, 1001, 1001}, + {"darwin root www-data", darwin, 0, 0, 33, 33, 33, 33}, + {"darwin root vscode", darwin, 0, 0, 1001, 1001, 1001, 1001}, + {"freebsd user www-data", "freebsd", 501, 20, 33, 33, 33, 33}, + {"freebsd user vscode", "freebsd", 501, 20, 1001, 1001, 1001, 1001}, + {"linux matching defaults www-data", linux, 33, 33, 33, 33, 33, 33}, + {"linux matching defaults vscode", linux, 1001, 1001, 1001, 1001, 1001, 1001}, + {"linux UID mismatch only www-data", linux, 2000, 33, 33, 33, 2000, 33}, + {"linux UID mismatch only vscode", linux, 2000, 1001, 1001, 1001, 2000, 1001}, + {"linux GID mismatch only www-data", linux, 33, 2002, 33, 33, 33, 2002}, + {"linux GID mismatch only vscode", linux, 1001, 2002, 1001, 1001, 1001, 2002}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + uid, gid := expectedUIDMapping( + tc.goos, + userIDs{uid: tc.hostUID, gid: tc.hostGID}, + userIDs{uid: tc.defaultUID, gid: tc.defaultGID}, + ) + if uid != tc.wantUID || gid != tc.wantGID { + t.Fatalf("got %d:%d want %d:%d", uid, gid, tc.wantUID, tc.wantGID) + } + }) + } +}