Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 5 additions & 2 deletions .github/workflows/controller-integration-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@ permissions:
jobs:
integration-test:
name: Integration Test (${{ matrix.name }})
runs-on: [self-hosted, linux, arm64]
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: PAT
runs-on: [self-hosted, linux, arm64]
features: ../../features/config,../../features/binpath,../../features/jobstore,../../features/vitals,../../features/management
needs-app-key: false
- name: GitHub App
features: ../../features/management
runs-on: [self-hosted, linux, arm64]
features: ../../features/config,../../features/binpath,../../features/jobstore,../../features/vitals,../../features/management
needs-app-key: true
- name: Tart
runs-on: [self-hosted, macos, arm64]
features: ../../features/tart
needs-app-key: false
steps:
Expand Down
26 changes: 26 additions & 0 deletions features/tart/vm_lifecycle.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Feature: Tart VM lifecycle

The tart manager wraps the tart CLI for VM operations: pull, clone,
start, IP discovery, SSH exec, stop, and delete. These tests require
a real tart installation with nested virtualization enabled.

Scenario: pull, clone, start, exec, and cleanup a VM
Given a tart manager
When I pull the VM image
Then the VM image should exist locally
When I clone a VM with a random name
And I start the cloned VM
And I wait for the VM IP address
Then the VM IP should be a valid address
When I exec "echo hello" in the VM
Then the exec should succeed
When I stop and delete the VM
Then the VM should no longer exist

Scenario: list and cleanup orphaned VMs
Given a tart manager
When I clone a VM with a random name
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pull image before cloning in orphan-cleanup scenario

The list and cleanup orphaned VMs scenario clones immediately without first ensuring the test image is present locally, so on a clean runner (or when this scenario is run in isolation) Clone can fail before any cleanup assertions run. This makes the scenario order-dependent on pull, clone, start, exec, and cleanup a VM instead of being self-contained.

Useful? React with 👍 / 👎.

And I start the cloned VM
Then listing local VMs should include the cloned VM
When I cleanup all VMs with the test prefix
Then listing local VMs should not include the cloned VM
129 changes: 129 additions & 0 deletions test/integration/steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/boring-design/elastic-fruit-runner/internal/binpath"
"github.com/boring-design/elastic-fruit-runner/internal/management"
"github.com/boring-design/elastic-fruit-runner/internal/management/migrations"
"github.com/boring-design/elastic-fruit-runner/internal/tart"
"github.com/boring-design/elastic-fruit-runner/internal/vitals"
)

Expand Down Expand Up @@ -67,6 +68,12 @@ type scenarioState struct {
workflowResult *github.WorkflowRun
runnerSetsResp *controlplanev1.ListRunnerSetsResponse
jobRecordsResp *controlplanev1.ListJobRecordsResponse

// tart steps
tartMgr *tart.Manager
tartVMName string
tartVMIP string
tartPrefix string
}

func initializeScenario(sc *godog.ScenarioContext) {
Expand Down Expand Up @@ -649,6 +656,128 @@ func initializeScenario(sc *godog.ScenarioContext) {
state.mgmtService.Close()
}
})

// ---- Tart VM steps ----
sc.Step(`^a tart manager$`, func(ctx context.Context) (context.Context, error) {
if binpath.Lookup("tart") == "tart" {
return ctx, godog.ErrPending
}
state.tartMgr = tart.NewManager()
state.tartPrefix = "efr-tart-test"
return ctx, nil
})

sc.Step(`^I pull the VM image$`, func() error {
image := envOrDefault("EFR_TEST_TART_IMAGE", "ghcr.io/cirruslabs/macos-tahoe-base:latest")
return state.tartMgr.Pull(context.Background(), image)
})

sc.Step(`^the VM image should exist locally$`, func() error {
image := envOrDefault("EFR_TEST_TART_IMAGE", "ghcr.io/cirruslabs/macos-tahoe-base:latest")
exists, err := state.tartMgr.ImageExists(context.Background(), image)
if err != nil {
return fmt.Errorf("check image exists: %w", err)
}
if !exists {
return fmt.Errorf("image %q not found locally after pull", image)
}
return nil
})

sc.Step(`^I clone a VM with a random name$`, func() error {
image := envOrDefault("EFR_TEST_TART_IMAGE", "ghcr.io/cirruslabs/macos-tahoe-base:latest")
state.tartVMName = state.tartPrefix + "-" + randomSuffix()
return state.tartMgr.Clone(context.Background(), image, state.tartVMName)
})

sc.Step(`^I start the cloned VM$`, func() error {
return state.tartMgr.Start(context.Background(), state.tartVMName)
})

sc.Step(`^I wait for the VM IP address$`, func() error {
ip, err := state.tartMgr.IPAddress(context.Background(), state.tartVMName)
if err != nil {
return err
}
state.tartVMIP = ip
return nil
})

sc.Step(`^the VM IP should be a valid address$`, func() error {
if net.ParseIP(state.tartVMIP) == nil {
return fmt.Errorf("invalid IP address: %q", state.tartVMIP)
}
return nil
})

sc.Step(`^I exec "([^"]*)" in the VM$`, func(cmd string) error {
return state.tartMgr.Exec(context.Background(), state.tartVMName, "bash", "-c", cmd)
})

sc.Step(`^the exec should succeed$`, func() error {
// The step above already returns error on failure
return nil
})

sc.Step(`^I stop and delete the VM$`, func() error {
if err := state.tartMgr.Stop(context.Background(), state.tartVMName); err != nil {
return fmt.Errorf("stop VM: %w", err)
}
return state.tartMgr.Delete(context.Background(), state.tartVMName)
})

sc.Step(`^the VM should no longer exist$`, func() error {
vms, err := state.tartMgr.List(context.Background())
if err != nil {
return err
}
for _, name := range vms {
if name == state.tartVMName {
return fmt.Errorf("VM %q still exists after delete", state.tartVMName)
}
}
return nil
})

sc.Step(`^listing local VMs should include the cloned VM$`, func() error {
vms, err := state.tartMgr.List(context.Background())
if err != nil {
return err
}
for _, name := range vms {
if name == state.tartVMName {
return nil
}
}
return fmt.Errorf("VM %q not found in list", state.tartVMName)
})

sc.Step(`^I cleanup all VMs with the test prefix$`, func() error {
vms, err := state.tartMgr.List(context.Background())
if err != nil {
return err
}
for _, name := range vms {
if strings.HasPrefix(name, state.tartPrefix+"-") {
_ = state.tartMgr.Stop(context.Background(), name)
_ = state.tartMgr.Delete(context.Background(), name)
Comment on lines +762 to +763
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Propagate tart cleanup failures instead of discarding them

This step ignores both Stop and Delete errors, so the scenario can still pass even when cleanup fails for prefixed VMs (especially stale orphans from prior runs). Because the next assertion only verifies the current cloned VM name, failures on other matched VMs are silently masked, which can leave orphaned Tart VMs behind and hide regressions in the cleanup path.

Useful? React with 👍 / 👎.

}
}
return nil
})

sc.Step(`^listing local VMs should not include the cloned VM$`, func() error {
vms, err := state.tartMgr.List(context.Background())
if err != nil {
return err
}
for _, name := range vms {
if name == state.tartVMName {
return fmt.Errorf("VM %q still exists after cleanup", state.tartVMName)
}
}
return nil
})
}

// buildMgmtConfig creates a management service config from env vars with the given auth.
Expand Down
Loading