From 81e4dbdbf6dd14a86b860ad1e6775d31b03b60f8 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Tue, 20 Jan 2026 11:21:14 -0500 Subject: [PATCH 1/3] Upgrade SDK --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 92f594c..8cbd49c 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.9.1 + github.com/kernel/hypeman-go v0.9.2 github.com/muesli/reflow v0.3.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 diff --git a/go.sum b/go.sum index ce57c68..97673d4 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.9.1 h1:4EcC9sOYH6f1365IIgJQqgFaZGPekl9DbhhpH/Tt0e8= -github.com/kernel/hypeman-go v0.9.1/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= +github.com/kernel/hypeman-go v0.9.2 h1:4/q35M8VpzMvT5WYdUC/7CliUF8EVI0XjIo9/dnMxhU= +github.com/kernel/hypeman-go v0.9.2/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= From 15206acc740bf2bd4446c6c735e10cf26e51ae2a Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Tue, 20 Jan 2026 14:06:25 -0500 Subject: [PATCH 2/3] Devices, Resources, Build --- go.mod | 6 +- go.sum | 12 +- pkg/cmd/build.go | 291 +++++++++------------------- pkg/cmd/cmd.go | 4 +- pkg/cmd/devicecmd.go | 372 ++++++++++++++++++++++++++++++++++++ pkg/cmd/devicecmd_test.go | 50 +++++ pkg/cmd/ps.go | 31 ++- pkg/cmd/ps_test.go | 80 ++++++++ pkg/cmd/resourcecmd.go | 252 ++++++++++++++++++++++++ pkg/cmd/resourcecmd_test.go | 70 +++++++ pkg/cmd/run.go | 109 ++++++++++- 11 files changed, 1063 insertions(+), 214 deletions(-) create mode 100644 pkg/cmd/devicecmd.go create mode 100644 pkg/cmd/devicecmd_test.go create mode 100644 pkg/cmd/ps_test.go create mode 100644 pkg/cmd/resourcecmd.go create mode 100644 pkg/cmd/resourcecmd_test.go diff --git a/go.mod b/go.mod index 8cbd49c..700d094 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,9 @@ require ( github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 - github.com/kernel/hypeman-go v0.9.2 + github.com/kernel/hypeman-go v0.9.3 github.com/muesli/reflow v0.3.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 github.com/tidwall/sjson v1.2.5 @@ -32,6 +33,7 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.0.3+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect @@ -57,6 +59,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -73,4 +76,5 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/grpc v1.75.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 97673d4..9b21f1a 100644 --- a/go.sum +++ b/go.sum @@ -74,10 +74,14 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/kernel/hypeman-go v0.9.2 h1:4/q35M8VpzMvT5WYdUC/7CliUF8EVI0XjIo9/dnMxhU= -github.com/kernel/hypeman-go v0.9.2/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= +github.com/kernel/hypeman-go v0.9.3 h1:UtlynELXJJ9Znnuq5mLWXCc1ymPh7PPYamEd6fb3UXM= +github.com/kernel/hypeman-go v0.9.3/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -119,6 +123,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -191,6 +197,8 @@ google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go index d0e0e7c..15f2c32 100644 --- a/pkg/cmd/build.go +++ b/pkg/cmd/build.go @@ -2,39 +2,19 @@ package cmd import ( "archive/tar" - "bufio" "bytes" "compress/gzip" "context" - "encoding/json" "fmt" "io" - "mime/multipart" - "net/http" "os" "path/filepath" - "strings" + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" "github.com/urfave/cli/v3" ) -// BuildEvent represents an event from the build SSE stream -type BuildEvent struct { - Type string `json:"type"` // "log", "status", "heartbeat" - Timestamp string `json:"timestamp"` - Content string `json:"content,omitempty"` // for type=log - Status string `json:"status,omitempty"` // for type=status -} - -// Build represents the build response from the API -type Build struct { - ID string `json:"id"` - Status string `json:"status"` - ImageDigest string `json:"image_digest,omitempty"` - ImageRef string `json:"image_ref,omitempty"` - Error string `json:"error,omitempty"` -} - var buildCmd = cli.Command{ Name: "build", Usage: "Build an image from a Dockerfile", @@ -97,31 +77,18 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { // Get Dockerfile path dockerfilePath := cmd.String("file") - var dockerfileContent []byte + var dockerfileContent string if dockerfilePath != "" { // If dockerfile is specified, read it if !filepath.IsAbs(dockerfilePath) { dockerfilePath = filepath.Join(absContextPath, dockerfilePath) } - dockerfileContent, err = os.ReadFile(dockerfilePath) + content, err := os.ReadFile(dockerfilePath) if err != nil { return fmt.Errorf("cannot read Dockerfile: %w", err) } - } - - // Get base URL and API key - baseURL := cmd.Root().String("base-url") - if baseURL == "" { - baseURL = os.Getenv("HYPEMAN_BASE_URL") - } - if baseURL == "" { - baseURL = "http://localhost:8080" - } - - apiKey := os.Getenv("HYPEMAN_API_KEY") - if apiKey == "" { - return fmt.Errorf("HYPEMAN_API_KEY environment variable required") + dockerfileContent = string(content) } timeout := cmd.Int("timeout") @@ -134,8 +101,26 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to create source archive: %w", err) } - // Upload build and get build ID - build, err := uploadBuild(ctx, baseURL, apiKey, tarball, dockerfileContent, int(timeout)) + // Create client with options + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + // Build params + params := hypeman.BuildNewParams{ + Source: bytes.NewReader(tarball.Bytes()), + TimeoutSeconds: hypeman.Opt(int64(timeout)), + } + + if dockerfileContent != "" { + params.Dockerfile = hypeman.Opt(dockerfileContent) + } + + // Start build + build, err := client.Builds.New(ctx, params, opts...) if err != nil { return fmt.Errorf("failed to start build: %w", err) } @@ -143,7 +128,7 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(os.Stderr, "Build started: %s\n", build.ID) // Stream build events - err = streamBuildEvents(ctx, baseURL, apiKey, build.ID) + err = streamBuildEventsSDK(ctx, client, build.ID, opts) if err != nil { return fmt.Errorf("build failed: %w", err) } @@ -151,6 +136,67 @@ func handleBuild(ctx context.Context, cmd *cli.Command) error { return nil } +// streamBuildEventsSDK streams build events using the SDK +func streamBuildEventsSDK(ctx context.Context, client hypeman.Client, buildID string, opts []option.RequestOption) error { + stream := client.Builds.EventsStreaming( + ctx, + buildID, + hypeman.BuildEventsParams{ + Follow: hypeman.Opt(true), + }, + opts..., + ) + defer stream.Close() + + var finalStatus hypeman.BuildStatus + var buildError string + + for stream.Next() { + event := stream.Current() + + switch event.Type { + case hypeman.BuildEventTypeLog: + // Print log content + fmt.Println(event.Content) + + case hypeman.BuildEventTypeStatus: + finalStatus = event.Status + switch event.Status { + case hypeman.BuildStatusQueued: + fmt.Fprintf(os.Stderr, "Build queued...\n") + case hypeman.BuildStatusBuilding: + fmt.Fprintf(os.Stderr, "Building...\n") + case hypeman.BuildStatusPushing: + fmt.Fprintf(os.Stderr, "Pushing image...\n") + case hypeman.BuildStatusReady: + fmt.Fprintf(os.Stderr, "Build complete!\n") + return nil + case hypeman.BuildStatusFailed: + buildError = "build failed" + case hypeman.BuildStatusCancelled: + return fmt.Errorf("build was cancelled") + } + + case hypeman.BuildEventTypeHeartbeat: + // Ignore heartbeat events + } + } + + if err := stream.Err(); err != nil { + return err + } + + // Check final status + if finalStatus == hypeman.BuildStatusFailed { + return fmt.Errorf("%s", buildError) + } + if finalStatus == hypeman.BuildStatusReady { + return nil + } + + return fmt.Errorf("build stream ended unexpectedly (status: %s)", finalStatus) +} + // createSourceTarball creates a gzipped tar archive of the build context func createSourceTarball(contextPath string) (*bytes.Buffer, error) { buf := new(bytes.Buffer) @@ -235,166 +281,3 @@ func createSourceTarball(contextPath string) (*bytes.Buffer, error) { return buf, nil } - -// uploadBuild uploads the source tarball to the builds API -func uploadBuild(ctx context.Context, baseURL, apiKey string, source *bytes.Buffer, dockerfile []byte, timeout int) (*Build, error) { - // Create multipart form - body := new(bytes.Buffer) - writer := multipart.NewWriter(body) - - // Add source tarball - sourcePart, err := writer.CreateFormFile("source", "source.tar.gz") - if err != nil { - return nil, err - } - if _, err := io.Copy(sourcePart, source); err != nil { - return nil, err - } - - // Add dockerfile if provided separately - if dockerfile != nil { - if err := writer.WriteField("dockerfile", string(dockerfile)); err != nil { - return nil, err - } - } - - // Add timeout - if err := writer.WriteField("timeout_seconds", fmt.Sprintf("%d", timeout)); err != nil { - return nil, err - } - - if err := writer.Close(); err != nil { - return nil, err - } - - // Create request - req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/builds", body) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+apiKey) - - // Send request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("build request failed (HTTP %d): %s", resp.StatusCode, string(respBody)) - } - - var build Build - if err := json.Unmarshal(respBody, &build); err != nil { - return nil, fmt.Errorf("failed to parse build response: %w", err) - } - - return &build, nil -} - -// streamBuildEvents streams build events from the SSE endpoint -func streamBuildEvents(ctx context.Context, baseURL, apiKey, buildID string) error { - url := fmt.Sprintf("%s/builds/%s/events?follow=true", baseURL, buildID) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "text/event-stream") - req.Header.Set("Authorization", "Bearer "+apiKey) - req.Header.Set("Cache-Control", "no-cache") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("failed to connect to build events (HTTP %d): %s", resp.StatusCode, string(body)) - } - - reader := bufio.NewReader(resp.Body) - var finalStatus string - var buildError string - - for { - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - break - } - return err - } - - line = strings.TrimSpace(line) - - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, ":") { - continue - } - - // Parse SSE data line - if strings.HasPrefix(line, "data:") { - data := strings.TrimPrefix(line, "data:") - data = strings.TrimSpace(data) - - if data == "" { - continue - } - - var event BuildEvent - if err := json.Unmarshal([]byte(data), &event); err != nil { - // Skip malformed events - continue - } - - switch event.Type { - case "log": - // Print log content - fmt.Println(event.Content) - - case "status": - finalStatus = event.Status - switch event.Status { - case "queued": - fmt.Fprintf(os.Stderr, "Build queued...\n") - case "building": - fmt.Fprintf(os.Stderr, "Building...\n") - case "pushing": - fmt.Fprintf(os.Stderr, "Pushing image...\n") - case "ready": - fmt.Fprintf(os.Stderr, "Build complete!\n") - return nil - case "failed": - buildError = "build failed" - case "cancelled": - return fmt.Errorf("build was cancelled") - } - - case "heartbeat": - // Ignore heartbeat events - } - } - } - - // Check final status - if finalStatus == "failed" { - return fmt.Errorf("%s", buildError) - } - if finalStatus == "ready" { - return nil - } - - return fmt.Errorf("build stream ended unexpectedly (status: %s)", finalStatus) -} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 7efd9ef..9c06729 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -75,12 +75,14 @@ func init() { &runCmd, &psCmd, &logsCmd, - &rmCmd, + &rmCmd, &stopCmd, &startCmd, &standbyCmd, &restoreCmd, &ingressCmd, + &resourcesCmd, + &deviceCmd, { Name: "health", Category: "API RESOURCE", diff --git a/pkg/cmd/devicecmd.go b/pkg/cmd/devicecmd.go new file mode 100644 index 0000000..ff02e6e --- /dev/null +++ b/pkg/cmd/devicecmd.go @@ -0,0 +1,372 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var deviceCmd = cli.Command{ + Name: "device", + Usage: "Manage PCI/GPU devices for passthrough", + Description: `Manage PCI devices for passthrough to virtual machines. + +This command allows you to discover available passthrough-capable devices, +register them for use with instances, and manage registered devices. + +Examples: + # Discover available devices on the host + hypeman device available + + # Register a GPU for passthrough + hypeman device register --pci-address 0000:a2:00.0 --name my-gpu + + # List registered devices + hypeman device list + + # Delete a registered device + hypeman device delete my-gpu`, + Commands: []*cli.Command{ + &deviceAvailableCmd, + &deviceRegisterCmd, + &deviceListCmd, + &deviceGetCmd, + &deviceDeleteCmd, + }, + HideHelpCommand: true, +} + +var deviceAvailableCmd = cli.Command{ + Name: "available", + Usage: "Discover passthrough-capable devices on host", + Description: `List all PCI devices on the host that are capable of passthrough. + +Shows devices with their PCI address, vendor/device info, IOMMU group, +and current driver binding.`, + Action: handleDeviceAvailable, + HideHelpCommand: true, +} + +var deviceRegisterCmd = cli.Command{ + Name: "register", + Usage: "Register a device for passthrough", + ArgsUsage: "[pci-address]", + Description: `Register a PCI device for use with VM passthrough. + +The device must be in an IOMMU group that supports passthrough. +Once registered, the device can be attached to instances using +the --device flag with 'hypeman run'. + +Examples: + # Register by PCI address + hypeman device register 0000:a2:00.0 + + # Register with a custom name + hypeman device register --pci-address 0000:a2:00.0 --name my-gpu`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "pci-address", + Usage: "PCI address of the device (e.g., 0000:a2:00.0)", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Optional name for the device (auto-generated if not provided)", + }, + }, + Action: handleDeviceRegister, + HideHelpCommand: true, +} + +var deviceListCmd = cli.Command{ + Name: "list", + Usage: "List registered devices", + Action: handleDeviceList, + HideHelpCommand: true, +} + +var deviceGetCmd = cli.Command{ + Name: "get", + Usage: "Get device details", + ArgsUsage: "", + Action: handleDeviceGet, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Device ID or name", + }, + }, + HideHelpCommand: true, +} + +var deviceDeleteCmd = cli.Command{ + Name: "delete", + Aliases: []string{"rm", "unregister"}, + Usage: "Unregister a device", + ArgsUsage: "", + Action: handleDeviceDelete, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Device ID or name", + }, + }, + HideHelpCommand: true, +} + +func handleDeviceAvailable(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Devices.ListAvailable(ctx, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + // If format is "auto", use our custom table format + if format == "auto" || format == "" { + return showAvailableDevicesTable(res) + } + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "devices available", obj, format, transform) +} + +func showAvailableDevicesTable(data []byte) error { + devices := gjson.ParseBytes(data) + + if !devices.IsArray() || len(devices.Array()) == 0 { + fmt.Println("No passthrough-capable devices found.") + return nil + } + + fmt.Println("PCI ADDRESS VENDOR DEVICE IOMMU DRIVER") + fmt.Println(strings.Repeat("-", 80)) + + devices.ForEach(func(key, value gjson.Result) bool { + pciAddr := value.Get("pci_address").String() + vendorID := value.Get("vendor_id").String() + deviceID := value.Get("device_id").String() + vendorName := value.Get("vendor_name").String() + deviceName := value.Get("device_name").String() + iommuGroup := value.Get("iommu_group").Int() + driver := value.Get("current_driver").String() + + // Format vendor info + vendor := vendorName + if vendor == "" { + vendor = vendorID + } else if len(vendor) > 18 { + vendor = vendor[:15] + "..." + } + + // Format device info + device := deviceName + if device == "" { + device = deviceID + } else if len(device) > 18 { + device = device[:15] + "..." + } + + if driver == "" { + driver = "-" + } + + fmt.Printf("%-16s %-19s %-19s %-7d %s\n", pciAddr, vendor, device, iommuGroup, driver) + return true + }) + + return nil +} + +func handleDeviceRegister(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + // Get PCI address from flag or first argument + pciAddress := cmd.String("pci-address") + args := cmd.Args().Slice() + if pciAddress == "" && len(args) > 0 { + pciAddress = args[0] + } + + if pciAddress == "" { + return fmt.Errorf("PCI address required\nUsage: hypeman device register [--pci-address] [--name ]") + } + + params := hypeman.DeviceNewParams{ + PciAddress: pciAddress, + } + + if name := cmd.String("name"); name != "" { + params.Name = hypeman.Opt(name) + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Devices.New(ctx, params, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format == "auto" || format == "" { + device := gjson.ParseBytes(res) + fmt.Printf("Registered device %s (%s)\n", device.Get("name").String(), device.Get("id").String()) + return nil + } + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "device register", obj, format, transform) +} + +func handleDeviceList(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Devices.List(ctx, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + if format == "auto" || format == "" { + return showDeviceListTable(res) + } + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "devices list", obj, format, transform) +} + +func showDeviceListTable(data []byte) error { + devices := gjson.ParseBytes(data) + + if !devices.IsArray() || len(devices.Array()) == 0 { + fmt.Println("No registered devices.") + return nil + } + + fmt.Println("ID NAME TYPE PCI ADDRESS VFIO ATTACHED TO") + fmt.Println(strings.Repeat("-", 90)) + + devices.ForEach(func(key, value gjson.Result) bool { + id := value.Get("id").String() + if len(id) > 20 { + id = id[:17] + "..." + } + + name := value.Get("name").String() + if len(name) > 20 { + name = name[:17] + "..." + } + + deviceType := value.Get("type").String() + pciAddr := value.Get("pci_address").String() + + vfio := "no" + if value.Get("bound_to_vfio").Bool() { + vfio = "yes" + } + + attachedTo := value.Get("attached_to").String() + if attachedTo == "" { + attachedTo = "-" + } else if len(attachedTo) > 15 { + attachedTo = attachedTo[:12] + "..." + } + + fmt.Printf("%-21s %-19s %-6s %-16s %-6s %s\n", id, name, deviceType, pciAddr, vfio, attachedTo) + return true + }) + + return nil +} + +func handleDeviceGet(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + // Get device ID from flag or first argument + id := cmd.String("id") + args := cmd.Args().Slice() + if id == "" && len(args) > 0 { + id = args[0] + } + + if id == "" { + return fmt.Errorf("device ID or name required\nUsage: hypeman device get ") + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Devices.Get(ctx, id, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "device get", obj, format, transform) +} + +func handleDeviceDelete(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + // Get device ID from flag or first argument + id := cmd.String("id") + args := cmd.Args().Slice() + if id == "" && len(args) > 0 { + id = args[0] + } + + if id == "" { + return fmt.Errorf("device ID or name required\nUsage: hypeman device delete ") + } + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + err := client.Devices.Delete(ctx, id, opts...) + if err != nil { + return err + } + + fmt.Printf("Deleted device %s\n", id) + return nil +} diff --git a/pkg/cmd/devicecmd_test.go b/pkg/cmd/devicecmd_test.go new file mode 100644 index 0000000..a6108e3 --- /dev/null +++ b/pkg/cmd/devicecmd_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeviceCommandStructure(t *testing.T) { + // Test that deviceCmd has the expected subcommands + assert.Equal(t, "device", deviceCmd.Name) + assert.Equal(t, "Manage PCI/GPU devices for passthrough", deviceCmd.Usage) + + // Verify subcommands exist + subcommandNames := make([]string, len(deviceCmd.Commands)) + for i, cmd := range deviceCmd.Commands { + subcommandNames[i] = cmd.Name + } + + assert.Contains(t, subcommandNames, "available") + assert.Contains(t, subcommandNames, "register") + assert.Contains(t, subcommandNames, "list") + assert.Contains(t, subcommandNames, "get") + assert.Contains(t, subcommandNames, "delete") +} + +func TestDeviceAvailableCmdStructure(t *testing.T) { + assert.Equal(t, "available", deviceAvailableCmd.Name) + assert.Equal(t, "Discover passthrough-capable devices on host", deviceAvailableCmd.Usage) +} + +func TestDeviceRegisterCmdStructure(t *testing.T) { + assert.Equal(t, "register", deviceRegisterCmd.Name) + assert.Equal(t, "Register a device for passthrough", deviceRegisterCmd.Usage) + + // Check flags exist + flagNames := make([]string, 0) + for _, flag := range deviceRegisterCmd.Flags { + flagNames = append(flagNames, flag.Names()...) + } + + assert.Contains(t, flagNames, "pci-address") + assert.Contains(t, flagNames, "name") +} + +func TestDeviceDeleteCmdAliases(t *testing.T) { + // Verify delete has aliases + assert.Contains(t, deviceDeleteCmd.Aliases, "rm") + assert.Contains(t, deviceDeleteCmd.Aliases, "unregister") +} diff --git a/pkg/cmd/ps.go b/pkg/cmd/ps.go index f9b04c8..2a2c10c 100644 --- a/pkg/cmd/ps.go +++ b/pkg/cmd/ps.go @@ -72,13 +72,15 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { return nil } - table := NewTableWriter(os.Stdout, "INSTANCE ID", "NAME", "IMAGE", "STATE", "CREATED") + table := NewTableWriter(os.Stdout, "INSTANCE ID", "NAME", "IMAGE", "STATE", "GPU", "HV", "CREATED") for _, inst := range filtered { table.AddRow( TruncateID(inst.ID), TruncateString(inst.Name, 20), TruncateString(inst.Image, 25), string(inst.State), + formatGPU(inst.GPU), + formatHypervisor(inst.Hypervisor), FormatTimeAgo(inst.CreatedAt), ) } @@ -87,3 +89,30 @@ func handlePs(ctx context.Context, cmd *cli.Command) error { return nil } +// formatGPU returns a short representation of GPU configuration +func formatGPU(gpu hypeman.InstanceGPU) string { + // Check if GPU profile is set + if gpu.Profile != "" { + return gpu.Profile + } + // Check if mdev UUID is set (indicates vGPU without profile name shown) + if gpu.MdevUuid != "" { + return "vgpu" + } + return "-" +} + +// formatHypervisor returns a short abbreviation for the hypervisor +func formatHypervisor(hv hypeman.InstanceHypervisor) string { + switch hv { + case hypeman.InstanceHypervisorCloudHypervisor: + return "ch" + case hypeman.InstanceHypervisorQemu: + return "qemu" + default: + if hv == "" { + return "ch" // default + } + return string(hv) + } +} diff --git a/pkg/cmd/ps_test.go b/pkg/cmd/ps_test.go new file mode 100644 index 0000000..3e48553 --- /dev/null +++ b/pkg/cmd/ps_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "testing" + + "github.com/kernel/hypeman-go" + "github.com/stretchr/testify/assert" +) + +func TestFormatGPU(t *testing.T) { + tests := []struct { + name string + gpu hypeman.InstanceGPU + expected string + }{ + { + name: "no GPU", + gpu: hypeman.InstanceGPU{}, + expected: "-", + }, + { + name: "vGPU with profile", + gpu: hypeman.InstanceGPU{ + Profile: "L40S-1Q", + MdevUuid: "abc-123", + }, + expected: "L40S-1Q", + }, + { + name: "vGPU without profile but with mdev", + gpu: hypeman.InstanceGPU{ + MdevUuid: "abc-123", + }, + expected: "vgpu", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatGPU(tt.gpu) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatHypervisor(t *testing.T) { + tests := []struct { + name string + hypervisor hypeman.InstanceHypervisor + expected string + }{ + { + name: "cloud-hypervisor", + hypervisor: hypeman.InstanceHypervisorCloudHypervisor, + expected: "ch", + }, + { + name: "qemu", + hypervisor: hypeman.InstanceHypervisorQemu, + expected: "qemu", + }, + { + name: "empty defaults to ch", + hypervisor: "", + expected: "ch", + }, + { + name: "unknown value", + hypervisor: hypeman.InstanceHypervisor("unknown"), + expected: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatHypervisor(tt.hypervisor) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cmd/resourcecmd.go b/pkg/cmd/resourcecmd.go new file mode 100644 index 0000000..062aa03 --- /dev/null +++ b/pkg/cmd/resourcecmd.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var resourcesCmd = cli.Command{ + Name: "resources", + Usage: "Show server resource capacity and allocation status", + Description: `Display current host resource capacity, allocation status, and per-instance breakdown. + +Resources include CPU, memory, disk, network, and GPU (if available). +Oversubscription ratios are applied to calculate effective limits. + +Examples: + # Show all resources (default table format) + hypeman resources + + # Show resources as JSON + hypeman resources --format json + + # Show only GPU information + hypeman resources --transform gpu`, + Action: handleResources, + HideHelpCommand: true, +} + +func handleResources(ctx context.Context, cmd *cli.Command) error { + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + _, err := client.Resources.Get(ctx, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + + // If format is "auto", use our custom table format + if format == "auto" || format == "" { + return showResourcesTable(res) + } + + // Otherwise use standard JSON display + obj := gjson.ParseBytes(res) + return ShowJSON(os.Stdout, "resources", obj, format, transform) +} + +func showResourcesTable(data []byte) error { + obj := gjson.ParseBytes(data) + + // Print resource summary table + fmt.Println("RESOURCE CAPACITY EFFECTIVE ALLOCATED AVAILABLE OVERSUB") + fmt.Println(strings.Repeat("-", 75)) + + printResourceRow("cpu", obj.Get("cpu"), "cores") + printResourceRow("memory", obj.Get("memory"), "bytes") + printResourceRow("disk", obj.Get("disk"), "bytes") + printResourceRow("network", obj.Get("network"), "bps") + + // Print GPU information if available + gpu := obj.Get("gpu") + if gpu.Exists() && gpu.Type != gjson.Null { + fmt.Println() + printGPUInfo(gpu) + } + + // Print disk breakdown if available + diskBreakdown := obj.Get("disk_breakdown") + if diskBreakdown.Exists() { + fmt.Println() + fmt.Println("DISK BREAKDOWN:") + if v := diskBreakdown.Get("images_bytes").Int(); v > 0 { + fmt.Printf(" Images: %s\n", formatBytes(v)) + } + if v := diskBreakdown.Get("volumes_bytes").Int(); v > 0 { + fmt.Printf(" Volumes: %s\n", formatBytes(v)) + } + if v := diskBreakdown.Get("overlays_bytes").Int(); v > 0 { + fmt.Printf(" Overlays: %s\n", formatBytes(v)) + } + if v := diskBreakdown.Get("oci_cache_bytes").Int(); v > 0 { + fmt.Printf(" OCI Cache: %s\n", formatBytes(v)) + } + } + + // Print allocations if any + allocations := obj.Get("allocations") + if allocations.Exists() && allocations.IsArray() && len(allocations.Array()) > 0 { + fmt.Println() + fmt.Println("ALLOCATIONS:") + fmt.Println("INSTANCE CPU MEMORY DISK NET DOWN NET UP") + fmt.Println(strings.Repeat("-", 80)) + allocations.ForEach(func(key, value gjson.Result) bool { + name := value.Get("instance_name").String() + if len(name) > 28 { + name = name[:25] + "..." + } + cpu := value.Get("cpu").Int() + mem := formatBytes(value.Get("memory_bytes").Int()) + disk := formatBytes(value.Get("disk_bytes").Int()) + netDown := formatBps(value.Get("network_download_bps").Int()) + netUp := formatBps(value.Get("network_upload_bps").Int()) + fmt.Printf("%-28s %3d %-9s %-9s %-10s %s\n", name, cpu, mem, disk, netDown, netUp) + return true + }) + } + + return nil +} + +func printResourceRow(name string, res gjson.Result, unit string) { + if !res.Exists() { + return + } + + capacity := res.Get("capacity").Int() + effective := res.Get("effective_limit").Int() + allocated := res.Get("allocated").Int() + available := res.Get("available").Int() + ratio := res.Get("oversub_ratio").Float() + + var capStr, effStr, allocStr, availStr string + + switch unit { + case "bytes": + capStr = formatBytes(capacity) + effStr = formatBytes(effective) + allocStr = formatBytes(allocated) + availStr = formatBytes(available) + case "bps": + capStr = formatBps(capacity) + effStr = formatBps(effective) + allocStr = formatBps(allocated) + availStr = formatBps(available) + default: + capStr = fmt.Sprintf("%d", capacity) + effStr = fmt.Sprintf("%d", effective) + allocStr = fmt.Sprintf("%d", allocated) + availStr = fmt.Sprintf("%d", available) + } + + ratioStr := fmt.Sprintf("%.1fx", ratio) + if ratio == 1.0 { + ratioStr = "1.0x" + } + + fmt.Printf("%-10s %-14s %-14s %-14s %-14s %s\n", name, capStr, effStr, allocStr, availStr, ratioStr) +} + +func printGPUInfo(gpu gjson.Result) { + mode := gpu.Get("mode").String() + totalSlots := gpu.Get("total_slots").Int() + usedSlots := gpu.Get("used_slots").Int() + + fmt.Printf("GPU: %s mode (%d/%d slots used)\n", mode, usedSlots, totalSlots) + + if mode == "vgpu" { + profiles := gpu.Get("profiles") + if profiles.Exists() && profiles.IsArray() && len(profiles.Array()) > 0 { + fmt.Println("PROFILE VRAM AVAILABLE") + fmt.Println(strings.Repeat("-", 40)) + profiles.ForEach(func(key, value gjson.Result) bool { + name := value.Get("name").String() + framebufferMB := value.Get("framebuffer_mb").Int() + available := value.Get("available").Int() + vram := formatMB(framebufferMB) + fmt.Printf("%-14s %-10s %d\n", name, vram, available) + return true + }) + } + } else if mode == "passthrough" { + devices := gpu.Get("devices") + if devices.Exists() && devices.IsArray() && len(devices.Array()) > 0 { + fmt.Println("DEVICE AVAILABLE") + fmt.Println(strings.Repeat("-", 45)) + devices.ForEach(func(key, value gjson.Result) bool { + name := value.Get("name").String() + available := value.Get("available").Bool() + availStr := "no" + if available { + availStr = "yes" + } + fmt.Printf("%-30s %s\n", name, availStr) + return true + }) + } + } +} + +func formatBytes(b int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + TB = GB * 1024 + ) + + switch { + case b >= TB: + return fmt.Sprintf("%.1f TB", float64(b)/TB) + case b >= GB: + return fmt.Sprintf("%.1f GB", float64(b)/GB) + case b >= MB: + return fmt.Sprintf("%.1f MB", float64(b)/MB) + case b >= KB: + return fmt.Sprintf("%.1f KB", float64(b)/KB) + default: + return fmt.Sprintf("%d B", b) + } +} + +func formatMB(mb int64) string { + if mb >= 1024 { + return fmt.Sprintf("%.1f GB", float64(mb)/1024) + } + return fmt.Sprintf("%d MB", mb) +} + +func formatBps(bps int64) string { + const ( + Kbps = 1000 + Mbps = Kbps * 1000 + Gbps = Mbps * 1000 + ) + + switch { + case bps >= Gbps: + return fmt.Sprintf("%.1f Gbps", float64(bps)/Gbps) + case bps >= Mbps: + return fmt.Sprintf("%.0f Mbps", float64(bps)/Mbps) + case bps >= Kbps: + return fmt.Sprintf("%.0f Kbps", float64(bps)/Kbps) + default: + return fmt.Sprintf("%d bps", bps) + } +} diff --git a/pkg/cmd/resourcecmd_test.go b/pkg/cmd/resourcecmd_test.go new file mode 100644 index 0000000..456fe3a --- /dev/null +++ b/pkg/cmd/resourcecmd_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatBytes(t *testing.T) { + tests := []struct { + bytes int64 + expected string + }{ + {0, "0 B"}, + {100, "100 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + {1099511627776, "1.0 TB"}, + {1649267441664, "1.5 TB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := formatBytes(tt.bytes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatMB(t *testing.T) { + tests := []struct { + mb int64 + expected string + }{ + {512, "512 MB"}, + {1024, "1.0 GB"}, + {2048, "2.0 GB"}, + {6144, "6.0 GB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := formatMB(tt.mb) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatBps(t *testing.T) { + tests := []struct { + bps int64 + expected string + }{ + {500, "500 bps"}, + {1000, "1 Kbps"}, + {1000000, "1 Mbps"}, + {1000000000, "1.0 Gbps"}, + {125000000, "125 Mbps"}, + {10000000000, "10.0 Gbps"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := formatBps(tt.bps) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 7adb301..aadc75b 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -16,6 +16,26 @@ var runCmd = cli.Command{ Name: "run", Usage: "Create and start a new instance from an image", ArgsUsage: "", + Description: `Create and start a new virtual machine instance from an OCI image. + +Examples: + # Basic run + hypeman run myimage:latest + + # Run with custom resources + hypeman run --cpus 4 --memory 8GB myimage:latest + + # Run with vGPU + hypeman run --gpu-profile L40S-1Q myimage:latest + + # Run with GPU passthrough + hypeman run --device my-gpu myimage:latest + + # Run with QEMU hypervisor + hypeman run --hypervisor qemu myimage:latest + + # Run with bandwidth limits + hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps myimage:latest`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", @@ -51,6 +71,33 @@ var runCmd = cli.Command{ Usage: "Enable network (default: true)", Value: true, }, + // GPU/vGPU flags + &cli.StringFlag{ + Name: "gpu-profile", + Usage: `vGPU profile name (e.g., "L40S-1Q", "L40S-2Q")`, + }, + &cli.StringSliceFlag{ + Name: "device", + Usage: "Device ID or name for PCI/GPU passthrough (can be repeated)", + }, + // Hypervisor flag + &cli.StringFlag{ + Name: "hypervisor", + Usage: `Hypervisor to use: "cloud-hypervisor" or "qemu"`, + }, + // Resource limit flags + &cli.StringFlag{ + Name: "disk-io", + Usage: `Disk I/O rate limit (e.g., "100MB/s", "500MB/s")`, + }, + &cli.StringFlag{ + Name: "bandwidth-down", + Usage: `Download bandwidth limit (e.g., "1Gbps", "125MB/s")`, + }, + &cli.StringFlag{ + Name: "bandwidth-up", + Usage: `Upload bandwidth limit (e.g., "1Gbps", "125MB/s")`, + }, }, Action: handleRun, HideHelpCommand: true, @@ -107,17 +154,69 @@ func handleRun(ctx context.Context, cmd *cli.Command) error { } // Build instance params - // Note: SDK uses memory in MB, but we accept human-readable format - // For simplicity, we pass memory as-is and let the server handle conversion params := hypeman.InstanceNewParams{ - Image: image, - Name: name, - Vcpus: hypeman.Opt(int64(cmd.Int("cpus"))), + Image: image, + Name: name, + Vcpus: hypeman.Opt(int64(cmd.Int("cpus"))), + Size: hypeman.Opt(cmd.String("memory")), + OverlaySize: hypeman.Opt(cmd.String("overlay-size")), + HotplugSize: hypeman.Opt(cmd.String("hotplug-size")), } + if len(env) > 0 { params.Env = env } + // Network configuration + networkEnabled := cmd.Bool("network") + bandwidthDown := cmd.String("bandwidth-down") + bandwidthUp := cmd.String("bandwidth-up") + + if !networkEnabled || bandwidthDown != "" || bandwidthUp != "" { + params.Network = hypeman.InstanceNewParamsNetwork{ + Enabled: hypeman.Opt(networkEnabled), + } + if bandwidthDown != "" { + params.Network.BandwidthDownload = hypeman.Opt(bandwidthDown) + } + if bandwidthUp != "" { + params.Network.BandwidthUpload = hypeman.Opt(bandwidthUp) + } + } + + // GPU configuration + gpuProfile := cmd.String("gpu-profile") + if gpuProfile != "" { + params.GPU = hypeman.InstanceNewParamsGPU{ + Profile: hypeman.Opt(gpuProfile), + } + } + + // Device passthrough + devices := cmd.StringSlice("device") + if len(devices) > 0 { + params.Devices = devices + } + + // Hypervisor selection + hypervisor := cmd.String("hypervisor") + if hypervisor != "" { + switch hypervisor { + case "cloud-hypervisor", "ch": + params.Hypervisor = hypeman.InstanceNewParamsHypervisorCloudHypervisor + case "qemu": + params.Hypervisor = hypeman.InstanceNewParamsHypervisorQemu + default: + return fmt.Errorf("invalid hypervisor: %s (must be 'cloud-hypervisor' or 'qemu')", hypervisor) + } + } + + // Disk I/O limit + diskIO := cmd.String("disk-io") + if diskIO != "" { + params.DiskIoBps = hypeman.Opt(diskIO) + } + fmt.Fprintf(os.Stderr, "Creating instance %s...\n", name) var opts []option.RequestOption From 851fd1a8636b0d04c64e3f3dce96ecf3356377dd Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Tue, 20 Jan 2026 14:14:50 -0500 Subject: [PATCH 3/3] Update README --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/README.md b/README.md index 75e1c66..d0a8e90 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,93 @@ The CLI also provides resource-based commands for more advanced usage: hypeman [resource] [command] [flags] ``` +## Resource Management + +### Viewing Server Resources + +Check available server capacity, current allocations, and GPU availability: + +```bash +# Show server resource status (CPU, memory, disk, network, GPU) +hypeman resources + +# Show resources as JSON +hypeman resources --format json + +# Show only GPU information +hypeman resources --transform gpu +``` + +### Per-VM Resource Limits + +Control resource allocation for instances: + +```bash +# Set disk I/O limit +hypeman run --disk-io 100MB/s --name io-limited myimage:latest + +# Set network bandwidth limits +hypeman run --bandwidth-down 1Gbps --bandwidth-up 500Mbps --name bw-limited myimage:latest + +# Combine multiple resource options +hypeman run \ + --cpus 4 \ + --memory 8GB \ + --gpu-profile L40S-2Q \ + --disk-io 200MB/s \ + --bandwidth-down 10Gbps \ + --name ml-training \ + pytorch:latest +``` + +## GPU support + + +### GPU Passthrough + +For full GPU passthrough (entire GPU dedicated to one VM): + +```bash +# Discover available passthrough-capable devices +hypeman device available + +# Register a GPU for passthrough +hypeman device register --pci-address 0000:a2:00.0 --name my-gpu + +# List registered devices +hypeman device list + +# Run an instance with the GPU attached +hypeman run --device my-gpu --hypervisor qemu --name gpu-workload cuda:12.0 + +# When done, unregister the device +hypeman device delete my-gpu +``` + +### Nvidia vGPU + +Use NVIDIA vGPU to share a physical GPU across multiple VMs: + +```bash +# Run with a vGPU profile +hypeman run --gpu-profile L40S-1Q --name ml-workload pytorch:latest + +# Run with more vGPU resources +hypeman run --gpu-profile L40S-4Q --cpus 8 --memory 32GB --name training-job tensorflow:latest +``` + +### Hypervisor Selection + +Choose between Cloud Hypervisor (default) and QEMU: + +```bash +# Run with QEMU (more compatible with some features like vGPU) +hypeman run --hypervisor qemu --name qemu-vm myimage:latest + +# Run with Cloud Hypervisor (default, faster boot) +hypeman run --hypervisor cloud-hypervisor --name ch-vm myimage:latest +``` + ## Global Flags - `--debug` - Enable debug logging (includes HTTP request/response details)