diff --git a/.gitignore b/.gitignore index d64bbf0a..e76a3b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ lib/vmm/binaries/cloud-hypervisor/*/*/cloud-hypervisor cloud-hypervisor cloud-hypervisor/** lib/system/exec_agent/exec-agent +lib/system/guest_agent/guest-agent +lib/system/guest_agent/guest_agent # Envoy binaries lib/ingress/binaries/** diff --git a/Makefile b/Makefile index 58e67156..4925096f 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,7 @@ generate-grpc: @echo "Generating gRPC code from proto..." protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ - lib/exec/exec.proto + lib/guest/guest.proto # Generate all code generate-all: oapi-generate generate-vmm-client generate-wire generate-grpc @@ -135,7 +135,15 @@ generate-all: oapi-generate generate-vmm-client generate-wire generate-grpc # Check if CH binaries exist, download if missing .PHONY: ensure-ch-binaries ensure-ch-binaries: - @if [ ! -f lib/vmm/binaries/cloud-hypervisor/v48.0/x86_64/cloud-hypervisor ]; then \ + @ARCH=$$(uname -m); \ + if [ "$$ARCH" = "x86_64" ]; then \ + CH_ARCH=x86_64; \ + elif [ "$$ARCH" = "aarch64" ] || [ "$$ARCH" = "arm64" ]; then \ + CH_ARCH=aarch64; \ + else \ + echo "Unsupported architecture: $$ARCH"; exit 1; \ + fi; \ + if [ ! -f lib/vmm/binaries/cloud-hypervisor/v48.0/$$CH_ARCH/cloud-hypervisor ]; then \ echo "Cloud Hypervisor binaries not found, downloading..."; \ $(MAKE) download-ch-binaries; \ fi @@ -156,27 +164,27 @@ ensure-caddy-binaries: $(MAKE) build-caddy; \ fi -# Build exec-agent (guest binary) into its own directory for embedding -lib/system/exec_agent/exec-agent: lib/system/exec_agent/main.go - @echo "Building exec-agent..." - cd lib/system/exec_agent && CGO_ENABLED=0 go build -ldflags="-s -w" -o exec-agent . +# Build guest-agent (guest binary) into its own directory for embedding +lib/system/guest_agent/guest-agent: lib/system/guest_agent/main.go + @echo "Building guest-agent..." + cd lib/system/guest_agent && CGO_ENABLED=0 go build -ldflags="-s -w" -o guest-agent . # Build the binary -build: ensure-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent | $(BIN_DIR) +build: ensure-ch-binaries ensure-caddy-binaries lib/system/guest_agent/guest-agent | $(BIN_DIR) go build -tags containers_image_openpgp -o $(BIN_DIR)/hypeman ./cmd/api # Build all binaries build-all: build # Run in development mode with hot reload -dev: ensure-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent $(AIR) +dev: ensure-ch-binaries ensure-caddy-binaries lib/system/guest_agent/guest-agent $(AIR) @rm -f ./tmp/main $(AIR) -c .air.toml # Run tests (as root for network capabilities, enables caching and parallelism) # Usage: make test - runs all tests # make test TEST=TestCreateInstanceWithNetwork - runs specific test -test: ensure-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent +test: ensure-ch-binaries ensure-caddy-binaries lib/system/guest_agent/guest-agent @if [ -n "$(TEST)" ]; then \ echo "Running specific test: $(TEST)"; \ sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp -run=$(TEST) -v -timeout=180s ./...; \ @@ -194,9 +202,9 @@ clean: rm -rf $(BIN_DIR) rm -rf lib/vmm/binaries/cloud-hypervisor/ rm -rf lib/ingress/binaries/ - rm -f lib/system/exec_agent/exec-agent + rm -f lib/system/guest_agent/guest-agent # Prepare for release build (called by GoReleaser) # Downloads all embedded binaries and builds embedded components -release-prep: download-ch-binaries build-caddy-binaries lib/system/exec_agent/exec-agent +release-prep: download-ch-binaries build-caddy-binaries lib/system/guest_agent/guest-agent go mod tidy diff --git a/cmd/api/api/cp.go b/cmd/api/api/cp.go new file mode 100644 index 00000000..b611f64e --- /dev/null +++ b/cmd/api/api/cp.go @@ -0,0 +1,409 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/onkernel/hypeman/lib/guest" + "github.com/onkernel/hypeman/lib/instances" + "github.com/onkernel/hypeman/lib/logger" + mw "github.com/onkernel/hypeman/lib/middleware" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// cpErrorSent wraps an error that has already been sent to the client. +// The caller should log this error but not send it again to avoid duplicates. +type cpErrorSent struct { + err error +} + +func (e *cpErrorSent) Error() string { return e.err.Error() } +func (e *cpErrorSent) Unwrap() error { return e.err } + +// CpRequest represents the JSON body for copy requests +type CpRequest struct { + // Direction: "to" copies from client to guest, "from" copies from guest to client + Direction string `json:"direction"` + // Path in the guest filesystem + GuestPath string `json:"guest_path"` + // IsDir indicates if the source is a directory (for "to" direction) + IsDir bool `json:"is_dir,omitempty"` + // Mode is the file mode/permissions (for "to" direction, optional) + Mode uint32 `json:"mode,omitempty"` + // FollowLinks follows symbolic links (for "from" direction) + FollowLinks bool `json:"follow_links,omitempty"` + // SrcBasename is the source file/dir basename (for "to" direction, used for path resolution) + SrcBasename string `json:"src_basename,omitempty"` + // Uid is the user ID (archive mode, for "to" direction) + Uid uint32 `json:"uid,omitempty"` + // Gid is the group ID (archive mode, for "to" direction) + Gid uint32 `json:"gid,omitempty"` +} + +// CpFileHeader is sent before file data in WebSocket protocol +type CpFileHeader struct { + Type string `json:"type"` // "header" + Path string `json:"path"` + Mode uint32 `json:"mode"` + IsDir bool `json:"is_dir"` + IsSymlink bool `json:"is_symlink,omitempty"` + LinkTarget string `json:"link_target,omitempty"` + Size int64 `json:"size"` + Mtime int64 `json:"mtime"` + Uid uint32 `json:"uid,omitempty"` + Gid uint32 `json:"gid,omitempty"` +} + +// CpEndMarker signals end of file or transfer +type CpEndMarker struct { + Type string `json:"type"` // "end" + Final bool `json:"final"` +} + +// CpError reports an error +type CpError struct { + Type string `json:"type"` // "error" + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +// CpResult reports the result of a copy-to operation +type CpResult struct { + Type string `json:"type"` // "result" + Success bool `json:"success"` + Error string `json:"error,omitempty"` + BytesWritten int64 `json:"bytes_written,omitempty"` +} + +// CpHandler handles file copy requests via WebSocket +func (s *ApiService) CpHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + startTime := time.Now() + log := logger.FromContext(ctx) + + // Get instance resolved by middleware + inst := mw.GetResolvedInstance[instances.Instance](ctx) + if inst == nil { + http.Error(w, `{"code":"internal_error","message":"resource not resolved"}`, http.StatusInternalServerError) + 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 + } + + // Upgrade to WebSocket + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.ErrorContext(ctx, "websocket upgrade failed", "error", err) + return + } + defer ws.Close() + + // Read JSON request from first WebSocket message + msgType, message, err := ws.ReadMessage() + if err != nil { + log.ErrorContext(ctx, "failed to read cp request", "error", err) + errMsg, _ := json.Marshal(CpError{Type: "error", Message: fmt.Sprintf("failed to read request: %v", err)}) + ws.WriteMessage(websocket.TextMessage, errMsg) + return + } + + if msgType != websocket.TextMessage { + log.ErrorContext(ctx, "expected text message with JSON request", "type", msgType) + errMsg, _ := json.Marshal(CpError{Type: "error", Message: "first message must be JSON text"}) + ws.WriteMessage(websocket.TextMessage, errMsg) + return + } + + // Parse JSON request + var cpReq CpRequest + if err := json.Unmarshal(message, &cpReq); err != nil { + log.ErrorContext(ctx, "invalid JSON request", "error", err) + errMsg, _ := json.Marshal(CpError{Type: "error", Message: fmt.Sprintf("invalid JSON: %v", err)}) + ws.WriteMessage(websocket.TextMessage, errMsg) + return + } + + // Get JWT subject for audit logging + subject := "unknown" + if claims, ok := r.Context().Value("claims").(map[string]interface{}); ok { + if sub, ok := claims["sub"].(string); ok { + subject = sub + } + } + + // Start OTEL span for tracing (WebSocket bypasses otelchi middleware) + tracer := otel.Tracer("hypeman/cp") + ctx, span := tracer.Start(ctx, "cp.session", + trace.WithAttributes( + attribute.String("instance_id", inst.Id), + attribute.String("direction", cpReq.Direction), + attribute.String("guest_path", cpReq.GuestPath), + attribute.String("subject", subject), + ), + ) + defer span.End() + + log.InfoContext(ctx, "cp session started", + "instance_id", inst.Id, + "subject", subject, + "direction", cpReq.Direction, + "guest_path", cpReq.GuestPath, + ) + + var cpErr error + var bytesTransferred int64 + switch cpReq.Direction { + case "to": + bytesTransferred, cpErr = s.handleCopyTo(ctx, ws, inst, cpReq) + case "from": + bytesTransferred, cpErr = s.handleCopyFrom(ctx, ws, inst, cpReq) + default: + cpErr = fmt.Errorf("invalid direction: %s (must be 'to' or 'from')", cpReq.Direction) + } + + duration := time.Since(startTime) + success := cpErr == nil + + // Record metrics + if guest.GuestMetrics != nil { + guest.GuestMetrics.RecordCpSession(ctx, startTime, cpReq.Direction, success, bytesTransferred) + } + + // Update span with result + span.SetAttributes( + attribute.Int64("bytes_transferred", bytesTransferred), + attribute.Bool("success", success), + ) + + if cpErr != nil { + span.RecordError(cpErr) + span.SetStatus(codes.Error, cpErr.Error()) + log.ErrorContext(ctx, "cp failed", + "error", cpErr, + "instance_id", inst.Id, + "subject", subject, + "duration_ms", duration.Milliseconds(), + ) + // Only send error message if it hasn't already been sent to the client + var sentErr *cpErrorSent + if !errors.As(cpErr, &sentErr) { + errMsg, _ := json.Marshal(CpError{Type: "error", Message: cpErr.Error()}) + ws.WriteMessage(websocket.TextMessage, errMsg) + } + return + } + + span.SetStatus(codes.Ok, "") + log.InfoContext(ctx, "cp session ended", + "instance_id", inst.Id, + "subject", subject, + "direction", cpReq.Direction, + "duration_ms", duration.Milliseconds(), + "bytes_transferred", bytesTransferred, + ) +} + +// handleCopyTo handles copying files from client to guest +// Returns the number of bytes transferred and any error. +func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst *instances.Instance, req CpRequest) (int64, error) { + grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket) + if err != nil { + return 0, fmt.Errorf("get grpc connection: %w", err) + } + + client := guest.NewGuestServiceClient(grpcConn) + stream, err := client.CopyToGuest(ctx) + if err != nil { + return 0, fmt.Errorf("start copy stream: %w", err) + } + + // Send start message + mode := req.Mode + if mode == 0 { + mode = 0644 + if req.IsDir { + mode = 0755 + } + } + + if err := stream.Send(&guest.CopyToGuestRequest{ + Request: &guest.CopyToGuestRequest_Start{ + Start: &guest.CopyToGuestStart{ + Path: req.GuestPath, + Mode: mode, + IsDir: req.IsDir, + Uid: req.Uid, + Gid: req.Gid, + }, + }, + }); err != nil { + return 0, fmt.Errorf("send start: %w", err) + } + + // Read data chunks from WebSocket and forward to guest + var receivedEndMessage bool + var bytesSent int64 + for { + msgType, data, err := ws.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + break + } + return bytesSent, fmt.Errorf("read websocket: %w", err) + } + + if msgType == websocket.TextMessage { + // Check for end message + var msg map[string]interface{} + if json.Unmarshal(data, &msg) == nil { + if msg["type"] == "end" { + receivedEndMessage = true + break + } + } + } else if msgType == websocket.BinaryMessage { + // Forward data chunk to guest + if err := stream.Send(&guest.CopyToGuestRequest{ + Request: &guest.CopyToGuestRequest_Data{Data: data}, + }); err != nil { + return bytesSent, fmt.Errorf("send data: %w", err) + } + bytesSent += int64(len(data)) + } + } + + // If the WebSocket closed without receiving an end message, the transfer is incomplete + if !receivedEndMessage { + return bytesSent, fmt.Errorf("client disconnected before completing transfer") + } + + // Send end message to guest + if err := stream.Send(&guest.CopyToGuestRequest{ + Request: &guest.CopyToGuestRequest_End{End: &guest.CopyToGuestEnd{}}, + }); err != nil { + return bytesSent, fmt.Errorf("send end: %w", err) + } + + // Get response + resp, err := stream.CloseAndRecv() + if err != nil { + return bytesSent, fmt.Errorf("close stream: %w", err) + } + + // Send result to client + result := CpResult{ + Type: "result", + Success: resp.Success, + Error: resp.Error, + BytesWritten: resp.BytesWritten, + } + resultJSON, _ := json.Marshal(result) + ws.WriteMessage(websocket.TextMessage, resultJSON) + + if !resp.Success { + // Return a wrapped error so the caller logs it correctly but doesn't send a duplicate + return resp.BytesWritten, &cpErrorSent{err: fmt.Errorf("copy to guest failed: %s", resp.Error)} + } + return resp.BytesWritten, nil +} + +// handleCopyFrom handles copying files from guest to client +// Returns the number of bytes transferred and any error. +func (s *ApiService) handleCopyFrom(ctx context.Context, ws *websocket.Conn, inst *instances.Instance, req CpRequest) (int64, error) { + grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket) + if err != nil { + return 0, fmt.Errorf("get grpc connection: %w", err) + } + + client := guest.NewGuestServiceClient(grpcConn) + stream, err := client.CopyFromGuest(ctx, &guest.CopyFromGuestRequest{ + Path: req.GuestPath, + FollowLinks: req.FollowLinks, + }) + if err != nil { + return 0, fmt.Errorf("start copy stream: %w", err) + } + + var receivedFinal bool + var bytesReceived int64 + + // Stream responses to WebSocket client + for { + resp, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return bytesReceived, fmt.Errorf("receive: %w", err) + } + + switch r := resp.Response.(type) { + case *guest.CopyFromGuestResponse_Header: + header := CpFileHeader{ + Type: "header", + Path: r.Header.Path, + Mode: r.Header.Mode, + IsDir: r.Header.IsDir, + IsSymlink: r.Header.IsSymlink, + LinkTarget: r.Header.LinkTarget, + Size: r.Header.Size, + Mtime: r.Header.Mtime, + Uid: r.Header.Uid, + Gid: r.Header.Gid, + } + headerJSON, _ := json.Marshal(header) + if err := ws.WriteMessage(websocket.TextMessage, headerJSON); err != nil { + return bytesReceived, fmt.Errorf("write header: %w", err) + } + + case *guest.CopyFromGuestResponse_Data: + if err := ws.WriteMessage(websocket.BinaryMessage, r.Data); err != nil { + return bytesReceived, fmt.Errorf("write data: %w", err) + } + bytesReceived += int64(len(r.Data)) + + case *guest.CopyFromGuestResponse_End: + endMarker := CpEndMarker{ + Type: "end", + Final: r.End.Final, + } + endJSON, _ := json.Marshal(endMarker) + if err := ws.WriteMessage(websocket.TextMessage, endJSON); err != nil { + return bytesReceived, fmt.Errorf("write end: %w", err) + } + if r.End.Final { + receivedFinal = true + return bytesReceived, nil + } + + case *guest.CopyFromGuestResponse_Error: + cpErr := CpError{ + Type: "error", + Message: r.Error.Message, + Path: r.Error.Path, + } + errJSON, _ := json.Marshal(cpErr) + ws.WriteMessage(websocket.TextMessage, errJSON) + // Return a wrapped error so the caller logs it correctly but doesn't send a duplicate + return bytesReceived, &cpErrorSent{err: fmt.Errorf("copy from guest failed: %s", r.Error.Message)} + } + } + + if !receivedFinal { + return bytesReceived, fmt.Errorf("copy stream ended without completion marker") + } + return bytesReceived, nil +} + diff --git a/cmd/api/api/cp_test.go b/cmd/api/api/cp_test.go new file mode 100644 index 00000000..3278852e --- /dev/null +++ b/cmd/api/api/cp_test.go @@ -0,0 +1,269 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/onkernel/hypeman/lib/guest" + "github.com/onkernel/hypeman/lib/oapi" + "github.com/onkernel/hypeman/lib/paths" + "github.com/onkernel/hypeman/lib/system" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCpToAndFromInstance(t *testing.T) { + // Require KVM access for VM creation + if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) { + t.Fatal("/dev/kvm not available - ensure KVM is enabled and user is in 'kvm' group (sudo usermod -aG kvm $USER)") + } + + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + svc := newTestService(t) + + // Ensure system files (kernel and initrd) are available + t.Log("Ensuring system files...") + systemMgr := system.NewManager(paths.New(svc.Config.DataDir)) + err := systemMgr.EnsureSystemFiles(ctx()) + require.NoError(t, err) + t.Log("System files ready") + + // Create and wait for nginx image (has a long-running process) + createAndWaitForImage(t, svc, "docker.io/library/nginx:alpine", 30*time.Second) + + // Create instance + t.Log("Creating instance...") + networkEnabled := false + instResp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{ + Body: &oapi.CreateInstanceRequest{ + Name: "cp-test", + Image: "docker.io/library/nginx:alpine", + Network: &struct { + Enabled *bool `json:"enabled,omitempty"` + }{ + Enabled: &networkEnabled, + }, + }, + }) + require.NoError(t, err) + + inst, ok := instResp.(oapi.CreateInstance201JSONResponse) + require.True(t, ok, "expected 201 response") + require.NotEmpty(t, inst.Id) + t.Logf("Instance created: %s", inst.Id) + + // Wait for guest-agent to be ready + t.Log("Waiting for guest-agent to start...") + agentReady := false + agentTimeout := time.After(15 * time.Second) + agentTicker := time.NewTicker(500 * time.Millisecond) + defer agentTicker.Stop() + + for !agentReady { + select { + case <-agentTimeout: + logs := collectTestLogs(t, svc, inst.Id, 200) + t.Logf("Console logs:\n%s", logs) + t.Fatal("Timeout waiting for guest-agent to start") + case <-agentTicker.C: + logs := collectTestLogs(t, svc, inst.Id, 100) + if strings.Contains(logs, "[guest-agent] listening on vsock port 2222") { + agentReady = true + t.Log("guest-agent is ready") + } + } + } + + // Get actual instance to access vsock fields + actualInst, err := svc.InstanceManager.GetInstance(ctx(), inst.Id) + require.NoError(t, err) + require.NotNil(t, actualInst) + + t.Logf("vsock CID: %d, socket: %s", actualInst.VsockCID, actualInst.VsockSocket) + + // Capture console log on failure + t.Cleanup(func() { + if t.Failed() { + consolePath := paths.New(svc.Config.DataDir).InstanceAppLog(inst.Id) + if consoleData, err := os.ReadFile(consolePath); err == nil { + lines := strings.Split(string(consoleData), "\n") + t.Logf("=== Guest Agent Logs ===") + for _, line := range lines { + if strings.Contains(line, "[guest-agent]") { + t.Logf("%s", line) + } + } + } + } + }) + + // Create a temporary file to copy + testContent := "Hello from hypeman cp test!\nLine 2\nLine 3\n" + srcFile := filepath.Join(t.TempDir(), "test-file.txt") + err = os.WriteFile(srcFile, []byte(testContent), 0644) + require.NoError(t, err) + + // Test 1: Copy file TO instance + t.Log("Testing CopyToInstance...") + dstPath := "/tmp/copied-file.txt" + err = guest.CopyToInstance(ctx(), actualInst.VsockSocket, guest.CopyToInstanceOptions{ + SrcPath: srcFile, + DstPath: dstPath, + }) + require.NoError(t, err, "CopyToInstance should succeed") + + // Verify the file was copied by reading it back via exec + t.Log("Verifying file was copied via exec...") + var stdout, stderr outputBuffer + exit, err := guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{ + Command: []string{"cat", dstPath}, + Stdout: &stdout, + Stderr: &stderr, + TTY: false, + }) + require.NoError(t, err) + require.Equal(t, 0, exit.Code, "cat should succeed") + assert.Equal(t, testContent, stdout.String(), "file content should match") + + // Test 2: Copy file FROM instance + t.Log("Testing CopyFromInstance...") + localDstDir := t.TempDir() + err = guest.CopyFromInstance(ctx(), actualInst.VsockSocket, guest.CopyFromInstanceOptions{ + SrcPath: dstPath, + DstPath: localDstDir, + }) + require.NoError(t, err, "CopyFromInstance should succeed") + + // Verify the file was copied back + copiedBack, err := os.ReadFile(filepath.Join(localDstDir, "copied-file.txt")) + require.NoError(t, err) + assert.Equal(t, testContent, string(copiedBack), "copied back content should match") + + t.Log("Cp tests passed!") +} + +func TestCpDirectoryToInstance(t *testing.T) { + // Require KVM access for VM creation + if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) { + t.Fatal("/dev/kvm not available - ensure KVM is enabled and user is in 'kvm' group (sudo usermod -aG kvm $USER)") + } + + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + svc := newTestService(t) + + // Ensure system files + t.Log("Ensuring system files...") + systemMgr := system.NewManager(paths.New(svc.Config.DataDir)) + err := systemMgr.EnsureSystemFiles(ctx()) + require.NoError(t, err) + + // Create and wait for nginx image (has a long-running process) + createAndWaitForImage(t, svc, "docker.io/library/nginx:alpine", 30*time.Second) + + // Create instance + t.Log("Creating instance...") + networkEnabled := false + instResp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{ + Body: &oapi.CreateInstanceRequest{ + Name: "cp-dir-test", + Image: "docker.io/library/nginx:alpine", + Network: &struct { + Enabled *bool `json:"enabled,omitempty"` + }{ + Enabled: &networkEnabled, + }, + }, + }) + require.NoError(t, err) + + inst, ok := instResp.(oapi.CreateInstance201JSONResponse) + require.True(t, ok, "expected 201 response") + t.Logf("Instance created: %s", inst.Id) + + // Wait for guest-agent + t.Log("Waiting for guest-agent...") + agentReady := false + agentTimeout := time.After(15 * time.Second) + agentTicker := time.NewTicker(500 * time.Millisecond) + defer agentTicker.Stop() + + for !agentReady { + select { + case <-agentTimeout: + t.Fatal("Timeout waiting for guest-agent") + case <-agentTicker.C: + logs := collectTestLogs(t, svc, inst.Id, 100) + if strings.Contains(logs, "[guest-agent] listening on vsock port 2222") { + agentReady = true + } + } + } + + actualInst, err := svc.InstanceManager.GetInstance(ctx(), inst.Id) + require.NoError(t, err) + + // Create a test directory structure + srcDir := filepath.Join(t.TempDir(), "testdir") + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("file1 content"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte("file2 content"), 0644)) + + // Copy directory to instance + t.Log("Copying directory to instance...") + err = guest.CopyToInstance(ctx(), actualInst.VsockSocket, guest.CopyToInstanceOptions{ + SrcPath: srcDir, + DstPath: "/tmp/testdir", + }) + require.NoError(t, err) + + // Verify files exist via exec + var stdout outputBuffer + exit, err := guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{ + Command: []string{"cat", "/tmp/testdir/file1.txt"}, + Stdout: &stdout, + TTY: false, + }) + require.NoError(t, err) + require.Equal(t, 0, exit.Code) + assert.Equal(t, "file1 content", stdout.String()) + + stdout = outputBuffer{} + exit, err = guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{ + Command: []string{"cat", "/tmp/testdir/subdir/file2.txt"}, + Stdout: &stdout, + TTY: false, + }) + require.NoError(t, err) + require.Equal(t, 0, exit.Code) + assert.Equal(t, "file2 content", stdout.String()) + + // Copy directory from instance + t.Log("Copying directory from instance...") + localDstDir := t.TempDir() + err = guest.CopyFromInstance(ctx(), actualInst.VsockSocket, guest.CopyFromInstanceOptions{ + SrcPath: "/tmp/testdir", + DstPath: localDstDir, + }) + require.NoError(t, err) + + // Verify files were copied back + content1, err := os.ReadFile(filepath.Join(localDstDir, "testdir", "file1.txt")) + require.NoError(t, err) + assert.Equal(t, "file1 content", string(content1)) + + content2, err := os.ReadFile(filepath.Join(localDstDir, "testdir", "subdir", "file2.txt")) + require.NoError(t, err) + assert.Equal(t, "file2 content", string(content2)) + + t.Log("Directory cp tests passed!") +} + diff --git a/cmd/api/api/exec.go b/cmd/api/api/exec.go index 9d529fc7..9c41197f 100644 --- a/cmd/api/api/exec.go +++ b/cmd/api/api/exec.go @@ -11,7 +11,7 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/instances" "github.com/onkernel/hypeman/lib/logger" mw "github.com/onkernel/hypeman/lib/middleware" @@ -111,7 +111,7 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) { wsConn := &wsReadWriter{ws: ws, ctx: ctx} // Execute via vsock - exit, err := exec.ExecIntoInstance(ctx, inst.VsockSocket, exec.ExecOptions{ + exit, err := guest.ExecIntoInstance(ctx, inst.VsockSocket, guest.ExecOptions{ Command: execReq.Command, Stdin: wsConn, Stdout: wsConn, diff --git a/cmd/api/api/exec_test.go b/cmd/api/api/exec_test.go index 219d2ea7..08ad1937 100644 --- a/cmd/api/api/exec_test.go +++ b/cmd/api/api/exec_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/instances" "github.com/onkernel/hypeman/lib/oapi" "github.com/onkernel/hypeman/lib/paths" @@ -89,17 +89,17 @@ func TestExecInstanceNonTTY(t *testing.T) { require.NotEmpty(t, actualInst.VsockSocket, "vsock socket path should be set") t.Logf("vsock CID: %d, socket: %s", actualInst.VsockCID, actualInst.VsockSocket) - // Capture console log on failure with exec-agent filtering + // Capture console log on failure with guest-agent filtering t.Cleanup(func() { if t.Failed() { consolePath := paths.New(svc.Config.DataDir).InstanceAppLog(inst.Id) if consoleData, err := os.ReadFile(consolePath); err == nil { lines := strings.Split(string(consoleData), "\n") - // Print exec-agent specific logs - t.Logf("=== Exec Agent Logs ===") + // Print guest-agent specific logs + t.Logf("=== Guest Agent Logs ===") for _, line := range lines { - if strings.Contains(line, "[exec-agent]") { + if strings.Contains(line, "[guest-agent]") { t.Logf("%s", line) } } @@ -115,7 +115,7 @@ func TestExecInstanceNonTTY(t *testing.T) { } // Wait for exec agent to be ready (retry a few times) - var exit *exec.ExitStatus + var exit *guest.ExitStatus var stdout, stderr outputBuffer var execErr error @@ -125,7 +125,7 @@ func TestExecInstanceNonTTY(t *testing.T) { stdout = outputBuffer{} stderr = outputBuffer{} - exit, execErr = exec.ExecIntoInstance(ctx(), actualInst.VsockSocket, exec.ExecOptions{ + exit, execErr = guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "whoami"}, Stdin: nil, Stdout: &stdout, @@ -164,7 +164,7 @@ func TestExecInstanceNonTTY(t *testing.T) { // TestExecWithDebianMinimal tests exec with a minimal Debian image. // This test specifically catches issues that wouldn't appear with Alpine-based images: // 1. Debian's default entrypoint (bash) exits immediately without a TTY -// 2. exec-agent must keep running even after the main app exits +// 2. guest-agent must keep running even after the main app exits // 3. The VM must not kernel panic when the entrypoint exits func TestExecWithDebianMinimal(t *testing.T) { // Require KVM access for VM creation @@ -220,9 +220,9 @@ func TestExecWithDebianMinimal(t *testing.T) { require.NoError(t, err) require.NotNil(t, actualInst) - // Wait for exec-agent to be ready by checking logs - // This is the key difference: we wait for exec-agent, not the app (which exits immediately) - t.Log("Waiting for exec-agent to start...") + // Wait for guest-agent to be ready by checking logs + // This is the key difference: we wait for guest-agent, not the app (which exits immediately) + t.Log("Waiting for guest-agent to start...") execAgentReady := false agentTimeout := time.After(15 * time.Second) agentTicker := time.NewTicker(500 * time.Millisecond) @@ -235,12 +235,12 @@ func TestExecWithDebianMinimal(t *testing.T) { // Dump logs on failure for debugging logs = collectTestLogs(t, svc, inst.Id, 200) t.Logf("Console logs:\n%s", logs) - t.Fatal("Timeout waiting for exec-agent to start") + t.Fatal("Timeout waiting for guest-agent to start") case <-agentTicker.C: logs = collectTestLogs(t, svc, inst.Id, 100) - if strings.Contains(logs, "[exec-agent] listening on vsock port 2222") { + if strings.Contains(logs, "[guest-agent] listening on vsock port 2222") { execAgentReady = true - t.Log("exec-agent is ready") + t.Log("guest-agent is ready") } } } @@ -252,7 +252,7 @@ func TestExecWithDebianMinimal(t *testing.T) { // Test exec commands work even though the main app (bash) has exited t.Log("Testing exec command: echo") var stdout, stderr outputBuffer - exit, err := exec.ExecIntoInstance(ctx(), actualInst.VsockSocket, exec.ExecOptions{ + exit, err := guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"echo", "hello from debian"}, Stdout: &stdout, Stderr: &stderr, @@ -266,7 +266,7 @@ func TestExecWithDebianMinimal(t *testing.T) { // Verify we're actually in Debian t.Log("Verifying OS release...") stdout = outputBuffer{} - exit, err = exec.ExecIntoInstance(ctx(), actualInst.VsockSocket, exec.ExecOptions{ + exit, err = guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"cat", "/etc/os-release"}, Stdout: &stdout, TTY: false, diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index acbd37c3..f01695db 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/c2h5oh/datasize" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/instances" "github.com/onkernel/hypeman/lib/logger" mw "github.com/onkernel/hypeman/lib/middleware" @@ -430,6 +431,73 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan return logsStreamResponse{logChan: logChan}, nil } +// StatInstancePath returns information about a path in the guest filesystem +// The id parameter can be an instance ID, name, or ID prefix +// Note: Resolution is handled by ResolveResource middleware +func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInstancePathRequestObject) (oapi.StatInstancePathResponseObject, error) { + log := logger.FromContext(ctx) + + inst := mw.GetResolvedInstance[instances.Instance](ctx) + if inst == nil { + return oapi.StatInstancePath500JSONResponse{ + Code: "internal_error", + Message: "resource not resolved", + }, nil + } + + if inst.State != instances.StateRunning { + return oapi.StatInstancePath409JSONResponse{ + Code: "invalid_state", + Message: fmt.Sprintf("instance must be running (current state: %s)", inst.State), + }, nil + } + + // Connect to guest agent + grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket) + if err != nil { + log.ErrorContext(ctx, "failed to get grpc connection", "error", err) + return oapi.StatInstancePath500JSONResponse{ + Code: "internal_error", + Message: "failed to connect to guest agent", + }, nil + } + + client := guest.NewGuestServiceClient(grpcConn) + followLinks := false + if request.Params.FollowLinks != nil { + followLinks = *request.Params.FollowLinks + } + + resp, err := client.StatPath(ctx, &guest.StatPathRequest{ + Path: request.Params.Path, + FollowLinks: followLinks, + }) + if err != nil { + log.ErrorContext(ctx, "stat path failed", "error", err, "path", request.Params.Path) + return oapi.StatInstancePath500JSONResponse{ + Code: "internal_error", + Message: "failed to stat path in guest", + }, nil + } + + // Convert types from protobuf to OAPI + mode := int(resp.Mode) + response := oapi.StatInstancePath200JSONResponse{ + Exists: resp.Exists, + IsDir: &resp.IsDir, + IsFile: &resp.IsFile, + IsSymlink: &resp.IsSymlink, + LinkTarget: &resp.LinkTarget, + Mode: &mode, + Size: &resp.Size, + } + // Include error message if stat failed (e.g., permission denied) + if resp.Error != "" { + response.Error = &resp.Error + } + return response, nil +} + // AttachVolume attaches a volume to an instance (not yet implemented) func (s *ApiService) AttachVolume(ctx context.Context, request oapi.AttachVolumeRequestObject) (oapi.AttachVolumeResponseObject, error) { return oapi.AttachVolume500JSONResponse{ diff --git a/cmd/api/main.go b/cmd/api/main.go index 48c9e312..5d003352 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -22,7 +22,7 @@ import ( "github.com/onkernel/hypeman" "github.com/onkernel/hypeman/cmd/api/api" "github.com/onkernel/hypeman/cmd/api/config" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/instances" mw "github.com/onkernel/hypeman/lib/middleware" "github.com/onkernel/hypeman/lib/oapi" @@ -72,11 +72,11 @@ func run() error { }() } - // Initialize exec and vmm metrics if OTel is enabled + // Initialize guest and vmm metrics if OTel is enabled if otelProvider != nil && otelProvider.Meter != nil { - execMetrics, err := exec.NewMetrics(otelProvider.Meter) + guestMetrics, err := guest.NewMetrics(otelProvider.Meter) if err == nil { - exec.SetMetrics(execMetrics) + guest.SetMetrics(guestMetrics) } vmmMetrics, err := vmm.NewMetrics(otelProvider.Meter) if err == nil { @@ -234,6 +234,17 @@ func run() error { mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), ).Get("/instances/{id}/exec", app.ApiService.ExecHandler) + // Custom cp endpoint (outside OpenAPI spec, uses WebSocket) + r.With( + middleware.RequestID, + middleware.RealIP, + middleware.Recoverer, + mw.InjectLogger(logger), + mw.AccessLogger(accessLogger), + mw.JwtAuth(app.Config.JwtSecret), + mw.ResolveResource(app.ApiService.NewResolvers(), api.ResolverErrorResponder), + ).Get("/instances/{id}/cp", app.ApiService.CpHandler) + // OCI Distribution registry endpoints for image push (outside OpenAPI spec) r.Route("/v2", func(r chi.Router) { r.Use(middleware.RequestID) diff --git a/lib/devices/gpu_e2e_test.go b/lib/devices/gpu_e2e_test.go index 4348ebdb..f742b9f9 100644 --- a/lib/devices/gpu_e2e_test.go +++ b/lib/devices/gpu_e2e_test.go @@ -11,7 +11,7 @@ import ( "github.com/onkernel/hypeman/cmd/api/config" "github.com/onkernel/hypeman/lib/devices" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/instances" "github.com/onkernel/hypeman/lib/network" @@ -232,7 +232,7 @@ func TestGPUPassthrough(t *testing.T) { stdout = outputBuffer{} stderr = outputBuffer{} - _, execErr = exec.ExecIntoInstance(execCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, execErr = guest.ExecIntoInstance(execCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", checkGPUCmd}, Stdin: nil, Stdout: &stdout, diff --git a/lib/devices/gpu_inference_test.go b/lib/devices/gpu_inference_test.go index 0749b840..0992193c 100644 --- a/lib/devices/gpu_inference_test.go +++ b/lib/devices/gpu_inference_test.go @@ -22,7 +22,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/onkernel/hypeman/cmd/api/config" "github.com/onkernel/hypeman/lib/devices" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/instances" "github.com/onkernel/hypeman/lib/network" @@ -292,7 +292,7 @@ func TestGPUInference(t *testing.T) { healthCtx, healthCancel := context.WithTimeout(ctx, 5*time.Second) var healthStdout, healthStderr inferenceOutputBuffer - _, err = exec.ExecIntoInstance(healthCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(healthCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "ollama list 2>&1"}, Stdout: &healthStdout, Stderr: &healthStderr, @@ -319,7 +319,7 @@ func TestGPUInference(t *testing.T) { // Check nvidia-smi (should work now with CUDA image) var nvidiaSmiStdout, nvidiaSmiStderr inferenceOutputBuffer - _, _ = exec.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, _ = guest.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "nvidia-smi 2>&1 || echo 'nvidia-smi failed'"}, Stdout: &nvidiaSmiStdout, Stderr: &nvidiaSmiStderr, @@ -333,7 +333,7 @@ func TestGPUInference(t *testing.T) { // Check NVIDIA kernel modules var modulesStdout inferenceOutputBuffer - exec.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, exec.ExecOptions{ + guest.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "cat /proc/modules | grep nvidia"}, Stdout: &modulesStdout, }) @@ -343,7 +343,7 @@ func TestGPUInference(t *testing.T) { // Check device nodes var devStdout inferenceOutputBuffer - exec.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, exec.ExecOptions{ + guest.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "ls -la /dev/nvidia* 2>&1"}, Stdout: &devStdout, }) @@ -355,7 +355,7 @@ func TestGPUInference(t *testing.T) { t.Log("Step 12: Ensuring TinyLlama model is available...") var listStdout inferenceOutputBuffer - exec.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, exec.ExecOptions{ + guest.ExecIntoInstance(gpuCheckCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "ollama list 2>&1"}, Stdout: &listStdout, }) @@ -366,7 +366,7 @@ func TestGPUInference(t *testing.T) { defer pullCancel() var pullStdout inferenceOutputBuffer - _, pullErr := exec.ExecIntoInstance(pullCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, pullErr := guest.ExecIntoInstance(pullCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "ollama pull tinyllama 2>&1"}, Stdout: &pullStdout, }) diff --git a/lib/devices/gpu_module_test.go b/lib/devices/gpu_module_test.go index 841faedd..251b2549 100644 --- a/lib/devices/gpu_module_test.go +++ b/lib/devices/gpu_module_test.go @@ -18,7 +18,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/onkernel/hypeman/cmd/api/config" "github.com/onkernel/hypeman/lib/devices" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/instances" "github.com/onkernel/hypeman/lib/network" @@ -204,7 +204,7 @@ func TestNVIDIAModuleLoading(t *testing.T) { for i := 0; i < 10; i++ { stdout = outputBuffer{} stderr = outputBuffer{} - _, err = exec.ExecIntoInstance(execCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(execCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", dmesgCmd}, Stdin: nil, Stdout: &stdout, @@ -234,7 +234,7 @@ func TestNVIDIAModuleLoading(t *testing.T) { // Check lsmod for nvidia modules stdout = outputBuffer{} stderr = outputBuffer{} - _, err = exec.ExecIntoInstance(execCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(execCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "cat /proc/modules | grep nvidia || echo 'No nvidia modules loaded'"}, Stdin: nil, Stdout: &stdout, @@ -254,7 +254,7 @@ func TestNVIDIAModuleLoading(t *testing.T) { // Check for /dev/nvidia* devices stdout = outputBuffer{} stderr = outputBuffer{} - _, err = exec.ExecIntoInstance(execCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(execCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "ls -la /dev/nvidia* 2>&1 || echo 'No nvidia devices found'"}, Stdin: nil, Stdout: &stdout, @@ -436,7 +436,7 @@ func TestNVMLDetection(t *testing.T) { defer cancel() var stdout, stderr outputBuffer - _, err = exec.ExecIntoInstance(execCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(execCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "python3 /usr/local/bin/test-nvml.py 2>&1"}, Stdin: nil, Stdout: &stdout, @@ -469,7 +469,7 @@ func TestNVMLDetection(t *testing.T) { t.Log("Step 6: Running CUDA driver test...") stdout = outputBuffer{} stderr = outputBuffer{} - _, err = exec.ExecIntoInstance(execCtx, actualInst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(execCtx, actualInst.VsockSocket, guest.ExecOptions{ Command: []string{"/bin/sh", "-c", "python3 /usr/local/bin/test-cuda.py 2>&1"}, Stdin: nil, Stdout: &stdout, diff --git a/lib/exec/README.md b/lib/exec/README.md deleted file mode 100644 index 5de81187..00000000 --- a/lib/exec/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# Exec Feature - -Remote command execution in microVM instances via vsock. - -## Architecture - -``` -Client (WebSocket) - ↓ -API Server (/instances/{id}/exec) - ↓ -lib/exec/client.go (ExecIntoInstance) - ↓ -Cloud Hypervisor vsock socket - ↓ -Guest: exec-agent (lib/system/exec_agent) - ↓ -Container (chroot /overlay/newroot) -``` - -## How It Works - -### 1. API Layer (`cmd/api/api/exec.go`) - -- WebSocket endpoint: `GET /instances/{id}/exec` -- **Note**: Uses GET method because WebSocket connections MUST be initiated with GET per RFC 6455 (the WebSocket specification). Even though this is semantically a command execution (which would normally be POST), the WebSocket upgrade handshake requires GET. -- Upgrades HTTP to WebSocket for bidirectional streaming -- First WebSocket message must be JSON with exec parameters: - ```json - { - "command": ["bash", "-c", "whoami"], - "tty": true, - "env": { // optional: environment variables - "FOO": "bar" - }, - "cwd": "/app", // optional: working directory - "timeout": 30 // optional: timeout in seconds - } - ``` -- Calls `exec.ExecIntoInstance()` with the instance's vsock socket path -- Logs audit trail: JWT subject, instance ID, command, start/end time, exit code - -### 2. Client (`lib/exec/client.go`) - -- **ExecIntoInstance()**: Main client function -- Connects to Cloud Hypervisor's vsock Unix socket -- Performs vsock handshake: `CONNECT 2222\n` → `OK ` -- Creates gRPC client over the vsock connection (pooled per VM for efficiency) -- Streams stdin/stdout/stderr bidirectionally -- Returns exit status when command completes - -**Concurrency**: Multiple exec calls to the same VM share the underlying gRPC connection but use separate streams, enabling concurrent command execution. - -### 3. Protocol (`exec.proto`) - -gRPC streaming RPC with protobuf messages: - -**Request (client → server):** -- `ExecStart`: Command, TTY flag, environment variables, working directory, timeout -- `stdin`: Input data bytes - -**Response (server → client):** -- `stdout`: Output data bytes -- `stderr`: Error output bytes (non-TTY only) -- `exit_code`: Final message with command's exit status - -### 4. Guest Agent (`lib/system/exec_agent/main.go`) - -- Embedded binary injected into microVM via initrd -- **Runs inside container namespace** (chrooted to `/overlay/newroot`) for proper PTY signal handling -- Listens on vsock port 2222 inside guest -- Implements gRPC `ExecService` server -- Executes commands directly (no chroot wrapper needed since agent is already in container) -- Two modes: - - **Non-TTY**: Separate stdout/stderr pipes - - **TTY**: Single PTY for interactive shells with proper Ctrl+C handling - -### 5. Embedding - -- `exec-agent` binary built by Makefile -- Embedded into host binary via `lib/system/exec_agent_binary.go` -- Injected into initrd at VM creation time -- Auto-started by init script in guest - -## Key Features - -- **Bidirectional streaming**: Real-time stdin/stdout/stderr -- **TTY support**: Interactive shells with terminal control -- **Concurrent exec**: Multiple simultaneous commands per VM (separate streams) -- **Exit codes**: Proper process exit status reporting -- **No SSH required**: Direct vsock communication (faster, simpler) -- **Container isolation**: Commands run in container context, not VM context - -## Why vsock? - -- **Low latency**: Direct host-guest communication without networking -- **No network setup**: Works even if container has no network -- **Secure**: No exposed ports, isolated to host-guest boundary -- **Simple**: No SSH keys, passwords, or network configuration - -## Security & Authorization - -- All authentication and authorization is handled at the API layer via JWT -- The guest agent trusts that the host has properly authorized the request -- User/UID switching is performed in the guest to enforce privilege boundaries -- Commands run in the container context (`chroot /overlay/newroot`), not the VM context - -## Observability - -### API Layer Logging - -The API logs comprehensive audit trails for all exec sessions: - -``` -# Session start -{"level":"info","msg":"exec session started","instance_id":"abc123","subject":"user@example.com", - "command":["bash","-c","whoami"],"tty":true,"user":"www-data","uid":0,"cwd":"/app","timeout":30} - -# Session end -{"level":"info","msg":"exec session ended","instance_id":"abc123","subject":"user@example.com", - "exit_code":0,"duration_ms":1234} - -# Errors -{"level":"error","msg":"exec failed","instance_id":"abc123","subject":"user@example.com", - "error":"connection refused","duration_ms":500} -``` - -### Guest Agent Logging - -The guest agent logs are written to the VM console log (accessible via `/var/lib/hypeman/guests/{id}/console.log`): - -``` -[exec-agent] listening on vsock port 2222 -[exec-agent] new exec stream -[exec-agent] exec: command=[bash -c whoami] tty=true cwd=/app timeout=30 -[exec-agent] command finished with exit code: 0 -``` - -## Timeout Behavior - -When a timeout is specified: -- The guest agent creates a context with the specified deadline -- If the command doesn't complete in time, it receives SIGKILL -- The exit code will be `124` (GNU timeout convention) -- Timeout is enforced in the guest, so network issues won't cause false timeouts - -## Architecture - -**exec-agent runs inside the container namespace**: -- Init script copies agent binary into `/overlay/newroot/usr/local/bin/` -- Bind-mounts `/dev/pts` so PTY devices are accessible -- Runs agent with `chroot /overlay/newroot` -- Commands execute directly (no chroot wrapper needed) - diff --git a/lib/exec/client.go b/lib/exec/client.go deleted file mode 100644 index a32e6338..00000000 --- a/lib/exec/client.go +++ /dev/null @@ -1,263 +0,0 @@ -package exec - -import ( - "bufio" - "context" - "fmt" - "io" - "log/slog" - "net" - "strings" - "sync" - "sync/atomic" - "time" - - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" -) - -const ( - // vsockDialTimeout is the timeout for connecting to the vsock Unix socket - vsockDialTimeout = 5 * time.Second - // vsockHandshakeTimeout is the timeout for the Cloud Hypervisor vsock handshake - vsockHandshakeTimeout = 5 * time.Second - // vsockGuestPort is the port the exec-agent listens on inside the guest - vsockGuestPort = 2222 -) - -// connPool manages reusable gRPC connections per vsock socket path -// This avoids the overhead and potential issues of rapidly creating/closing connections -var connPool = struct { - sync.RWMutex - conns map[string]*grpc.ClientConn -}{ - conns: make(map[string]*grpc.ClientConn), -} - -// getOrCreateConn returns an existing connection or creates a new one -func getOrCreateConn(ctx context.Context, vsockSocketPath string) (*grpc.ClientConn, error) { - // Try read lock first for existing connection - connPool.RLock() - if conn, ok := connPool.conns[vsockSocketPath]; ok { - connPool.RUnlock() - return conn, nil - } - connPool.RUnlock() - - // Need to create new connection - acquire write lock - connPool.Lock() - defer connPool.Unlock() - - // Double-check after acquiring write lock - if conn, ok := connPool.conns[vsockSocketPath]; ok { - return conn, nil - } - - // Create new connection - conn, err := grpc.Dial("passthrough:///vsock", - grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { - return dialVsock(ctx, vsockSocketPath) - }), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - if err != nil { - return nil, fmt.Errorf("create grpc connection: %w", err) - } - - connPool.conns[vsockSocketPath] = conn - slog.Debug("created new gRPC connection", "socket", vsockSocketPath) - return conn, nil -} - -// CloseConn closes and removes a connection from the pool (call when VM is deleted) -func CloseConn(vsockSocketPath string) { - connPool.Lock() - defer connPool.Unlock() - - if conn, ok := connPool.conns[vsockSocketPath]; ok { - conn.Close() - delete(connPool.conns, vsockSocketPath) - slog.Debug("closed gRPC connection", "socket", vsockSocketPath) - } -} - -// ExitStatus represents command exit information -type ExitStatus struct { - Code int -} - -// ExecOptions configures command execution -type ExecOptions struct { - Command []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - TTY bool - Env map[string]string // Environment variables - Cwd string // Working directory (optional) - Timeout int32 // Execution timeout in seconds (0 = no timeout) -} - -// bufferedConn wraps a net.Conn with a bufio.Reader to ensure any buffered -// data from the handshake is properly drained before reading from the connection -type bufferedConn struct { - net.Conn - reader *bufio.Reader -} - -func (c *bufferedConn) Read(p []byte) (int, error) { - return c.reader.Read(p) -} - -// ExecIntoInstance executes command in instance via vsock using gRPC -// vsockSocketPath is the Unix socket created by Cloud Hypervisor (e.g., /var/lib/hypeman/guests/{id}/vsock.sock) -func ExecIntoInstance(ctx context.Context, vsockSocketPath string, opts ExecOptions) (*ExitStatus, error) { - start := time.Now() - var bytesSent int64 - - // Get or create a reusable gRPC connection for this vsock socket - // Connection pooling avoids issues with rapid connect/disconnect cycles - grpcConn, err := getOrCreateConn(ctx, vsockSocketPath) - if err != nil { - return nil, fmt.Errorf("get grpc connection: %w", err) - } - // Note: Don't close the connection - it's pooled and reused - - // Create exec client - client := NewExecServiceClient(grpcConn) - stream, err := client.Exec(ctx) - if err != nil { - return nil, fmt.Errorf("start exec stream: %w", err) - } - // Ensure stream is properly closed when we're done - defer stream.CloseSend() - - // Send start request - if err := stream.Send(&ExecRequest{ - Request: &ExecRequest_Start{ - Start: &ExecStart{ - Command: opts.Command, - Tty: opts.TTY, - Env: opts.Env, - Cwd: opts.Cwd, - TimeoutSeconds: opts.Timeout, - }, - }, - }); err != nil { - return nil, fmt.Errorf("send start request: %w", err) - } - - // Handle stdin in background - if opts.Stdin != nil { - go func() { - buf := make([]byte, 32*1024) - for { - n, err := opts.Stdin.Read(buf) - if n > 0 { - stream.Send(&ExecRequest{ - Request: &ExecRequest_Stdin{Stdin: buf[:n]}, - }) - atomic.AddInt64(&bytesSent, int64(n)) - } - if err != nil { - stream.CloseSend() - return - } - } - }() - } - - // Receive responses - var totalStdout, totalStderr int - for { - resp, err := stream.Recv() - if err == io.EOF { - return nil, fmt.Errorf("stream closed without exit code (stdout=%d, stderr=%d)", totalStdout, totalStderr) - } - if err != nil { - return nil, fmt.Errorf("receive response (stdout=%d, stderr=%d): %w", totalStdout, totalStderr, err) - } - - switch r := resp.Response.(type) { - case *ExecResponse_Stdout: - totalStdout += len(r.Stdout) - if opts.Stdout != nil { - opts.Stdout.Write(r.Stdout) - } - case *ExecResponse_Stderr: - totalStderr += len(r.Stderr) - if opts.Stderr != nil { - opts.Stderr.Write(r.Stderr) - } - case *ExecResponse_ExitCode: - exitCode := int(r.ExitCode) - // Record metrics - if ExecMetrics != nil { - bytesReceived := int64(totalStdout + totalStderr) - ExecMetrics.RecordSession(ctx, start, exitCode, atomic.LoadInt64(&bytesSent), bytesReceived) - } - return &ExitStatus{Code: exitCode}, nil - } - } -} - -// dialVsock connects to Cloud Hypervisor's vsock Unix socket and performs the handshake -func dialVsock(ctx context.Context, vsockSocketPath string) (net.Conn, error) { - slog.DebugContext(ctx, "connecting to vsock", "socket", vsockSocketPath) - - // Use dial timeout, respecting context deadline if shorter - dialTimeout := vsockDialTimeout - if deadline, ok := ctx.Deadline(); ok { - if remaining := time.Until(deadline); remaining < dialTimeout { - dialTimeout = remaining - } - } - - // Connect to CH's Unix socket with timeout - dialer := net.Dialer{Timeout: dialTimeout} - conn, err := dialer.DialContext(ctx, "unix", vsockSocketPath) - if err != nil { - return nil, fmt.Errorf("dial vsock socket %s: %w", vsockSocketPath, err) - } - - slog.DebugContext(ctx, "connected to vsock socket, performing handshake", "port", vsockGuestPort) - - // Set deadline for handshake - if err := conn.SetDeadline(time.Now().Add(vsockHandshakeTimeout)); err != nil { - conn.Close() - return nil, fmt.Errorf("set handshake deadline: %w", err) - } - - // Perform Cloud Hypervisor vsock handshake - handshakeCmd := fmt.Sprintf("CONNECT %d\n", vsockGuestPort) - if _, err := conn.Write([]byte(handshakeCmd)); err != nil { - conn.Close() - return nil, fmt.Errorf("send vsock handshake: %w", err) - } - - // Read handshake response - reader := bufio.NewReader(conn) - response, err := reader.ReadString('\n') - if err != nil { - conn.Close() - return nil, fmt.Errorf("read vsock handshake response (is exec-agent running in guest?): %w", err) - } - - // Clear deadline after successful handshake - if err := conn.SetDeadline(time.Time{}); err != nil { - conn.Close() - return nil, fmt.Errorf("clear deadline: %w", err) - } - - response = strings.TrimSpace(response) - if !strings.HasPrefix(response, "OK ") { - conn.Close() - return nil, fmt.Errorf("vsock handshake failed: %s", response) - } - - slog.DebugContext(ctx, "vsock handshake successful", "response", response) - - // Return wrapped connection that uses the bufio.Reader - // This ensures any bytes buffered during handshake are not lost - return &bufferedConn{Conn: conn, reader: reader}, nil -} diff --git a/lib/exec/exec.pb.go b/lib/exec/exec.pb.go deleted file mode 100644 index 68e60129..00000000 --- a/lib/exec/exec.pb.go +++ /dev/null @@ -1,372 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 -// source: lib/exec/exec.proto - -package exec - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// ExecRequest represents messages from client to server -type ExecRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Request: - // - // *ExecRequest_Start - // *ExecRequest_Stdin - Request isExecRequest_Request `protobuf_oneof:"request"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExecRequest) Reset() { - *x = ExecRequest{} - mi := &file_lib_exec_exec_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExecRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecRequest) ProtoMessage() {} - -func (x *ExecRequest) ProtoReflect() protoreflect.Message { - mi := &file_lib_exec_exec_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. -func (*ExecRequest) Descriptor() ([]byte, []int) { - return file_lib_exec_exec_proto_rawDescGZIP(), []int{0} -} - -func (x *ExecRequest) GetRequest() isExecRequest_Request { - if x != nil { - return x.Request - } - return nil -} - -func (x *ExecRequest) GetStart() *ExecStart { - if x != nil { - if x, ok := x.Request.(*ExecRequest_Start); ok { - return x.Start - } - } - return nil -} - -func (x *ExecRequest) GetStdin() []byte { - if x != nil { - if x, ok := x.Request.(*ExecRequest_Stdin); ok { - return x.Stdin - } - } - return nil -} - -type isExecRequest_Request interface { - isExecRequest_Request() -} - -type ExecRequest_Start struct { - Start *ExecStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` // Initial exec request -} - -type ExecRequest_Stdin struct { - Stdin []byte `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"` // Stdin data -} - -func (*ExecRequest_Start) isExecRequest_Request() {} - -func (*ExecRequest_Stdin) isExecRequest_Request() {} - -// ExecStart initiates command execution -type ExecStart struct { - state protoimpl.MessageState `protogen:"open.v1"` - Command []string `protobuf:"bytes,1,rep,name=command,proto3" json:"command,omitempty"` // Command and arguments - Tty bool `protobuf:"varint,2,opt,name=tty,proto3" json:"tty,omitempty"` // Allocate pseudo-TTY - Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Environment variables - Cwd string `protobuf:"bytes,4,opt,name=cwd,proto3" json:"cwd,omitempty"` // Working directory (optional) - TimeoutSeconds int32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` // Execution timeout in seconds (0 = no timeout) - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExecStart) Reset() { - *x = ExecStart{} - mi := &file_lib_exec_exec_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExecStart) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecStart) ProtoMessage() {} - -func (x *ExecStart) ProtoReflect() protoreflect.Message { - mi := &file_lib_exec_exec_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecStart.ProtoReflect.Descriptor instead. -func (*ExecStart) Descriptor() ([]byte, []int) { - return file_lib_exec_exec_proto_rawDescGZIP(), []int{1} -} - -func (x *ExecStart) GetCommand() []string { - if x != nil { - return x.Command - } - return nil -} - -func (x *ExecStart) GetTty() bool { - if x != nil { - return x.Tty - } - return false -} - -func (x *ExecStart) GetEnv() map[string]string { - if x != nil { - return x.Env - } - return nil -} - -func (x *ExecStart) GetCwd() string { - if x != nil { - return x.Cwd - } - return "" -} - -func (x *ExecStart) GetTimeoutSeconds() int32 { - if x != nil { - return x.TimeoutSeconds - } - return 0 -} - -// ExecResponse represents messages from server to client -type ExecResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Response: - // - // *ExecResponse_Stdout - // *ExecResponse_Stderr - // *ExecResponse_ExitCode - Response isExecResponse_Response `protobuf_oneof:"response"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExecResponse) Reset() { - *x = ExecResponse{} - mi := &file_lib_exec_exec_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExecResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecResponse) ProtoMessage() {} - -func (x *ExecResponse) ProtoReflect() protoreflect.Message { - mi := &file_lib_exec_exec_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead. -func (*ExecResponse) Descriptor() ([]byte, []int) { - return file_lib_exec_exec_proto_rawDescGZIP(), []int{2} -} - -func (x *ExecResponse) GetResponse() isExecResponse_Response { - if x != nil { - return x.Response - } - return nil -} - -func (x *ExecResponse) GetStdout() []byte { - if x != nil { - if x, ok := x.Response.(*ExecResponse_Stdout); ok { - return x.Stdout - } - } - return nil -} - -func (x *ExecResponse) GetStderr() []byte { - if x != nil { - if x, ok := x.Response.(*ExecResponse_Stderr); ok { - return x.Stderr - } - } - return nil -} - -func (x *ExecResponse) GetExitCode() int32 { - if x != nil { - if x, ok := x.Response.(*ExecResponse_ExitCode); ok { - return x.ExitCode - } - } - return 0 -} - -type isExecResponse_Response interface { - isExecResponse_Response() -} - -type ExecResponse_Stdout struct { - Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` // Stdout data -} - -type ExecResponse_Stderr struct { - Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` // Stderr data -} - -type ExecResponse_ExitCode struct { - ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3,oneof"` // Command exit code (final message) -} - -func (*ExecResponse_Stdout) isExecResponse_Response() {} - -func (*ExecResponse_Stderr) isExecResponse_Response() {} - -func (*ExecResponse_ExitCode) isExecResponse_Response() {} - -var File_lib_exec_exec_proto protoreflect.FileDescriptor - -const file_lib_exec_exec_proto_rawDesc = "" + - "\n" + - "\x13lib/exec/exec.proto\x12\x04exec\"Y\n" + - "\vExecRequest\x12'\n" + - "\x05start\x18\x01 \x01(\v2\x0f.exec.ExecStartH\x00R\x05start\x12\x16\n" + - "\x05stdin\x18\x02 \x01(\fH\x00R\x05stdinB\t\n" + - "\arequest\"\xd6\x01\n" + - "\tExecStart\x12\x18\n" + - "\acommand\x18\x01 \x03(\tR\acommand\x12\x10\n" + - "\x03tty\x18\x02 \x01(\bR\x03tty\x12*\n" + - "\x03env\x18\x03 \x03(\v2\x18.exec.ExecStart.EnvEntryR\x03env\x12\x10\n" + - "\x03cwd\x18\x04 \x01(\tR\x03cwd\x12'\n" + - "\x0ftimeout_seconds\x18\x05 \x01(\x05R\x0etimeoutSeconds\x1a6\n" + - "\bEnvEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"m\n" + - "\fExecResponse\x12\x18\n" + - "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + - "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x1d\n" + - "\texit_code\x18\x03 \x01(\x05H\x00R\bexitCodeB\n" + - "\n" + - "\bresponse2@\n" + - "\vExecService\x121\n" + - "\x04Exec\x12\x11.exec.ExecRequest\x1a\x12.exec.ExecResponse(\x010\x01B&Z$github.com/onkernel/hypeman/lib/execb\x06proto3" - -var ( - file_lib_exec_exec_proto_rawDescOnce sync.Once - file_lib_exec_exec_proto_rawDescData []byte -) - -func file_lib_exec_exec_proto_rawDescGZIP() []byte { - file_lib_exec_exec_proto_rawDescOnce.Do(func() { - file_lib_exec_exec_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_lib_exec_exec_proto_rawDesc), len(file_lib_exec_exec_proto_rawDesc))) - }) - return file_lib_exec_exec_proto_rawDescData -} - -var file_lib_exec_exec_proto_msgTypes = make([]protoimpl.MessageInfo, 4) -var file_lib_exec_exec_proto_goTypes = []any{ - (*ExecRequest)(nil), // 0: exec.ExecRequest - (*ExecStart)(nil), // 1: exec.ExecStart - (*ExecResponse)(nil), // 2: exec.ExecResponse - nil, // 3: exec.ExecStart.EnvEntry -} -var file_lib_exec_exec_proto_depIdxs = []int32{ - 1, // 0: exec.ExecRequest.start:type_name -> exec.ExecStart - 3, // 1: exec.ExecStart.env:type_name -> exec.ExecStart.EnvEntry - 0, // 2: exec.ExecService.Exec:input_type -> exec.ExecRequest - 2, // 3: exec.ExecService.Exec:output_type -> exec.ExecResponse - 3, // [3:4] is the sub-list for method output_type - 2, // [2:3] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_lib_exec_exec_proto_init() } -func file_lib_exec_exec_proto_init() { - if File_lib_exec_exec_proto != nil { - return - } - file_lib_exec_exec_proto_msgTypes[0].OneofWrappers = []any{ - (*ExecRequest_Start)(nil), - (*ExecRequest_Stdin)(nil), - } - file_lib_exec_exec_proto_msgTypes[2].OneofWrappers = []any{ - (*ExecResponse_Stdout)(nil), - (*ExecResponse_Stderr)(nil), - (*ExecResponse_ExitCode)(nil), - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_lib_exec_exec_proto_rawDesc), len(file_lib_exec_exec_proto_rawDesc)), - NumEnums: 0, - NumMessages: 4, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_lib_exec_exec_proto_goTypes, - DependencyIndexes: file_lib_exec_exec_proto_depIdxs, - MessageInfos: file_lib_exec_exec_proto_msgTypes, - }.Build() - File_lib_exec_exec_proto = out.File - file_lib_exec_exec_proto_goTypes = nil - file_lib_exec_exec_proto_depIdxs = nil -} diff --git a/lib/exec/exec.proto b/lib/exec/exec.proto deleted file mode 100644 index 7cfafd5c..00000000 --- a/lib/exec/exec.proto +++ /dev/null @@ -1,38 +0,0 @@ -syntax = "proto3"; - -package exec; - -option go_package = "github.com/onkernel/hypeman/lib/exec"; - -// ExecService provides remote command execution in guest VMs -service ExecService { - // Exec executes a command with bidirectional streaming - rpc Exec(stream ExecRequest) returns (stream ExecResponse); -} - -// ExecRequest represents messages from client to server -message ExecRequest { - oneof request { - ExecStart start = 1; // Initial exec request - bytes stdin = 2; // Stdin data - } -} - -// ExecStart initiates command execution -message ExecStart { - repeated string command = 1; // Command and arguments - bool tty = 2; // Allocate pseudo-TTY - map env = 3; // Environment variables - string cwd = 4; // Working directory (optional) - int32 timeout_seconds = 5; // Execution timeout in seconds (0 = no timeout) -} - -// ExecResponse represents messages from server to client -message ExecResponse { - oneof response { - bytes stdout = 1; // Stdout data - bytes stderr = 2; // Stderr data - int32 exit_code = 3; // Command exit code (final message) - } -} - diff --git a/lib/exec/exec_grpc.pb.go b/lib/exec/exec_grpc.pb.go deleted file mode 100644 index c7e8d6e2..00000000 --- a/lib/exec/exec_grpc.pb.go +++ /dev/null @@ -1,121 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v3.21.12 -// source: lib/exec/exec.proto - -package exec - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - ExecService_Exec_FullMethodName = "/exec.ExecService/Exec" -) - -// ExecServiceClient is the client API for ExecService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// ExecService provides remote command execution in guest VMs -type ExecServiceClient interface { - // Exec executes a command with bidirectional streaming - Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error) -} - -type execServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewExecServiceClient(cc grpc.ClientConnInterface) ExecServiceClient { - return &execServiceClient{cc} -} - -func (c *execServiceClient) Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &ExecService_ServiceDesc.Streams[0], ExecService_Exec_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[ExecRequest, ExecResponse]{ClientStream: stream} - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ExecService_ExecClient = grpc.BidiStreamingClient[ExecRequest, ExecResponse] - -// ExecServiceServer is the server API for ExecService service. -// All implementations must embed UnimplementedExecServiceServer -// for forward compatibility. -// -// ExecService provides remote command execution in guest VMs -type ExecServiceServer interface { - // Exec executes a command with bidirectional streaming - Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error - mustEmbedUnimplementedExecServiceServer() -} - -// UnimplementedExecServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedExecServiceServer struct{} - -func (UnimplementedExecServiceServer) Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error { - return status.Errorf(codes.Unimplemented, "method Exec not implemented") -} -func (UnimplementedExecServiceServer) mustEmbedUnimplementedExecServiceServer() {} -func (UnimplementedExecServiceServer) testEmbeddedByValue() {} - -// UnsafeExecServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ExecServiceServer will -// result in compilation errors. -type UnsafeExecServiceServer interface { - mustEmbedUnimplementedExecServiceServer() -} - -func RegisterExecServiceServer(s grpc.ServiceRegistrar, srv ExecServiceServer) { - // If the following call pancis, it indicates UnimplementedExecServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&ExecService_ServiceDesc, srv) -} - -func _ExecService_Exec_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(ExecServiceServer).Exec(&grpc.GenericServerStream[ExecRequest, ExecResponse]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ExecService_ExecServer = grpc.BidiStreamingServer[ExecRequest, ExecResponse] - -// ExecService_ServiceDesc is the grpc.ServiceDesc for ExecService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ExecService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "exec.ExecService", - HandlerType: (*ExecServiceServer)(nil), - Methods: []grpc.MethodDesc{}, - Streams: []grpc.StreamDesc{ - { - StreamName: "Exec", - Handler: _ExecService_Exec_Handler, - ServerStreams: true, - ClientStreams: true, - }, - }, - Metadata: "lib/exec/exec.proto", -} diff --git a/lib/exec/metrics.go b/lib/exec/metrics.go deleted file mode 100644 index ef1c83c7..00000000 --- a/lib/exec/metrics.go +++ /dev/null @@ -1,105 +0,0 @@ -package exec - -import ( - "context" - "time" - - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" -) - -// Metrics holds the metrics instruments for exec operations. -type Metrics struct { - sessionsTotal metric.Int64Counter - duration metric.Float64Histogram - bytesSentTotal metric.Int64Counter - bytesReceivedTotal metric.Int64Counter -} - -// ExecMetrics is the global metrics instance for the exec package. -// Set this via SetMetrics() during application initialization. -var ExecMetrics *Metrics - -// SetMetrics sets the global metrics instance. -func SetMetrics(m *Metrics) { - ExecMetrics = m -} - -// NewMetrics creates exec metrics instruments. -// If meter is nil, returns nil (metrics disabled). -func NewMetrics(meter metric.Meter) (*Metrics, error) { - if meter == nil { - return nil, nil - } - - sessionsTotal, err := meter.Int64Counter( - "hypeman_exec_sessions_total", - metric.WithDescription("Total number of exec sessions"), - ) - if err != nil { - return nil, err - } - - duration, err := meter.Float64Histogram( - "hypeman_exec_duration_seconds", - metric.WithDescription("Exec command duration"), - metric.WithUnit("s"), - ) - if err != nil { - return nil, err - } - - bytesSentTotal, err := meter.Int64Counter( - "hypeman_exec_bytes_sent_total", - metric.WithDescription("Total bytes sent to guest (stdin)"), - metric.WithUnit("By"), - ) - if err != nil { - return nil, err - } - - bytesReceivedTotal, err := meter.Int64Counter( - "hypeman_exec_bytes_received_total", - metric.WithDescription("Total bytes received from guest (stdout+stderr)"), - metric.WithUnit("By"), - ) - if err != nil { - return nil, err - } - - return &Metrics{ - sessionsTotal: sessionsTotal, - duration: duration, - bytesSentTotal: bytesSentTotal, - bytesReceivedTotal: bytesReceivedTotal, - }, nil -} - -// RecordSession records metrics for a completed exec session. -func (m *Metrics) RecordSession(ctx context.Context, start time.Time, exitCode int, bytesSent, bytesReceived int64) { - if m == nil { - return - } - - duration := time.Since(start).Seconds() - status := "success" - if exitCode != 0 { - status = "error" - } - - m.sessionsTotal.Add(ctx, 1, - metric.WithAttributes( - attribute.String("status", status), - attribute.Int("exit_code", exitCode), - )) - - m.duration.Record(ctx, duration, - metric.WithAttributes(attribute.String("status", status))) - - if bytesSent > 0 { - m.bytesSentTotal.Add(ctx, bytesSent) - } - if bytesReceived > 0 { - m.bytesReceivedTotal.Add(ctx, bytesReceived) - } -} diff --git a/lib/guest/README.md b/lib/guest/README.md new file mode 100644 index 00000000..67344f9b --- /dev/null +++ b/lib/guest/README.md @@ -0,0 +1,107 @@ +# Guest Agent + +Remote guest VM operations via vsock - command execution and file copying. + +## Architecture + +``` +Client (WebSocket) + ↓ +API Server (/instances/{id}/exec, /instances/{id}/cp) + ↓ +lib/guest/client.go (ExecIntoInstance, CopyToInstance, CopyFromInstance) + ↓ +Cloud Hypervisor vsock socket + ↓ +Guest: guest-agent (lib/system/guest_agent) + ↓ +Container (chroot /overlay/newroot) +``` + +## Features + +### Command Execution (Exec) + +- **ExecIntoInstance()**: Execute commands with bidirectional stdin/stdout streaming +- **TTY support**: Interactive shells with terminal control +- **Concurrent exec**: Multiple simultaneous commands per VM (separate streams) +- **Exit codes**: Proper process exit status reporting + +### File Copy (CP) + +- **CopyToInstance()**: Copy files/directories from host to guest +- **CopyFromInstance()**: Copy files/directories from guest to host +- **Streaming**: Efficient chunked transfer for large files +- **Permissions**: Preserve file mode and ownership where possible + +## How It Works + +### 1. API Layer + +- WebSocket endpoint: `GET /instances/{id}/exec` - command execution +- WebSocket endpoint: `GET /instances/{id}/cp` - file copy operations +- **Note**: Uses GET method because WebSocket connections MUST be initiated with GET per RFC 6455. +- Upgrades HTTP to WebSocket for bidirectional streaming +- Calls `guest.ExecIntoInstance()` or `guest.CopyTo/FromInstance()` with the instance's vsock socket path +- Logs audit trail: JWT subject, instance ID, operation, start/end time + +### 2. Client (`lib/guest/client.go`) + +- Connects to Cloud Hypervisor's vsock Unix socket +- Performs vsock handshake: `CONNECT 2222\n` → `OK ` +- Creates gRPC client over the vsock connection (pooled per VM for efficiency) +- Streams data bidirectionally + +**Concurrency**: Multiple calls to the same VM share the underlying gRPC connection but use separate streams. + +### 3. Protocol (`guest.proto`) + +gRPC streaming RPC with protobuf messages: + +**Exec Request (client → server):** +- `ExecStart`: Command, TTY flag, environment variables, working directory, timeout +- `stdin`: Input data bytes + +**Exec Response (server → client):** +- `stdout`: Output data bytes +- `stderr`: Error output bytes (non-TTY only) +- `exit_code`: Final message with command's exit status + +**Copy Request (client → server):** +- `CopyStart`: Destination path, file mode +- `data`: File content chunks +- `done`: Indicates transfer complete + +**Copy Response (server → client):** +- `data`: File content chunks (for CopyFromInstance) +- `error`: Error message if operation failed +- `done`: Indicates transfer complete + +### 4. Guest Agent (`lib/system/guest_agent/main.go`) + +- Embedded binary injected into microVM via initrd +- **Runs inside container namespace** (chrooted to `/overlay/newroot`) for proper file access +- Listens on vsock port 2222 inside guest +- Implements gRPC `GuestService` server +- Executes commands and handles file operations directly + +### 5. Embedding + +- `guest-agent` binary built by Makefile +- Embedded into host binary via `lib/system/guest_agent_binary.go` +- Injected into initrd at VM creation time +- Auto-started by init script in guest + +## Why vsock? + +- **Low latency**: Direct host-guest communication without networking +- **No network setup**: Works even if container has no network +- **Secure**: No exposed ports, isolated to host-guest boundary +- **Simple**: No SSH keys, passwords, or network configuration + +## Security & Authorization + +- All authentication and authorization is handled at the API layer via JWT +- The guest agent trusts that the host has properly authorized the request +- Commands and file operations run in the container context, not the VM context + diff --git a/lib/guest/client.go b/lib/guest/client.go new file mode 100644 index 00000000..8f06cf82 --- /dev/null +++ b/lib/guest/client.go @@ -0,0 +1,588 @@ +package guest + +import ( + "bufio" + "context" + "fmt" + "io" + "io/fs" + "log/slog" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + securejoin "github.com/cyphar/filepath-securejoin" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + // vsockDialTimeout is the timeout for connecting to the vsock Unix socket + vsockDialTimeout = 5 * time.Second + // vsockHandshakeTimeout is the timeout for the Cloud Hypervisor vsock handshake + vsockHandshakeTimeout = 5 * time.Second + // vsockGuestPort is the port the guest-agent listens on inside the guest + vsockGuestPort = 2222 +) + +// connPool manages reusable gRPC connections per vsock socket path +// This avoids the overhead and potential issues of rapidly creating/closing connections +var connPool = struct { + sync.RWMutex + conns map[string]*grpc.ClientConn +}{ + conns: make(map[string]*grpc.ClientConn), +} + +// GetOrCreateConnPublic is a public wrapper for getOrCreateConn for use by the API layer +func GetOrCreateConnPublic(ctx context.Context, vsockSocketPath string) (*grpc.ClientConn, error) { + return getOrCreateConn(ctx, vsockSocketPath) +} + +// getOrCreateConn returns an existing connection or creates a new one +func getOrCreateConn(ctx context.Context, vsockSocketPath string) (*grpc.ClientConn, error) { + // Try read lock first for existing connection + connPool.RLock() + if conn, ok := connPool.conns[vsockSocketPath]; ok { + connPool.RUnlock() + return conn, nil + } + connPool.RUnlock() + + // Need to create new connection - acquire write lock + connPool.Lock() + defer connPool.Unlock() + + // Double-check after acquiring write lock + if conn, ok := connPool.conns[vsockSocketPath]; ok { + return conn, nil + } + + // Create new connection + conn, err := grpc.Dial("passthrough:///vsock", + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return dialVsock(ctx, vsockSocketPath) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("create grpc connection: %w", err) + } + + connPool.conns[vsockSocketPath] = conn + slog.Debug("created new gRPC connection", "socket", vsockSocketPath) + return conn, nil +} + +// CloseConn closes and removes a connection from the pool (call when VM is deleted) +func CloseConn(vsockSocketPath string) { + connPool.Lock() + defer connPool.Unlock() + + if conn, ok := connPool.conns[vsockSocketPath]; ok { + conn.Close() + delete(connPool.conns, vsockSocketPath) + slog.Debug("closed gRPC connection", "socket", vsockSocketPath) + } +} + +// ExitStatus represents command exit information +type ExitStatus struct { + Code int +} + +// ExecOptions configures command execution +type ExecOptions struct { + Command []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + TTY bool + Env map[string]string // Environment variables + Cwd string // Working directory (optional) + Timeout int32 // Execution timeout in seconds (0 = no timeout) +} + +// bufferedConn wraps a net.Conn with a bufio.Reader to ensure any buffered +// data from the handshake is properly drained before reading from the connection +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +// ExecIntoInstance executes command in instance via vsock using gRPC +// vsockSocketPath is the Unix socket created by Cloud Hypervisor (e.g., /var/lib/hypeman/guests/{id}/vsock.sock) +func ExecIntoInstance(ctx context.Context, vsockSocketPath string, opts ExecOptions) (*ExitStatus, error) { + start := time.Now() + var bytesSent int64 + + // Get or create a reusable gRPC connection for this vsock socket + // Connection pooling avoids issues with rapid connect/disconnect cycles + grpcConn, err := getOrCreateConn(ctx, vsockSocketPath) + if err != nil { + return nil, fmt.Errorf("get grpc connection: %w", err) + } + // Note: Don't close the connection - it's pooled and reused + + // Create guest client + client := NewGuestServiceClient(grpcConn) + stream, err := client.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("start exec stream: %w", err) + } + // Ensure stream is properly closed when we're done + defer stream.CloseSend() + + // Send start request + if err := stream.Send(&ExecRequest{ + Request: &ExecRequest_Start{ + Start: &ExecStart{ + Command: opts.Command, + Tty: opts.TTY, + Env: opts.Env, + Cwd: opts.Cwd, + TimeoutSeconds: opts.Timeout, + }, + }, + }); err != nil { + return nil, fmt.Errorf("send start request: %w", err) + } + + // Handle stdin in background + if opts.Stdin != nil { + go func() { + buf := make([]byte, 32*1024) + for { + n, err := opts.Stdin.Read(buf) + if n > 0 { + stream.Send(&ExecRequest{ + Request: &ExecRequest_Stdin{Stdin: buf[:n]}, + }) + atomic.AddInt64(&bytesSent, int64(n)) + } + if err != nil { + stream.CloseSend() + return + } + } + }() + } + + // Receive responses + var totalStdout, totalStderr int + for { + resp, err := stream.Recv() + if err == io.EOF { + return nil, fmt.Errorf("stream closed without exit code (stdout=%d, stderr=%d)", totalStdout, totalStderr) + } + if err != nil { + return nil, fmt.Errorf("receive response (stdout=%d, stderr=%d): %w", totalStdout, totalStderr, err) + } + + switch r := resp.Response.(type) { + case *ExecResponse_Stdout: + totalStdout += len(r.Stdout) + if opts.Stdout != nil { + opts.Stdout.Write(r.Stdout) + } + case *ExecResponse_Stderr: + totalStderr += len(r.Stderr) + if opts.Stderr != nil { + opts.Stderr.Write(r.Stderr) + } + case *ExecResponse_ExitCode: + exitCode := int(r.ExitCode) + // Record metrics + if GuestMetrics != nil { + bytesReceived := int64(totalStdout + totalStderr) + GuestMetrics.RecordExecSession(ctx, start, exitCode, atomic.LoadInt64(&bytesSent), bytesReceived) + } + return &ExitStatus{Code: exitCode}, nil + } + } +} + +// dialVsock connects to Cloud Hypervisor's vsock Unix socket and performs the handshake +func dialVsock(ctx context.Context, vsockSocketPath string) (net.Conn, error) { + slog.DebugContext(ctx, "connecting to vsock", "socket", vsockSocketPath) + + // Use dial timeout, respecting context deadline if shorter + dialTimeout := vsockDialTimeout + if deadline, ok := ctx.Deadline(); ok { + if remaining := time.Until(deadline); remaining < dialTimeout { + dialTimeout = remaining + } + } + + // Connect to CH's Unix socket with timeout + dialer := net.Dialer{Timeout: dialTimeout} + conn, err := dialer.DialContext(ctx, "unix", vsockSocketPath) + if err != nil { + return nil, fmt.Errorf("dial vsock socket %s: %w", vsockSocketPath, err) + } + + slog.DebugContext(ctx, "connected to vsock socket, performing handshake", "port", vsockGuestPort) + + // Set deadline for handshake + if err := conn.SetDeadline(time.Now().Add(vsockHandshakeTimeout)); err != nil { + conn.Close() + return nil, fmt.Errorf("set handshake deadline: %w", err) + } + + // Perform Cloud Hypervisor vsock handshake + handshakeCmd := fmt.Sprintf("CONNECT %d\n", vsockGuestPort) + if _, err := conn.Write([]byte(handshakeCmd)); err != nil { + conn.Close() + return nil, fmt.Errorf("send vsock handshake: %w", err) + } + + // Read handshake response + reader := bufio.NewReader(conn) + response, err := reader.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read vsock handshake response (is guest-agent running in guest?): %w", err) + } + + // Clear deadline after successful handshake + if err := conn.SetDeadline(time.Time{}); err != nil { + conn.Close() + return nil, fmt.Errorf("clear deadline: %w", err) + } + + response = strings.TrimSpace(response) + if !strings.HasPrefix(response, "OK ") { + conn.Close() + return nil, fmt.Errorf("vsock handshake failed: %s", response) + } + + slog.DebugContext(ctx, "vsock handshake successful", "response", response) + + // Return wrapped connection that uses the bufio.Reader + // This ensures any bytes buffered during handshake are not lost + return &bufferedConn{Conn: conn, reader: reader}, nil +} + +// CopyToInstanceOptions configures a copy-to-instance operation +type CopyToInstanceOptions struct { + SrcPath string // Local source path + DstPath string // Destination path in guest + Mode fs.FileMode // Optional: override file mode (0 = preserve source) +} + +// CopyToInstance copies a file or directory to an instance via vsock +func CopyToInstance(ctx context.Context, vsockSocketPath string, opts CopyToInstanceOptions) error { + grpcConn, err := getOrCreateConn(ctx, vsockSocketPath) + if err != nil { + return fmt.Errorf("get grpc connection: %w", err) + } + + client := NewGuestServiceClient(grpcConn) + + // Stat the source + srcInfo, err := os.Stat(opts.SrcPath) + if err != nil { + return fmt.Errorf("stat source: %w", err) + } + + if srcInfo.IsDir() { + return copyDirToInstance(ctx, client, opts.SrcPath, opts.DstPath) + } + return copyFileToInstance(ctx, client, opts.SrcPath, opts.DstPath, opts.Mode) +} + +// copyFileToInstance copies a single file to the instance +func copyFileToInstance(ctx context.Context, client GuestServiceClient, srcPath, dstPath string, mode fs.FileMode) error { + srcInfo, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("stat source: %w", err) + } + + if mode == 0 { + mode = srcInfo.Mode().Perm() + } + + stream, err := client.CopyToGuest(ctx) + if err != nil { + return fmt.Errorf("start copy stream: %w", err) + } + + // Send start message + if err := stream.Send(&CopyToGuestRequest{ + Request: &CopyToGuestRequest_Start{ + Start: &CopyToGuestStart{ + Path: dstPath, + Mode: uint32(mode), + IsDir: false, + Size: srcInfo.Size(), + Mtime: srcInfo.ModTime().Unix(), + }, + }, + }); err != nil { + return fmt.Errorf("send start: %w", err) + } + + // Open and stream file content + file, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer file.Close() + + buf := make([]byte, 32*1024) + for { + n, err := file.Read(buf) + if n > 0 { + if sendErr := stream.Send(&CopyToGuestRequest{ + Request: &CopyToGuestRequest_Data{Data: buf[:n]}, + }); sendErr != nil { + return fmt.Errorf("send data: %w", sendErr) + } + } + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read source: %w", err) + } + } + + // Send end message + if err := stream.Send(&CopyToGuestRequest{ + Request: &CopyToGuestRequest_End{End: &CopyToGuestEnd{}}, + }); err != nil { + return fmt.Errorf("send end: %w", err) + } + + resp, err := stream.CloseAndRecv() + if err != nil { + return fmt.Errorf("close stream: %w", err) + } + + if !resp.Success { + return fmt.Errorf("copy failed: %s", resp.Error) + } + + return nil +} + +// copyDirToInstance copies a directory recursively to the instance +func copyDirToInstance(ctx context.Context, client GuestServiceClient, srcPath, dstPath string) error { + return filepath.WalkDir(srcPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcPath, path) + if err != nil { + return fmt.Errorf("relative path: %w", err) + } + + targetPath := filepath.Join(dstPath, relPath) + if targetPath == dstPath && relPath == "." { + targetPath = dstPath + } + + info, err := d.Info() + if err != nil { + return fmt.Errorf("info: %w", err) + } + + if d.IsDir() { + // Create directory + stream, err := client.CopyToGuest(ctx) + if err != nil { + return fmt.Errorf("start copy stream: %w", err) + } + + if err := stream.Send(&CopyToGuestRequest{ + Request: &CopyToGuestRequest_Start{ + Start: &CopyToGuestStart{ + Path: targetPath, + Mode: uint32(info.Mode().Perm()), + IsDir: true, + Mtime: info.ModTime().Unix(), + }, + }, + }); err != nil { + return fmt.Errorf("send start: %w", err) + } + + if err := stream.Send(&CopyToGuestRequest{ + Request: &CopyToGuestRequest_End{End: &CopyToGuestEnd{}}, + }); err != nil { + return fmt.Errorf("send end: %w", err) + } + + resp, err := stream.CloseAndRecv() + if err != nil { + return fmt.Errorf("close stream: %w", err) + } + + if !resp.Success { + return fmt.Errorf("create directory failed: %s", resp.Error) + } + return nil + } + + // Copy file + return copyFileToInstance(ctx, client, path, targetPath, 0) + }) +} + +// CopyFromInstanceOptions configures a copy-from-instance operation +type CopyFromInstanceOptions struct { + SrcPath string // Source path in guest + DstPath string // Local destination path + FollowLinks bool // Follow symbolic links +} + +// FileHandler is called for each file received from the instance +type FileHandler func(header *CopyFromGuestHeader, data io.Reader) error + +// CopyFromInstance copies a file or directory from an instance via vsock +func CopyFromInstance(ctx context.Context, vsockSocketPath string, opts CopyFromInstanceOptions) error { + grpcConn, err := getOrCreateConn(ctx, vsockSocketPath) + if err != nil { + return fmt.Errorf("get grpc connection: %w", err) + } + + client := NewGuestServiceClient(grpcConn) + + stream, err := client.CopyFromGuest(ctx, &CopyFromGuestRequest{ + Path: opts.SrcPath, + FollowLinks: opts.FollowLinks, + }) + if err != nil { + return fmt.Errorf("start copy stream: %w", err) + } + + var currentFile *os.File + var currentHeader *CopyFromGuestHeader + var receivedFinal bool + + // Ensure file is closed on error paths + defer func() { + if currentFile != nil { + currentFile.Close() + } + }() + + for { + resp, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("receive: %w", err) + } + + switch r := resp.Response.(type) { + case *CopyFromGuestResponse_Header: + // Close previous file if any + if currentFile != nil { + currentFile.Close() + currentFile = nil + } + + currentHeader = r.Header + // Use securejoin to prevent path traversal attacks + targetPath, err := securejoin.SecureJoin(opts.DstPath, r.Header.Path) + if err != nil { + return fmt.Errorf("invalid path %s: %w", r.Header.Path, err) + } + + if r.Header.IsDir { + if err := os.MkdirAll(targetPath, fs.FileMode(r.Header.Mode)); err != nil { + return fmt.Errorf("create directory %s: %w", targetPath, err) + } + } else if r.Header.IsSymlink { + // Validate symlink target to prevent path traversal attacks + // Reject absolute paths + if filepath.IsAbs(r.Header.LinkTarget) { + return fmt.Errorf("invalid symlink target (absolute path not allowed): %s", r.Header.LinkTarget) + } + // Reject targets that escape the destination directory + // Resolve the link target relative to the symlink's parent directory + linkDir := filepath.Dir(targetPath) + resolvedTarget := filepath.Clean(filepath.Join(linkDir, r.Header.LinkTarget)) + cleanDst := filepath.Clean(opts.DstPath) + // Check path containment - handle root destination specially + var contained bool + if cleanDst == "/" { + // For root destination, any absolute path that doesn't contain ".." after cleaning is valid + contained = !strings.Contains(resolvedTarget, "..") + } else { + contained = strings.HasPrefix(resolvedTarget, cleanDst+string(filepath.Separator)) || resolvedTarget == cleanDst + } + if !contained { + return fmt.Errorf("invalid symlink target (escapes destination): %s", r.Header.LinkTarget) + } + + // Create parent directory if needed + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("create parent dir for symlink: %w", err) + } + // Create symlink + os.Remove(targetPath) // Remove existing if any + if err := os.Symlink(r.Header.LinkTarget, targetPath); err != nil { + return fmt.Errorf("create symlink %s: %w", targetPath, err) + } + } else { + // Create parent directory + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("create parent dir: %w", err) + } + // Create file + f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fs.FileMode(r.Header.Mode)) + if err != nil { + return fmt.Errorf("create file %s: %w", targetPath, err) + } + currentFile = f + } + + case *CopyFromGuestResponse_Data: + if currentFile != nil { + if _, err := currentFile.Write(r.Data); err != nil { + return fmt.Errorf("write: %w", err) + } + } + + case *CopyFromGuestResponse_End: + if currentFile != nil { + currentFile.Close() + currentFile = nil + } + // Set modification time for files and directories + if currentHeader != nil && currentHeader.Mtime > 0 { + targetPath, err := securejoin.SecureJoin(opts.DstPath, currentHeader.Path) + if err == nil { + mtime := time.Unix(currentHeader.Mtime, 0) + os.Chtimes(targetPath, mtime, mtime) + } + } + currentHeader = nil + if r.End.Final { + receivedFinal = true + return nil + } + + case *CopyFromGuestResponse_Error: + return fmt.Errorf("copy error at %s: %s", r.Error.Path, r.Error.Message) + } + } + + if !receivedFinal { + return fmt.Errorf("copy stream ended without completion marker") + } + return nil +} + diff --git a/lib/guest/guest.pb.go b/lib/guest/guest.pb.go new file mode 100644 index 00000000..80549077 --- /dev/null +++ b/lib/guest/guest.pb.go @@ -0,0 +1,1124 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: lib/guest/guest.proto + +package guest + +import ( + fmt "fmt" + proto "github.com/golang/protobuf/proto" + math "math" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package + +// ExecRequest represents messages from client to server +type ExecRequest struct { + // Types that are valid to be assigned to Request: + // + // *ExecRequest_Start + // *ExecRequest_Stdin + Request isExecRequest_Request `protobuf_oneof:"request"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ExecRequest) Reset() { *m = ExecRequest{} } +func (m *ExecRequest) String() string { return proto.CompactTextString(m) } +func (*ExecRequest) ProtoMessage() {} +func (*ExecRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{0} +} + +func (m *ExecRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ExecRequest.Unmarshal(m, b) +} +func (m *ExecRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ExecRequest.Marshal(b, m, deterministic) +} +func (m *ExecRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_ExecRequest.Merge(m, src) +} +func (m *ExecRequest) XXX_Size() int { + return xxx_messageInfo_ExecRequest.Size(m) +} +func (m *ExecRequest) XXX_DiscardUnknown() { + xxx_messageInfo_ExecRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_ExecRequest proto.InternalMessageInfo + +type isExecRequest_Request interface { + isExecRequest_Request() +} + +type ExecRequest_Start struct { + Start *ExecStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ExecRequest_Stdin struct { + Stdin []byte `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"` +} + +func (*ExecRequest_Start) isExecRequest_Request() {} + +func (*ExecRequest_Stdin) isExecRequest_Request() {} + +func (m *ExecRequest) GetRequest() isExecRequest_Request { + if m != nil { + return m.Request + } + return nil +} + +func (m *ExecRequest) GetStart() *ExecStart { + if x, ok := m.GetRequest().(*ExecRequest_Start); ok { + return x.Start + } + return nil +} + +func (m *ExecRequest) GetStdin() []byte { + if x, ok := m.GetRequest().(*ExecRequest_Stdin); ok { + return x.Stdin + } + return nil +} + +// XXX_OneofWrappers is for the internal use of the proto package. +func (*ExecRequest) XXX_OneofWrappers() []interface{} { + return []interface{}{ + (*ExecRequest_Start)(nil), + (*ExecRequest_Stdin)(nil), + } +} + +// ExecStart initiates command execution +type ExecStart struct { + Command []string `protobuf:"bytes,1,rep,name=command,proto3" json:"command,omitempty"` + Tty bool `protobuf:"varint,2,opt,name=tty,proto3" json:"tty,omitempty"` + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Cwd string `protobuf:"bytes,4,opt,name=cwd,proto3" json:"cwd,omitempty"` + TimeoutSeconds int32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ExecStart) Reset() { *m = ExecStart{} } +func (m *ExecStart) String() string { return proto.CompactTextString(m) } +func (*ExecStart) ProtoMessage() {} +func (*ExecStart) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{1} +} + +func (m *ExecStart) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ExecStart.Unmarshal(m, b) +} +func (m *ExecStart) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ExecStart.Marshal(b, m, deterministic) +} +func (m *ExecStart) XXX_Merge(src proto.Message) { + xxx_messageInfo_ExecStart.Merge(m, src) +} +func (m *ExecStart) XXX_Size() int { + return xxx_messageInfo_ExecStart.Size(m) +} +func (m *ExecStart) XXX_DiscardUnknown() { + xxx_messageInfo_ExecStart.DiscardUnknown(m) +} + +var xxx_messageInfo_ExecStart proto.InternalMessageInfo + +func (m *ExecStart) GetCommand() []string { + if m != nil { + return m.Command + } + return nil +} + +func (m *ExecStart) GetTty() bool { + if m != nil { + return m.Tty + } + return false +} + +func (m *ExecStart) GetEnv() map[string]string { + if m != nil { + return m.Env + } + return nil +} + +func (m *ExecStart) GetCwd() string { + if m != nil { + return m.Cwd + } + return "" +} + +func (m *ExecStart) GetTimeoutSeconds() int32 { + if m != nil { + return m.TimeoutSeconds + } + return 0 +} + +// ExecResponse represents messages from server to client +type ExecResponse struct { + // Types that are valid to be assigned to Response: + // + // *ExecResponse_Stdout + // *ExecResponse_Stderr + // *ExecResponse_ExitCode + Response isExecResponse_Response `protobuf_oneof:"response"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ExecResponse) Reset() { *m = ExecResponse{} } +func (m *ExecResponse) String() string { return proto.CompactTextString(m) } +func (*ExecResponse) ProtoMessage() {} +func (*ExecResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{2} +} + +func (m *ExecResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_ExecResponse.Unmarshal(m, b) +} +func (m *ExecResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_ExecResponse.Marshal(b, m, deterministic) +} +func (m *ExecResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_ExecResponse.Merge(m, src) +} +func (m *ExecResponse) XXX_Size() int { + return xxx_messageInfo_ExecResponse.Size(m) +} +func (m *ExecResponse) XXX_DiscardUnknown() { + xxx_messageInfo_ExecResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_ExecResponse proto.InternalMessageInfo + +type isExecResponse_Response interface { + isExecResponse_Response() +} + +type ExecResponse_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ExecResponse_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ExecResponse_ExitCode struct { + ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3,oneof"` +} + +func (*ExecResponse_Stdout) isExecResponse_Response() {} + +func (*ExecResponse_Stderr) isExecResponse_Response() {} + +func (*ExecResponse_ExitCode) isExecResponse_Response() {} + +func (m *ExecResponse) GetResponse() isExecResponse_Response { + if m != nil { + return m.Response + } + return nil +} + +func (m *ExecResponse) GetStdout() []byte { + if x, ok := m.GetResponse().(*ExecResponse_Stdout); ok { + return x.Stdout + } + return nil +} + +func (m *ExecResponse) GetStderr() []byte { + if x, ok := m.GetResponse().(*ExecResponse_Stderr); ok { + return x.Stderr + } + return nil +} + +func (m *ExecResponse) GetExitCode() int32 { + if x, ok := m.GetResponse().(*ExecResponse_ExitCode); ok { + return x.ExitCode + } + return 0 +} + +// XXX_OneofWrappers is for the internal use of the proto package. +func (*ExecResponse) XXX_OneofWrappers() []interface{} { + return []interface{}{ + (*ExecResponse_Stdout)(nil), + (*ExecResponse_Stderr)(nil), + (*ExecResponse_ExitCode)(nil), + } +} + +// CopyToGuestRequest represents messages for copying files to guest +type CopyToGuestRequest struct { + // Types that are valid to be assigned to Request: + // + // *CopyToGuestRequest_Start + // *CopyToGuestRequest_Data + // *CopyToGuestRequest_End + Request isCopyToGuestRequest_Request `protobuf_oneof:"request"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyToGuestRequest) Reset() { *m = CopyToGuestRequest{} } +func (m *CopyToGuestRequest) String() string { return proto.CompactTextString(m) } +func (*CopyToGuestRequest) ProtoMessage() {} +func (*CopyToGuestRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{3} +} + +func (m *CopyToGuestRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyToGuestRequest.Unmarshal(m, b) +} +func (m *CopyToGuestRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyToGuestRequest.Marshal(b, m, deterministic) +} +func (m *CopyToGuestRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyToGuestRequest.Merge(m, src) +} +func (m *CopyToGuestRequest) XXX_Size() int { + return xxx_messageInfo_CopyToGuestRequest.Size(m) +} +func (m *CopyToGuestRequest) XXX_DiscardUnknown() { + xxx_messageInfo_CopyToGuestRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyToGuestRequest proto.InternalMessageInfo + +type isCopyToGuestRequest_Request interface { + isCopyToGuestRequest_Request() +} + +type CopyToGuestRequest_Start struct { + Start *CopyToGuestStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type CopyToGuestRequest_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type CopyToGuestRequest_End struct { + End *CopyToGuestEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +func (*CopyToGuestRequest_Start) isCopyToGuestRequest_Request() {} + +func (*CopyToGuestRequest_Data) isCopyToGuestRequest_Request() {} + +func (*CopyToGuestRequest_End) isCopyToGuestRequest_Request() {} + +func (m *CopyToGuestRequest) GetRequest() isCopyToGuestRequest_Request { + if m != nil { + return m.Request + } + return nil +} + +func (m *CopyToGuestRequest) GetStart() *CopyToGuestStart { + if x, ok := m.GetRequest().(*CopyToGuestRequest_Start); ok { + return x.Start + } + return nil +} + +func (m *CopyToGuestRequest) GetData() []byte { + if x, ok := m.GetRequest().(*CopyToGuestRequest_Data); ok { + return x.Data + } + return nil +} + +func (m *CopyToGuestRequest) GetEnd() *CopyToGuestEnd { + if x, ok := m.GetRequest().(*CopyToGuestRequest_End); ok { + return x.End + } + return nil +} + +// XXX_OneofWrappers is for the internal use of the proto package. +func (*CopyToGuestRequest) XXX_OneofWrappers() []interface{} { + return []interface{}{ + (*CopyToGuestRequest_Start)(nil), + (*CopyToGuestRequest_Data)(nil), + (*CopyToGuestRequest_End)(nil), + } +} + +// CopyToGuestStart initiates a copy-to-guest operation +type CopyToGuestStart struct { + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Mode uint32 `protobuf:"varint,2,opt,name=mode,proto3" json:"mode,omitempty"` + IsDir bool `protobuf:"varint,3,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Mtime int64 `protobuf:"varint,5,opt,name=mtime,proto3" json:"mtime,omitempty"` + Uid uint32 `protobuf:"varint,6,opt,name=uid,proto3" json:"uid,omitempty"` + Gid uint32 `protobuf:"varint,7,opt,name=gid,proto3" json:"gid,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyToGuestStart) Reset() { *m = CopyToGuestStart{} } +func (m *CopyToGuestStart) String() string { return proto.CompactTextString(m) } +func (*CopyToGuestStart) ProtoMessage() {} +func (*CopyToGuestStart) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{4} +} + +func (m *CopyToGuestStart) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyToGuestStart.Unmarshal(m, b) +} +func (m *CopyToGuestStart) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyToGuestStart.Marshal(b, m, deterministic) +} +func (m *CopyToGuestStart) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyToGuestStart.Merge(m, src) +} +func (m *CopyToGuestStart) XXX_Size() int { + return xxx_messageInfo_CopyToGuestStart.Size(m) +} +func (m *CopyToGuestStart) XXX_DiscardUnknown() { + xxx_messageInfo_CopyToGuestStart.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyToGuestStart proto.InternalMessageInfo + +func (m *CopyToGuestStart) GetPath() string { + if m != nil { + return m.Path + } + return "" +} + +func (m *CopyToGuestStart) GetMode() uint32 { + if m != nil { + return m.Mode + } + return 0 +} + +func (m *CopyToGuestStart) GetIsDir() bool { + if m != nil { + return m.IsDir + } + return false +} + +func (m *CopyToGuestStart) GetSize() int64 { + if m != nil { + return m.Size + } + return 0 +} + +func (m *CopyToGuestStart) GetMtime() int64 { + if m != nil { + return m.Mtime + } + return 0 +} + +func (m *CopyToGuestStart) GetUid() uint32 { + if m != nil { + return m.Uid + } + return 0 +} + +func (m *CopyToGuestStart) GetGid() uint32 { + if m != nil { + return m.Gid + } + return 0 +} + +// CopyToGuestEnd signals the end of a file transfer +type CopyToGuestEnd struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyToGuestEnd) Reset() { *m = CopyToGuestEnd{} } +func (m *CopyToGuestEnd) String() string { return proto.CompactTextString(m) } +func (*CopyToGuestEnd) ProtoMessage() {} +func (*CopyToGuestEnd) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{5} +} + +func (m *CopyToGuestEnd) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyToGuestEnd.Unmarshal(m, b) +} +func (m *CopyToGuestEnd) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyToGuestEnd.Marshal(b, m, deterministic) +} +func (m *CopyToGuestEnd) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyToGuestEnd.Merge(m, src) +} +func (m *CopyToGuestEnd) XXX_Size() int { + return xxx_messageInfo_CopyToGuestEnd.Size(m) +} +func (m *CopyToGuestEnd) XXX_DiscardUnknown() { + xxx_messageInfo_CopyToGuestEnd.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyToGuestEnd proto.InternalMessageInfo + +// CopyToGuestResponse is the response after a copy-to-guest operation +type CopyToGuestResponse struct { + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + BytesWritten int64 `protobuf:"varint,3,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyToGuestResponse) Reset() { *m = CopyToGuestResponse{} } +func (m *CopyToGuestResponse) String() string { return proto.CompactTextString(m) } +func (*CopyToGuestResponse) ProtoMessage() {} +func (*CopyToGuestResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{6} +} + +func (m *CopyToGuestResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyToGuestResponse.Unmarshal(m, b) +} +func (m *CopyToGuestResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyToGuestResponse.Marshal(b, m, deterministic) +} +func (m *CopyToGuestResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyToGuestResponse.Merge(m, src) +} +func (m *CopyToGuestResponse) XXX_Size() int { + return xxx_messageInfo_CopyToGuestResponse.Size(m) +} +func (m *CopyToGuestResponse) XXX_DiscardUnknown() { + xxx_messageInfo_CopyToGuestResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyToGuestResponse proto.InternalMessageInfo + +func (m *CopyToGuestResponse) GetSuccess() bool { + if m != nil { + return m.Success + } + return false +} + +func (m *CopyToGuestResponse) GetError() string { + if m != nil { + return m.Error + } + return "" +} + +func (m *CopyToGuestResponse) GetBytesWritten() int64 { + if m != nil { + return m.BytesWritten + } + return 0 +} + +// CopyFromGuestRequest initiates a copy-from-guest operation +type CopyFromGuestRequest struct { + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + FollowLinks bool `protobuf:"varint,2,opt,name=follow_links,json=followLinks,proto3" json:"follow_links,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyFromGuestRequest) Reset() { *m = CopyFromGuestRequest{} } +func (m *CopyFromGuestRequest) String() string { return proto.CompactTextString(m) } +func (*CopyFromGuestRequest) ProtoMessage() {} +func (*CopyFromGuestRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{7} +} + +func (m *CopyFromGuestRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyFromGuestRequest.Unmarshal(m, b) +} +func (m *CopyFromGuestRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyFromGuestRequest.Marshal(b, m, deterministic) +} +func (m *CopyFromGuestRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyFromGuestRequest.Merge(m, src) +} +func (m *CopyFromGuestRequest) XXX_Size() int { + return xxx_messageInfo_CopyFromGuestRequest.Size(m) +} +func (m *CopyFromGuestRequest) XXX_DiscardUnknown() { + xxx_messageInfo_CopyFromGuestRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyFromGuestRequest proto.InternalMessageInfo + +func (m *CopyFromGuestRequest) GetPath() string { + if m != nil { + return m.Path + } + return "" +} + +func (m *CopyFromGuestRequest) GetFollowLinks() bool { + if m != nil { + return m.FollowLinks + } + return false +} + +// CopyFromGuestResponse streams file data from guest +type CopyFromGuestResponse struct { + // Types that are valid to be assigned to Response: + // + // *CopyFromGuestResponse_Header + // *CopyFromGuestResponse_Data + // *CopyFromGuestResponse_End + // *CopyFromGuestResponse_Error + Response isCopyFromGuestResponse_Response `protobuf_oneof:"response"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyFromGuestResponse) Reset() { *m = CopyFromGuestResponse{} } +func (m *CopyFromGuestResponse) String() string { return proto.CompactTextString(m) } +func (*CopyFromGuestResponse) ProtoMessage() {} +func (*CopyFromGuestResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{8} +} + +func (m *CopyFromGuestResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyFromGuestResponse.Unmarshal(m, b) +} +func (m *CopyFromGuestResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyFromGuestResponse.Marshal(b, m, deterministic) +} +func (m *CopyFromGuestResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyFromGuestResponse.Merge(m, src) +} +func (m *CopyFromGuestResponse) XXX_Size() int { + return xxx_messageInfo_CopyFromGuestResponse.Size(m) +} +func (m *CopyFromGuestResponse) XXX_DiscardUnknown() { + xxx_messageInfo_CopyFromGuestResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyFromGuestResponse proto.InternalMessageInfo + +type isCopyFromGuestResponse_Response interface { + isCopyFromGuestResponse_Response() +} + +type CopyFromGuestResponse_Header struct { + Header *CopyFromGuestHeader `protobuf:"bytes,1,opt,name=header,proto3,oneof"` +} + +type CopyFromGuestResponse_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type CopyFromGuestResponse_End struct { + End *CopyFromGuestEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type CopyFromGuestResponse_Error struct { + Error *CopyFromGuestError `protobuf:"bytes,4,opt,name=error,proto3,oneof"` +} + +func (*CopyFromGuestResponse_Header) isCopyFromGuestResponse_Response() {} + +func (*CopyFromGuestResponse_Data) isCopyFromGuestResponse_Response() {} + +func (*CopyFromGuestResponse_End) isCopyFromGuestResponse_Response() {} + +func (*CopyFromGuestResponse_Error) isCopyFromGuestResponse_Response() {} + +func (m *CopyFromGuestResponse) GetResponse() isCopyFromGuestResponse_Response { + if m != nil { + return m.Response + } + return nil +} + +func (m *CopyFromGuestResponse) GetHeader() *CopyFromGuestHeader { + if x, ok := m.GetResponse().(*CopyFromGuestResponse_Header); ok { + return x.Header + } + return nil +} + +func (m *CopyFromGuestResponse) GetData() []byte { + if x, ok := m.GetResponse().(*CopyFromGuestResponse_Data); ok { + return x.Data + } + return nil +} + +func (m *CopyFromGuestResponse) GetEnd() *CopyFromGuestEnd { + if x, ok := m.GetResponse().(*CopyFromGuestResponse_End); ok { + return x.End + } + return nil +} + +func (m *CopyFromGuestResponse) GetError() *CopyFromGuestError { + if x, ok := m.GetResponse().(*CopyFromGuestResponse_Error); ok { + return x.Error + } + return nil +} + +// XXX_OneofWrappers is for the internal use of the proto package. +func (*CopyFromGuestResponse) XXX_OneofWrappers() []interface{} { + return []interface{}{ + (*CopyFromGuestResponse_Header)(nil), + (*CopyFromGuestResponse_Data)(nil), + (*CopyFromGuestResponse_End)(nil), + (*CopyFromGuestResponse_Error)(nil), + } +} + +// CopyFromGuestHeader provides metadata about a file being copied +type CopyFromGuestHeader struct { + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Mode uint32 `protobuf:"varint,2,opt,name=mode,proto3" json:"mode,omitempty"` + IsDir bool `protobuf:"varint,3,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + IsSymlink bool `protobuf:"varint,4,opt,name=is_symlink,json=isSymlink,proto3" json:"is_symlink,omitempty"` + LinkTarget string `protobuf:"bytes,5,opt,name=link_target,json=linkTarget,proto3" json:"link_target,omitempty"` + Size int64 `protobuf:"varint,6,opt,name=size,proto3" json:"size,omitempty"` + Mtime int64 `protobuf:"varint,7,opt,name=mtime,proto3" json:"mtime,omitempty"` + Uid uint32 `protobuf:"varint,8,opt,name=uid,proto3" json:"uid,omitempty"` + Gid uint32 `protobuf:"varint,9,opt,name=gid,proto3" json:"gid,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyFromGuestHeader) Reset() { *m = CopyFromGuestHeader{} } +func (m *CopyFromGuestHeader) String() string { return proto.CompactTextString(m) } +func (*CopyFromGuestHeader) ProtoMessage() {} +func (*CopyFromGuestHeader) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{9} +} + +func (m *CopyFromGuestHeader) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyFromGuestHeader.Unmarshal(m, b) +} +func (m *CopyFromGuestHeader) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyFromGuestHeader.Marshal(b, m, deterministic) +} +func (m *CopyFromGuestHeader) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyFromGuestHeader.Merge(m, src) +} +func (m *CopyFromGuestHeader) XXX_Size() int { + return xxx_messageInfo_CopyFromGuestHeader.Size(m) +} +func (m *CopyFromGuestHeader) XXX_DiscardUnknown() { + xxx_messageInfo_CopyFromGuestHeader.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyFromGuestHeader proto.InternalMessageInfo + +func (m *CopyFromGuestHeader) GetPath() string { + if m != nil { + return m.Path + } + return "" +} + +func (m *CopyFromGuestHeader) GetMode() uint32 { + if m != nil { + return m.Mode + } + return 0 +} + +func (m *CopyFromGuestHeader) GetIsDir() bool { + if m != nil { + return m.IsDir + } + return false +} + +func (m *CopyFromGuestHeader) GetIsSymlink() bool { + if m != nil { + return m.IsSymlink + } + return false +} + +func (m *CopyFromGuestHeader) GetLinkTarget() string { + if m != nil { + return m.LinkTarget + } + return "" +} + +func (m *CopyFromGuestHeader) GetSize() int64 { + if m != nil { + return m.Size + } + return 0 +} + +func (m *CopyFromGuestHeader) GetMtime() int64 { + if m != nil { + return m.Mtime + } + return 0 +} + +func (m *CopyFromGuestHeader) GetUid() uint32 { + if m != nil { + return m.Uid + } + return 0 +} + +func (m *CopyFromGuestHeader) GetGid() uint32 { + if m != nil { + return m.Gid + } + return 0 +} + +// CopyFromGuestEnd signals the end of a file or transfer +type CopyFromGuestEnd struct { + Final bool `protobuf:"varint,1,opt,name=final,proto3" json:"final,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyFromGuestEnd) Reset() { *m = CopyFromGuestEnd{} } +func (m *CopyFromGuestEnd) String() string { return proto.CompactTextString(m) } +func (*CopyFromGuestEnd) ProtoMessage() {} +func (*CopyFromGuestEnd) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{10} +} + +func (m *CopyFromGuestEnd) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyFromGuestEnd.Unmarshal(m, b) +} +func (m *CopyFromGuestEnd) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyFromGuestEnd.Marshal(b, m, deterministic) +} +func (m *CopyFromGuestEnd) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyFromGuestEnd.Merge(m, src) +} +func (m *CopyFromGuestEnd) XXX_Size() int { + return xxx_messageInfo_CopyFromGuestEnd.Size(m) +} +func (m *CopyFromGuestEnd) XXX_DiscardUnknown() { + xxx_messageInfo_CopyFromGuestEnd.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyFromGuestEnd proto.InternalMessageInfo + +func (m *CopyFromGuestEnd) GetFinal() bool { + if m != nil { + return m.Final + } + return false +} + +// CopyFromGuestError reports an error during copy +type CopyFromGuestError struct { + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CopyFromGuestError) Reset() { *m = CopyFromGuestError{} } +func (m *CopyFromGuestError) String() string { return proto.CompactTextString(m) } +func (*CopyFromGuestError) ProtoMessage() {} +func (*CopyFromGuestError) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{11} +} + +func (m *CopyFromGuestError) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_CopyFromGuestError.Unmarshal(m, b) +} +func (m *CopyFromGuestError) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_CopyFromGuestError.Marshal(b, m, deterministic) +} +func (m *CopyFromGuestError) XXX_Merge(src proto.Message) { + xxx_messageInfo_CopyFromGuestError.Merge(m, src) +} +func (m *CopyFromGuestError) XXX_Size() int { + return xxx_messageInfo_CopyFromGuestError.Size(m) +} +func (m *CopyFromGuestError) XXX_DiscardUnknown() { + xxx_messageInfo_CopyFromGuestError.DiscardUnknown(m) +} + +var xxx_messageInfo_CopyFromGuestError proto.InternalMessageInfo + +func (m *CopyFromGuestError) GetMessage() string { + if m != nil { + return m.Message + } + return "" +} + +func (m *CopyFromGuestError) GetPath() string { + if m != nil { + return m.Path + } + return "" +} + +// StatPathRequest requests information about a path +type StatPathRequest struct { + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + FollowLinks bool `protobuf:"varint,2,opt,name=follow_links,json=followLinks,proto3" json:"follow_links,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *StatPathRequest) Reset() { *m = StatPathRequest{} } +func (m *StatPathRequest) String() string { return proto.CompactTextString(m) } +func (*StatPathRequest) ProtoMessage() {} +func (*StatPathRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{12} +} + +func (m *StatPathRequest) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_StatPathRequest.Unmarshal(m, b) +} +func (m *StatPathRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_StatPathRequest.Marshal(b, m, deterministic) +} +func (m *StatPathRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_StatPathRequest.Merge(m, src) +} +func (m *StatPathRequest) XXX_Size() int { + return xxx_messageInfo_StatPathRequest.Size(m) +} +func (m *StatPathRequest) XXX_DiscardUnknown() { + xxx_messageInfo_StatPathRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_StatPathRequest proto.InternalMessageInfo + +func (m *StatPathRequest) GetPath() string { + if m != nil { + return m.Path + } + return "" +} + +func (m *StatPathRequest) GetFollowLinks() bool { + if m != nil { + return m.FollowLinks + } + return false +} + +// StatPathResponse contains information about a path +type StatPathResponse struct { + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` + IsDir bool `protobuf:"varint,2,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + IsFile bool `protobuf:"varint,3,opt,name=is_file,json=isFile,proto3" json:"is_file,omitempty"` + IsSymlink bool `protobuf:"varint,4,opt,name=is_symlink,json=isSymlink,proto3" json:"is_symlink,omitempty"` + LinkTarget string `protobuf:"bytes,5,opt,name=link_target,json=linkTarget,proto3" json:"link_target,omitempty"` + Mode uint32 `protobuf:"varint,6,opt,name=mode,proto3" json:"mode,omitempty"` + Size int64 `protobuf:"varint,7,opt,name=size,proto3" json:"size,omitempty"` + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *StatPathResponse) Reset() { *m = StatPathResponse{} } +func (m *StatPathResponse) String() string { return proto.CompactTextString(m) } +func (*StatPathResponse) ProtoMessage() {} +func (*StatPathResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_44c1cba55f3bcb29, []int{13} +} + +func (m *StatPathResponse) XXX_Unmarshal(b []byte) error { + return xxx_messageInfo_StatPathResponse.Unmarshal(m, b) +} +func (m *StatPathResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + return xxx_messageInfo_StatPathResponse.Marshal(b, m, deterministic) +} +func (m *StatPathResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_StatPathResponse.Merge(m, src) +} +func (m *StatPathResponse) XXX_Size() int { + return xxx_messageInfo_StatPathResponse.Size(m) +} +func (m *StatPathResponse) XXX_DiscardUnknown() { + xxx_messageInfo_StatPathResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_StatPathResponse proto.InternalMessageInfo + +func (m *StatPathResponse) GetExists() bool { + if m != nil { + return m.Exists + } + return false +} + +func (m *StatPathResponse) GetIsDir() bool { + if m != nil { + return m.IsDir + } + return false +} + +func (m *StatPathResponse) GetIsFile() bool { + if m != nil { + return m.IsFile + } + return false +} + +func (m *StatPathResponse) GetIsSymlink() bool { + if m != nil { + return m.IsSymlink + } + return false +} + +func (m *StatPathResponse) GetLinkTarget() string { + if m != nil { + return m.LinkTarget + } + return "" +} + +func (m *StatPathResponse) GetMode() uint32 { + if m != nil { + return m.Mode + } + return 0 +} + +func (m *StatPathResponse) GetSize() int64 { + if m != nil { + return m.Size + } + return 0 +} + +func (m *StatPathResponse) GetError() string { + if m != nil { + return m.Error + } + return "" +} + +func init() { + proto.RegisterType((*ExecRequest)(nil), "guest.ExecRequest") + proto.RegisterType((*ExecStart)(nil), "guest.ExecStart") + proto.RegisterMapType((map[string]string)(nil), "guest.ExecStart.EnvEntry") + proto.RegisterType((*ExecResponse)(nil), "guest.ExecResponse") + proto.RegisterType((*CopyToGuestRequest)(nil), "guest.CopyToGuestRequest") + proto.RegisterType((*CopyToGuestStart)(nil), "guest.CopyToGuestStart") + proto.RegisterType((*CopyToGuestEnd)(nil), "guest.CopyToGuestEnd") + proto.RegisterType((*CopyToGuestResponse)(nil), "guest.CopyToGuestResponse") + proto.RegisterType((*CopyFromGuestRequest)(nil), "guest.CopyFromGuestRequest") + proto.RegisterType((*CopyFromGuestResponse)(nil), "guest.CopyFromGuestResponse") + proto.RegisterType((*CopyFromGuestHeader)(nil), "guest.CopyFromGuestHeader") + proto.RegisterType((*CopyFromGuestEnd)(nil), "guest.CopyFromGuestEnd") + proto.RegisterType((*CopyFromGuestError)(nil), "guest.CopyFromGuestError") + proto.RegisterType((*StatPathRequest)(nil), "guest.StatPathRequest") + proto.RegisterType((*StatPathResponse)(nil), "guest.StatPathResponse") +} + +func init() { + proto.RegisterFile("lib/guest/guest.proto", fileDescriptor_44c1cba55f3bcb29) +} + +var fileDescriptor_44c1cba55f3bcb29 = []byte{ + // 897 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xac, 0x56, 0x5d, 0x6f, 0xe3, 0x44, + 0x14, 0xad, 0xe3, 0xc4, 0xb1, 0x6f, 0xd2, 0xdd, 0x68, 0xb6, 0x1f, 0x6e, 0x60, 0x45, 0x30, 0x42, + 0x6b, 0xb4, 0x52, 0xb3, 0x74, 0x11, 0x42, 0xf0, 0xd6, 0xa5, 0x25, 0x0f, 0x8b, 0x84, 0xa6, 0x2b, + 0x21, 0xed, 0x4b, 0xe4, 0xda, 0xd3, 0x64, 0xa8, 0x3f, 0xc2, 0xcc, 0xa4, 0x6d, 0xf8, 0x17, 0xbc, + 0xf0, 0xca, 0x4f, 0xe2, 0x11, 0x9e, 0xf9, 0x25, 0xe8, 0xce, 0xd8, 0xa9, 0x9d, 0x86, 0xa7, 0xee, + 0x4b, 0x3b, 0xf7, 0xf8, 0xfa, 0xcc, 0xf5, 0x39, 0x67, 0x1c, 0xc3, 0x7e, 0xca, 0x2f, 0xc7, 0xb3, + 0x25, 0x93, 0xca, 0xfc, 0x3d, 0x5e, 0x88, 0x42, 0x15, 0xa4, 0xa3, 0x8b, 0xe0, 0x3d, 0xf4, 0xce, + 0xee, 0x58, 0x4c, 0xd9, 0xaf, 0x58, 0x92, 0x10, 0x3a, 0x52, 0x45, 0x42, 0xf9, 0xd6, 0xc8, 0x0a, + 0x7b, 0x27, 0x83, 0x63, 0x73, 0x0b, 0xb6, 0x5c, 0x20, 0x3e, 0xd9, 0xa1, 0xa6, 0x81, 0x1c, 0x60, + 0x67, 0xc2, 0x73, 0xbf, 0x35, 0xb2, 0xc2, 0xbe, 0xc1, 0x13, 0x9e, 0x9f, 0x7a, 0xd0, 0x15, 0x86, + 0x2c, 0xf8, 0xdb, 0x02, 0x6f, 0x7d, 0x27, 0xf1, 0xa1, 0x1b, 0x17, 0x59, 0x16, 0xe5, 0x89, 0x6f, + 0x8d, 0xec, 0xd0, 0xa3, 0x55, 0x49, 0x06, 0x60, 0x2b, 0xb5, 0xd2, 0x44, 0x2e, 0xc5, 0x25, 0x79, + 0x09, 0x36, 0xcb, 0x6f, 0x7c, 0x7b, 0x64, 0x87, 0xbd, 0x93, 0xa3, 0xcd, 0x21, 0x8e, 0xcf, 0xf2, + 0x9b, 0xb3, 0x5c, 0x89, 0x15, 0xc5, 0x2e, 0xbc, 0x3d, 0xbe, 0x4d, 0xfc, 0xf6, 0xc8, 0x0a, 0x3d, + 0x8a, 0x4b, 0xf2, 0x02, 0x9e, 0x2a, 0x9e, 0xb1, 0x62, 0xa9, 0xa6, 0x92, 0xc5, 0x45, 0x9e, 0x48, + 0xbf, 0x33, 0xb2, 0xc2, 0x0e, 0x7d, 0x52, 0xc2, 0x17, 0x06, 0x1d, 0x7e, 0x0d, 0x6e, 0xc5, 0x85, + 0x34, 0xd7, 0x6c, 0xa5, 0x1f, 0xdc, 0xa3, 0xb8, 0x24, 0x7b, 0xd0, 0xb9, 0x89, 0xd2, 0x25, 0xd3, + 0x93, 0x79, 0xd4, 0x14, 0xdf, 0xb6, 0xbe, 0xb1, 0x82, 0x0c, 0xfa, 0x46, 0x35, 0xb9, 0x28, 0x72, + 0xc9, 0x88, 0x0f, 0x8e, 0x54, 0x49, 0xb1, 0x34, 0xba, 0xa1, 0x1a, 0x65, 0x5d, 0x5e, 0x61, 0x42, + 0xac, 0x75, 0x2a, 0x6b, 0xf2, 0x1c, 0x3c, 0x76, 0xc7, 0xd5, 0x34, 0x2e, 0x12, 0xe6, 0xdb, 0x38, + 0xde, 0x64, 0x87, 0xba, 0x08, 0xbd, 0x29, 0x12, 0x76, 0x0a, 0xe0, 0x8a, 0x92, 0x3e, 0xf8, 0xdd, + 0x02, 0xf2, 0xa6, 0x58, 0xac, 0xde, 0x15, 0x3f, 0xa0, 0x12, 0x95, 0x59, 0xe3, 0xa6, 0x59, 0x87, + 0xa5, 0x4e, 0xb5, 0xce, 0x0d, 0xcf, 0xf6, 0xa0, 0x9d, 0x44, 0x2a, 0x5a, 0x8f, 0xa2, 0x2b, 0xf2, + 0x05, 0x8a, 0x9d, 0xe8, 0x11, 0x7a, 0x27, 0xfb, 0x0f, 0x49, 0xce, 0xf2, 0x64, 0xb2, 0x83, 0x52, + 0x27, 0x75, 0x73, 0xff, 0xb4, 0x60, 0xb0, 0xb9, 0x13, 0x21, 0xd0, 0x5e, 0x44, 0x6a, 0x5e, 0x8a, + 0xa8, 0xd7, 0x88, 0x65, 0xf8, 0x88, 0xb8, 0xe9, 0x2e, 0xd5, 0x6b, 0xb2, 0x0f, 0x0e, 0x97, 0xd3, + 0x84, 0x0b, 0xbd, 0xab, 0x4b, 0x3b, 0x5c, 0x7e, 0xcf, 0x05, 0xb6, 0x4a, 0xfe, 0x1b, 0xd3, 0x56, + 0xda, 0x54, 0xaf, 0xd1, 0x84, 0x0c, 0x5d, 0xd3, 0x0e, 0xda, 0xd4, 0x14, 0x68, 0xd6, 0x92, 0x27, + 0xbe, 0xa3, 0x39, 0x71, 0x89, 0xc8, 0x8c, 0x27, 0x7e, 0xd7, 0x20, 0x33, 0x9e, 0x04, 0x03, 0x78, + 0xd2, 0x7c, 0x8a, 0xe0, 0x17, 0x78, 0xd6, 0x90, 0x71, 0xed, 0x5e, 0x57, 0x2e, 0xe3, 0x98, 0x49, + 0xa9, 0x07, 0x77, 0x69, 0x55, 0xe2, 0xe6, 0x4c, 0x88, 0x42, 0x54, 0x09, 0xd0, 0x05, 0xf9, 0x0c, + 0x76, 0x2f, 0x57, 0x8a, 0xc9, 0xe9, 0xad, 0xe0, 0x4a, 0xb1, 0x5c, 0x3f, 0x84, 0x4d, 0xfb, 0x1a, + 0xfc, 0xd9, 0x60, 0xc1, 0x8f, 0xb0, 0x87, 0x7b, 0x9d, 0x8b, 0x22, 0x6b, 0x98, 0xb6, 0x4d, 0xa2, + 0x4f, 0xa1, 0x7f, 0x55, 0xa4, 0x69, 0x71, 0x3b, 0x4d, 0x79, 0x7e, 0x2d, 0xcb, 0x93, 0xd0, 0x33, + 0xd8, 0x5b, 0x84, 0x82, 0xbf, 0x2c, 0xd8, 0xdf, 0xe0, 0x2b, 0xa7, 0xff, 0x0a, 0x9c, 0x39, 0x8b, + 0x12, 0x26, 0xca, 0x18, 0x0c, 0x6b, 0x0e, 0xae, 0xbb, 0x27, 0xba, 0x03, 0xd3, 0x67, 0x7a, 0xff, + 0x27, 0x0a, 0x2f, 0xeb, 0x51, 0x38, 0xdc, 0x46, 0x74, 0x1f, 0x06, 0xf2, 0x65, 0x25, 0x4e, 0x5b, + 0xb7, 0x1f, 0x6d, 0x6d, 0xc7, 0x06, 0x0c, 0xa0, 0xee, 0x6c, 0x84, 0xfa, 0x5f, 0xcb, 0xb8, 0xb1, + 0x31, 0xe3, 0x63, 0x33, 0xf4, 0x1c, 0x80, 0xcb, 0xa9, 0x5c, 0x65, 0x28, 0xa5, 0x1e, 0xcd, 0xa5, + 0x1e, 0x97, 0x17, 0x06, 0x20, 0x9f, 0x40, 0x0f, 0xff, 0x4f, 0x55, 0x24, 0x66, 0x4c, 0xe9, 0x50, + 0x79, 0x14, 0x10, 0x7a, 0xa7, 0x91, 0x75, 0x06, 0x9d, 0x6d, 0x19, 0xec, 0x6e, 0xc9, 0xa0, 0xfb, + 0x20, 0x83, 0xde, 0x7d, 0x06, 0x43, 0x73, 0x48, 0xea, 0xf2, 0x21, 0xdb, 0x15, 0xcf, 0xa3, 0xb4, + 0x0c, 0x9b, 0x29, 0x82, 0x53, 0x73, 0xc4, 0x9b, 0xca, 0x61, 0x34, 0x33, 0x26, 0x65, 0x34, 0x63, + 0xa5, 0x1e, 0x55, 0xb9, 0x96, 0xa9, 0x75, 0x2f, 0x53, 0x30, 0x81, 0xa7, 0x17, 0x2a, 0x52, 0x3f, + 0x45, 0x6a, 0xfe, 0xc8, 0xb8, 0xfd, 0x63, 0xc1, 0xe0, 0x9e, 0xaa, 0x4c, 0xda, 0x01, 0x38, 0xec, + 0x8e, 0x4b, 0x55, 0x1d, 0x93, 0xb2, 0xaa, 0x39, 0xd1, 0xaa, 0x3b, 0x71, 0x08, 0x5d, 0x2e, 0xa7, + 0x57, 0x3c, 0x65, 0xa5, 0x43, 0x0e, 0x97, 0xe7, 0x3c, 0x65, 0x1f, 0xc2, 0x22, 0x9d, 0x06, 0xa7, + 0x96, 0x86, 0xca, 0xb6, 0x6e, 0xd3, 0x36, 0x13, 0x50, 0xb7, 0x76, 0x7a, 0x4f, 0xfe, 0x68, 0x41, + 0xdf, 0xbc, 0xb2, 0x98, 0xb8, 0xe1, 0x31, 0x23, 0xaf, 0xa1, 0x8d, 0x2f, 0x73, 0x42, 0x6a, 0xbf, + 0x33, 0xa5, 0x7c, 0xc3, 0x67, 0x0d, 0xcc, 0xe8, 0x10, 0x5a, 0xaf, 0x2c, 0x72, 0x0e, 0xbd, 0xda, + 0xab, 0x84, 0x1c, 0x3d, 0x7c, 0x6d, 0x56, 0x14, 0xc3, 0x6d, 0x97, 0x2a, 0x26, 0xf2, 0x16, 0x76, + 0x1b, 0xb6, 0x93, 0x8f, 0xb6, 0x1d, 0xa3, 0x8a, 0xeb, 0xe3, 0xed, 0x17, 0x0d, 0xdb, 0x2b, 0x8b, + 0x7c, 0x07, 0x6e, 0xe5, 0x1a, 0x39, 0x28, 0x7b, 0x37, 0x12, 0x31, 0x3c, 0x7c, 0x80, 0x9b, 0xdb, + 0x4f, 0x5f, 0xbc, 0xff, 0x7c, 0xc6, 0xd5, 0x7c, 0x79, 0x79, 0x1c, 0x17, 0xd9, 0xb8, 0xc8, 0xaf, + 0x99, 0xc8, 0x59, 0x3a, 0x9e, 0xaf, 0x16, 0x2c, 0x8b, 0xf2, 0xf1, 0xfa, 0x33, 0xe2, 0xd2, 0xd1, + 0x5f, 0x10, 0xaf, 0xff, 0x0b, 0x00, 0x00, 0xff, 0xff, 0x85, 0xa3, 0x7e, 0x65, 0x5a, 0x08, 0x00, + 0x00, +} diff --git a/lib/guest/guest.proto b/lib/guest/guest.proto new file mode 100644 index 00000000..7c9e5496 --- /dev/null +++ b/lib/guest/guest.proto @@ -0,0 +1,136 @@ +syntax = "proto3"; + +package guest; + +option go_package = "github.com/onkernel/hypeman/lib/guest"; + +// GuestService provides remote operations in guest VMs +service GuestService { + // Exec executes a command with bidirectional streaming + rpc Exec(stream ExecRequest) returns (stream ExecResponse); + + // CopyToGuest streams file data to the guest filesystem + rpc CopyToGuest(stream CopyToGuestRequest) returns (CopyToGuestResponse); + + // CopyFromGuest streams file data from the guest filesystem + rpc CopyFromGuest(CopyFromGuestRequest) returns (stream CopyFromGuestResponse); + + // StatPath returns information about a path in the guest filesystem + rpc StatPath(StatPathRequest) returns (StatPathResponse); +} + +// ExecRequest represents messages from client to server +message ExecRequest { + oneof request { + ExecStart start = 1; // Initial exec request + bytes stdin = 2; // Stdin data + } +} + +// ExecStart initiates command execution +message ExecStart { + repeated string command = 1; // Command and arguments + bool tty = 2; // Allocate pseudo-TTY + map env = 3; // Environment variables + string cwd = 4; // Working directory (optional) + int32 timeout_seconds = 5; // Execution timeout in seconds (0 = no timeout) +} + +// ExecResponse represents messages from server to client +message ExecResponse { + oneof response { + bytes stdout = 1; // Stdout data + bytes stderr = 2; // Stderr data + int32 exit_code = 3; // Command exit code (final message) + } +} + +// CopyToGuestRequest represents messages for copying files to guest +message CopyToGuestRequest { + oneof request { + CopyToGuestStart start = 1; // Initial copy request with metadata + bytes data = 2; // File content chunk + CopyToGuestEnd end = 3; // End of file marker + } +} + +// CopyToGuestStart initiates a copy-to-guest operation +message CopyToGuestStart { + string path = 1; // Destination path in guest + uint32 mode = 2; // File mode (permissions) + bool is_dir = 3; // True if this is a directory + int64 size = 4; // Expected total size (0 for directories) + int64 mtime = 5; // Modification time (Unix timestamp) + uint32 uid = 6; // User ID (archive mode only, 0 = use default) + uint32 gid = 7; // Group ID (archive mode only, 0 = use default) +} + +// CopyToGuestEnd signals the end of a file transfer +message CopyToGuestEnd { + // Empty message signals transfer complete +} + +// CopyToGuestResponse is the response after a copy-to-guest operation +message CopyToGuestResponse { + bool success = 1; // Whether the copy succeeded + string error = 2; // Error message if failed + int64 bytes_written = 3; // Total bytes written +} + +// CopyFromGuestRequest initiates a copy-from-guest operation +message CopyFromGuestRequest { + string path = 1; // Source path in guest + bool follow_links = 2; // Follow symbolic links (like -L flag) +} + +// CopyFromGuestResponse streams file data from guest +message CopyFromGuestResponse { + oneof response { + CopyFromGuestHeader header = 1; // File/directory metadata + bytes data = 2; // File content chunk + CopyFromGuestEnd end = 3; // End of file/transfer marker + CopyFromGuestError error = 4; // Error during copy + } +} + +// CopyFromGuestHeader provides metadata about a file being copied +message CopyFromGuestHeader { + string path = 1; // Relative path from copy root + uint32 mode = 2; // File mode (permissions) + bool is_dir = 3; // True if this is a directory + bool is_symlink = 4; // True if this is a symbolic link + string link_target = 5; // Symlink target (if is_symlink) + int64 size = 6; // File size (0 for directories) + int64 mtime = 7; // Modification time (Unix timestamp) + uint32 uid = 8; // User ID + uint32 gid = 9; // Group ID +} + +// CopyFromGuestEnd signals the end of a file or transfer +message CopyFromGuestEnd { + bool final = 1; // True if this is the final file +} + +// CopyFromGuestError reports an error during copy +message CopyFromGuestError { + string message = 1; // Error message + string path = 2; // Path that caused error (if applicable) +} + +// StatPathRequest requests information about a path +message StatPathRequest { + string path = 1; // Path to stat + bool follow_links = 2; // Follow symbolic links +} + +// StatPathResponse contains information about a path +message StatPathResponse { + bool exists = 1; // Whether the path exists + bool is_dir = 2; // True if this is a directory + bool is_file = 3; // True if this is a regular file + bool is_symlink = 4; // True if this is a symbolic link (only if follow_links=false) + string link_target = 5; // Symlink target (if is_symlink) + uint32 mode = 6; // File mode (permissions) + int64 size = 7; // File size + string error = 8; // Error message if stat failed (e.g., permission denied) +} diff --git a/lib/guest/guest_grpc.pb.go b/lib/guest/guest_grpc.pb.go new file mode 100644 index 00000000..d656cb60 --- /dev/null +++ b/lib/guest/guest_grpc.pb.go @@ -0,0 +1,238 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc v3.21.12 +// source: lib/guest/guest.proto + +package guest + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GuestService_Exec_FullMethodName = "/guest.GuestService/Exec" + GuestService_CopyToGuest_FullMethodName = "/guest.GuestService/CopyToGuest" + GuestService_CopyFromGuest_FullMethodName = "/guest.GuestService/CopyFromGuest" + GuestService_StatPath_FullMethodName = "/guest.GuestService/StatPath" +) + +// GuestServiceClient is the client API for GuestService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// GuestService provides remote operations in guest VMs +type GuestServiceClient interface { + // Exec executes a command with bidirectional streaming + Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error) + // CopyToGuest streams file data to the guest filesystem + CopyToGuest(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[CopyToGuestRequest, CopyToGuestResponse], error) + // CopyFromGuest streams file data from the guest filesystem + CopyFromGuest(ctx context.Context, in *CopyFromGuestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CopyFromGuestResponse], error) + // StatPath returns information about a path in the guest filesystem + StatPath(ctx context.Context, in *StatPathRequest, opts ...grpc.CallOption) (*StatPathResponse, error) +} + +type guestServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGuestServiceClient(cc grpc.ClientConnInterface) GuestServiceClient { + return &guestServiceClient{cc} +} + +func (c *guestServiceClient) Exec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecRequest, ExecResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GuestService_ServiceDesc.Streams[0], GuestService_Exec_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecRequest, ExecResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GuestService_ExecClient = grpc.BidiStreamingClient[ExecRequest, ExecResponse] + +func (c *guestServiceClient) CopyToGuest(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[CopyToGuestRequest, CopyToGuestResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GuestService_ServiceDesc.Streams[1], GuestService_CopyToGuest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[CopyToGuestRequest, CopyToGuestResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GuestService_CopyToGuestClient = grpc.ClientStreamingClient[CopyToGuestRequest, CopyToGuestResponse] + +func (c *guestServiceClient) CopyFromGuest(ctx context.Context, in *CopyFromGuestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CopyFromGuestResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &GuestService_ServiceDesc.Streams[2], GuestService_CopyFromGuest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[CopyFromGuestRequest, CopyFromGuestResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GuestService_CopyFromGuestClient = grpc.ServerStreamingClient[CopyFromGuestResponse] + +func (c *guestServiceClient) StatPath(ctx context.Context, in *StatPathRequest, opts ...grpc.CallOption) (*StatPathResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StatPathResponse) + err := c.cc.Invoke(ctx, GuestService_StatPath_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// GuestServiceServer is the server API for GuestService service. +// All implementations must embed UnimplementedGuestServiceServer +// for forward compatibility. +// +// GuestService provides remote operations in guest VMs +type GuestServiceServer interface { + // Exec executes a command with bidirectional streaming + Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error + // CopyToGuest streams file data to the guest filesystem + CopyToGuest(grpc.ClientStreamingServer[CopyToGuestRequest, CopyToGuestResponse]) error + // CopyFromGuest streams file data from the guest filesystem + CopyFromGuest(*CopyFromGuestRequest, grpc.ServerStreamingServer[CopyFromGuestResponse]) error + // StatPath returns information about a path in the guest filesystem + StatPath(context.Context, *StatPathRequest) (*StatPathResponse, error) + mustEmbedUnimplementedGuestServiceServer() +} + +// UnimplementedGuestServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGuestServiceServer struct{} + +func (UnimplementedGuestServiceServer) Exec(grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error { + return status.Error(codes.Unimplemented, "method Exec not implemented") +} +func (UnimplementedGuestServiceServer) CopyToGuest(grpc.ClientStreamingServer[CopyToGuestRequest, CopyToGuestResponse]) error { + return status.Error(codes.Unimplemented, "method CopyToGuest not implemented") +} +func (UnimplementedGuestServiceServer) CopyFromGuest(*CopyFromGuestRequest, grpc.ServerStreamingServer[CopyFromGuestResponse]) error { + return status.Error(codes.Unimplemented, "method CopyFromGuest not implemented") +} +func (UnimplementedGuestServiceServer) StatPath(context.Context, *StatPathRequest) (*StatPathResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StatPath not implemented") +} +func (UnimplementedGuestServiceServer) mustEmbedUnimplementedGuestServiceServer() {} +func (UnimplementedGuestServiceServer) testEmbeddedByValue() {} + +// UnsafeGuestServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GuestServiceServer will +// result in compilation errors. +type UnsafeGuestServiceServer interface { + mustEmbedUnimplementedGuestServiceServer() +} + +func RegisterGuestServiceServer(s grpc.ServiceRegistrar, srv GuestServiceServer) { + // If the following call panics, it indicates UnimplementedGuestServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GuestService_ServiceDesc, srv) +} + +func _GuestService_Exec_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GuestServiceServer).Exec(&grpc.GenericServerStream[ExecRequest, ExecResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GuestService_ExecServer = grpc.BidiStreamingServer[ExecRequest, ExecResponse] + +func _GuestService_CopyToGuest_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(GuestServiceServer).CopyToGuest(&grpc.GenericServerStream[CopyToGuestRequest, CopyToGuestResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GuestService_CopyToGuestServer = grpc.ClientStreamingServer[CopyToGuestRequest, CopyToGuestResponse] + +func _GuestService_CopyFromGuest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(CopyFromGuestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(GuestServiceServer).CopyFromGuest(m, &grpc.GenericServerStream[CopyFromGuestRequest, CopyFromGuestResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type GuestService_CopyFromGuestServer = grpc.ServerStreamingServer[CopyFromGuestResponse] + +func _GuestService_StatPath_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatPathRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GuestServiceServer).StatPath(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GuestService_StatPath_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GuestServiceServer).StatPath(ctx, req.(*StatPathRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// GuestService_ServiceDesc is the grpc.ServiceDesc for GuestService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GuestService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "guest.GuestService", + HandlerType: (*GuestServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "StatPath", + Handler: _GuestService_StatPath_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Exec", + Handler: _GuestService_Exec_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "CopyToGuest", + Handler: _GuestService_CopyToGuest_Handler, + ClientStreams: true, + }, + { + StreamName: "CopyFromGuest", + Handler: _GuestService_CopyFromGuest_Handler, + ServerStreams: true, + }, + }, + Metadata: "lib/guest/guest.proto", +} diff --git a/lib/guest/metrics.go b/lib/guest/metrics.go new file mode 100644 index 00000000..ce17e56a --- /dev/null +++ b/lib/guest/metrics.go @@ -0,0 +1,172 @@ +package guest + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// Metrics holds the metrics instruments for guest operations. +type Metrics struct { + execSessionsTotal metric.Int64Counter + execDuration metric.Float64Histogram + execBytesSentTotal metric.Int64Counter + execBytesReceivedTotal metric.Int64Counter + + cpSessionsTotal metric.Int64Counter + cpDuration metric.Float64Histogram + cpBytesTotal metric.Int64Counter +} + +// GuestMetrics is the global metrics instance for the guest package. +// Set this via SetMetrics() during application initialization. +var GuestMetrics *Metrics + +// SetMetrics sets the global metrics instance. +func SetMetrics(m *Metrics) { + GuestMetrics = m +} + +// NewMetrics creates guest metrics instruments. +// If meter is nil, returns nil (metrics disabled). +func NewMetrics(meter metric.Meter) (*Metrics, error) { + if meter == nil { + return nil, nil + } + + execSessionsTotal, err := meter.Int64Counter( + "hypeman_exec_sessions_total", + metric.WithDescription("Total number of exec sessions"), + ) + if err != nil { + return nil, err + } + + execDuration, err := meter.Float64Histogram( + "hypeman_exec_duration_seconds", + metric.WithDescription("Exec command duration"), + metric.WithUnit("s"), + ) + if err != nil { + return nil, err + } + + execBytesSentTotal, err := meter.Int64Counter( + "hypeman_exec_bytes_sent_total", + metric.WithDescription("Total bytes sent to guest (stdin)"), + metric.WithUnit("By"), + ) + if err != nil { + return nil, err + } + + execBytesReceivedTotal, err := meter.Int64Counter( + "hypeman_exec_bytes_received_total", + metric.WithDescription("Total bytes received from guest (stdout+stderr)"), + metric.WithUnit("By"), + ) + if err != nil { + return nil, err + } + + cpSessionsTotal, err := meter.Int64Counter( + "hypeman_cp_sessions_total", + metric.WithDescription("Total number of cp (copy) sessions"), + ) + if err != nil { + return nil, err + } + + cpDuration, err := meter.Float64Histogram( + "hypeman_cp_duration_seconds", + metric.WithDescription("Copy operation duration"), + metric.WithUnit("s"), + ) + if err != nil { + return nil, err + } + + cpBytesTotal, err := meter.Int64Counter( + "hypeman_cp_bytes_total", + metric.WithDescription("Total bytes transferred during copy operations"), + metric.WithUnit("By"), + ) + if err != nil { + return nil, err + } + + return &Metrics{ + execSessionsTotal: execSessionsTotal, + execDuration: execDuration, + execBytesSentTotal: execBytesSentTotal, + execBytesReceivedTotal: execBytesReceivedTotal, + cpSessionsTotal: cpSessionsTotal, + cpDuration: cpDuration, + cpBytesTotal: cpBytesTotal, + }, nil +} + +// RecordExecSession records metrics for a completed exec session. +func (m *Metrics) RecordExecSession(ctx context.Context, start time.Time, exitCode int, bytesSent, bytesReceived int64) { + if m == nil { + return + } + + duration := time.Since(start).Seconds() + status := "success" + if exitCode != 0 { + status = "error" + } + + m.execSessionsTotal.Add(ctx, 1, + metric.WithAttributes( + attribute.String("status", status), + attribute.Int("exit_code", exitCode), + )) + + m.execDuration.Record(ctx, duration, + metric.WithAttributes(attribute.String("status", status))) + + if bytesSent > 0 { + m.execBytesSentTotal.Add(ctx, bytesSent) + } + if bytesReceived > 0 { + m.execBytesReceivedTotal.Add(ctx, bytesReceived) + } +} + +// RecordCpSession records metrics for a completed cp (copy) session. +// direction should be "to" (copy to instance) or "from" (copy from instance). +func (m *Metrics) RecordCpSession(ctx context.Context, start time.Time, direction string, success bool, bytesTransferred int64) { + if m == nil { + return + } + + duration := time.Since(start).Seconds() + status := "success" + if !success { + status = "error" + } + + m.cpSessionsTotal.Add(ctx, 1, + metric.WithAttributes( + attribute.String("direction", direction), + attribute.String("status", status), + )) + + m.cpDuration.Record(ctx, duration, + metric.WithAttributes( + attribute.String("direction", direction), + attribute.String("status", status), + )) + + if bytesTransferred > 0 { + m.cpBytesTotal.Add(ctx, bytesTransferred, + metric.WithAttributes( + attribute.String("direction", direction), + )) + } +} + diff --git a/lib/instances/exec_test.go b/lib/instances/exec_test.go index d3dbfde9..46109309 100644 --- a/lib/instances/exec_test.go +++ b/lib/instances/exec_test.go @@ -9,19 +9,19 @@ import ( "testing" "time" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/paths" "github.com/onkernel/hypeman/lib/system" "github.com/stretchr/testify/require" ) -// waitForExecAgent polls until exec-agent is ready -func waitForExecAgent(ctx context.Context, mgr *manager, instanceID string, timeout time.Duration) error { +// waitForGuestAgent polls until guest-agent is ready +func waitForGuestAgent(ctx context.Context, mgr *manager, instanceID string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { logs, err := collectLogs(ctx, mgr, instanceID, 100) - if err == nil && strings.Contains(logs, "[exec-agent] listening on vsock port 2222") { + if err == nil && strings.Contains(logs, "[guest-agent] listening on vsock port 2222") { return nil } time.Sleep(500 * time.Millisecond) @@ -89,11 +89,11 @@ func TestExecConcurrent(t *testing.T) { manager.DeleteInstance(ctx, inst.Id) }) - // Wait for exec-agent to be ready (retry here is OK - we're just waiting for startup) - err = waitForExecAgent(ctx, manager, inst.Id, 15*time.Second) - require.NoError(t, err, "exec-agent should be ready") + // Wait for guest-agent to be ready (retry here is OK - we're just waiting for startup) + err = waitForGuestAgent(ctx, manager, inst.Id, 15*time.Second) + require.NoError(t, err, "guest-agent should be ready") - // Verify exec-agent works with a simple command first + // Verify guest-agent works with a simple command first _, code, err := execCommand(ctx, inst.VsockSocket, "echo", "ready") require.NoError(t, err, "initial exec should work") require.Equal(t, 0, code, "initial exec should succeed") @@ -223,7 +223,7 @@ func TestExecConcurrent(t *testing.T) { // Test without TTY start := time.Now() var stdout, stderr strings.Builder - _, err = exec.ExecIntoInstance(ctx, inst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(ctx, inst.VsockSocket, guest.ExecOptions{ Command: []string{"nonexistent_command_asdfasdf"}, Stdout: &stdout, Stderr: &stderr, @@ -240,7 +240,7 @@ func TestExecConcurrent(t *testing.T) { start = time.Now() stdout.Reset() stderr.Reset() - _, err = exec.ExecIntoInstance(ctx, inst.VsockSocket, exec.ExecOptions{ + _, err = guest.ExecIntoInstance(ctx, inst.VsockSocket, guest.ExecOptions{ Command: []string{"nonexistent_command_xyz123"}, Stdout: &stdout, Stderr: &stderr, diff --git a/lib/instances/manager_test.go b/lib/instances/manager_test.go index b2795333..7efb8285 100644 --- a/lib/instances/manager_test.go +++ b/lib/instances/manager_test.go @@ -18,7 +18,7 @@ import ( "github.com/joho/godotenv" "github.com/onkernel/hypeman/cmd/api/config" "github.com/onkernel/hypeman/lib/devices" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/hypervisor" "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/ingress" @@ -610,7 +610,7 @@ func TestBasicEndToEnd(t *testing.T) { } var stdout, stderr bytes.Buffer - exit, err := exec.ExecIntoInstance(ctx, inst.VsockSocket, exec.ExecOptions{ + exit, err := guest.ExecIntoInstance(ctx, inst.VsockSocket, guest.ExecOptions{ Command: command, Stdout: &stdout, Stderr: &stderr, diff --git a/lib/instances/network_test.go b/lib/instances/network_test.go index 419115e8..72dce064 100644 --- a/lib/instances/network_test.go +++ b/lib/instances/network_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/onkernel/hypeman/lib/exec" + "github.com/onkernel/hypeman/lib/guest" "github.com/onkernel/hypeman/lib/images" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -109,7 +109,7 @@ func TestCreateInstanceWithNetwork(t *testing.T) { // Wait for exec agent to be ready t.Log("Waiting for exec agent...") - err = waitForLogMessage(ctx, manager, inst.Id, "[exec-agent] listening", 10*time.Second) + err = waitForLogMessage(ctx, manager, inst.Id, "[guest-agent] listening", 10*time.Second) require.NoError(t, err, "Exec agent should be listening") t.Log("Exec agent is ready") @@ -226,7 +226,7 @@ func TestCreateInstanceWithNetwork(t *testing.T) { func execCommand(ctx context.Context, vsockSocket string, command ...string) (string, int, error) { var stdout, stderr bytes.Buffer - exit, err := exec.ExecIntoInstance(ctx, vsockSocket, exec.ExecOptions{ + exit, err := guest.ExecIntoInstance(ctx, vsockSocket, guest.ExecOptions{ Command: command, Stdin: nil, Stdout: &stdout, diff --git a/lib/instances/volumes_test.go b/lib/instances/volumes_test.go index 3237db3b..cb8304be 100644 --- a/lib/instances/volumes_test.go +++ b/lib/instances/volumes_test.go @@ -18,7 +18,7 @@ import ( "github.com/stretchr/testify/require" ) -// execWithRetry runs a command with retries until exec-agent is ready +// execWithRetry runs a command with retries until guest-agent is ready func execWithRetry(ctx context.Context, vsockSocket string, command []string) (string, int, error) { var output string var code int @@ -105,9 +105,9 @@ func TestVolumeMultiAttachReadOnly(t *testing.T) { require.NoError(t, err) t.Logf("Writer instance created: %s", writerInst.Id) - // Wait for exec-agent - err = waitForExecAgent(ctx, manager, writerInst.Id, 15*time.Second) - require.NoError(t, err, "exec-agent should be ready") + // Wait for guest-agent + err = waitForGuestAgent(ctx, manager, writerInst.Id, 15*time.Second) + require.NoError(t, err, "guest-agent should be ready") // Write test file, sync, and verify in one command to ensure data persistence t.Log("Writing test file to volume...") @@ -168,12 +168,12 @@ func TestVolumeMultiAttachReadOnly(t *testing.T) { require.NoError(t, err) assert.Len(t, vol.Attachments, 2, "Volume should have 2 attachments") - // Wait for exec-agent on both readers - err = waitForExecAgent(ctx, manager, reader1.Id, 15*time.Second) - require.NoError(t, err, "reader-1 exec-agent should be ready") + // Wait for guest-agent on both readers + err = waitForGuestAgent(ctx, manager, reader1.Id, 15*time.Second) + require.NoError(t, err, "reader-1 guest-agent should be ready") - err = waitForExecAgent(ctx, manager, reader2.Id, 15*time.Second) - require.NoError(t, err, "reader-2 exec-agent should be ready") + err = waitForGuestAgent(ctx, manager, reader2.Id, 15*time.Second) + require.NoError(t, err, "reader-2 guest-agent should be ready") // Verify data is readable from reader-1 t.Log("Verifying data from reader-1...") @@ -406,9 +406,9 @@ func TestVolumeFromArchive(t *testing.T) { require.NoError(t, err) t.Logf("Instance created: %s", inst.Id) - // Wait for exec-agent - err = waitForExecAgent(ctx, manager, inst.Id, 15*time.Second) - require.NoError(t, err, "exec-agent should be ready") + // Wait for guest-agent + err = waitForGuestAgent(ctx, manager, inst.Id, 15*time.Second) + require.NoError(t, err, "guest-agent should be ready") // Verify files from archive are present t.Log("Verifying archive files are accessible...") diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 0401b78c..e170ac25 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -414,6 +414,33 @@ type Instance struct { // - Unknown: Failed to determine state (see state_error for details) type InstanceState string +// PathInfo defines model for PathInfo. +type PathInfo struct { + // Error Error message if stat failed (e.g., permission denied). Only set when exists is false due to an error rather than the path not existing. + Error *string `json:"error"` + + // Exists Whether the path exists + Exists bool `json:"exists"` + + // IsDir True if this is a directory + IsDir *bool `json:"is_dir,omitempty"` + + // IsFile True if this is a regular file + IsFile *bool `json:"is_file,omitempty"` + + // IsSymlink True if this is a symbolic link (only set when follow_links=false) + IsSymlink *bool `json:"is_symlink,omitempty"` + + // LinkTarget Symlink target path (only set when is_symlink=true) + LinkTarget *string `json:"link_target"` + + // Mode File mode (Unix permissions) + Mode *int `json:"mode,omitempty"` + + // Size File size in bytes + Size *int64 `json:"size,omitempty"` +} + // Volume defines model for Volume. type Volume struct { // Attachments List of current attachments (empty if not attached) @@ -480,6 +507,15 @@ type GetInstanceLogsParams struct { // GetInstanceLogsParamsSource defines parameters for GetInstanceLogs. type GetInstanceLogsParamsSource string +// StatInstancePathParams defines parameters for StatInstancePath. +type StatInstancePathParams struct { + // Path Path to stat in the guest filesystem + Path string `form:"path" json:"path"` + + // FollowLinks Follow symbolic links (like stat vs lstat) + FollowLinks *bool `form:"follow_links,omitempty" json:"follow_links,omitempty"` +} + // CreateVolumeMultipartBody defines parameters for CreateVolume. type CreateVolumeMultipartBody struct { // Content tar.gz archive file containing the volume content @@ -663,6 +699,9 @@ type ClientInterface interface { // StartInstance request StartInstance(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + // StatInstancePath request + StatInstancePath(ctx context.Context, id string, params *StatInstancePathParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // StopInstance request StopInstance(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1001,6 +1040,18 @@ func (c *Client) StartInstance(ctx context.Context, id string, reqEditors ...Req return c.Client.Do(req) } +func (c *Client) StatInstancePath(ctx context.Context, id string, params *StatInstancePathParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewStatInstancePathRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) StopInstance(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewStopInstanceRequest(c.Server, id) if err != nil { @@ -1893,6 +1944,74 @@ func NewStartInstanceRequest(server string, id string) (*http.Request, error) { return req, nil } +// NewStatInstancePathRequest generates requests for StatInstancePath +func NewStatInstancePathRequest(server string, id string, params *StatInstancePathParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/instances/%s/stat", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "path", runtime.ParamLocationQuery, params.Path); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.FollowLinks != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "follow_links", runtime.ParamLocationQuery, *params.FollowLinks); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewStopInstanceRequest generates requests for StopInstance func NewStopInstanceRequest(server string, id string) (*http.Request, error) { var err error @@ -2274,6 +2393,9 @@ type ClientWithResponsesInterface interface { // StartInstanceWithResponse request StartInstanceWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*StartInstanceResponse, error) + // StatInstancePathWithResponse request + StatInstancePathWithResponse(ctx context.Context, id string, params *StatInstancePathParams, reqEditors ...RequestEditorFn) (*StatInstancePathResponse, error) + // StopInstanceWithResponse request StopInstanceWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*StopInstanceResponse, error) @@ -2835,6 +2957,31 @@ func (r StartInstanceResponse) StatusCode() int { return 0 } +type StatInstancePathResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PathInfo + JSON404 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r StatInstancePathResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r StatInstancePathResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type StopInstanceResponse struct { Body []byte HTTPResponse *http.Response @@ -3237,6 +3384,15 @@ func (c *ClientWithResponses) StartInstanceWithResponse(ctx context.Context, id return ParseStartInstanceResponse(rsp) } +// StatInstancePathWithResponse request returning *StatInstancePathResponse +func (c *ClientWithResponses) StatInstancePathWithResponse(ctx context.Context, id string, params *StatInstancePathParams, reqEditors ...RequestEditorFn) (*StatInstancePathResponse, error) { + rsp, err := c.StatInstancePath(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseStatInstancePathResponse(rsp) +} + // StopInstanceWithResponse request returning *StopInstanceResponse func (c *ClientWithResponses) StopInstanceWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*StopInstanceResponse, error) { rsp, err := c.StopInstance(ctx, id, reqEditors...) @@ -4245,6 +4401,53 @@ func ParseStartInstanceResponse(rsp *http.Response) (*StartInstanceResponse, err return response, nil } +// ParseStatInstancePathResponse parses an HTTP response from a StatInstancePathWithResponse call +func ParseStatInstancePathResponse(rsp *http.Response) (*StatInstancePathResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &StatInstancePathResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PathInfo + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseStopInstanceResponse parses an HTTP response from a StopInstanceWithResponse call func ParseStopInstanceResponse(rsp *http.Response) (*StopInstanceResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -4621,6 +4824,9 @@ type ServerInterface interface { // Start a stopped instance // (POST /instances/{id}/start) StartInstance(w http.ResponseWriter, r *http.Request, id string) + // Get filesystem path info + // (GET /instances/{id}/stat) + StatInstancePath(w http.ResponseWriter, r *http.Request, id string, params StatInstancePathParams) // Stop instance (graceful shutdown) // (POST /instances/{id}/stop) StopInstance(w http.ResponseWriter, r *http.Request, id string) @@ -4780,6 +4986,12 @@ func (_ Unimplemented) StartInstance(w http.ResponseWriter, r *http.Request, id w.WriteHeader(http.StatusNotImplemented) } +// Get filesystem path info +// (GET /instances/{id}/stat) +func (_ Unimplemented) StatInstancePath(w http.ResponseWriter, r *http.Request, id string, params StatInstancePathParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // Stop instance (graceful shutdown) // (POST /instances/{id}/stop) func (_ Unimplemented) StopInstance(w http.ResponseWriter, r *http.Request, id string) { @@ -5424,6 +5636,63 @@ func (siw *ServerInterfaceWrapper) StartInstance(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// StatInstancePath operation middleware +func (siw *ServerInterfaceWrapper) StatInstancePath(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params StatInstancePathParams + + // ------------- Required query parameter "path" ------------- + + if paramValue := r.URL.Query().Get("path"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "follow_links" ------------- + + err = runtime.BindQueryParameter("form", true, false, "follow_links", r.URL.Query(), ¶ms.FollowLinks) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "follow_links", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StatInstancePath(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // StopInstance operation middleware func (siw *ServerInterfaceWrapper) StopInstance(w http.ResponseWriter, r *http.Request) { @@ -5816,6 +6085,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/instances/{id}/start", wrapper.StartInstance) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/instances/{id}/stat", wrapper.StatInstancePath) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/instances/{id}/stop", wrapper.StopInstance) }) @@ -6720,6 +6992,51 @@ func (response StartInstance500JSONResponse) VisitStartInstanceResponse(w http.R return json.NewEncoder(w).Encode(response) } +type StatInstancePathRequestObject struct { + Id string `json:"id"` + Params StatInstancePathParams +} + +type StatInstancePathResponseObject interface { + VisitStatInstancePathResponse(w http.ResponseWriter) error +} + +type StatInstancePath200JSONResponse PathInfo + +func (response StatInstancePath200JSONResponse) VisitStatInstancePathResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type StatInstancePath404JSONResponse Error + +func (response StatInstancePath404JSONResponse) VisitStatInstancePathResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type StatInstancePath409JSONResponse Error + +func (response StatInstancePath409JSONResponse) VisitStatInstancePathResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type StatInstancePath500JSONResponse Error + +func (response StatInstancePath500JSONResponse) VisitStatInstancePathResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type StopInstanceRequestObject struct { Id string `json:"id"` } @@ -7080,6 +7397,9 @@ type StrictServerInterface interface { // Start a stopped instance // (POST /instances/{id}/start) StartInstance(ctx context.Context, request StartInstanceRequestObject) (StartInstanceResponseObject, error) + // Get filesystem path info + // (GET /instances/{id}/stat) + StatInstancePath(ctx context.Context, request StatInstancePathRequestObject) (StatInstancePathResponseObject, error) // Stop instance (graceful shutdown) // (POST /instances/{id}/stop) StopInstance(ctx context.Context, request StopInstanceRequestObject) (StopInstanceResponseObject, error) @@ -7713,6 +8033,33 @@ func (sh *strictHandler) StartInstance(w http.ResponseWriter, r *http.Request, i } } +// StatInstancePath operation middleware +func (sh *strictHandler) StatInstancePath(w http.ResponseWriter, r *http.Request, id string, params StatInstancePathParams) { + var request StatInstancePathRequestObject + + request.Id = id + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.StatInstancePath(ctx, request.(StatInstancePathRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StatInstancePath") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StatInstancePathResponseObject); ok { + if err := validResponse.VisitStatInstancePathResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // StopInstance operation middleware func (sh *strictHandler) StopInstance(w http.ResponseWriter, r *http.Request, id string) { var request StopInstanceRequestObject @@ -7921,104 +8268,111 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/e76/wIAAP//cHRGgNyPAAA=", + "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", + "AA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/system/exec_agent_binary.go b/lib/system/exec_agent_binary.go deleted file mode 100644 index eb7ac875..00000000 --- a/lib/system/exec_agent_binary.go +++ /dev/null @@ -1,9 +0,0 @@ -package system - -import _ "embed" - -// ExecAgentBinary contains the embedded exec-agent binary -// This is built by the Makefile before the main binary is compiled -//go:embed exec_agent/exec-agent -var ExecAgentBinary []byte - diff --git a/lib/system/guest_agent/cp.go b/lib/system/guest_agent/cp.go new file mode 100644 index 00000000..19c681e8 --- /dev/null +++ b/lib/system/guest_agent/cp.go @@ -0,0 +1,402 @@ +package main + +import ( + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "syscall" + "time" + + pb "github.com/onkernel/hypeman/lib/guest" +) + +// CopyToGuest handles copying files to the guest filesystem +func (s *guestServer) CopyToGuest(stream pb.GuestService_CopyToGuestServer) error { + log.Printf("[guest-agent] new copy-to-guest stream") + + // Receive start request + req, err := stream.Recv() + if err != nil { + return fmt.Errorf("receive start request: %w", err) + } + + start := req.GetStart() + if start == nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: "first message must be CopyToGuestStart", + }) + } + + log.Printf("[guest-agent] copy-to-guest: path=%s mode=%o is_dir=%v size=%d", + start.Path, start.Mode, start.IsDir, start.Size) + + // Handle directory creation + if start.IsDir { + // Check if destination exists and is a file + if info, err := os.Stat(start.Path); err == nil && !info.IsDir() { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("cannot create directory: %s is a file", start.Path), + }) + } + + if err := os.MkdirAll(start.Path, fs.FileMode(start.Mode)); err != nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("create directory: %v", err), + }) + } + // Wait for end message + for { + req, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("receive: %v", err), + }) + } + if req.GetEnd() != nil { + break + } + } + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: true, + BytesWritten: 0, + }) + } + + // Create parent directories if needed + dir := filepath.Dir(start.Path) + if err := os.MkdirAll(dir, 0755); err != nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("create parent directory: %v", err), + }) + } + + // Check if destination exists and is a directory + if info, err := os.Stat(start.Path); err == nil && info.IsDir() { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("cannot copy file: %s is a directory", start.Path), + }) + } + + // Create file + file, err := os.OpenFile(start.Path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fs.FileMode(start.Mode)) + if err != nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("create file: %v", err), + }) + } + defer file.Close() + + var bytesWritten int64 + + // Receive data chunks + for { + req, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("receive: %v", err), + }) + } + + if data := req.GetData(); data != nil { + n, err := file.Write(data) + if err != nil { + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: false, + Error: fmt.Sprintf("write: %v", err), + }) + } + bytesWritten += int64(n) + } + + if req.GetEnd() != nil { + break + } + } + + // Set modification time if provided + if start.Mtime > 0 { + mtime := time.Unix(start.Mtime, 0) + os.Chtimes(start.Path, mtime, mtime) + } + + // Set ownership if provided (archive mode) + // Only chown when both UID and GID are explicitly set (non-zero) + // to avoid accidentally setting one to root (0) when only the other is specified + if start.Uid > 0 && start.Gid > 0 { + if err := os.Chown(start.Path, int(start.Uid), int(start.Gid)); err != nil { + log.Printf("[guest-agent] warning: failed to set ownership on %s: %v", start.Path, err) + } + } + + log.Printf("[guest-agent] copy-to-guest complete: %d bytes written to %s", bytesWritten, start.Path) + + return stream.SendAndClose(&pb.CopyToGuestResponse{ + Success: true, + BytesWritten: bytesWritten, + }) +} + +// CopyFromGuest handles copying files from the guest filesystem +func (s *guestServer) CopyFromGuest(req *pb.CopyFromGuestRequest, stream pb.GuestService_CopyFromGuestServer) error { + log.Printf("[guest-agent] copy-from-guest: path=%s follow_links=%v", req.Path, req.FollowLinks) + + // Stat the source path + var info os.FileInfo + var err error + if req.FollowLinks { + info, err = os.Stat(req.Path) + } else { + info, err = os.Lstat(req.Path) + } + if err != nil { + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("stat: %v", err), + Path: req.Path, + }, + }, + }) + } + + if info.IsDir() { + // Walk directory and stream all files + return s.copyFromGuestDir(req.Path, req.FollowLinks, stream) + } + + // Single file + return s.copyFromGuestFile(req.Path, "", info, req.FollowLinks, stream, true) +} + +// copyFromGuestFile streams a single file +func (s *guestServer) copyFromGuestFile(fullPath, relativePath string, info os.FileInfo, followLinks bool, stream pb.GuestService_CopyFromGuestServer, isFinal bool) error { + if relativePath == "" { + relativePath = filepath.Base(fullPath) + } + + // Check if it's a symlink + isSymlink := info.Mode()&os.ModeSymlink != 0 + var linkTarget string + if isSymlink && !followLinks { + target, err := os.Readlink(fullPath) + if err != nil { + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("readlink: %v", err), + Path: fullPath, + }, + }, + }) + } + linkTarget = target + } + + // Extract UID/GID from file info + var uid, gid uint32 + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + uid = stat.Uid + gid = stat.Gid + } + + // Send header + header := &pb.CopyFromGuestHeader{ + Path: relativePath, + Mode: uint32(info.Mode().Perm()), + IsDir: false, + IsSymlink: isSymlink && !followLinks, + LinkTarget: linkTarget, + Size: info.Size(), + Mtime: info.ModTime().Unix(), + Uid: uid, + Gid: gid, + } + + if err := stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Header{Header: header}, + }); err != nil { + return err + } + + // If it's a symlink and we're not following, we're done with this file + if isSymlink && !followLinks { + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_End{End: &pb.CopyFromGuestEnd{Final: isFinal}}, + }) + } + + // Stream file content + file, err := os.Open(fullPath) + if err != nil { + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("open: %v", err), + Path: fullPath, + }, + }, + }) + } + defer file.Close() + + buf := make([]byte, 32*1024) + for { + n, err := file.Read(buf) + if n > 0 { + if sendErr := stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Data{Data: buf[:n]}, + }); sendErr != nil { + return sendErr + } + } + if err == io.EOF { + break + } + if err != nil { + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("read: %v", err), + Path: fullPath, + }, + }, + }) + } + } + + // Send end marker + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_End{End: &pb.CopyFromGuestEnd{Final: isFinal}}, + }) +} + +// copyFromGuestDir walks a directory and streams all files +func (s *guestServer) copyFromGuestDir(rootPath string, followLinks bool, stream pb.GuestService_CopyFromGuestServer) error { + // Collect all entries first to know which is final + type entry struct { + fullPath string + relativePath string + info os.FileInfo + } + var entries []entry + + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + // Send error but continue + stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("walk: %v", err), + Path: path, + }, + }, + }) + return nil + } + + // Use os.Stat when followLinks is true to get the target's info + // Use d.Info() (same as os.Lstat) when followLinks is false to get symlink's info + var info os.FileInfo + if followLinks { + info, err = os.Stat(path) + } else { + info, err = d.Info() + } + if err != nil { + stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("info: %v", err), + Path: path, + }, + }, + }) + return nil + } + + relPath, _ := filepath.Rel(rootPath, path) + if relPath == "." { + relPath = filepath.Base(rootPath) + } else { + relPath = filepath.Join(filepath.Base(rootPath), relPath) + } + + entries = append(entries, entry{ + fullPath: path, + relativePath: relPath, + info: info, + }) + return nil + }) + if err != nil { + return stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Error{ + Error: &pb.CopyFromGuestError{ + Message: fmt.Sprintf("walk directory: %v", err), + Path: rootPath, + }, + }, + }) + } + + // Stream each entry + for i, e := range entries { + isFinal := i == len(entries)-1 + + if e.info.IsDir() { + // Extract UID/GID from file info + var uid, gid uint32 + if stat, ok := e.info.Sys().(*syscall.Stat_t); ok { + uid = stat.Uid + gid = stat.Gid + } + + // Send directory header + if err := stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_Header{ + Header: &pb.CopyFromGuestHeader{ + Path: e.relativePath, + Mode: uint32(e.info.Mode().Perm()), + IsDir: true, + Mtime: e.info.ModTime().Unix(), + Uid: uid, + Gid: gid, + }, + }, + }); err != nil { + return err + } + // Send end for directory + if err := stream.Send(&pb.CopyFromGuestResponse{ + Response: &pb.CopyFromGuestResponse_End{End: &pb.CopyFromGuestEnd{Final: isFinal}}, + }); err != nil { + return err + } + } else { + if err := s.copyFromGuestFile(e.fullPath, e.relativePath, e.info, followLinks, stream, isFinal); err != nil { + return err + } + } + } + + log.Printf("[guest-agent] copy-from-guest complete: %d entries from %s", len(entries), rootPath) + return nil +} + diff --git a/lib/system/exec_agent/main.go b/lib/system/guest_agent/exec.go similarity index 74% rename from lib/system/exec_agent/main.go rename to lib/system/guest_agent/exec.go index 35744d5a..b48fd842 100644 --- a/lib/system/exec_agent/main.go +++ b/lib/system/guest_agent/exec.go @@ -11,50 +11,12 @@ import ( "time" "github.com/creack/pty" - "github.com/mdlayher/vsock" - pb "github.com/onkernel/hypeman/lib/exec" - "google.golang.org/grpc" + pb "github.com/onkernel/hypeman/lib/guest" ) -// execServer implements the gRPC ExecService -type execServer struct { - pb.UnimplementedExecServiceServer -} - -func main() { - // Listen on vsock port 2222 with retries - var l *vsock.Listener - var err error - - for i := 0; i < 10; i++ { - l, err = vsock.Listen(2222, nil) - if err == nil { - break - } - log.Printf("[exec-agent] vsock listen attempt %d/10 failed: %v (retrying in 1s)", i+1, err) - time.Sleep(1 * time.Second) - } - - if err != nil { - log.Fatalf("[exec-agent] failed to listen on vsock port 2222 after retries: %v", err) - } - defer l.Close() - - log.Println("[exec-agent] listening on vsock port 2222") - - // Create gRPC server - grpcServer := grpc.NewServer() - pb.RegisterExecServiceServer(grpcServer, &execServer{}) - - // Serve gRPC over vsock - if err := grpcServer.Serve(l); err != nil { - log.Fatalf("[exec-agent] gRPC server failed: %v", err) - } -} - // Exec handles command execution with bidirectional streaming -func (s *execServer) Exec(stream pb.ExecService_ExecServer) error { - log.Printf("[exec-agent] new exec stream") +func (s *guestServer) Exec(stream pb.GuestService_ExecServer) error { + log.Printf("[guest-agent] new exec stream") // Receive start request req, err := stream.Recv() @@ -72,7 +34,7 @@ func (s *execServer) Exec(stream pb.ExecService_ExecServer) error { command = []string{"/bin/sh"} } - log.Printf("[exec-agent] exec: command=%v tty=%v cwd=%s timeout=%d", + log.Printf("[guest-agent] exec: command=%v tty=%v cwd=%s timeout=%d", command, start.Tty, start.Cwd, start.TimeoutSeconds) // Create context with timeout if specified @@ -90,17 +52,17 @@ func (s *execServer) Exec(stream pb.ExecService_ExecServer) error { } // executeNoTTY executes command without TTY -func (s *execServer) executeNoTTY(ctx context.Context, stream pb.ExecService_ExecServer, start *pb.ExecStart) error { - // Run command directly - exec-agent is already running in container namespace +func (s *guestServer) executeNoTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { + // Run command directly - guest-agent is already running in container namespace if len(start.Command) == 0 { return fmt.Errorf("empty command") } - + cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...) - + // Set up environment cmd.Env = s.buildEnv(start.Env) - + // Set up working directory if start.Cwd != "" { cmd.Dir = start.Cwd @@ -152,7 +114,7 @@ func (s *execServer) executeNoTTY(ctx context.Context, stream pb.ExecService_Exe // Wait for all reads to complete FIRST (before Wait closes pipes) wg.Wait() - + // Now safe to call Wait - pipes are fully drained waitErr := cmd.Wait() @@ -189,7 +151,7 @@ func (s *execServer) executeNoTTY(ctx context.Context, stream pb.ExecService_Exe exitCode = 124 } - log.Printf("[exec-agent] command finished with exit code: %d", exitCode) + log.Printf("[guest-agent] command finished with exit code: %d", exitCode) // Send exit code return stream.Send(&pb.ExecResponse{ @@ -198,23 +160,23 @@ func (s *execServer) executeNoTTY(ctx context.Context, stream pb.ExecService_Exe } // executeTTY executes command with TTY -func (s *execServer) executeTTY(ctx context.Context, stream pb.ExecService_ExecServer, start *pb.ExecStart) error { - // Run command directly with PTY - exec-agent is already running in container namespace +func (s *guestServer) executeTTY(ctx context.Context, stream pb.GuestService_ExecServer, start *pb.ExecStart) error { + // Run command directly with PTY - guest-agent is already running in container namespace // This ensures PTY and shell are in the same namespace, fixing Ctrl+C signal handling if len(start.Command) == 0 { return fmt.Errorf("empty command") } - + cmd := exec.CommandContext(ctx, start.Command[0], start.Command[1:]...) - + // Set up environment cmd.Env = s.buildEnv(start.Env) - + // Set up working directory if start.Cwd != "" { cmd.Dir = start.Cwd } - + // Start with PTY ptmx, err := pty.Start(cmd) if err != nil { @@ -246,7 +208,7 @@ func (s *execServer) executeTTY(ctx context.Context, stream pb.ExecService_ExecS wg.Add(1) go func() { defer wg.Done() - buf := make([]byte, 32 * 1024) + buf := make([]byte, 32*1024) for { n, err := ptmx.Read(buf) if n > 0 { @@ -264,7 +226,7 @@ func (s *execServer) executeTTY(ctx context.Context, stream pb.ExecService_ExecS // Wait for command or context cancellation waitErr := cmd.Wait() - + // Wait for all output to be sent wg.Wait() @@ -276,7 +238,7 @@ func (s *execServer) executeTTY(ctx context.Context, stream pb.ExecService_ExecS exitCode = 124 } - log.Printf("[exec-agent] TTY command finished with exit code: %d", exitCode) + log.Printf("[guest-agent] TTY command finished with exit code: %d", exitCode) // Send exit code return stream.Send(&pb.ExecResponse{ @@ -285,14 +247,15 @@ func (s *execServer) executeTTY(ctx context.Context, stream pb.ExecService_ExecS } // buildEnv constructs environment variables by merging provided env with defaults -func (s *execServer) buildEnv(envMap map[string]string) []string { +func (s *guestServer) buildEnv(envMap map[string]string) []string { // Start with current environment as base env := os.Environ() - + // Merge in provided environment variables for k, v := range envMap { env = append(env, fmt.Sprintf("%s=%s", k, v)) } - + return env } + diff --git a/lib/system/guest_agent/main.go b/lib/system/guest_agent/main.go new file mode 100644 index 00000000..6cb2f817 --- /dev/null +++ b/lib/system/guest_agent/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "log" + "time" + + "github.com/mdlayher/vsock" + pb "github.com/onkernel/hypeman/lib/guest" + "google.golang.org/grpc" +) + +// guestServer implements the gRPC GuestService +type guestServer struct { + pb.UnimplementedGuestServiceServer +} + +func main() { + // Listen on vsock port 2222 with retries + var l *vsock.Listener + var err error + + for i := 0; i < 10; i++ { + l, err = vsock.Listen(2222, nil) + if err == nil { + break + } + log.Printf("[guest-agent] vsock listen attempt %d/10 failed: %v (retrying in 1s)", i+1, err) + time.Sleep(1 * time.Second) + } + + if err != nil { + log.Fatalf("[guest-agent] failed to listen on vsock port 2222 after retries: %v", err) + } + defer l.Close() + + log.Println("[guest-agent] listening on vsock port 2222") + + // Create gRPC server + grpcServer := grpc.NewServer() + pb.RegisterGuestServiceServer(grpcServer, &guestServer{}) + + // Serve gRPC over vsock + if err := grpcServer.Serve(l); err != nil { + log.Fatalf("[guest-agent] gRPC server failed: %v", err) + } +} diff --git a/lib/system/guest_agent/stat.go b/lib/system/guest_agent/stat.go new file mode 100644 index 00000000..92842f3b --- /dev/null +++ b/lib/system/guest_agent/stat.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "log" + "os" + + pb "github.com/onkernel/hypeman/lib/guest" +) + +// StatPath returns information about a path in the guest filesystem +func (s *guestServer) StatPath(ctx context.Context, req *pb.StatPathRequest) (*pb.StatPathResponse, error) { + log.Printf("[guest-agent] stat-path: path=%s follow_links=%v", req.Path, req.FollowLinks) + + var info os.FileInfo + var err error + if req.FollowLinks { + info, err = os.Stat(req.Path) + } else { + info, err = os.Lstat(req.Path) + } + + if err != nil { + if os.IsNotExist(err) { + return &pb.StatPathResponse{ + Exists: false, + }, nil + } + return &pb.StatPathResponse{ + Exists: false, + Error: err.Error(), + }, nil + } + + resp := &pb.StatPathResponse{ + Exists: true, + IsDir: info.IsDir(), + IsFile: info.Mode().IsRegular(), + Mode: uint32(info.Mode().Perm()), + Size: info.Size(), + } + + // Check if it's a symlink (only relevant if follow_links=false) + if info.Mode()&os.ModeSymlink != 0 { + resp.IsSymlink = true + target, err := os.Readlink(req.Path) + if err == nil { + resp.LinkTarget = target + } + } + + return resp, nil +} + diff --git a/lib/system/guest_agent_binary.go b/lib/system/guest_agent_binary.go new file mode 100644 index 00000000..78a5b7b3 --- /dev/null +++ b/lib/system/guest_agent_binary.go @@ -0,0 +1,9 @@ +package system + +import _ "embed" + +// GuestAgentBinary contains the embedded guest-agent binary +// This is built by the Makefile before the main binary is compiled +//go:embed guest_agent/guest-agent +var GuestAgentBinary []byte + diff --git a/lib/system/init_script.go b/lib/system/init_script.go index ebe9f8b4..9e9b397a 100644 --- a/lib/system/init_script.go +++ b/lib/system/init_script.go @@ -28,7 +28,7 @@ mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs none /dev -# Setup PTY support (needed for exec-agent and interactive shells) +# Setup PTY support (needed for guest-agent and interactive shells) mkdir -p /dev/pts /dev/shm mount -t devpts devpts /dev/pts chmod 1777 /dev/shm @@ -36,7 +36,12 @@ chmod 1777 /dev/shm echo "overlay-init: mounted proc/sys/dev" > /dev/kmsg # Redirect all output to serial console -exec >/dev/ttyS0 2>&1 +# ttyS0 for x86_64, ttyAMA0 for ARM64 (PL011 UART) +if [ -e /dev/ttyAMA0 ]; then + exec >/dev/ttyAMA0 2>&1 +else + exec >/dev/ttyS0 2>&1 +fi echo "overlay-init: redirected to serial console" @@ -132,7 +137,12 @@ if [ "${HAS_GPU:-0}" = "1" ]; then echo "overlay-init: injecting NVIDIA driver libraries into container" DRIVER_VERSION=$(cat /usr/lib/nvidia/version 2>/dev/null || echo "unknown") - LIB_DST="/overlay/newroot/usr/lib/x86_64-linux-gnu" + # Determine library path based on architecture + if [ "$(uname -m)" = "aarch64" ]; then + LIB_DST="/overlay/newroot/usr/lib/aarch64-linux-gnu" + else + LIB_DST="/overlay/newroot/usr/lib/x86_64-linux-gnu" + fi BIN_DST="/overlay/newroot/usr/bin" mkdir -p "$LIB_DST" "$BIN_DST" @@ -215,7 +225,7 @@ fi # Prepare new root mount points # We use bind mounts instead of move so that the original /dev remains populated -# for processes running in the initrd namespace (like exec-agent). +# for processes running in the initrd namespace (like guest-agent). mkdir -p /overlay/newroot/proc mkdir -p /overlay/newroot/sys mkdir -p /overlay/newroot/dev @@ -250,15 +260,15 @@ fi export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' export HOME='/root' -# Copy exec-agent into container rootfs and start it in container namespace +# Copy guest-agent into container rootfs and start it in container namespace # This way the PTY and shell run in the same namespace, fixing signal handling -echo "overlay-init: copying exec-agent to container" +echo "overlay-init: copying guest-agent to container" mkdir -p /overlay/newroot/usr/local/bin -cp /usr/local/bin/exec-agent /overlay/newroot/usr/local/bin/exec-agent +cp /usr/local/bin/guest-agent /overlay/newroot/usr/local/bin/guest-agent -# Start vsock exec agent inside the container namespace -echo "overlay-init: starting exec agent in container namespace" -chroot /overlay/newroot /usr/local/bin/exec-agent & +# Start vsock guest agent inside the container namespace +echo "overlay-init: starting guest agent in container namespace" +chroot /overlay/newroot /usr/local/bin/guest-agent & echo "overlay-init: launching entrypoint" echo "overlay-init: workdir=${WORKDIR:-/} entrypoint=${ENTRYPOINT} cmd=${CMD}" @@ -278,7 +288,7 @@ APP_EXIT=$? echo "overlay-init: app exited with code $APP_EXIT" -# Wait for all background jobs (exec-agent runs forever, keeping init alive) +# Wait for all background jobs (guest-agent runs forever, keeping init alive) # This prevents kernel panic from killing init (PID 1) wait` } diff --git a/lib/system/initrd.go b/lib/system/initrd.go index 09f286c3..3048b75b 100644 --- a/lib/system/initrd.go +++ b/lib/system/initrd.go @@ -20,7 +20,7 @@ import ( const alpineBaseImage = "alpine:3.22" -// buildInitrd builds initrd from Alpine base + embedded exec-agent + generated init script +// buildInitrd builds initrd from Alpine base + embedded guest-agent + generated init script func (m *manager) buildInitrd(ctx context.Context, arch string) (string, error) { // Create temp directory for building tempDir, err := os.MkdirTemp("", "hypeman-initrd-*") @@ -49,15 +49,15 @@ func (m *manager) buildInitrd(ctx context.Context, arch string) (string, error) return "", fmt.Errorf("pull alpine base: %w", err) } - // Write embedded exec-agent binary + // Write embedded guest-agent binary binDir := filepath.Join(rootfsDir, "usr/local/bin") if err := os.MkdirAll(binDir, 0755); err != nil { return "", fmt.Errorf("create bin dir: %w", err) } - agentPath := filepath.Join(binDir, "exec-agent") - if err := os.WriteFile(agentPath, ExecAgentBinary, 0755); err != nil { - return "", fmt.Errorf("write exec-agent: %w", err) + agentPath := filepath.Join(binDir, "guest-agent") + if err := os.WriteFile(agentPath, GuestAgentBinary, 0755); err != nil { + return "", fmt.Errorf("write guest-agent: %w", err) } // Add NVIDIA kernel modules (for GPU passthrough support) @@ -150,7 +150,7 @@ func (m *manager) isInitrdStale(initrdPath string) bool { // computeInitrdHash computes a hash of the embedded binary, init script, and NVIDIA assets func computeInitrdHash() string { h := sha256.New() - h.Write(ExecAgentBinary) + h.Write(GuestAgentBinary) h.Write([]byte(GenerateInitScript())) // Include NVIDIA driver version in hash so initrd is rebuilt when driver changes if ver, ok := NvidiaDriverVersion[DefaultKernelVersion]; ok { diff --git a/lib/system/manager_test.go b/lib/system/manager_test.go index 3540e448..ce32df12 100644 --- a/lib/system/manager_test.go +++ b/lib/system/manager_test.go @@ -67,7 +67,7 @@ func TestInitScriptGeneration(t *testing.T) { assert.Contains(t, script, "/dev/vda") // rootfs disk assert.Contains(t, script, "/dev/vdb") // overlay disk assert.Contains(t, script, "/dev/vdc") // config disk - assert.Contains(t, script, "exec-agent") // vsock exec agent + assert.Contains(t, script, "guest-agent") // vsock guest agent assert.Contains(t, script, "${ENTRYPOINT}") assert.Contains(t, script, "wait $APP_PID") // Supervisor pattern } diff --git a/openapi.yaml b/openapi.yaml index a096083c..7b25f121 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -255,6 +255,46 @@ components: description: Whether a snapshot exists for this instance example: false + PathInfo: + type: object + required: [exists] + properties: + exists: + type: boolean + description: Whether the path exists + example: true + is_dir: + type: boolean + description: True if this is a directory + example: false + is_file: + type: boolean + description: True if this is a regular file + example: true + is_symlink: + type: boolean + description: True if this is a symbolic link (only set when follow_links=false) + example: false + link_target: + type: string + description: Symlink target path (only set when is_symlink=true) + nullable: true + example: "/actual/target/path" + mode: + type: integer + description: File mode (Unix permissions) + example: 420 + size: + type: integer + format: int64 + description: File size in bytes + example: 1024 + error: + type: string + description: Error message if stat failed (e.g., permission denied). Only set when exists is false due to an error rather than the path not existing. + nullable: true + example: "permission denied" + CreateImageRequest: type: object required: [name] @@ -1101,6 +1141,63 @@ paths: schema: $ref: "#/components/schemas/Error" + /instances/{id}/stat: + get: + summary: Get filesystem path info + description: | + Returns information about a path in the guest filesystem. + Useful for checking if a path exists, its type, and permissions + before performing file operations. + operationId: statInstancePath + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Instance ID or name + - name: path + in: query + required: true + schema: + type: string + description: Path to stat in the guest filesystem + example: "/app/data" + - name: follow_links + in: query + required: false + schema: + type: boolean + default: false + description: Follow symbolic links (like stat vs lstat) + responses: + 200: + description: Path information + content: + application/json: + schema: + $ref: "#/components/schemas/PathInfo" + 404: + description: Instance not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 409: + description: Instance not in running state + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /instances/{id}/volumes/{volumeId}: post: summary: Attach volume to instance diff --git a/stainless.yaml b/stainless.yaml index c083e4d1..792d5a79 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -81,6 +81,7 @@ resources: volume_mount: "#/components/schemas/VolumeMount" port_mapping: "#/components/schemas/PortMapping" instance: "#/components/schemas/Instance" + path_info: "#/components/schemas/PathInfo" methods: list: get /instances create: post /instances @@ -91,6 +92,7 @@ resources: start: post /instances/{id}/start stop: post /instances/{id}/stop logs: get /instances/{id}/logs + stat: get /instances/{id}/stat # Subresources define resources that are nested within another for more powerful # logical groupings, e.g. `cards.payments`. subresources: