From 8ab31e89c70baa967842c1c160d0b49db44b089a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 05:29:03 +0000 Subject: [PATCH 01/10] feat(encoder): support bracket encoding form-data object members --- internal/apiform/encoder.go | 80 +++++++++++++++++++---------------- internal/apiform/form_test.go | 51 +++++++++++++++++++++- 2 files changed, 94 insertions(+), 37 deletions(-) diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index d66b33b..55f53b7 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -60,6 +60,7 @@ type encoderField struct { type encoderEntry struct { reflect.Type dateFormat string + arrayFmt string root bool } @@ -77,6 +78,7 @@ func (e *encoder) typeEncoder(t reflect.Type) encoderFunc { entry := encoderEntry{ Type: t, dateFormat: e.dateFormat, + arrayFmt: e.arrayFmt, root: e.root, } @@ -178,34 +180,9 @@ func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { } } -func arrayKeyEncoder(arrayFmt string) func(string, int) string { - var keyFn func(string, int) string - switch arrayFmt { - case "comma", "repeat": - keyFn = func(k string, _ int) string { return k } - case "brackets": - keyFn = func(key string, _ int) string { return key + "[]" } - case "indices:dots": - keyFn = func(k string, i int) string { - if k == "" { - return strconv.Itoa(i) - } - return k + "." + strconv.Itoa(i) - } - case "indices:brackets": - keyFn = func(k string, i int) string { - if k == "" { - return strconv.Itoa(i) - } - return k + "[" + strconv.Itoa(i) + "]" - } - } - return keyFn -} - func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { itemEncoder := e.typeEncoder(t.Elem()) - keyFn := arrayKeyEncoder(e.arrayFmt) + keyFn := e.arrayKeyEncoder() return func(key string, v reflect.Value, writer *multipart.Writer) error { if keyFn == nil { return fmt.Errorf("apiform: unsupported array format") @@ -303,13 +280,10 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { }) return func(key string, value reflect.Value, writer *multipart.Writer) error { - if key != "" { - key = key + "." - } - + keyFn := e.objKeyEncoder(key) for _, ef := range encoderFields { field := value.FieldByIndex(ef.idx) - err := ef.fn(key+ef.tag.name, field, writer) + err := ef.fn(keyFn(ef.tag.name), field, writer) if err != nil { return err } @@ -405,6 +379,43 @@ func (e *encoder) newReaderTypeEncoder() encoderFunc { } } +func (e encoder) arrayKeyEncoder() func(string, int) string { + var keyFn func(string, int) string + switch e.arrayFmt { + case "comma", "repeat": + keyFn = func(k string, _ int) string { return k } + case "brackets": + keyFn = func(key string, _ int) string { return key + "[]" } + case "indices:dots": + keyFn = func(k string, i int) string { + if k == "" { + return strconv.Itoa(i) + } + return k + "." + strconv.Itoa(i) + } + case "indices:brackets": + keyFn = func(k string, i int) string { + if k == "" { + return strconv.Itoa(i) + } + return k + "[" + strconv.Itoa(i) + "]" + } + } + return keyFn +} + +func (e encoder) objKeyEncoder(parent string) func(string) string { + if parent == "" { + return func(child string) string { return child } + } + switch e.arrayFmt { + case "brackets": + return func(child string) string { return parent + "[" + child + "]" } + default: + return func(child string) string { return parent + "." + child } + } +} + // Given a []byte of json (may either be an empty object or an object that already contains entries) // encode all of the entries in the map to the json byte array. func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipart.Writer) error { @@ -413,10 +424,6 @@ func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipar value reflect.Value } - if key != "" { - key = key + "." - } - pairs := []mapPair{} iter := v.MapRange() @@ -434,8 +441,9 @@ func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipar }) elementEncoder := e.typeEncoder(v.Type().Elem()) + keyFn := e.objKeyEncoder(key) for _, p := range pairs { - err := elementEncoder(key+string(p.key), p.value, writer) + err := elementEncoder(keyFn(p.key), p.value, writer) if err != nil { return err } diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index e5eb680..75b3972 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -123,6 +123,18 @@ type StructUnion struct { param.APIUnion } +type MultipartMarshalerParent struct { + Middle MultipartMarshalerMiddleNext `form:"middle"` +} + +type MultipartMarshalerMiddleNext struct { + MiddleNext MultipartMarshalerMiddle `form:"middleNext"` +} + +type MultipartMarshalerMiddle struct { + Child int `form:"child"` +} + var tests = map[string]struct { buf string val any @@ -366,6 +378,19 @@ true }, }, }, + "recursive_struct,brackets": { + `--xxx +Content-Disposition: form-data; name="child[name]" + +Alex +--xxx +Content-Disposition: form-data; name="name" + +Robert +--xxx-- +`, + Recursive{Name: "Robert", Child: &Recursive{Name: "Alex"}}, + }, "recursive_struct": { `--xxx @@ -529,6 +554,30 @@ Content-Disposition: form-data; name="union" Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)), }, }, + "deeply-nested-struct,brackets": { + `--xxx +Content-Disposition: form-data; name="middle[middleNext][child]" + +10 +--xxx-- +`, + MultipartMarshalerParent{ + Middle: MultipartMarshalerMiddleNext{ + MiddleNext: MultipartMarshalerMiddle{ + Child: 10, + }, + }, + }, + }, + "deeply-nested-map,brackets": { + `--xxx +Content-Disposition: form-data; name="middle[middleNext][child]" + +10 +--xxx-- +`, + map[string]any{"middle": map[string]any{"middleNext": map[string]any{"child": 10}}}, + }, } func TestEncode(t *testing.T) { @@ -553,7 +602,7 @@ func TestEncode(t *testing.T) { } raw := buf.Bytes() if string(raw) != strings.ReplaceAll(test.buf, "\n", "\r\n") { - t.Errorf("expected %+#v to serialize to '%s' but got '%s'", test.val, test.buf, string(raw)) + t.Errorf("expected %+#v to serialize to '%s' but got '%s' (with format %s)", test.val, test.buf, string(raw), arrayFmt) } }) } From 067a01b4ac06e82c2db6b165127144afa18a691d Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:50:20 +0000 Subject: [PATCH 02/10] feat: gpu passthrough --- .stats.yml | 8 +- api.md | 16 ++++ client.go | 2 + device.go | 198 +++++++++++++++++++++++++++++++++++++++++++++++ device_test.go | 132 +++++++++++++++++++++++++++++++ instance.go | 2 + instance_test.go | 5 +- 7 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 device.go create mode 100644 device_test.go diff --git a/.stats.yml b/.stats.yml index dbab236..870f8be 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 24 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-8fded10e90df28c07b64a92d12d665d54749b9fc13c35520667637fc596957d9.yml -openapi_spec_hash: 7374a732372bddf7f2c0b532b56ae3fb -config_hash: 510018ffa6ad6a17875954f66fe69598 +configured_endpoints: 29 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-95df8b193133def744aa61dc372f286663ffc20d833488d242fa288af65adc39.yml +openapi_spec_hash: 833120a235ecb298688c2fb1122b3574 +config_hash: d34daaeca7d2ee972fa0b30a6a292465 diff --git a/api.md b/api.md index 11cb961..547beba 100644 --- a/api.md +++ b/api.md @@ -65,6 +65,22 @@ Methods: - client.Volumes.Delete(ctx context.Context, id string) error - client.Volumes.Get(ctx context.Context, id string) (hypeman.Volume, error) +# Devices + +Response Types: + +- hypeman.AvailableDevice +- hypeman.Device +- hypeman.DeviceType + +Methods: + +- client.Devices.New(ctx context.Context, body hypeman.DeviceNewParams) (hypeman.Device, error) +- client.Devices.Get(ctx context.Context, id string) (hypeman.Device, error) +- client.Devices.List(ctx context.Context) ([]hypeman.Device, error) +- client.Devices.Delete(ctx context.Context, id string) error +- client.Devices.ListAvailable(ctx context.Context) ([]hypeman.AvailableDevice, error) + # Ingresses Params Types: diff --git a/client.go b/client.go index 70aefcc..69ad745 100644 --- a/client.go +++ b/client.go @@ -21,6 +21,7 @@ type Client struct { Images ImageService Instances InstanceService Volumes VolumeService + Devices DeviceService Ingresses IngressService } @@ -50,6 +51,7 @@ func NewClient(opts ...option.RequestOption) (r Client) { r.Images = NewImageService(opts...) r.Instances = NewInstanceService(opts...) r.Volumes = NewVolumeService(opts...) + r.Devices = NewDeviceService(opts...) r.Ingresses = NewIngressService(opts...) return diff --git a/device.go b/device.go new file mode 100644 index 0000000..1a1d3b7 --- /dev/null +++ b/device.go @@ -0,0 +1,198 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "time" + + "github.com/onkernel/hypeman-go/internal/apijson" + "github.com/onkernel/hypeman-go/internal/requestconfig" + "github.com/onkernel/hypeman-go/option" + "github.com/onkernel/hypeman-go/packages/param" + "github.com/onkernel/hypeman-go/packages/respjson" +) + +// DeviceService contains methods and other services that help with interacting +// with the hypeman API. +// +// Note, unlike clients, this service does not read variables from the environment +// automatically. You should not instantiate this service directly, and instead use +// the [NewDeviceService] method instead. +type DeviceService struct { + Options []option.RequestOption +} + +// NewDeviceService generates a new service that applies the given options to each +// request. These options are applied after the parent client's options (if there +// is one), and before any request-specific options. +func NewDeviceService(opts ...option.RequestOption) (r DeviceService) { + r = DeviceService{} + r.Options = opts + return +} + +// Register a device for passthrough +func (r *DeviceService) New(ctx context.Context, body DeviceNewParams, opts ...option.RequestOption) (res *Device, err error) { + opts = slices.Concat(r.Options, opts) + path := "devices" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return +} + +// Get device details +func (r *DeviceService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Device, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("devices/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// List registered devices +func (r *DeviceService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Device, err error) { + opts = slices.Concat(r.Options, opts) + path := "devices" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +// Unregister device +func (r *DeviceService) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) { + opts = slices.Concat(r.Options, opts) + opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("devices/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) + return +} + +// Discover passthrough-capable devices on host +func (r *DeviceService) ListAvailable(ctx context.Context, opts ...option.RequestOption) (res *[]AvailableDevice, err error) { + opts = slices.Concat(r.Options, opts) + path := "devices/available" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +type AvailableDevice struct { + // PCI device ID (hex) + DeviceID string `json:"device_id,required"` + // IOMMU group number + IommuGroup int64 `json:"iommu_group,required"` + // PCI address + PciAddress string `json:"pci_address,required"` + // PCI vendor ID (hex) + VendorID string `json:"vendor_id,required"` + // Currently bound driver (null if none) + CurrentDriver string `json:"current_driver,nullable"` + // Human-readable device name + DeviceName string `json:"device_name"` + // Human-readable vendor name + VendorName string `json:"vendor_name"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + DeviceID respjson.Field + IommuGroup respjson.Field + PciAddress respjson.Field + VendorID respjson.Field + CurrentDriver respjson.Field + DeviceName respjson.Field + VendorName respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r AvailableDevice) RawJSON() string { return r.JSON.raw } +func (r *AvailableDevice) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type Device struct { + // Auto-generated unique identifier (CUID2 format) + ID string `json:"id,required"` + // Whether the device is currently bound to the vfio-pci driver, which is required + // for VM passthrough. + // + // - true: Device is bound to vfio-pci and ready for (or currently in use by) a VM. + // The device's native driver has been unloaded. + // - false: Device is using its native driver (e.g., nvidia) or no driver. Hypeman + // will automatically bind to vfio-pci when attaching to an instance. + BoundToVfio bool `json:"bound_to_vfio,required"` + // Registration timestamp (RFC3339) + CreatedAt time.Time `json:"created_at,required" format:"date-time"` + // PCI device ID (hex) + DeviceID string `json:"device_id,required"` + // IOMMU group number + IommuGroup int64 `json:"iommu_group,required"` + // PCI address + PciAddress string `json:"pci_address,required"` + // Type of PCI device + // + // Any of "gpu", "pci". + Type DeviceType `json:"type,required"` + // PCI vendor ID (hex) + VendorID string `json:"vendor_id,required"` + // Instance ID if attached + AttachedTo string `json:"attached_to,nullable"` + // Device name (user-provided or auto-generated from PCI address) + Name string `json:"name"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ID respjson.Field + BoundToVfio respjson.Field + CreatedAt respjson.Field + DeviceID respjson.Field + IommuGroup respjson.Field + PciAddress respjson.Field + Type respjson.Field + VendorID respjson.Field + AttachedTo respjson.Field + Name respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r Device) RawJSON() string { return r.JSON.raw } +func (r *Device) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Type of PCI device +type DeviceType string + +const ( + DeviceTypeGPU DeviceType = "gpu" + DeviceTypePci DeviceType = "pci" +) + +type DeviceNewParams struct { + // PCI address of the device (required, e.g., "0000:a2:00.0") + PciAddress string `json:"pci_address,required"` + // Optional globally unique device name. If not provided, a name is auto-generated + // from the PCI address (e.g., "pci-0000-a2-00-0") + Name param.Opt[string] `json:"name,omitzero"` + paramObj +} + +func (r DeviceNewParams) MarshalJSON() (data []byte, err error) { + type shadow DeviceNewParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *DeviceNewParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/device_test.go b/device_test.go new file mode 100644 index 0000000..6de5480 --- /dev/null +++ b/device_test.go @@ -0,0 +1,132 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/internal/testutil" + "github.com/onkernel/hypeman-go/option" +) + +func TestDeviceNewWithOptionalParams(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Devices.New(context.TODO(), hypeman.DeviceNewParams{ + PciAddress: "0000:a2:00.0", + Name: hypeman.String("l4-gpu"), + }) + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestDeviceGet(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Devices.Get(context.TODO(), "id") + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestDeviceList(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Devices.List(context.TODO()) + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestDeviceDelete(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + err := client.Devices.Delete(context.TODO(), "id") + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestDeviceListAvailable(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Devices.ListAvailable(context.TODO()) + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/instance.go b/instance.go index 6d37852..f639a29 100644 --- a/instance.go +++ b/instance.go @@ -354,6 +354,8 @@ type InstanceNewParams struct { Size param.Opt[string] `json:"size,omitzero"` // Number of virtual CPUs Vcpus param.Opt[int64] `json:"vcpus,omitzero"` + // Device IDs or names to attach for GPU/PCI passthrough + Devices []string `json:"devices,omitzero"` // Environment variables Env map[string]string `json:"env,omitzero"` // Network configuration for the instance diff --git a/instance_test.go b/instance_test.go index 4910848..ccb5e98 100644 --- a/instance_test.go +++ b/instance_test.go @@ -27,8 +27,9 @@ func TestInstanceNewWithOptionalParams(t *testing.T) { option.WithAPIKey("My API Key"), ) _, err := client.Instances.New(context.TODO(), hypeman.InstanceNewParams{ - Image: "docker.io/library/alpine:latest", - Name: "my-workload-1", + Image: "docker.io/library/alpine:latest", + Name: "my-workload-1", + Devices: []string{"l4-gpu"}, Env: map[string]string{ "PORT": "3000", "NODE_ENV": "production", From d62b2466715247e7d083ab7ef33040e5da036bd8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 08:37:23 +0000 Subject: [PATCH 03/10] fix: skip usage tests that don't work with Prism --- usage_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/usage_test.go b/usage_test.go index cffd18e..6662078 100644 --- a/usage_test.go +++ b/usage_test.go @@ -24,6 +24,7 @@ func TestUsage(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) + t.Skip("Prism tests are disabled") response, err := client.Health.Check(context.TODO()) if err != nil { t.Fatalf("err should be nil: %s", err.Error()) From b4666fd1bfcdd17b0a4d4bf88541670cd40c8b1c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 07:07:13 +0000 Subject: [PATCH 04/10] chore: add float64 to valid types for RegisterFieldValidator --- internal/apijson/enum.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/apijson/enum.go b/internal/apijson/enum.go index 18b218a..5bef11c 100644 --- a/internal/apijson/enum.go +++ b/internal/apijson/enum.go @@ -29,7 +29,7 @@ type validatorFunc func(reflect.Value) exactness var validators sync.Map var validationRegistry = map[reflect.Type][]validationEntry{} -func RegisterFieldValidator[T any, V string | bool | int](fieldName string, values ...V) { +func RegisterFieldValidator[T any, V string | bool | int | float64](fieldName string, values ...V) { var t T parentType := reflect.TypeOf(t) From 4fefe60b6f0c7b94fb22d95263cb18f951d48107 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:01:56 +0000 Subject: [PATCH 05/10] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 870f8be..1d10fd2 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 29 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-95df8b193133def744aa61dc372f286663ffc20d833488d242fa288af65adc39.yml openapi_spec_hash: 833120a235ecb298688c2fb1122b3574 -config_hash: d34daaeca7d2ee972fa0b30a6a292465 +config_hash: 934c902d7a6d54ba8deccc794b3e9284 From c4f225e5d3f2830caafdedfbd34e4bab47425045 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:03:49 +0000 Subject: [PATCH 06/10] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 1d10fd2..4f94ed7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 29 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-95df8b193133def744aa61dc372f286663ffc20d833488d242fa288af65adc39.yml openapi_spec_hash: 833120a235ecb298688c2fb1122b3574 -config_hash: 934c902d7a6d54ba8deccc794b3e9284 +config_hash: 6fc319c89215960a2b26b940e2fc80b9 From 87dcbb068f9c79f070bbe47e87a8b4a7237eae02 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:20:25 +0000 Subject: [PATCH 07/10] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 4f94ed7..8c80d5f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 29 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-95df8b193133def744aa61dc372f286663ffc20d833488d242fa288af65adc39.yml openapi_spec_hash: 833120a235ecb298688c2fb1122b3574 -config_hash: 6fc319c89215960a2b26b940e2fc80b9 +config_hash: 48d3be2bdbf56b770c9695a338382558 From bd30abaa96ff75beb09ba09ec871434523389cdf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:47:48 +0000 Subject: [PATCH 08/10] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 8c80d5f..d100027 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 29 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-95df8b193133def744aa61dc372f286663ffc20d833488d242fa288af65adc39.yml openapi_spec_hash: 833120a235ecb298688c2fb1122b3574 -config_hash: 48d3be2bdbf56b770c9695a338382558 +config_hash: 01911ae3d5491505b04f842d7edff72a From 49ea89852eed5e0893febc4c68d295a0d1a8bfe5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:08:12 +0000 Subject: [PATCH 09/10] feat: add hypeman cp for file copy to/from running VMs --- .stats.yml | 8 +++--- api.md | 2 ++ instance.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ instance_test.go | 30 +++++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/.stats.yml b/.stats.yml index d100027..377ee95 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 29 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-95df8b193133def744aa61dc372f286663ffc20d833488d242fa288af65adc39.yml -openapi_spec_hash: 833120a235ecb298688c2fb1122b3574 -config_hash: 01911ae3d5491505b04f842d7edff72a +configured_endpoints: 30 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-28e78b73c796f9ee866671ed946402b5d569e683c3207d57c9143eb7d6f83fb6.yml +openapi_spec_hash: fce0ac8713369a5f048bac684ed34fc8 +config_hash: f65a6a2bcef49a9f623212f9de6d6f6f diff --git a/api.md b/api.md index 547beba..6738d2c 100644 --- a/api.md +++ b/api.md @@ -30,6 +30,7 @@ Params Types: Response Types: - hypeman.Instance +- hypeman.PathInfo - hypeman.VolumeMount Methods: @@ -42,6 +43,7 @@ Methods: - client.Instances.Restore(ctx context.Context, id string) (hypeman.Instance, error) - client.Instances.Standby(ctx context.Context, id string) (hypeman.Instance, error) - client.Instances.Start(ctx context.Context, id string) (hypeman.Instance, error) +- client.Instances.Stat(ctx context.Context, id string, query hypeman.InstanceStatParams) (hypeman.PathInfo, error) - client.Instances.Stop(ctx context.Context, id string) (hypeman.Instance, error) ## Volumes diff --git a/instance.go b/instance.go index f639a29..d393460 100644 --- a/instance.go +++ b/instance.go @@ -144,6 +144,19 @@ func (r *InstanceService) Start(ctx context.Context, id string, opts ...option.R return } +// Returns information about a path in the guest filesystem. Useful for checking if +// a path exists, its type, and permissions before performing file operations. +func (r *InstanceService) Stat(ctx context.Context, id string, query InstanceStatParams, opts ...option.RequestOption) (res *PathInfo, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("instances/%s/stat", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return +} + // Stop instance (graceful shutdown) func (r *InstanceService) Stop(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { opts = slices.Concat(r.Options, opts) @@ -277,6 +290,45 @@ func (r *InstanceNetwork) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type PathInfo struct { + // Whether the path exists + Exists bool `json:"exists,required"` + // 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,nullable"` + // True if this is a directory + IsDir bool `json:"is_dir"` + // True if this is a regular file + IsFile bool `json:"is_file"` + // True if this is a symbolic link (only set when follow_links=false) + IsSymlink bool `json:"is_symlink"` + // Symlink target path (only set when is_symlink=true) + LinkTarget string `json:"link_target,nullable"` + // File mode (Unix permissions) + Mode int64 `json:"mode"` + // File size in bytes + Size int64 `json:"size"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Exists respjson.Field + Error respjson.Field + IsDir respjson.Field + IsFile respjson.Field + IsSymlink respjson.Field + LinkTarget respjson.Field + Mode respjson.Field + Size respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r PathInfo) RawJSON() string { return r.JSON.raw } +func (r *PathInfo) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type VolumeMount struct { // Path where volume is mounted in the guest MountPath string `json:"mount_path,required"` @@ -424,3 +476,19 @@ const ( InstanceLogsParamsSourceVmm InstanceLogsParamsSource = "vmm" InstanceLogsParamsSourceHypeman InstanceLogsParamsSource = "hypeman" ) + +type InstanceStatParams struct { + // Path to stat in the guest filesystem + Path string `query:"path,required" json:"-"` + // Follow symbolic links (like stat vs lstat) + FollowLinks param.Opt[bool] `query:"follow_links,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [InstanceStatParams]'s query parameters as `url.Values`. +func (r InstanceStatParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} diff --git a/instance_test.go b/instance_test.go index ccb5e98..8b83e37 100644 --- a/instance_test.go +++ b/instance_test.go @@ -196,6 +196,36 @@ func TestInstanceStart(t *testing.T) { } } +func TestInstanceStatWithOptionalParams(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Instances.Stat( + context.TODO(), + "id", + hypeman.InstanceStatParams{ + Path: "path", + FollowLinks: hypeman.Bool(true), + }, + ) + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + func TestInstanceStop(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" From 076f35c6adef8ae931177238452d6b1b87045c89 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:08:29 +0000 Subject: [PATCH 10/10] release: 0.8.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 2 +- internal/version.go | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1b77f50..6538ca9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.7.0" + ".": "0.8.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bef0d6..7b777d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.8.0 (2025-12-23) + +Full Changelog: [v0.7.0...v0.8.0](https://github.com/onkernel/hypeman-go/compare/v0.7.0...v0.8.0) + +### Features + +* add hypeman cp for file copy to/from running VMs ([49ea898](https://github.com/onkernel/hypeman-go/commit/49ea89852eed5e0893febc4c68d295a0d1a8bfe5)) +* **encoder:** support bracket encoding form-data object members ([8ab31e8](https://github.com/onkernel/hypeman-go/commit/8ab31e89c70baa967842c1c160d0b49db44b089a)) +* gpu passthrough ([067a01b](https://github.com/onkernel/hypeman-go/commit/067a01b4ac06e82c2db6b165127144afa18a691d)) + + +### Bug Fixes + +* skip usage tests that don't work with Prism ([d62b246](https://github.com/onkernel/hypeman-go/commit/d62b2466715247e7d083ab7ef33040e5da036bd8)) + + +### Chores + +* add float64 to valid types for RegisterFieldValidator ([b4666fd](https://github.com/onkernel/hypeman-go/commit/b4666fd1bfcdd17b0a4d4bf88541670cd40c8b1c)) + ## 0.7.0 (2025-12-11) Full Changelog: [v0.6.0...v0.7.0](https://github.com/onkernel/hypeman-go/compare/v0.6.0...v0.7.0) diff --git a/README.md b/README.md index e535d13..7f8b79a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Or to pin the version: ```sh -go get -u 'github.com/onkernel/hypeman-go@v0.7.0' +go get -u 'github.com/onkernel/hypeman-go@v0.8.0' ``` diff --git a/internal/version.go b/internal/version.go index 79301ae..3c8392e 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.7.0" // x-release-please-version +const PackageVersion = "0.8.0" // x-release-please-version