Skip to content
Open
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
5 changes: 5 additions & 0 deletions cmd/api/api/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ func (s *ApiService) CpHandler(w http.ResponseWriter, r *http.Request) {
return
}

if reason := inst.GuestOperationUnsupportedReason(instances.GuestOpFileCopy); reason != "" {
writeGuestUnsupported(w, reason)
return
}

if inst.State != instances.StateRunning {
http.Error(w, fmt.Sprintf(`{"code":"invalid_state","message":"instance must be running (current state: %s)"}`, inst.State), http.StatusConflict)
return
Expand Down
21 changes: 19 additions & 2 deletions cmd/api/api/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) {
return
}

if inst.MacOS != nil {
http.Error(w, `{"code":"unsupported","message":"exec is not implemented for experimental macOS instances"}`, http.StatusNotImplemented)
if reason := inst.GuestOperationUnsupportedReason(instances.GuestOpExec); reason != "" {
writeGuestUnsupported(w, reason)
return
}

Expand Down Expand Up @@ -151,6 +151,14 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) {

// Create WebSocket read/writer wrapper that handles resize messages
wsConn := &wsReadWriter{ws: ws, ctx: ctx, resizeChan: resizeChan}
if inst.MacOS != nil {
// The macOS guest agent cancels a command when its stream ends, so a lost
// websocket ends the session instead of leaving the command running.
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
defer cancel()
wsConn.ctx, wsConn.cancel = ctx, cancel
}

dialer, err := s.InstanceManager.GetVsockDialer(ctx, inst.Id)
if err != nil {
Expand Down Expand Up @@ -221,6 +229,7 @@ type wsReadWriter struct {
reader io.Reader
mu sync.Mutex
resizeChan chan<- *guest.WindowSize // Channel to send resize events (nil if not TTY)
cancel context.CancelFunc // ends a macOS exec session when the websocket fails
}

func (w *wsReadWriter) Read(p []byte) (n int, err error) {
Expand All @@ -241,6 +250,7 @@ func (w *wsReadWriter) Read(p []byte) (n int, err error) {
// Read next WebSocket message
messageType, data, err := w.ws.ReadMessage()
if err != nil {
w.cancelSession()
return 0, err
}

Expand Down Expand Up @@ -273,7 +283,14 @@ func (w *wsReadWriter) Read(p []byte) (n int, err error) {

func (w *wsReadWriter) Write(p []byte) (n int, err error) {
if err := w.ws.WriteMessage(websocket.BinaryMessage, p); err != nil {
w.cancelSession()
return 0, err
}
return len(p), nil
}

func (w *wsReadWriter) cancelSession() {
if w.cancel != nil {
w.cancel()
}
}
45 changes: 45 additions & 0 deletions cmd/api/api/exec_disconnect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/gorilla/websocket"
"github.com/stretchr/testify/require"
)

func TestExecWebsocketDisconnectCancelsSession(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
readDone := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
readDone <- err
return
}
defer conn.Close()
wrapped := &wsReadWriter{ws: conn, ctx: ctx, cancel: cancel}
_, err = wrapped.Read(make([]byte, 1))
readDone <- err
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
require.NoError(t, err)
require.NoError(t, conn.Close())
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
t.Fatal("WebSocket disconnect did not cancel exec session")
}
select {
case err := <-readDone:
require.Error(t, err)
case <-time.After(5 * time.Second):
t.Fatal("WebSocket reader did not exit")
}
}
13 changes: 13 additions & 0 deletions cmd/api/api/guest_unsupported.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package api

import (
"encoding/json"
"net/http"
)

// writeGuestUnsupported writes the shared 501 "unsupported" body for raw HTTP handlers.
func writeGuestUnsupported(w http.ResponseWriter, reason string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
_ = json.NewEncoder(w).Encode(map[string]string{"code": "unsupported", "message": reason})
}
7 changes: 2 additions & 5 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -988,11 +988,8 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst
}, nil
}

if inst.MacOS != nil {
return oapi.StatInstancePath501JSONResponse{
Code: "unsupported",
Message: "stat is not implemented for experimental macOS instances",
}, nil
if reason := inst.GuestOperationUnsupportedReason(instances.GuestOpStat); reason != "" {
return oapi.StatInstancePath501JSONResponse{Code: "unsupported", Message: reason}, nil
}

if inst.State != instances.StateRunning {
Expand Down
36 changes: 33 additions & 3 deletions cmd/api/api/macos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,50 @@ func TestMacOSExecRejectedBeforeWebsocketUpgrade(t *testing.T) {
w := httptest.NewRecorder()
s.ExecHandler(w, r)
require.Equal(t, http.StatusNotImplemented, w.Code)
require.Contains(t, w.Body.String(), "not implemented")
require.Contains(t, w.Body.String(), "requires the shared guest agent")
}

func TestMacOSStatRejectedBeforeGuestDial(t *testing.T) {
func TestMacOSStatRequiresGuestAgentBeforeDial(t *testing.T) {
s := &ApiService{}
inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{MacOS: &images.MacOSImage{}}, State: instances.StateRunning}
ctx := mw.WithResolvedInstance(context.Background(), "test", inst)
resp, err := s.StatInstancePath(ctx, oapi.StatInstancePathRequestObject{Id: "test"})
require.NoError(t, err)
unsupported, ok := resp.(oapi.StatInstancePath501JSONResponse)
require.True(t, ok, "macOS stat must be rejected with 501, got %T", resp)
require.True(t, ok, "macOS stat without the agent must be rejected with 501, got %T", resp)
require.Equal(t, "unsupported", unsupported.Code)
}

func TestMacOSGuestAgentAdmissionBeforeUpgrade(t *testing.T) {
for _, tc := range []struct {
name string
declared, skipped bool
status int
}{
{"unmanaged", false, false, http.StatusNotImplemented},
{"disabled", true, true, http.StatusNotImplemented},
{"enabled but stopped", true, false, http.StatusConflict},
} {
for _, operation := range []string{"exec", "cp"} {
t.Run(tc.name+"/"+operation, func(t *testing.T) {
s := &ApiService{}
inst := &instances.Instance{StoredMetadata: instances.StoredMetadata{
MacOS: &images.MacOSImage{GuestAgent: tc.declared}, SkipGuestAgent: tc.skipped,
}, State: instances.StateStopped}
ctx := mw.WithResolvedInstance(context.Background(), "test", inst)
r := httptest.NewRequest(http.MethodGet, "/instances/test/"+operation, nil).WithContext(ctx)
w := httptest.NewRecorder()
if operation == "exec" {
s.ExecHandler(w, r)
} else {
s.CpHandler(w, r)
}
require.Equal(t, tc.status, w.Code)
})
}
}
}

func TestLinuxCreateDefaultsMatchSchema(t *testing.T) {
spec, err := oapi.GetSwagger()
require.NoError(t, err)
Expand Down
3 changes: 2 additions & 1 deletion cmd/vz-shim/macos_arm64.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func configureMacPlatform(vc *vz.VirtualMachineConfiguration, c *shimconfig.Shim
if e != nil {
return e
}
display, e := vz.NewMacGraphicsDisplayConfiguration(1280, 800, 80)
// 3024x1964 pixels at 2x: 1512x982 points, the logical size of a 14-inch MacBook Pro.
display, e := vz.NewMacGraphicsDisplayConfiguration(3024, 1964, 160)
if e != nil {
return e
}
Expand Down
16 changes: 16 additions & 0 deletions cmd/vz-shim/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func (s *ShimServer) Handler() http.Handler {
mux.HandleFunc("PUT /api/v1/vm.resume", s.handleResume)
mux.HandleFunc("PUT /api/v1/vm.shutdown", s.handleShutdown)
mux.HandleFunc("PUT /api/v1/vm.snapshot", s.handleSnapshot)
mux.HandleFunc("GET /api/v1/vm.snapshot-support", s.handleSnapshotSupport)
mux.HandleFunc("GET /api/v1/vm.balloon", s.handleGetBalloon)
mux.HandleFunc("PUT /api/v1/vm.balloon", s.handleSetBalloon)
mux.HandleFunc("PUT /api/v1/vm.power-button", s.handlePowerButton)
Expand Down Expand Up @@ -138,6 +139,21 @@ func (s *ShimServer) handleShutdown(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}

func (s *ShimServer) handleSnapshotSupport(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
response := struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
}{Supported: true}
if err := validateSaveRestoreSupport(s.vmConfig); err != nil {
response.Supported = false
response.Reason = err.Error()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

func (s *ShimServer) handleSnapshot(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down
62 changes: 51 additions & 11 deletions docs/macos-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,44 @@ go run -tags containers_image_openpgp ./cmd/import-macos \
--name localhost/macos:spike
```

The data directory needs APFS clonefile support, and a path short enough that
each guest's sockets fit the 103-byte macOS limit; a boot that would exceed it is
refused. Images are `darwin/arm64`, not
OCI macOS containers. Registry pulls, manifest resolution and tagging/promotion
of macOS images are unsupported. Do not mutate the imported image files.
The local importer needs APFS clonefile support. The data directory path must be
short enough that each guest's sockets fit the 103-byte macOS limit; a boot that
would exceed it is refused. Images are `darwin/arm64` machine images, not OCI
macOS containers. Registry pulls also support experimental complete-machine
bundles described below. Additional tags within the same
repository are supported; cross-repository promotion remains unsupported.
Do not mutate the imported image files.

## Experimental OCI bundles

The normal image API accepts a `darwin/arm64` OCI manifest whose configuration
labels describe a complete installed machine:

| Label | Value |
|---|---|
| `io.hypeman.machine-image.version` | `1` |
| `io.hypeman.machine-image.kind` | `macos-image` |
| `io.hypeman.machine-image.disk-format` | `raw` |
| `io.hypeman.machine-image.disk-path` | Relative installed boot disk path |
| `io.hypeman.machine-image.aux-path` | Relative auxiliary storage path |
| `io.hypeman.machine-image.platform-path` | Relative macOS platform configuration JSON path |

All three files must be distinct, nonempty regular files inside the unpacked
image. Platform configuration uses the local bundle's `MacOSImage` schema and
is limited to 64 KiB. Absolute paths, traversal and symlink escapes are rejected.
The installed disk is materialized directly rather than converted to a Linux
root filesystem. Machine layers are unpacked sparsely (zero chunks become holes),
accept only regular files (not GNU sparse tar entries) and directories, and are
checked against their diff IDs, so a mostly empty boot disk occupies only its
data. A bundle's layers may decompress to at most 1 TiB in total. The boot disk
is stored as `rootfs.raw`, as for a local import; machine images pulled before
that naming must be deleted and pulled again. The matching auxiliary storage and platform
identity are retained. Do not package live mutable guest storage.

This schema is experimental pending reconciliation with other machine-image
platforms. Complete bundles only: no base/delta chain, identity rebinding, or
concurrent-template guarantee. Ordinary OCI transport does not imply sparse
distribution efficiency, boot portability, or container execution semantics.

## API

Expand All @@ -40,13 +73,19 @@ Use the usual JWT-authenticated API with a VZ-capable, signed build:
```

CPU and memory default to the imported template (minimum 2 CPUs and 4GB).
Each instance gets its own writable complete boot disk and auxiliary storage;
no Linux kernel, initrd, configuration disk, overlay filesystem or balloon is
Each instance gets its own writable complete boot disk (`boot.raw`) and auxiliary
storage; instances created before that naming must be deleted and recreated. No
Linux kernel, initrd, configuration disk, overlay filesystem or balloon is
attached. NAT uses the preserved MAC. The IP is observed from the host's VZ
DHCP leases, which may retain an old lease before the guest is ready.

**`Running` means the VMM is running, not that SSH, the desktop or Chrome is
ready.** Readiness is currently tested over SSH. Startup does not launch Chrome.
**Without the guest agent, `Running` means the VMM is running, not that SSH,
the desktop or Chrome is ready.** Unmanaged templates still need an external
readiness check. Templates provisioned with the shared Darwin GuestService can opt
in with `guest_agent: true` in their platform configuration; those instances stay
`Initializing` until the agent answers, as Linux guests do, so waiting for
`Running` waits for the agent. See [Darwin GuestService](macos-guest-agent.md).
Startup does not launch Chrome.
A shim killed without cleanup (for example, SIGKILL) leaves its control socket
behind; when nothing accepts on it and the recorded shim is gone, the instance
reads as Stopped and its resources are released for admission. If the API dies
Expand All @@ -58,8 +97,9 @@ instance with a given Mac identifier is allowed. Do not run the source bundle
or an external clone concurrently. Production provisioning needs identity and
credential rekeying; this spike is not a multi-tenant image format.

`POST /instances/{id}/stop` shuts down the VMM; without a Darwin guest agent,
it does **not** guarantee an orderly guest OS/application shutdown. Use a human
`POST /instances/{id}/stop` attempts orderly GuestService shutdown for opted-in
images, then falls back to VMM shutdown if necessary. Without an enabled Darwin
guest agent it does **not** guarantee an orderly guest OS/application shutdown. Use a human
or an authorized guest shutdown workflow before destructive operations when
application consistency matters. Start cold-boots the instance's existing disk.
Delete removes instance storage, not the imported image. Deleting the imported
Expand Down
Loading
Loading