Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Tests

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
integration:
name: Integration (no machine)
# The provider binary is darwin-only, so the suite runs on macOS.
runs-on: macos-15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false

- name: setup Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: go.mod
Comment on lines +20 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow =="
cat -n .github/workflows/tests.yaml | sed -n '1,80p'

echo
echo "== package manifests =="
for f in go.mod package.json pyproject.toml; do
  if [ -f "$f" ]; then
    echo "-- $f --"
    sed -n '1,40p' "$f"
  fi
done

echo
echo "== search for cache settings in workflows =="
rg -n "setup-go|cache:|pull-request|pull_request|workflow_dispatch|permissions:" .github/workflows || true

Repository: devsy-org/devsy-provider-orbstack

Length of output: 4225


🌐 Web query:

actions/setup-go v6 cache input default true pull request cache poisoning

💡 Result:

In actions/setup-go v6, the cache input defaults to true, which enables automatic caching of Go modules and build outputs [1][2]. Regarding cache poisoning concerns, while the default cache: true configuration enables both restoration and saving of the cache, you can mitigate risks using the following mechanisms: 1. New Read-Only Cache Controls: The actions/setup-go action has introduced a cache-write input (defaulting to true) [3]. By setting cache-write: ${{ github.event_name != 'pull_request' }}, you can allow the action to restore the cache while preventing it from saving (and potentially overwriting) the cache during pull request workflows [3]. This is a recommended best practice for untrusted PR environments [3][4]. 2. Platform-Level Security: As of June 2026, GitHub now automatically issues read-only cache tokens to the default branch for workflow events that can be triggered without repository write permissions (such as certain pull_request and workflow_run contexts) [5]. This provides a native layer of protection against cache poisoning by restricting the ability of untrusted workflows to modify the cache used by more privileged, trusted workflows [5]. If you are concerned about security in sensitive workflows, explicitly setting cache: false or using the cache-write input to disable writes for pull request events are effective ways to prevent cache poisoning [3][4].

Citations:


Disable setup-go cache writes for PRs.

This workflow runs untrusted pull_request events, and setup-go v6 enables caching by default. Set cache-write: ${{ github.event_name != 'pull_request' }}, or disable setup-go caching entirely with cache: false.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 21-21: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default

(cache-poisoning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/tests.yaml around lines 20 - 23, Update the setup Go step
using actions/setup-go to prevent cache writes for pull_request events by
setting cache-write to evaluate false for pull requests and true otherwise,
while preserving caching for trusted workflow events.

Source: Linters/SAST tools


- name: install goreleaser
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7
with:
version: "~> v2"
args: release --snapshot --clean

- name: integration tests
working-directory: ./e2e
run: go test -tags=e2e -v -ginkgo.v -timeout 3600s --ginkgo.label-filter=integration
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/dist/
*.out
.DS_Store
/e2e/bin/
12 changes: 12 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ tasks:
desc: Run unit tests
cmd: go test ./...

test:e2e:
desc: Run no-machine e2e integration tests (macOS; no OrbStack required)
deps: ["build:cli"]
dir: e2e
cmd: go test -tags=e2e -v -ginkgo.v -timeout 3600s --ginkgo.label-filter=integration

test:e2e:vm:
desc: Run full machine-lifecycle e2e tests (requires OrbStack)
deps: ["build:cli"]
dir: e2e
cmd: go test -tags=e2e -v -ginkgo.v -timeout 3600s --ginkgo.label-filter=vm

lint:
desc: Run go vet
cmd: go vet ./...
23 changes: 23 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## E2E tests

The suite has two label groups (both require macOS, since the provider binary is
darwin-only):

- `integration` builds the provider and exercises the no-machine paths (manifest
generation, option validation, provider-binary error handling). It does not
require OrbStack to be installed or running.
- `vm` provisions a real OrbStack machine and runs a full workspace lifecycle.
It requires OrbStack (`orbctl` on `PATH`, OrbStack running). This label does
not run in CI: OrbStack cannot start on GitHub-hosted macOS runners (they lack
the Apple Virtualization capability it needs). Run it on a real Mac or a
self-hosted macOS runner.

### Run

Build the provider binaries first, then run a label group from this directory:

```sh
task build:cli
go test -v -ginkgo.v -timeout 3600s --ginkgo.label-filter=integration
go test -v -ginkgo.v -timeout 3600s --ginkgo.label-filter=vm
```
16 changes: 16 additions & 0 deletions e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build e2e

package e2e

import (
"testing"

_ "github.com/devsy-org/devsy-provider-orbstack/e2e/tests/integration"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

func TestRunE2ETests(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Devsy OrbStack Provider e2e suite")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
4 changes: 4 additions & 0 deletions e2e/fixtures/workspace/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "orbstack-e2e",
"image": "mcr.microsoft.com/devcontainers/base:alpine"
}
172 changes: 172 additions & 0 deletions e2e/tests/integration/integration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package integration

import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"time"

"github.com/devsy-org/devsy/e2e/framework"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)

// uniqueWorkspaceID returns a per-run workspace ID so the vm suite never
// collides with or deletes a workspace a developer already has.
func uniqueWorkspaceID() string {
b := make([]byte, 4)
_, err := rand.Read(b)
framework.ExpectNoError(err)
return "devsy-orbstack-e2e-" + hex.EncodeToString(b)
}

func providerBinary() string {
archDir := map[string]string{"amd64": "amd64_v1", "arm64": "arm64_v8.0"}[goruntime.GOARCH]
dir := fmt.Sprintf("build_%s_%s", goruntime.GOOS, archDir)
bin := fmt.Sprintf("devsy-provider-orbstack-%s-%s", goruntime.GOOS, goruntime.GOARCH)
return filepath.Join("..", "dist", dir, bin)
}

func mustRun(cmd *exec.Cmd) {
out, err := cmd.CombinedOutput()
gomega.Expect(err).NotTo(gomega.HaveOccurred(), string(out))
}

func isolateDevsyHome() {
dir, err := os.MkdirTemp("", "devsy-orbstack-e2e-")
framework.ExpectNoError(err)
framework.ExpectNoError(os.Setenv("DEVSY_HOME", dir))
}

func setupProvider() {
cmd := exec.Command("go", "run", "hack/provider/main.go", "0.0.0")
cmd.Dir = "../"
projectRoot, err := filepath.Abs("../")
framework.ExpectNoError(err)
cmd.Env = append(os.Environ(), "PROJECT_ROOT="+filepath.Join(projectRoot, "dist"))

output, err := cmd.Output()
framework.ExpectNoError(err)
framework.ExpectNoError(os.WriteFile("../dist/provider.yaml", output, 0o600))
}

func setupDevsyCLI() {
client := &http.Client{Timeout: 30 * time.Second}
url := fmt.Sprintf(
"https://github.com/devsy-org/devsy/releases/latest/download/devsy-%s-%s",
goruntime.GOOS, goruntime.GOARCH,
)
resp, err := client.Get(url) //nolint:gosec // fixed release URL
framework.ExpectNoError(err)
defer func() { _ = resp.Body.Close() }()
framework.ExpectEqual(resp.StatusCode, http.StatusOK)

framework.ExpectNoError(os.MkdirAll("bin", 0o750))
binPath := filepath.Join("bin", "devsy")
//nolint:gosec // fixed path; the devsy binary needs the executable bit
out, err := os.OpenFile(binPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
framework.ExpectNoError(err)
_, err = io.Copy(out, resp.Body)
framework.ExpectNoError(err)
// Close before exec: running a file still open for write triggers ETXTBSY.
framework.ExpectNoError(out.Close())
Comment on lines +66 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Register resource cleanup before assertions can abort.

resp.Body.Close() is deferred only after the status assertion, and out.Close() is reached only after a successful copy. Failed assertions or I/O can therefore leave descriptors open. Defer each close immediately after the corresponding successful open.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/integration/integration.go` around lines 55 - 67, Update the
resource handling in the integration test around the HTTP response and binary
file creation: defer resp.Body.Close immediately after confirming the response
was obtained, before status assertions, and defer out.Close immediately after
os.OpenFile succeeds, before io.Copy. Preserve the existing assertions while
ensuring cleanup runs when any later assertion or I/O operation aborts
execution.

framework.ExpectNoError(exec.Command(binPath, "--version").Run()) //nolint:gosec // fixed path
Comment on lines +62 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '1,120p' e2e/tests/integration/integration.go

echo
echo "== related files/usages =="
git ls-files | rg 'e2e/tests/integration|integration\.go|Makefile|go.mod|go\.sum'
echo
rg -n "releases/latest/download|github.com/devsy-org/devsy|exec\\.Command\\(|os\\.OpenFile|Checksum|SHA|signature|Pinning|PIN" .

Repository: devsy-org/devsy-provider-orbstack

Length of output: 7169


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository references to devsy integration binary download =="
python3 - <<'PY'
from pathlib import Path
terms = [
    "github.com/devsy-org/devsy/releases/latest/download/devsy-",
    "releases/latest/download/devsy-",
    "devsy",
]
for p in Path(".").glob("**/*.go"):
    if not p.is_file():
        continue
    try:
        text = p.read_text(errors="replace")
    except Exception:
        continue
    lines = text.splitlines()
    for term in terms:
        for i,l in enumerate(lines, 1):
            if term in l:
                start=max(1,i-8); end=min(len(lines),i+12)
                print(f"\n--- {p}:{i} ---")
                for j in range(start,end+1):
                    print(f"{j:5}: {lines[j-1]}")
                print()
PY

echo "== file excerpt =="
sed -n '1,120p' e2e/tests/integration/integration.go

echo
echo "== candidate checksum/signature/download pins =="
rg -n "releases/latest/download|checksumsum|sha256|sha1|integrity|provenance|cosign|cosign\.verify|gh verify|sigstore|gpg|gpgv|signed|Signature|checksum" e2e tests || true

Repository: devsy-org/devsy-provider-orbstack

Length of output: 33668


Pin and verify the CLI binary before executing it.

In setupDevsyCLI() (lines 50-68), the test resolves releases/latest/download/devsy-..., writes the response body to bin/devsy, and immediately executes it. releases/latest is mutable and HTTPS only protects transport; use a pinned release artifact and verify a pinned checksum or signature before writing/running the binary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/integration/integration.go` around lines 51 - 68, Update
setupDevsyCLI to download a specific immutable release artifact instead of the
mutable releases/latest URL, then verify the downloaded bytes against the
repository’s pinned checksum or signature before writing bin/devsy. Abort on
verification failure and only execute the binary after successful verification,
preserving the existing platform-specific artifact naming and execution flow.

}

var _ = ginkgo.Describe(
"devsy provider orbstack integration",
ginkgo.Label("integration"),
ginkgo.Ordered,
func() {
ginkgo.BeforeAll(func() {
setupProvider()
})

ginkgo.It("should generate a non-empty provider.yaml", func() {
data, err := os.ReadFile("../dist/provider.yaml")
framework.ExpectNoError(err)
framework.ExpectNotEqual(len(data), 0)
})

ginkgo.It("should have required provider options", func() {
data, err := os.ReadFile("../dist/provider.yaml")
framework.ExpectNoError(err)
content := string(data)
for _, opt := range []string{"ORBSTACK_DISTRO", "ORBSTACK_ISOLATED", "AGENT_PATH"} {
gomega.Expect(content).To(gomega.ContainSubstring(opt))
}
})

ginkgo.It("should fail init when orbctl is missing", func() {
cmd := exec.Command(providerBinary(), "init") //nolint:gosec // built provider path
cmd.Env = append(cmd.Environ(), "ORBSTACK_PATH=/nonexistent/orbctl")
output, err := cmd.CombinedOutput()
framework.ExpectError(err)
gomega.Expect(string(output)).To(gomega.ContainSubstring("orbctl not found"))
})
Comment on lines +107 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ensure this test actually executes the provider binary.

cmd.Run() returns an error when providerBinary() does not exist, so this test can pass without invoking the provider at all. Assert that the binary exists first, and/or validate the expected missing-orbctl error output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/integration/integration.go` around lines 95 - 99, Update the
“should fail init when orbctl is missing” test to verify providerBinary() exists
and is executable before running it, then validate that cmd.Run() reaches the
provider and produces the expected missing-orbctl failure rather than accepting
any execution error. Preserve the ORBSTACK_PATH=/nonexistent/orbctl setup and
use the test’s existing assertion conventions.


ginkgo.It("should fail create without a machine id", func() {
cmd := exec.Command(providerBinary(), "create") //nolint:gosec // built provider path
cmd.Env = append(cmd.Environ(), "MACHINE_ID=")
output, err := cmd.CombinedOutput()
framework.ExpectError(err)
gomega.Expect(string(output)).To(gomega.ContainSubstring("MACHINE_ID"))
})
},
)

var _ = ginkgo.Describe(
"devsy provider orbstack vm lifecycle",
ginkgo.Label("vm"),
ginkgo.Ordered,
func() {
var workspaceID string

ginkgo.BeforeAll(func() {
workspaceID = uniqueWorkspaceID()
isolateDevsyHome()
setupProvider()
setupDevsyCLI()
mustRun(exec.Command("bin/devsy", "provider", "add", "../dist/provider.yaml",
"--name", "orbstack", "-o", "ORBSTACK_CPUS=2", "-o", "ORBSTACK_MEMORY=4G"))

ginkgo.DeferCleanup(func() {
//nolint:gosec // fixed devsy binary; test-controlled args
_ = exec.Command("bin/devsy", "workspace", "delete", "--force", workspaceID).Run()
})
Comment on lines +140 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant e2e integration test and surrounding cleanup/workspace lifecycle.
if [ -f e2e/tests/integration/integration.go ]; then
  wc -l e2e/tests/integration/integration.go
  sed -n '1,220p' e2e/tests/integration/integration.go
else
  echo "e2e/tests/integration/integration.go not found"
  fd -a 'integration\.go$' .
fi

# Search for workspace creation/deletion and DeferCleanup usages.
rg -n "workspace|DeferCleanup|devsy|workspaceID|delete|--force|Create|Delete|idempoten|exists" e2e tests . --glob '!vendor/**' --glob '!node_modules/**' || true

Repository: devsy-org/devsy-provider-orbstack

Length of output: 16093


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== e2e/README.md cleanup context =="
sed -n '1,80p' e2e/README.md

echo
echo "== pkg/orb/orbctl.go delete implementation =="
sed -n '90,125p' pkg/orb/orbctl.go

echo
echo "== cmd/lifecycle.go delete implementation =="
sed -n '30,55p' cmd/lifecycle.go

echo
echo "== Ginkgo DeferCleanup handling patterns in go modules =="
python3 - <<'PY'
# Read-only check of local ginkgo source availability and the relevant DeferCleanup implementation, if vendored/module cache is present.
import glob, os, re, pathlib
candidates = []
for root in ["/root/go/pkg/mod/github.com/onsi/ginkgo*"]:
    candidates += glob.glob(root + "/core/deferpanic.go")
    candidates += glob.glob(root + "/core/spec_runner.go")
print("ginkgo candidate files:", candidates[:10])
for path in candidates[:10]:
    print(f"\n--- {path} ---")
    text = pathlib.Path(path).read_text()
    for needle in ["DeferCleanup", "AfterEach", "Cleanup", "CleanupFailed"]:
        idx = text.find(needle)
        if idx != -1:
            start=max(0, idx-400); end=min(len(text), idx+1400)
            print(text[start:end])
PY

Repository: devsy-org/devsy-provider-orbstack

Length of output: 2584


🌐 Web query:

Ginkgo DeferCleanup runs after specs in spec group fail DeferCleanup returns non-nil error

💡 Result:

Yes, Ginkgo runs DeferCleanup functions even if a spec fails [1]. When a failure occurs during the execution of a test (such as in a BeforeEach, JustBeforeEach, or It closure), Ginkgo halts the current spec but proceeds to execute all registered cleanup nodes, including AfterEach and DeferCleanup nodes, to ensure proper state cleanup [1]. Regarding the return value, if a function passed to DeferCleanup returns a non-nil error, Ginkgo will treat this as a failure [1]. Specifically, DeferCleanup ignores all return values from the passed function except for the last one; if that last value is a non-nil error, Ginkgo will mark the spec as having failed [1]. If the spec had already failed before the cleanup node ran, the error returned by the DeferCleanup function is typically tracked as an additional failure associated with that spec [2][3].

Citations:


🌐 Web query:

GitHub Ginkgo Ginkgo DeferCleanup failed cleanup spec

💡 Result:

In the Ginkgo testing framework, DeferCleanup is a convenience function used to register cleanup callbacks that run automatically when a spec completes [1][2]. If a DeferCleanup callback function returns an error, Ginkgo handles it by failing the spec [1]. Key behaviors regarding DeferCleanup and spec failures include: Error Handling: When DeferCleanup is passed a function that returns an error as its last return value, Ginkgo checks this value [1]. If the returned error is non-nil, Ginkgo treats it as a spec failure [1]. Execution Order: DeferCleanup callbacks are executed in a first-in-last-out (FILO) order [3]. They are triggered after other standard cleanup nodes (like AfterEach) have finished [3][4]. Placement Restrictions: DeferCleanup must be called within setup or subject nodes (e.g., BeforeEach, It, BeforeSuite) [2][5]. It cannot be called at the top level or within container nodes (e.g., Describe, Context) [2][5]. Attempting to call it in an invalid location will result in an error during test execution [5][6]. Context: If a test fails in a BeforeEach, JustBeforeEach, or It closure, Ginkgo halts the current spec execution and proceeds to run registered cleanup nodes, including DeferCleanup callbacks, to ensure the environment is cleaned up properly [1]. If you are experiencing a failed cleanup spec, ensure that: 1. Your cleanup function is correctly returning an error (if intended) or nil (if successful) [1]. 2. You are not attempting to call DeferCleanup from an invalid location, such as directly inside a Describe or Context container [2][5]. 3. You are aware of the execution order; if your AfterAll block relies on state that a DeferCleanup is responsible for clearing, remember that DeferCleanups typically run after the standard After* family of nodes [4].

Citations:


Do not discard VM cleanup failures.

A failed DeferCleanup runs even when a later spec fails, and swallowing the delete error can leave the OrbStack workspace/machine behind. Report cleanup failures from DeferCleanup, and make cleanup idempotent or avoid deleting the workspace twice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/integration/integration.go` around lines 125 - 127, Update the
DeferCleanup callback around the workspace deletion command to report failures
instead of discarding the error returned by exec.Command(...).Run(). Ensure the
cleanup remains idempotent by avoiding a second delete attempt or treating an
already-deleted workspace as successful.

})

ginkgo.It("should bring up a workspace on an OrbStack machine", func() {
//nolint:gosec // fixed devsy binary; test-controlled args
mustRun(exec.Command("bin/devsy", "workspace", "up", "--ide=none",
"--id", workspaceID, "--provider", "orbstack", "fixtures/workspace"))
})

ginkgo.It("should run a command in the workspace", func() {
//nolint:gosec // fixed devsy binary; test-controlled args
cmd := exec.Command(
"bin/devsy",
"workspace",
"ssh",
workspaceID,
"--command",
"echo test",
)
output, err := cmd.Output()
framework.ExpectNoError(err)
gomega.Expect(strings.TrimSpace(string(output))).To(gomega.Equal("test"))
})

ginkgo.It("should delete the workspace", func() {
//nolint:gosec // fixed devsy binary; test-controlled args
mustRun(exec.Command("bin/devsy", "workspace", "delete", "--force", workspaceID))
})
},
)
Loading
Loading