diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d596ff6..3382319 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,10 +3,22 @@ name: Tests on: pull_request: workflow_dispatch: + inputs: + core_tag: + description: >- + Core release tag to qualify with this provider checkout (for example + v1.25.0-beta.3). Runs the release-qualification job. + required: false + type: string + provider_tag: + description: Published SSH provider release to qualify + required: true + default: v1.1.47 + type: string jobs: test: - name: Run Integration Tests + name: Integration tests (compatibility core v1.24.0, not release qualification) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -68,3 +80,225 @@ jobs: go-version-file: go.mod - name: test redirected pipe bytes and cancellation run: go test ./pkg/ssh -run TestWindows -timeout 60s + + release-qualification: + name: Release qualification (${{ matrix.core_artifact }}, explicit pair) + if: github.event_name == 'workflow_dispatch' && inputs.core_tag != '' + runs-on: ubuntu-latest + timeout-minutes: 240 + strategy: + fail-fast: false + matrix: + core_artifact: [cli, desktop] + permissions: + contents: read + env: + CORE_TAG: ${{ inputs.core_tag }} + CORE_REPO: devsy-org/devsy + PROVIDER_TAG: ${{ inputs.provider_tag }} + CORE_ARTIFACT: ${{ matrix.core_artifact }} + # Merge commits that must be ancestors of the tested pair (issue #1621). + CORE_FIX_SHA: e22be00149ace87d512de0ec953541e3f7642c69 + PROVIDER_FIX_SHA: d31fed1b2695861b76e922c530ad8a2436f5acb9 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: choose the qualification artifact directory + run: echo "QUALIFICATION_DIR=$RUNNER_TEMP/qualification" >> "$GITHUB_ENV" + + - name: setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + + - name: resolve and verify the candidate core + run: | + set -euo pipefail + if ! [[ "$CORE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::core_tag must look like v1.2.3 or v1.2.3-beta.1" + exit 1 + fi + mkdir -p "$QUALIFICATION_DIR/core" + + core_sha="$(gh api "repos/$CORE_REPO/commits/$CORE_TAG" --jq .sha)" + core_status="$(gh api "repos/$CORE_REPO/compare/$CORE_FIX_SHA...$core_sha" --jq .status)" + case "$core_status" in + ahead | identical) ;; + *) + echo "::error::core $CORE_TAG ($core_sha) does not contain $CORE_FIX_SHA (compare status: $core_status)" + exit 1 + ;; + esac + provider_sha="$(git rev-parse --verify "refs/tags/$PROVIDER_TAG^{commit}")" + git merge-base --is-ancestor "$PROVIDER_FIX_SHA" "$provider_sha" || { + echo "::error::provider $PROVIDER_TAG does not contain $PROVIDER_FIX_SHA" + exit 1 + } + + case "$CORE_ARTIFACT" in + cli) asset=devsy-linux-amd64 ;; + desktop) asset=Devsy_linux_amd64.deb ;; + *) echo "::error::unknown core artifact $CORE_ARTIFACT"; exit 1 ;; + esac + gh release download "$CORE_TAG" --repo "$CORE_REPO" \ + --pattern "$asset" --dir "$QUALIFICATION_DIR/core" --clobber + artifact="$QUALIFICATION_DIR/core/$asset" + artifact_sha256="$(sha256sum "$artifact" | cut -d' ' -f1)" + published_digest="$(gh api "repos/$CORE_REPO/releases/tags/$CORE_TAG" \ + --jq ".assets[] | select(.name == \"$asset\") | .digest")" + if [ "$published_digest" != "sha256:$artifact_sha256" ]; then + echo "::error::downloaded $asset sha256:$artifact_sha256 differs from the published digest '$published_digest'" + exit 1 + fi + core_binary="$artifact" + if [ "$CORE_ARTIFACT" = desktop ]; then + dpkg-deb --extract "$artifact" "$QUALIFICATION_DIR/core/desktop" + core_binary="$QUALIFICATION_DIR/core/desktop/opt/Devsy/resources/bin/devsy" + fi + test -f "$core_binary" + chmod 0755 "$core_binary" + core_sha256="$(sha256sum "$core_binary" | cut -d' ' -f1)" + core_version="$("$core_binary" --version)" + if [ "$core_version" != "$CORE_TAG" ]; then + echo "::error::core binary reports '$core_version', expected '$CORE_TAG'" + exit 1 + fi + + { + echo "DEVSY_E2E_BINARY=$core_binary" + echo "DEVSY_E2E_CORE_TAG=$CORE_TAG" + echo "DEVSY_E2E_CORE_SHA=$core_sha" + echo "DEVSY_E2E_CORE_VERSION=$core_version" + echo "DEVSY_E2E_PROVIDER_TAG=$PROVIDER_TAG" + echo "DEVSY_E2E_PROVIDER_SHA=$provider_sha" + echo "CORE_SHA256=$core_sha256" + } >> "$GITHUB_ENV" + { + echo "core tag: $CORE_TAG" + echo "core sha: $core_sha" + echo "core artifact: $asset" + echo "core artifact source: https://github.com/$CORE_REPO/releases/download/$CORE_TAG/$asset" + echo "tested binary platform: linux/amd64" + echo "core artifact sha256: $artifact_sha256 (matches published release digest)" + echo "tested core binary sha256: $core_sha256" + echo "core version: $core_version" + echo "core fix $CORE_FIX_SHA is an ancestor (compare status: $core_status)" + echo "provider tag: $PROVIDER_TAG" + echo "provider sha: $provider_sha" + echo "qualification harness sha: $(git rev-parse HEAD)" + echo "provider fix $PROVIDER_FIX_SHA is an ancestor" + } | tee "$QUALIFICATION_DIR/pair-verification.txt" >> "$GITHUB_STEP_SUMMARY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: install login shells + run: sudo apt-get update && sudo apt-get install -y bash zsh fish openssh-server + + - name: download and verify the released provider + run: | + set -euo pipefail + mkdir -p dist/build_linux_amd64_v1 + asset=devsy-provider-ssh-linux-amd64 + gh release download "$PROVIDER_TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "$asset" --dir dist/build_linux_amd64_v1 --clobber + gh release download "$PROVIDER_TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern checksums.txt --dir dist --clobber + binary="dist/build_linux_amd64_v1/$asset" + digest="$(sha256sum "$binary" | cut -d' ' -f1)" + published_digest="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$PROVIDER_TAG" \ + --jq ".assets[] | select(.name == \"$asset\") | .digest")" + if [ "$published_digest" != "sha256:$digest" ]; then + echo "::error::downloaded provider sha256:$digest differs from '$published_digest'" + exit 1 + fi + chmod 0755 "$binary" + echo "provider binary sha256: $digest (matches published release digest)" \ + | tee -a "$QUALIFICATION_DIR/pair-verification.txt" >> "$GITHUB_STEP_SUMMARY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: require the explicit candidate before SSH setup + working-directory: ./e2e + run: | + status=0 + env -u DEVSY_E2E_BINARY go test -v -timeout 300s --ginkgo.v \ + --ginkgo.label-filter=release-qualification \ + --ginkgo.focus='bash builtin=false installs a cold agent by remote download' \ + > "$QUALIFICATION_DIR/missing-binary-guard.log" 2>&1 || status=$? + cat "$QUALIFICATION_DIR/missing-binary-guard.log" + test "$status" -eq 1 + grep -F 'DEVSY_E2E_BINARY is required' "$QUALIFICATION_DIR/missing-binary-guard.log" + env: + DEVSY_E2E_RELEASE_QUALIFICATION: "true" + + - name: provision login shell accounts + run: | + sudo service ssh start + for shell in bash zsh fish; do + sudo useradd --create-home --shell "$(command -v "$shell")" "devsy-$shell" + sudo usermod --append --groups docker "devsy-$shell" + done + sudo useradd --create-home --shell "$(command -v bash)" --groups docker devsy-sudo + policy=/etc/sudoers.d/devsy-e2e-qualification + sudo test ! -e "$policy" + rule="$(mktemp)" + trap 'rm -f "$rule"' EXIT + printf '%s\n' 'devsy-sudo ALL=(root) NOPASSWD: SETENV: /bin/sh -c * devsy-install *' > "$rule" + sudo visudo -cf "$rule" + echo "DEVSY_SUDO_FIXTURE_POLICY=$policy" >> "$GITHUB_ENV" + sudo install -m 0440 -o root -g root "$rule" "$policy" + sudo visudo -cf "$policy" + + - name: qualify login shells with the explicit pair + working-directory: ./e2e + run: >- + go test -v -ginkgo.v -timeout 14400s + --ginkgo.label-filter='release-qualification || provider-command' + env: + DEVSY_E2E_RELEASE_QUALIFICATION: "true" + DEVSY_E2E_SHELL_MATRIX: "true" + DEVSY_E2E_REPORT: ${{ runner.temp }}/qualification/qualification-report.json + + - name: remove owned sudo fixture policy + if: always() && env.DEVSY_SUDO_FIXTURE_POLICY != '' + run: sudo rm -f -- "$DEVSY_SUDO_FIXTURE_POLICY" + + - name: require a complete, passing report + run: | + jq -e --arg sha "$CORE_SHA256" ' + .pair.coreBinarySha256 == $sha + and (.cells | length == 6) + and ([.cells[] | [.shell, .builtinSsh]] | unique | length == 6) + and all(.cells[]; + .cleanup == "ok" + and (.scenarios | length == 5) + and all(.scenarios[]; .outcome == "passed") + and (.agentDir | contains(" ") and contains("\u0027") + and contains("\\") and contains("$") and contains("\u03bb")) + and .agentSha256.download == $sha + and .agentSha256.stdin == $sha) + and .privilege.user == "devsy-sudo" + and .privilege.userUid > 0 + and .privilege.directoryState == "0:755" + and .privilege.writeDenied == true + and .privilege.agentUid == 0 + and .privilege.agentVersion == .pair.coreVersion + and .privilege.agentSha256 == $sha + and .privilege.liveCommand == true + and .privilege.outcome == "passed" + and .privilege.cleanup == "ok" + ' "$QUALIFICATION_DIR/qualification-report.json" + + - name: upload qualification report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-qualification-${{ inputs.core_tag }}-${{ matrix.core_artifact }} + path: | + ${{ runner.temp }}/qualification/pair-verification.txt + ${{ runner.temp }}/qualification/qualification-report.json + ${{ runner.temp }}/qualification/missing-binary-guard.log + if-no-files-found: error diff --git a/e2e/README.md b/e2e/README.md index 28d6767..2a3ae42 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,3 +12,63 @@ go get github.com/onsi/ginkgo/ginkgo # Install ginkgo and run in this directory go test -v -ginkgo.v -timeout 3600s ``` + +#### Release qualification (explicit core/provider pair) + +The default lifecycle suite downloads core v1.24.0 unless `DEVSY_E2E_BINARY` is set. +That run checks backward compatibility only and is not release qualification. + +The qualification suite requires an explicit `DEVSY_E2E_BINARY`; missing candidate +metadata or host prerequisites fail rather than selecting the compatibility core. +Dispatch the `Tests` workflow with `core_tag` and `provider_tag` (default `v1.1.47`). +Two independent jobs qualify the standalone Linux amd64 CLI and the CLI extracted +from the Desktop candidate's `Devsy_linux_amd64.deb`. Each job verifies the published +asset digest, executes that binary's `--version`, records its SHA256, and checks that +core `e22be00` and provider `d31fed1` are ancestors of the selected release commits. +The provider binary and checksums come from the selected provider release. + +The jobs run `release-qualification || provider-command` and upload +`release-qualification--cli` and `release-qualification--desktop` reports. +The report identifies the exact core/provider pair and harness commit, all six +shell/transport cells, five agent scenarios per cell, binary hashes, and cleanup. +All six cells use an agent path containing a space, apostrophe, backslash, dollar +sign, and Unicode character; every remote helper quotes the path literally. +One additional focused scenario uses the dedicated non-root `devsy-sudo` account +and a root-owned `0755` agent directory. It proves that an ordinary write is +denied, a real sudo installation produces a root-owned candidate agent with the +expected version/checksum, and workspace SSH still executes a live command. +A skipped or incomplete matrix or privilege scenario cannot pass the report gate. + +To run by hand, use a disposable Linux amd64 host with local sshd, Docker, and +provisioned `devsy-bash`, `devsy-zsh`, `devsy-fish`, and `devsy-sudo` accounts in the +`docker` group. Use the workflow's provisioning on that owned host: only +`devsy-sudo` receives the validated, temporary sudoers rule for +`/bin/sh -c * devsy-install *`, with `NOPASSWD` and `SETENV` for the installer's +explicit dispatch. Remove `/etc/sudoers.d/devsy-e2e-qualification` after the run, +including failure paths, as the workflow's `always()` cleanup does. +Run from `e2e/`, after placing the released provider binary at +`dist/build_linux_amd64_v1/devsy-provider-ssh-linux-amd64` and its release checksums +at `dist/checksums.txt` relative to the repository root. Unset `DEVSY_AGENT_URL`, +`DEVSY_AGENT_BINARY`, and `DEVSY_AGENT_PREFER_DOWNLOAD`: inherited overrides can +disable the version check or select the wrong agent transport. + +``` +DEVSY_E2E_RELEASE_QUALIFICATION=true DEVSY_E2E_SHELL_MATRIX=true \ +DEVSY_E2E_BINARY=/path/to/devsy-linux-amd64 DEVSY_E2E_CORE_TAG=v1.25.0-beta.3 \ +DEVSY_E2E_CORE_SHA=fe22f9a1225d6c1547001755e94c95bb2cdbc8db \ +DEVSY_E2E_CORE_VERSION=v1.25.0-beta.3 \ +DEVSY_E2E_PROVIDER_TAG=v1.1.47 \ +DEVSY_E2E_PROVIDER_SHA=c53ba2742db162f587aa57fbc87572d19ec11cd7 \ +go test -v -ginkgo.v -timeout 14400s \ + --ginkgo.label-filter='release-qualification || provider-command' +``` + +Only disposable fixture accounts are provisioned; never change a user's login shell, +`SHELL`, `/etc/passwd`, or `.zshrc` to make qualification pass. Download and stdin +transfer must deliver the candidate's exact bytes. The matrix retains the existing +command-boundary stream, stderr, and nonzero-exit coverage. + +Extracting and qualifying the Desktop-bundled CLI is not native Desktop UI +acceptance, installation/update validation, or a customer-host reproduction. +Stable release publication, supported-platform artifact smokes, reporter-equivalent +Desktop fresh/reopen traces, and customer confirmation remain separate release work. diff --git a/e2e/tests/integration/integration.go b/e2e/tests/integration/integration.go index 3e5fa01..a1a64e5 100644 --- a/e2e/tests/integration/integration.go +++ b/e2e/tests/integration/integration.go @@ -72,8 +72,16 @@ func setupSSHKeys() { } } +// compatCoreVersion is the core release the lifecycle suite downloads when no +// binary override is supplied. It checks backward compatibility only. +const compatCoreVersion = "v1.24.0" + func setupDevsyCLI() { - if local := os.Getenv("DEVSY_E2E_BINARY"); local != "" { + if qualificationEnabled() { + requireQualifiedPair() + } + + if local := os.Getenv(envCoreBinary); local != "" { // #nosec G304,G703 -- Test runner explicitly selects the local core binary. data, err := os.ReadFile(local) framework.ExpectNoError(err) @@ -83,9 +91,15 @@ func setupDevsyCLI() { return } + downloadCompatCore() +} + +func downloadCompatCore() { + ginkgo.GinkgoWriter.Printf("compatibility run against core %s\n", compatCoreVersion) client := &http.Client{Timeout: time.Second * 30} resp, err := client.Get( - "https://github.com/devsy-org/devsy/releases/download/v1.24.0/devsy-linux-amd64", + "https://github.com/devsy-org/devsy/releases/download/" + compatCoreVersion + + "/devsy-linux-amd64", ) framework.ExpectNoError(err) framework.ExpectEqual(resp.StatusCode, http.StatusOK) @@ -115,7 +129,7 @@ func setupDevsyCLI() { } var _ = ginkgo.Describe( - "devsy provider ssh test suite", + "devsy provider ssh lifecycle suite (compatibility core "+compatCoreVersion+" unless overridden)", ginkgo.Label("integration"), ginkgo.Ordered, func() { diff --git a/e2e/tests/integration/login_shells.go b/e2e/tests/integration/login_shells.go index 3d55250..1f8ed07 100644 --- a/e2e/tests/integration/login_shells.go +++ b/e2e/tests/integration/login_shells.go @@ -13,56 +13,66 @@ import ( "github.com/onsi/gomega" ) -var _ = ginkgo.Describe("SSH login shells", ginkgo.Label("integration"), func() { - ginkgo.BeforeEach(func() { - if os.Getenv("DEVSY_E2E_SHELL_MATRIX") != "true" { - ginkgo.Skip("requires provisioned Linux login shell accounts") - } - setupSSHKeys() - }) - for _, shell := range []string{"bash", "zsh", "fish"} { - for _, builtin := range []bool{false, true} { - ginkgo.It( - fmt.Sprintf("preserves bootstrap streams with %s builtin=%t", shell, builtin), - func() { - home, err := os.UserHomeDir() - framework.ExpectNoError(err) - user := "devsy-" + shell - sshDir := "/home/" + user + "/.ssh" - for _, args := range [][]string{ - {"install", "-d", "-m", "700", "-o", user, "-g", user, sshDir}, - { - "install", "-m", "600", "-o", user, "-g", user, - filepath.Join(home, ".ssh", "id_rsa.pub"), sshDir + "/authorized_keys", - }, - } { - // #nosec G204 -- Install only the controlled fixture key into provisioned test accounts. - output, err := exec.Command("sudo", args...).CombinedOutput() - framework.ExpectNoError(err, string(output)) - } - payload := bytes.Repeat([]byte{0, 1, 2, 255, '\n', '\'', '\\'}, 512) - cmd := exec.Command(providerPath, "command") - cmd.Env = append( - os.Environ(), - "HOST="+user+"@127.0.0.1", - "PORT=22", - "USE_BUILTIN_SSH="+strconv.FormatBool(builtin), - "EXTRA_FLAGS=-i "+filepath.Join(home, ".ssh", "id_rsa"), - "AGENT_PATH=/tmp/devsy/agent", - "DOCKER_PATH=docker", - "COMMAND=printf 'READY\\n'\ncat\nprintf 'diagnostic' >&2\nexit 37", - ) - cmd.Stdin = bytes.NewReader(payload) - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - err = cmd.Run() - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(cmd.ProcessState.ExitCode()).To(gomega.Equal(37)) - gomega.Expect(stdout.Bytes()). - To(gomega.Equal(append([]byte("READY\n"), payload...))) - gomega.Expect(stderr.String()).To(gomega.ContainSubstring("diagnostic")) - }, - ) +var _ = ginkgo.Describe( + "SSH login shells", + ginkgo.Label("integration", "provider-command"), + func() { + ginkgo.BeforeEach(func() { + if os.Getenv("DEVSY_E2E_SHELL_MATRIX") != "true" { + ginkgo.Skip("requires provisioned Linux login shell accounts") + } + setupSSHKeys() + }) + for _, shell := range []string{"bash", "zsh", "fish"} { + for _, builtin := range []bool{false, true} { + ginkgo.It( + fmt.Sprintf("preserves bootstrap streams with %s builtin=%t", shell, builtin), + func() { + home, err := os.UserHomeDir() + framework.ExpectNoError(err) + user := "devsy-" + shell + installLoginAccountKey(user) + payload := bytes.Repeat([]byte{0, 1, 2, 255, '\n', '\'', '\\'}, 512) + cmd := exec.Command(providerPath, "command") + cmd.Env = append( + os.Environ(), + "HOST="+user+"@127.0.0.1", + "PORT=22", + "USE_BUILTIN_SSH="+strconv.FormatBool(builtin), + "EXTRA_FLAGS=-i "+filepath.Join(home, ".ssh", "id_rsa"), + "AGENT_PATH=/tmp/devsy/agent", + "DOCKER_PATH=docker", + "COMMAND=printf 'READY\\n'\ncat\nprintf 'diagnostic' >&2\nexit 37", + ) + cmd.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err = cmd.Run() + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(cmd.ProcessState.ExitCode()).To(gomega.Equal(37)) + gomega.Expect(stdout.Bytes()). + To(gomega.Equal(append([]byte("READY\n"), payload...))) + gomega.Expect(stderr.String()).To(gomega.ContainSubstring("diagnostic")) + }, + ) + } } + }, +) + +func installLoginAccountKey(user string) { + home, err := os.UserHomeDir() + framework.ExpectNoError(err) + sshDir := "/home/" + user + "/.ssh" + for _, args := range [][]string{ + {"install", "-d", "-m", "700", "-o", user, "-g", user, sshDir}, + { + "install", "-m", "600", "-o", user, "-g", user, + filepath.Join(home, ".ssh", "id_rsa.pub"), sshDir + "/authorized_keys", + }, + } { + // #nosec G204 -- Install only the controlled fixture key into provisioned test accounts. + output, err := exec.Command("sudo", args...).CombinedOutput() + framework.ExpectNoError(err, string(output)) } -}) +} diff --git a/e2e/tests/integration/login_shells_sudo.go b/e2e/tests/integration/login_shells_sudo.go new file mode 100644 index 0000000..1f0cfac --- /dev/null +++ b/e2e/tests/integration/login_shells_sudo.go @@ -0,0 +1,99 @@ +package integration + +import ( + "context" + "fmt" + "os/exec" + "strconv" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/kballard/go-shellquote" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe( + "SSH installer privilege transition", + ginkgo.Label("release-qualification"), + func() { + ginkgo.It( + "installs an executable agent through real sudo for a non-root user", + qualifySudoInstallation, + ) + }, +) + +func qualifySudoInstallation() { + if !qualificationEnabled() { + ginkgo.Skip(envQualification + " is not set to true") + } + prepareQualification() + cell := newShellCell("bash", false) + cell.user = "devsy-sudo" + cell.rootAgentDir = true + result := &privilegeReport{User: cell.user, AgentDir: cell.agentDir, Outcome: "failed"} + registerPrivilegeReport(result) + ginkgo.DeferCleanup(func() { cell.finishPrivilegeReport(result) }) + ginkgo.DeferCleanup(func() { + // Root directory creation can fail before setup registers its cleanup. + if cell.report == nil { + framework.ExpectNoError(cell.removeAgentDirectory()) + } + }) + + framework.ExpectNoError(runFixtureSudo( + "-n", "install", "-d", "-m", "0755", "-o", "root", "-g", "root", cell.agentDir, + )) + cell.setup() + cell.requireSudoPreconditions(result) + cell.up() + cell.expectCandidateAgentInstalled() + cell.expectWorkspaceSSH() + result.LiveCommand = true + result.AgentVersion = cell.agentVersion() + result.AgentSHA256 = cell.agentSHA256() + var err error + result.AgentUID, err = strconv.ParseUint( + cell.mustRemote(shellquote.Join("stat", "-c", "%u", "--", cell.agentPath)), 10, 32, + ) + framework.ExpectNoError(err) + gomega.Expect(result.AgentUID).To(gomega.BeZero(), "sudo must own the newly installed agent") + gomega.Expect(cell.passwdEntry()).To(gomega.Equal(cell.loginShell)) + result.Outcome = "passed" +} + +func (c *shellCell) requireSudoPreconditions(result *privilegeReport) { + var err error + result.UserUID, err = strconv.ParseUint(c.mustRemote("id -u"), 10, 32) + framework.ExpectNoError(err) + gomega.Expect(result.UserUID).To(gomega.BeNumerically(">", 0)) + result.DirectoryState = c.mustRemote(shellquote.Join("stat", "-c", "%u:%a", "--", c.agentDir)) + gomega.Expect(result.DirectoryState).To(gomega.Equal("0:755")) + probe := c.agentDir + "/nonroot-write-probe" + _, err = c.remote(shellquote.Join("touch", "--", probe)) + gomega.Expect(err). + To(gomega.HaveOccurred(), "the non-root account must not write the agent directory") + result.WriteError = err.Error() + gomega.Expect(c.mustRemote(shellquote.Join("test", "!", "-e", probe))).To(gomega.BeEmpty()) + result.WriteDenied = true +} + +func (c *shellCell) finishPrivilegeReport(result *privilegeReport) { + if c.report != nil { + result.CleanupStatus = c.report.CleanupStatus + } + if ginkgo.CurrentSpecReport().Failed() { + result.Outcome = "failed" + } +} + +func runFixtureSudo(args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), remoteTimeout) + defer cancel() + // #nosec G204 -- Only fixed commands manage this job's unique owned fixture directory. + output, err := exec.CommandContext(ctx, "sudo", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("fixture sudo: %w: %s", err, output) + } + return nil +} diff --git a/e2e/tests/integration/login_shells_workspace.go b/e2e/tests/integration/login_shells_workspace.go new file mode 100644 index 0000000..99993a0 --- /dev/null +++ b/e2e/tests/integration/login_shells_workspace.go @@ -0,0 +1,402 @@ +package integration + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/kballard/go-shellquote" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/ginkgo/v2/types" + "github.com/onsi/gomega" +) + +const ( + workspaceFixture = "tests/integration/testdata/login-shell-workspace" + remoteAgentDirRoot = "/tmp/devsy-e2e-" + literalAgentSuffix = " space'\\$-\u03bb" + + upTimeout = 15 * time.Minute + cliTimeout = 3 * time.Minute + remoteTimeout = time.Minute + pollTimeout = 2 * time.Minute + pollInterval = 5 * time.Second + + // Debug lines the core CLI logs for the agent transport it selected. + logStdinTransfer = "inject binary" + logRemoteDownload = "prefer downloading agent from URL" + logHostSSHServerStarted = "starting ssh server: stdio=true" + staleAgentVersion = "v0.0.1-stale" + agentBinaryName = "agent" +) + +var prepareQualificationOnce sync.Once + +func prepareQualification() { + prepareQualificationOnce.Do(func() { + requireQualifiedPair() + setupProvider() + setupSSHKeys() + }) +} + +// shellCell is one login shell and SSH implementation pair with its own Devsy home, +// provider, workspace, and remote agent directory. +type shellCell struct { + pair qualifiedPair + shell string + user string + builtin bool + id string + devsyHome string + agentDir string + agentPath string + rootAgentDir bool + loginShell string + report *cellReport +} + +func newShellCell(shell string, builtin bool) *shellCell { + suffix := make([]byte, 4) + _, err := rand.Read(suffix) + framework.ExpectNoError(err) + transport := "external" + if builtin { + transport = "builtin" + } + id := fmt.Sprintf("e2e-1621-%s-%s-%s", shell, transport, hex.EncodeToString(suffix)) + agentDir := remoteAgentDirRoot + id + literalAgentSuffix + return &shellCell{ + shell: shell, + user: "devsy-" + shell, + builtin: builtin, + id: id, + agentDir: agentDir, + agentPath: agentDir + "/" + agentBinaryName, + } +} + +func (c *shellCell) devsy(timeout time.Duration, env []string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + // #nosec G204 -- The test runner explicitly selects the candidate core binary. + cmd := exec.CommandContext(ctx, c.pair.CoreBinary, args...) + cmd.Env = append(os.Environ(), append([]string{"DEVSY_HOME=" + c.devsyHome}, env...)...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func (c *shellCell) mustDevsy(timeout time.Duration, env []string, args ...string) string { + output, err := c.devsy(timeout, env, args...) + framework.ExpectNoError(err, "devsy "+strings.Join(args, " ")+"\n"+output) + return output +} + +// remote runs a POSIX script on the account through the provider under test. +// External SSH keeps helper commands independent of the transport being qualified. +func (c *shellCell) remote(script string) (string, error) { + home, err := os.UserHomeDir() + framework.ExpectNoError(err) + ctx, cancel := context.WithTimeout(context.Background(), remoteTimeout) + defer cancel() + // #nosec G204 -- The provider binary is the artifact under test. + cmd := exec.CommandContext(ctx, providerPath, "command") + cmd.Env = append( + os.Environ(), + "HOST="+c.user+"@127.0.0.1", + "PORT=22", + "USE_BUILTIN_SSH=false", + "EXTRA_FLAGS="+shellquote.Join("-i", filepath.Join(home, ".ssh", "id_rsa")), + "AGENT_PATH="+c.agentPath, + "DOCKER_PATH=docker", + "COMMAND="+script, + ) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} + +func (c *shellCell) mustRemote(script string) string { + output, err := c.remote(script) + framework.ExpectNoError(err, script) + return output +} + +func (c *shellCell) up(env ...string) string { + fixture, err := filepath.Abs(workspaceFixture) + framework.ExpectNoError(err) + output := c.mustDevsy( + upTimeout, env, + "workspace", "up", fixture, + "--id", c.id, "--provider", c.id, "--ide=none", "--debug", + ) + ginkgo.GinkgoWriter.Println(output) + return output +} + +func (c *shellCell) expectWorkspaceSSH() { + marker := "ssh-ok-" + c.id + gomega.Eventually(func() (string, error) { + output, err := c.devsy( + cliTimeout, nil, "workspace", "ssh", c.id, "--command", "echo "+marker, + ) + return strings.TrimSpace(output), err + }).WithTimeout(pollTimeout).WithPolling(pollInterval).Should(gomega.ContainSubstring(marker)) +} + +// hostBootstrapOutput excludes the separate injection that delivers the agent +// from the remote SSH host into the devcontainer. +func hostBootstrapOutput(output string) string { + end := strings.Index(output, logHostSSHServerStarted) + gomega.Expect(end).To(gomega.BeNumerically(">", 0), "remote SSH server did not start") + return output[:end] +} + +func (c *shellCell) agentVersion() string { + return c.mustRemote(shellquote.Join(c.agentPath, "--version")) +} + +func (c *shellCell) agentSHA256() string { + return strings.Fields(c.mustRemote(shellquote.Join("sha256sum", "--", c.agentPath)))[0] +} + +// agentInode changes on every atomic replacement and survives a reused agent. +func (c *shellCell) agentInode() string { + return c.mustRemote(shellquote.Join("stat", "-c", "%i", "--", c.agentPath)) +} + +func (c *shellCell) expectCandidateAgentInstalled() { + gomega.Expect(c.agentVersion()).To(gomega.Equal(c.pair.CoreVersion)) + gomega.Expect(c.agentSHA256()).To(gomega.Equal(c.pair.CoreBinarySHA256)) + gomega.Expect(c.mustRemote(shellquote.Join("ls", "-A", "--", c.agentDir))).To( + gomega.Equal(agentBinaryName), "installer must leave no temporary files", + ) +} + +func (c *shellCell) installStaleAgent() { + // A prior agent process can still hold the installed executable open. + staged := shellquote.Join(c.agentPath + ".stale") + c.mustRemote(fmt.Sprintf( + "mkdir -p -- %s && printf '#!/bin/sh\\necho %s\\n' > %s "+ + "&& chmod 0755 -- %s && mv -f -- %s %s", + shellquote.Join( + c.agentDir, + ), + staleAgentVersion, + staged, + staged, + staged, + shellquote.Join(c.agentPath), + )) + gomega.Expect(c.agentVersion()).To(gomega.Equal(staleAgentVersion)) +} + +func (c *shellCell) passwdEntry() string { + // #nosec G204 -- Fixed command with a controlled fixture account name. + output, err := exec.Command("getent", "passwd", c.user).Output() + framework.ExpectNoError(err) + return strings.TrimSpace(string(output)) +} + +func (c *shellCell) requireDockerAccess() { + output, err := c.remote("docker version --format '{{.Server.Version}}'") + framework.ExpectNoError(err, fmt.Sprintf( + "account %s needs docker access for the full workspace lifecycle", c.user, + )) + gomega.Expect(output).NotTo(gomega.BeEmpty()) +} + +func (c *shellCell) cleanup() { + var failures []string + if c.devsyHome != "" { + if output, err := c.devsy( + cliTimeout, nil, "workspace", "delete", "--force", "--debug", c.id, + ); err != nil { + failures = append(failures, "workspace: "+err.Error()+": "+output) + } + if output, err := c.devsy(cliTimeout, nil, "provider", "delete", c.id); err != nil { + failures = append(failures, "provider: "+err.Error()+": "+output) + } + if err := os.RemoveAll(c.devsyHome); err != nil { + failures = append(failures, "devsy home: "+err.Error()) + } + } + if err := c.removeAgentDirectory(); err != nil { + failures = append(failures, "agent dir: "+err.Error()) + } + c.report.CleanupStatus = "ok" + if len(failures) > 0 { + c.report.CleanupStatus = strings.Join(failures, "; ") + } + gomega.Expect(failures).To(gomega.BeEmpty()) +} + +func (c *shellCell) removeAgentDirectory() error { + // Only the per-cell directory is removed, never a shared agent path. + if !strings.HasPrefix(c.agentDir, remoteAgentDirRoot) || + len(c.agentDir) == len(remoteAgentDirRoot) { + return nil + } + if c.rootAgentDir { + return runFixtureSudo("-n", "rm", "-rf", "--", c.agentDir) + } + _, err := c.remote(shellquote.Join("rm", "-rf", "--", c.agentDir)) + return err +} + +var _ = ginkgo.Describe( + "SSH login shell full workspace lifecycle", + ginkgo.Label("release-qualification"), + func() { + for _, shell := range []string{"bash", "zsh", "fish"} { + for _, builtin := range []bool{false, true} { + describeShellCell(shell, builtin) + } + } + }, +) + +func describeShellCell(shell string, builtin bool) { + ginkgo.Describe(shell+" builtin="+strconv.FormatBool(builtin), ginkgo.Ordered, func() { + cell := newShellCell(shell, builtin) + ginkgo.BeforeAll(cell.setup) + ginkgo.ReportAfterEach(cell.recordScenario) + ginkgo.It("installs a cold agent by remote download", cell.expectColdDownload) + ginkgo.It( + "reuses the current agent when the workspace is opened again", + cell.expectCurrentAgentReuse, + ) + ginkgo.It("replaces a deliberately old agent", cell.expectStaleAgentReplacement) + ginkgo.It( + "installs a cold agent by stdin transfer and keeps stdin for the live stream", + cell.expectStdinTransfer, + ) + ginkgo.It("leaves the account login shell unchanged", func() { + gomega.Expect(cell.passwdEntry()).To(gomega.Equal(cell.loginShell)) + }) + }) +} + +func (c *shellCell) setup() { + if !qualificationEnabled() { + ginkgo.Skip(envQualification + " is not set to true") + } + prepareQualification() + c.pair = requireQualifiedPair() + c.report = &cellReport{ + Shell: c.shell, + User: c.user, + BuiltinSSH: c.builtin, + Workspace: c.id, + AgentDir: c.agentDir, + AgentSHA256: map[string]string{}, + } + if !c.rootAgentDir { + registerCellReport(c.report) + } + ginkgo.DeferCleanup(c.cleanup) + + installLoginAccountKey(c.user) + c.requireDockerAccess() + c.loginShell = c.passwdEntry() + c.report.LoginShell = c.loginShell + gomega.Expect(c.loginShell).To(gomega.HaveSuffix("/" + c.shell)) + + var err error + c.devsyHome, err = os.MkdirTemp("", "devsy-e2e-home-") + framework.ExpectNoError(err) + + c.mustDevsy( + cliTimeout, nil, + "provider", "add", "../dist/provider.yaml", "--name", c.id, + "-o", "HOST="+c.user+"@127.0.0.1", + "-o", "USE_BUILTIN_SSH="+strconv.FormatBool(c.builtin), + "-o", "AGENT_PATH="+c.agentPath, + "-o", "EXTRA_FLAGS="+shellquote.Join("-i", sshPrivateKeyPath()), + "-o", "INJECT_GIT_CREDENTIALS=false", + "-o", "INJECT_DOCKER_CREDENTIALS=false", + ) + output, err := c.remote(shellquote.Join("test", "!", "-e", c.agentPath)) + framework.ExpectNoError(err, "agent binary must start absent after provider init") + gomega.Expect(output).To(gomega.BeEmpty()) +} + +func (c *shellCell) recordScenario(report ginkgo.SpecReport) { + if c.report == nil || + report.State.Is(types.SpecStateSkipped|types.SpecStatePending) { + return + } + c.report.Scenarios = append(c.report.Scenarios, scenarioResult{ + Name: report.LeafNodeText, + Outcome: report.State.String(), + }) +} + +func (c *shellCell) expectColdDownload() { + output := c.up() + hostOutput := hostBootstrapOutput(output) + gomega.Expect(hostOutput).To(gomega.ContainSubstring(logRemoteDownload)) + gomega.Expect(hostOutput).NotTo(gomega.ContainSubstring(logStdinTransfer)) + c.expectCandidateAgentInstalled() + c.report.AgentSHA256["download"] = c.agentSHA256() + c.expectWorkspaceSSH() +} + +func (c *shellCell) expectCurrentAgentReuse() { + inode := c.agentInode() + output := c.up() + hostOutput := hostBootstrapOutput(output) + gomega.Expect(hostOutput).NotTo(gomega.ContainSubstring(logStdinTransfer)) + gomega.Expect(c.agentInode()).To(gomega.Equal(inode)) + c.expectCandidateAgentInstalled() + c.expectWorkspaceSSH() +} + +func (c *shellCell) expectStaleAgentReplacement() { + c.installStaleAgent() + staleInode := c.agentInode() + output := c.up() + hostOutput := hostBootstrapOutput(output) + gomega.Expect(hostOutput).To(gomega.ContainSubstring(logRemoteDownload)) + gomega.Expect(hostOutput).NotTo(gomega.ContainSubstring(logStdinTransfer)) + gomega.Expect(c.agentInode()).NotTo(gomega.Equal(staleInode)) + c.expectCandidateAgentInstalled() + c.expectWorkspaceSSH() +} + +func (c *shellCell) expectStdinTransfer() { + c.mustRemote(shellquote.Join("rm", "-rf", "--", c.agentDir)) + stdinMode := []string{envAgentPreferDownload + "=false"} + output := c.up(stdinMode...) + hostOutput := hostBootstrapOutput(output) + gomega.Expect(hostOutput).To(gomega.ContainSubstring(logStdinTransfer)) + c.expectCandidateAgentInstalled() + c.report.AgentSHA256["stdin"] = c.agentSHA256() + c.expectWorkspaceSSH() + + inode := c.agentInode() + output = c.up(stdinMode...) + hostOutput = hostBootstrapOutput(output) + gomega.Expect(hostOutput).NotTo(gomega.ContainSubstring(logStdinTransfer)) + gomega.Expect(c.agentInode()).To(gomega.Equal(inode)) + c.expectWorkspaceSSH() +} + +func sshPrivateKeyPath() string { + home, err := os.UserHomeDir() + framework.ExpectNoError(err) + return filepath.Join(home, ".ssh", "id_rsa") +} diff --git a/e2e/tests/integration/qualification.go b/e2e/tests/integration/qualification.go new file mode 100644 index 0000000..d789b73 --- /dev/null +++ b/e2e/tests/integration/qualification.go @@ -0,0 +1,221 @@ +package integration + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "sync" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" +) + +const ( + envQualification = "DEVSY_E2E_RELEASE_QUALIFICATION" + envCoreBinary = "DEVSY_E2E_BINARY" + envCoreVersion = "DEVSY_E2E_CORE_VERSION" + envCoreTag = "DEVSY_E2E_CORE_TAG" + envCoreSHA = "DEVSY_E2E_CORE_SHA" + envReportPath = "DEVSY_E2E_REPORT" + envProviderTag = "DEVSY_E2E_PROVIDER_TAG" + envProviderSHA = "DEVSY_E2E_PROVIDER_SHA" + + defaultReportPath = "qualification-report.json" + + // Any of these changes how the core selects the agent transport or disables the + // remote version check, so a qualification run must not inherit them. + envAgentURL = "DEVSY_AGENT_URL" + envAgentBinary = "DEVSY_AGENT_BINARY" + envAgentPreferDownload = "DEVSY_AGENT_PREFER_DOWNLOAD" + + devVersion = "v0.0.0" +) + +// qualifiedPair identifies the exact core artifact and provider build under test. +type qualifiedPair struct { + CoreBinary string `json:"coreBinary"` + CoreTag string `json:"coreTag"` + CoreSHA string `json:"coreSha"` + CoreVersion string `json:"coreVersion"` + CoreBinarySHA256 string `json:"coreBinarySha256"` + ProviderRef string `json:"providerRef"` + ProviderSHA string `json:"providerSha"` + ProviderBinary string `json:"providerBinary"` + ProviderBinarySHA256 string `json:"providerBinarySha256"` + HarnessSHA string `json:"harnessSha"` +} + +type scenarioResult struct { + Name string `json:"name"` + Outcome string `json:"outcome"` +} + +type cellReport struct { + Shell string `json:"shell"` + User string `json:"user"` + BuiltinSSH bool `json:"builtinSsh"` + Workspace string `json:"workspace"` + AgentDir string `json:"agentDir"` + LoginShell string `json:"loginShell"` + Scenarios []scenarioResult `json:"scenarios"` + AgentSHA256 map[string]string `json:"agentSha256"` + CleanupStatus string `json:"cleanup"` +} + +type privilegeReport struct { + User string `json:"user"` + UserUID uint64 `json:"userUid"` + AgentDir string `json:"agentDir"` + DirectoryState string `json:"directoryState"` + WriteDenied bool `json:"writeDenied"` + WriteError string `json:"writeError"` + AgentUID uint64 `json:"agentUid"` + AgentVersion string `json:"agentVersion"` + AgentSHA256 string `json:"agentSha256"` + LiveCommand bool `json:"liveCommand"` + Outcome string `json:"outcome"` + CleanupStatus string `json:"cleanup"` +} + +type qualificationReport struct { + Pair qualifiedPair `json:"pair"` + Cells []*cellReport `json:"cells"` + Privilege *privilegeReport `json:"privilege"` +} + +var ( + qualificationMu sync.Mutex + qualificationState *qualifiedPair + currentReport = &qualificationReport{} +) + +func qualificationEnabled() bool { + return os.Getenv(envQualification) == "true" +} + +// requireQualifiedPair fails instead of skipping: a release row that silently +// falls back to a downloaded core would qualify the wrong pair. +func requireQualifiedPair() qualifiedPair { + qualificationMu.Lock() + defer qualificationMu.Unlock() + if qualificationState != nil { + return *qualificationState + } + + requireQualificationEnvironment() + + pair := qualifiedPair{ + CoreBinary: requiredEnv(envCoreBinary, "the candidate core CLI binary"), + CoreVersion: requiredEnv(envCoreVersion, "the expected `devsy --version` output"), + CoreTag: requiredEnv(envCoreTag, "the core release tag under test"), + CoreSHA: requiredEnv(envCoreSHA, "the core commit the tag resolves to"), + ProviderBinary: providerPath, + } + if pair.CoreVersion == devVersion { + ginkgo.Fail( + envCoreVersion + " must be a release version: dev builds skip the remote version check", + ) + } + + pair.CoreBinarySHA256 = mustSHA256(pair.CoreBinary) + pair.ProviderBinarySHA256 = mustSHA256(pair.ProviderBinary) + pair.HarnessSHA = gitOutput("rev-parse", "HEAD") + pair.ProviderRef = os.Getenv(envProviderTag) + pair.ProviderSHA = os.Getenv(envProviderSHA) + if (pair.ProviderRef == "") != (pair.ProviderSHA == "") { + ginkgo.Fail(envProviderTag + " and " + envProviderSHA + " must be set together") + } + if pair.ProviderRef == "" { + pair.ProviderRef = gitOutput("describe", "--tags", "--always", "--dirty") + pair.ProviderSHA = pair.HarnessSHA + } + + // #nosec G204 -- The test runner explicitly selects the candidate core binary. + output, err := exec.Command(pair.CoreBinary, "--version").Output() + framework.ExpectNoError(err, "run candidate core --version") + if actual := strings.TrimSpace(string(output)); actual != pair.CoreVersion { + ginkgo.Fail(fmt.Sprintf( + "candidate core reports version %q, expected %q", actual, pair.CoreVersion, + )) + } + + qualificationState = &pair + currentReport.Pair = pair + return pair +} + +func requireQualificationEnvironment() { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + ginkgo.Fail(fmt.Sprintf( + "release qualification requires linux/amd64, running on %s/%s", + runtime.GOOS, runtime.GOARCH, + )) + } + for _, name := range []string{envAgentURL, envAgentBinary, envAgentPreferDownload} { + if os.Getenv(name) != "" { + ginkgo.Fail( + name + " must be unset: it overrides agent transport or disables the version check", + ) + } + } +} + +func requiredEnv(name, description string) string { + value := os.Getenv(name) + if value == "" { + ginkgo.Fail(fmt.Sprintf("%s is required: set it to %s", name, description)) + } + return value +} + +func mustSHA256(path string) string { + // #nosec G304 -- The test runner selects these artifact paths. + file, err := os.Open(path) + framework.ExpectNoError(err, "open "+path) + defer func() { _ = file.Close() }() + hash := sha256.New() + _, err = io.Copy(hash, file) + framework.ExpectNoError(err, "hash "+path) + return hex.EncodeToString(hash.Sum(nil)) +} + +func gitOutput(args ...string) string { + // #nosec G204 -- Fixed git subcommands inspect the provider checkout under test. + output, err := exec.Command("git", append([]string{"-C", ".."}, args...)...).Output() + framework.ExpectNoError(err, "git "+strings.Join(args, " ")) + return strings.TrimSpace(string(output)) +} + +func registerCellReport(report *cellReport) { + qualificationMu.Lock() + defer qualificationMu.Unlock() + currentReport.Cells = append(currentReport.Cells, report) +} + +func registerPrivilegeReport(report *privilegeReport) { + qualificationMu.Lock() + defer qualificationMu.Unlock() + currentReport.Privilege = report +} + +var _ = ginkgo.ReportAfterSuite("release qualification report", func(ginkgo.Report) { + qualificationMu.Lock() + defer qualificationMu.Unlock() + if len(currentReport.Cells) == 0 && currentReport.Privilege == nil { + return + } + path := os.Getenv(envReportPath) + if path == "" { + path = defaultReportPath + } + data, err := json.MarshalIndent(currentReport, "", " ") + framework.ExpectNoError(err) + // #nosec G306,G703 -- The report holds no secrets and is uploaded as a CI artifact. + framework.ExpectNoError(os.WriteFile(path, append(data, '\n'), 0o644)) +}) diff --git a/e2e/tests/integration/testdata/login-shell-workspace/.devcontainer/devcontainer.json b/e2e/tests/integration/testdata/login-shell-workspace/.devcontainer/devcontainer.json new file mode 100644 index 0000000..351e8dc --- /dev/null +++ b/e2e/tests/integration/testdata/login-shell-workspace/.devcontainer/devcontainer.json @@ -0,0 +1,3 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:ubuntu" +}