Skip to content

Commit 3c0646a

Browse files
committed
Support Vetu virtualization on Linux in addition to Tart on macOS
1 parent 5691835 commit 3c0646a

27 files changed

Lines changed: 1108 additions & 326 deletions

File tree

.cirrus.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ task:
55
name: dev-mini
66
resources:
77
tart-vms: 2
8-
pull_script:
9-
- tart pull ghcr.io/cirruslabs/macos-sonoma-base:latest
8+
pre_pull_script:
9+
- tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
1010
test_script:
11-
- go test -ldflags="-B gobuildid" -v -count=1 ./...
11+
- go test -v -count=1 ./...
1212
always:
1313
cleanup_script:
1414
- tart list

.github/workflows/main.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
on:
2+
push:
3+
4+
jobs:
5+
test:
6+
name: Test (Linux)
7+
runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-md
8+
steps:
9+
- uses: actions/checkout@v4
10+
- uses: actions/setup-go@v5
11+
with:
12+
go-version: stable
13+
- name: Install Vetu
14+
run: |
15+
sudo apt-get update && sudo apt-get -y install apt-transport-https ca-certificates
16+
echo "deb [trusted=yes] https://apt.fury.io/cirruslabs/ /" | sudo tee /etc/apt/sources.list.d/cirruslabs.list
17+
sudo apt-get update && sudo apt-get -y install vetu
18+
- name: Pre-pull default Vetu image for use in tests
19+
run: |
20+
vetu pull ghcr.io/cirruslabs/ubuntu-runner-amd64:latest
21+
- name: Run tests
22+
run: go test -v -count=1 ./...

api/openapi.yaml

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,32 @@ components:
558558
title: Virtual Machine Specification
559559
type: object
560560
properties:
561+
os:
562+
type: string
563+
description: |
564+
Operating system used by a VM.
565+
566+
Set to `linux` to work around the Apple's limitation of 2 macOS VMs per host.
567+
568+
This field cannot be changed after the VM is created.
569+
default: darwin
570+
enum: [ darwin, linux ]
571+
arch:
572+
type: string
573+
description: |
574+
Hardware architecture to use for a VM.
575+
576+
This field cannot be changed after the VM is created.
577+
default: arm64
578+
enum: [ arm64, amd64 ]
579+
runtime:
580+
type: string
581+
description: |
582+
Runtime to use for a VM.
583+
584+
This field cannot be changed after the VM is created.
585+
default: tart
586+
enum: [ tart, vetu ]
561587
image:
562588
type: string
563589
description: VM image for this VM
@@ -709,20 +735,20 @@ components:
709735
710736
When set to `stopped` or `suspended`, the VM does not consume any `resources`
711737
and can serve as a source for creating new Orchard VMs on the same worker. See
712-
`tartName` for more details.
738+
`localName` for more details.
713739
714740
Note that you can only transition into `stopped` or `suspended` only once
715741
at the moment.
716742
default: running
717743
enum: [ running, stopped, suspended ]
718-
tartName:
744+
localName:
719745
type: string
720746
description: |
721-
Name of the Tart VM backing this VM resource.
747+
Name of the local VM backing this VM resource.
722748
723-
`tartName` is specific to a worker, whereas `name` is cluster-wide.
749+
`localName` is specific to a worker, whereas `name` is cluster-wide.
724750
725-
`tartName` is useful in combination with `powerState` for creating stopped or suspended VMs
751+
`localName` is useful in combination with `powerState` for creating stopped or suspended VMs
726752
that can be used to start or resume new VMs on the same worker.
727753
728754
However, with great power comes great responsibility. You need to make sure:
@@ -735,6 +761,12 @@ components:
735761
new MAC addresses automatically, which will stop them from booting,
736762
since the suspend‑resume machinery expects the same MAC address
737763
readOnly: true
764+
tartName:
765+
type: string
766+
description: |
767+
Deprecated alias for `localName`.
768+
readOnly: true
769+
deprecated: true
738770
VMState:
739771
title: Virtual Machine State
740772
type: object

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ go 1.25.1
66
replace github.com/gin-gonic/gin v1.11.0 => github.com/gin-gonic/gin v1.10.0
77

88
require (
9-
github.com/avast/retry-go v3.0.0+incompatible
109
github.com/avast/retry-go/v4 v4.7.0
1110
github.com/cirruslabs/chacha v0.16.3
1211
github.com/coder/websocket v1.8.14

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE
2121
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
2222
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
2323
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
24-
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
25-
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
2624
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
2725
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
2826
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=

internal/controller/api_vms.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ func (controller *Controller) createVM(ctx *gin.Context) responder.Responder {
4949
vm.RestartCount = 0
5050
vm.UID = uuid.New().String()
5151
vm.PowerState = v1.PowerStateRunning
52-
vm.TartName = ondiskname.New(vm.Name, vm.UID, vm.RestartCount).String()
52+
vm.LocalName = ondiskname.New(vm.Name, vm.UID, vm.RestartCount).String()
53+
//nolint:staticcheck // yes, this is deprecated, but we still maintain it for backward compatibility
54+
vm.TartName = vm.LocalName
5355
vm.Generation = 0
5456
vm.ObservedGeneration = 0
5557
vm.Conditions = []v1.Condition{
@@ -66,12 +68,17 @@ func (controller *Controller) createVM(ctx *gin.Context) responder.Responder {
6668
vm.NetSoftnet = true
6769
}
6870

69-
// Provide resource defaults
70-
if vm.Resources == nil {
71-
vm.Resources = make(v1.Resources)
72-
}
73-
if _, ok := vm.Resources[v1.ResourceTartVMs]; !ok {
74-
vm.Resources[v1.ResourceTartVMs] = 1
71+
// Apple limits the number of macOS VMs to 2,
72+
// so we need to provide a resource default
73+
// (if not otherwise overridden by the user)
74+
// to avoid a case when more than 2 VMs run
75+
if vm.OS == v1.OSDarwin && vm.Runtime == v1.RuntimeTart {
76+
if vm.Resources == nil {
77+
vm.Resources = make(v1.Resources)
78+
}
79+
if _, ok := vm.Resources[v1.ResourceTartVMs]; !ok {
80+
vm.Resources[v1.ResourceTartVMs] = 1
81+
}
7582
}
7683

7784
// Validate image pull policy and provide a default value if it's missing
@@ -148,6 +155,12 @@ func (controller *Controller) updateVMSpec(ctx *gin.Context) responder.Responder
148155
NewErrorResponse("cannot update VM in a terminal state"))
149156
}
150157

158+
// Platform sanity checks
159+
if dbVM.OS != userVM.OS || dbVM.Arch != userVM.Arch || dbVM.Runtime != userVM.Runtime {
160+
return responder.JSON(http.StatusPreconditionFailed, NewErrorResponse("\"os\", \"arch\" "+
161+
"and \"runtime\" fields cannot be modified"))
162+
}
163+
151164
// Softnet-specific logic: automatically enable Softnet when NetSoftnetAllow or NetSoftnetBlock are set
152165
// and propagate deprecated and non-deprecated boolean fields into each other
153166
if userVM.NetSoftnetDeprecated || userVM.NetSoftnet || len(userVM.NetSoftnetAllow) != 0 || len(userVM.NetSoftnetBlock) != 0 {

internal/controller/api_workers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ func (controller *Controller) createWorker(ctx *gin.Context) responder.Responder
9797
"from a different machine ID, delete this worker first to be able to re-create it"))
9898
}
9999

100+
dbWorker.Arch = worker.Arch
101+
dbWorker.Runtime = worker.Runtime
100102
dbWorker.LastSeen = worker.LastSeen
101103
dbWorker.Resources = worker.Resources
102104
dbWorker.Labels = worker.Labels

internal/controller/scheduler/scheduler.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ NextVM:
273273

274274
if worker.Offline(scheduler.workerOfflineTimeout) ||
275275
worker.SchedulingPaused ||
276+
!compatibleArchAndRuntime(unscheduledVM, worker) ||
276277
!resourcesRemaining.CanFit(unscheduledVM.Resources) ||
277278
!worker.Labels.Contains(unscheduledVM.Labels) {
278279
continue NextWorker
@@ -328,6 +329,10 @@ NextVM:
328329
return ErrWorkerSchedulingSkipped
329330
}
330331

332+
if !compatibleArchAndRuntime(unscheduledVM, *currentWorker) {
333+
return ErrWorkerSchedulingSkipped
334+
}
335+
331336
if currentWorker.MachineID != worker.MachineID ||
332337
!currentWorker.Resources.Equal(worker.Resources) {
333338
// Worker has changed
@@ -433,6 +438,19 @@ func ProcessVMs(vms []v1.VM) ([]v1.VM, WorkerInfos) {
433438
return unscheduledVMs, workerToResources
434439
}
435440

441+
func compatibleArchAndRuntime(vm v1.VM, worker v1.Worker) bool {
442+
// Backward compatibility
443+
if vm.Arch == "" {
444+
vm.Arch = v1.ArchitectureARM64
445+
}
446+
447+
if vm.Runtime == "" {
448+
vm.Runtime = v1.RuntimeTart
449+
}
450+
451+
return vm.Arch == worker.Arch && vm.Runtime == worker.Runtime
452+
}
453+
436454
func (scheduler *Scheduler) healthCheckingLoopIteration() (int, error) {
437455
// Stats for the caller
438456
var numVMs int
@@ -515,7 +533,9 @@ func (scheduler *Scheduler) healthCheckVM(txn storepkg.Transaction, vm v1.VM) er
515533
vm.ScheduledAt = time.Time{}
516534
vm.StartedAt = time.Time{}
517535
vm.PowerState = v1.PowerStateRunning
518-
vm.TartName = ondiskname.New(vm.Name, vm.UID, vm.RestartCount).String()
536+
vm.LocalName = ondiskname.New(vm.Name, vm.UID, vm.RestartCount).String()
537+
//nolint:staticcheck // yes, this is deprecated, but we still maintain it for backward compatibility
538+
vm.TartName = vm.LocalName
519539
vm.Conditions = []v1.Condition{
520540
{
521541
Type: v1.ConditionTypeScheduled,
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
package imageconstant
22

3-
const DefaultMacosImage = "ghcr.io/cirruslabs/macos-tahoe-base:latest"
3+
const (
4+
DefaultMacosImage = "ghcr.io/cirruslabs/macos-tahoe-base:latest"
5+
DefaultLinuxImage = "ghcr.io/cirruslabs/ubuntu-runner-amd64:latest"
6+
)

internal/tests/integration_test.go

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import (
1414
"github.com/cirruslabs/orchard/internal/controller"
1515
"github.com/cirruslabs/orchard/internal/imageconstant"
1616
"github.com/cirruslabs/orchard/internal/tests/devcontroller"
17+
"github.com/cirruslabs/orchard/internal/tests/platformdependent"
1718
"github.com/cirruslabs/orchard/internal/tests/wait"
1819
"github.com/cirruslabs/orchard/internal/worker/ondiskname"
20+
"github.com/cirruslabs/orchard/internal/worker/vmmanager"
1921
"github.com/cirruslabs/orchard/internal/worker/vmmanager/tart"
2022
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
2123
"github.com/google/uuid"
@@ -34,20 +36,14 @@ func TestSingleVM(t *testing.T) {
3436
t.Fatal(err)
3537
}
3638
assert.Equal(t, 1, len(workers))
37-
err = devClient.VMs().Create(context.Background(), &v1.VM{
38-
Meta: v1.Meta{
39-
Name: "test-vm",
40-
},
41-
Image: imageconstant.DefaultMacosImage,
42-
CPU: 4,
43-
Memory: 8 * 1024,
44-
Headless: true,
45-
Status: v1.VMStatusPending,
46-
StartupScript: &v1.VMScript{
47-
ScriptContent: "echo \"Hello, $FOO!\"\nfor i in $(seq 1 1000); do echo \"$i\"; done",
48-
Env: map[string]string{"FOO": "Bar"},
49-
},
50-
})
39+
40+
vm := platformdependent.VM("test-vm")
41+
vm.StartupScript = &v1.VMScript{
42+
ScriptContent: "echo \"Hello, $FOO!\"\nfor i in $(seq 1 1000); do echo \"$i\"; done",
43+
Env: map[string]string{"FOO": "Bar"},
44+
}
45+
46+
err = devClient.VMs().Create(context.Background(), vm)
5147
if err != nil {
5248
t.Fatal(err)
5349
}
@@ -82,7 +78,7 @@ func TestSingleVM(t *testing.T) {
8278
assert.Contains(t, strings.Join(logLines, "\n"), strings.Join(expectedLogs, "\n"))
8379

8480
// Ensure that the VM exists on disk before deleting it
85-
require.True(t, hasVMByPredicate(t, func(info tart.VMInfo) bool {
81+
require.True(t, hasVMByPredicate(t, func(info vmmanager.VMInfo) bool {
8682
return strings.Contains(info.Name, runningVM.UID)
8783
}, nil))
8884

@@ -93,7 +89,7 @@ func TestSingleVM(t *testing.T) {
9389
assert.True(t, wait.Wait(2*time.Minute, func() bool {
9490
t.Logf("Waiting for the VM to be garbage collected...")
9591

96-
return !hasVMByPredicate(t, func(info tart.VMInfo) bool {
92+
return !hasVMByPredicate(t, func(info vmmanager.VMInfo) bool {
9793
return strings.Contains(info.Name, runningVM.UID)
9894
}, nil)
9995
}), "VM was not garbage collected in a timely manner")
@@ -107,19 +103,13 @@ func TestFailedStartupScript(t *testing.T) {
107103
t.Fatal(err)
108104
}
109105
assert.Equal(t, 1, len(workers))
110-
err = devClient.VMs().Create(context.Background(), &v1.VM{
111-
Meta: v1.Meta{
112-
Name: "test-vm",
113-
},
114-
Image: imageconstant.DefaultMacosImage,
115-
CPU: 4,
116-
Memory: 8 * 1024,
117-
Headless: true,
118-
Status: v1.VMStatusPending,
119-
StartupScript: &v1.VMScript{
120-
ScriptContent: "exit 123",
121-
},
122-
})
106+
107+
vm := platformdependent.VM("test-vm")
108+
vm.StartupScript = &v1.VMScript{
109+
ScriptContent: "exit 123",
110+
}
111+
112+
err = devClient.VMs().Create(context.Background(), vm)
123113
if err != nil {
124114
t.Fatal(err)
125115
}
@@ -145,15 +135,7 @@ func TestPortForwarding(t *testing.T) {
145135
devClient, _, _ := devcontroller.StartIntegrationTestEnvironment(t)
146136

147137
// Create a generic macOS VM
148-
err := devClient.VMs().Create(ctx, &v1.VM{
149-
Meta: v1.Meta{
150-
Name: "test-vm",
151-
},
152-
Image: imageconstant.DefaultMacosImage,
153-
CPU: 4,
154-
Memory: 8 * 1024,
155-
Headless: true,
156-
})
138+
err := devClient.VMs().Create(ctx, platformdependent.VM("test-vm"))
157139
require.NoError(t, err)
158140

159141
// Establish port forwarding to VMs SSH port
@@ -468,12 +450,12 @@ func TestHostDirsInvalidPolicy(t *testing.T) {
468450
}
469451

470452
func hasVM(t *testing.T, name string, logger *zap.Logger) bool {
471-
return hasVMByPredicate(t, func(vmInfo tart.VMInfo) bool {
453+
return hasVMByPredicate(t, func(vmInfo vmmanager.VMInfo) bool {
472454
return vmInfo.Name == name
473455
}, logger)
474456
}
475457

476-
func hasVMByPredicate(t *testing.T, predicate func(tart.VMInfo) bool, logger *zap.Logger) bool {
458+
func hasVMByPredicate(t *testing.T, predicate func(vmmanager.VMInfo) bool, logger *zap.Logger) bool {
477459
if logger == nil {
478460
logger = zap.Must(zap.NewDevelopment())
479461
}

0 commit comments

Comments
 (0)