diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab1f59c..5fb9003 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' @@ -27,14 +29,18 @@ jobs: - uses: actions/checkout@v6 - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/hypeman-go' + if: |- + github.repository == 'stainless-sdks/hypeman-go' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/hypeman-go' + if: |- + github.repository == 'stainless-sdks/hypeman-go' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8f3e0a4..b4e9013 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.15.0" + ".": "0.16.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 511176d..4d77a66 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 39 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-68bd472fc1704fc7ff7ed01b4213dda068a0865d42693d47ecef90651526febb.yml -openapi_spec_hash: 18ec995954b05d8dfb1e9e3254cf579a -config_hash: 368f8c9248e41f12124ab83b6f5b2eec +configured_endpoints: 47 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-8251e13b6a8ec8965b5cb0b5ab33d4a16e274a4be7cd6d9fa36642878108797c.yml +openapi_spec_hash: 5c1c0d21d430074ffa76ae62ea137f0b +config_hash: 47cce606a7f8af4dac9c2a8dbc822484 diff --git a/CHANGELOG.md b/CHANGELOG.md index 646e8f4..4e2ab44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.16.0 (2026-03-23) + +Full Changelog: [v0.15.0...v0.16.0](https://github.com/kernel/hypeman-go/compare/v0.15.0...v0.16.0) + +### Features + +* add active ballooning reclaim controller ([b8ecb54](https://github.com/kernel/hypeman-go/commit/b8ecb541b8237d1283a05422eed6e30ff2b0536f)) +* Add always-on /metrics endpoint with dual pull/push telemetry ([0b3751a](https://github.com/kernel/hypeman-go/commit/0b3751a37068dca3771ad04fc1af876c5e52384f)) +* Add optional snapshot compression defaults and standby integration ([b6d9ab3](https://github.com/kernel/hypeman-go/commit/b6d9ab3d0b4ffdd7d56153464387ed9e603d8d0a)) +* add optional VM egress MITM proxy with mock-secret header rewriting ([e8b721c](https://github.com/kernel/hypeman-go/commit/e8b721cbc709af739827929839e443981d636233)) +* Add strict metadata tags across mutable resources ([8b5543e](https://github.com/kernel/hypeman-go/commit/8b5543e6f8fed7062d28c37fa35ba031e14c6f79)) +* Rename tag fields from metadata to tags ([2f8e29e](https://github.com/kernel/hypeman-go/commit/2f8e29e033b44a8a492ec9a3bdcbc44cabf2eb11)) +* Snapshot ([c4a0fbb](https://github.com/kernel/hypeman-go/commit/c4a0fbb34ee2b325229f6a02ae9cc3c57346788d)) +* support updating egress proxy secret envs for key rotation ([96b3209](https://github.com/kernel/hypeman-go/commit/96b32092d707499c6a962d89a3530b413be4ccab)) + + +### Chores + +* **ci:** skip uploading artifacts on stainless-internal branches ([0d9d654](https://github.com/kernel/hypeman-go/commit/0d9d654f8074387de55f5befe709477c6905deaa)) +* **internal:** codegen related update ([e6a6702](https://github.com/kernel/hypeman-go/commit/e6a6702ba1fcf355cf64a60fd016393e5474e9bc)) +* **internal:** minor cleanup ([1917d6d](https://github.com/kernel/hypeman-go/commit/1917d6de179a6a36358598cf3b1f6d4ebbe7f034)) +* **internal:** tweak CI branches ([6dc7e68](https://github.com/kernel/hypeman-go/commit/6dc7e68725e65b75bd7c2aa07d4427909ad2365c)) +* **internal:** use explicit returns ([923db74](https://github.com/kernel/hypeman-go/commit/923db74ba5750233d429a5e98dc6cb5fb0e806ba)) +* **internal:** use explicit returns in more places ([16131b8](https://github.com/kernel/hypeman-go/commit/16131b802aa3f5a2b32d298d179966a440f53af5)) +* update placeholder string ([bea84ac](https://github.com/kernel/hypeman-go/commit/bea84ac19f1178f378cc6ba8bad07a26848b5492)) + ## 0.15.0 (2026-03-04) Full Changelog: [v0.14.0...v0.15.0](https://github.com/kernel/hypeman-go/compare/v0.14.0...v0.15.0) diff --git a/README.md b/README.md index 47f44f5..62dd1ed 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Or to pin the version: ```sh -go get -u 'github.com/kernel/hypeman-go@v0.15.0' +go get -u 'github.com/kernel/hypeman-go@v0.16.0' ``` diff --git a/aliases.go b/aliases.go index 1eefe6d..1f5b004 100644 --- a/aliases.go +++ b/aliases.go @@ -5,6 +5,7 @@ package hypeman import ( "github.com/kernel/hypeman-go/internal/apierror" "github.com/kernel/hypeman-go/packages/param" + "github.com/kernel/hypeman-go/shared" ) // aliased to make [param.APIUnion] private when embedding @@ -14,3 +15,21 @@ type paramUnion = param.APIUnion type paramObj = param.APIObject type Error = apierror.Error + +// This is an alias to an internal type. +type SnapshotCompressionConfig = shared.SnapshotCompressionConfig + +// Compression algorithm (defaults to zstd when enabled). Ignored when enabled is +// false. +// +// This is an alias to an internal type. +type SnapshotCompressionConfigAlgorithm = shared.SnapshotCompressionConfigAlgorithm + +// Equals "zstd" +const SnapshotCompressionConfigAlgorithmZstd = shared.SnapshotCompressionConfigAlgorithmZstd + +// Equals "lz4" +const SnapshotCompressionConfigAlgorithmLz4 = shared.SnapshotCompressionConfigAlgorithmLz4 + +// This is an alias to an internal type. +type SnapshotCompressionConfigParam = shared.SnapshotCompressionConfigParam diff --git a/api.md b/api.md index 3fa23c1..01ab836 100644 --- a/api.md +++ b/api.md @@ -1,3 +1,11 @@ +# Shared Params Types + +- shared.SnapshotCompressionConfigParam + +# Shared Response Types + +- shared.SnapshotCompressionConfig + # Health Response Types: @@ -17,7 +25,7 @@ Response Types: Methods: - client.Images.New(ctx context.Context, body hypeman.ImageNewParams) (\*hypeman.Image, error) -- client.Images.List(ctx context.Context) (\*[]hypeman.Image, error) +- client.Images.List(ctx context.Context, query hypeman.ImageListParams) (\*[]hypeman.Image, error) - client.Images.Delete(ctx context.Context, name string) error - client.Images.Get(ctx context.Context, name string) (\*hypeman.Image, error) @@ -25,6 +33,7 @@ Methods: Params Types: +- hypeman.SnapshotPolicyParam - hypeman.VolumeMountParam Response Types: @@ -32,18 +41,20 @@ Response Types: - hypeman.Instance - hypeman.InstanceStats - hypeman.PathInfo +- hypeman.SnapshotPolicy - hypeman.VolumeMount Methods: - client.Instances.New(ctx context.Context, body hypeman.InstanceNewParams) (\*hypeman.Instance, error) +- client.Instances.Update(ctx context.Context, id string, body hypeman.InstanceUpdateParams) (\*hypeman.Instance, error) - client.Instances.List(ctx context.Context, query hypeman.InstanceListParams) (\*[]hypeman.Instance, error) - client.Instances.Delete(ctx context.Context, id string) error - client.Instances.Fork(ctx context.Context, id string, body hypeman.InstanceForkParams) (\*hypeman.Instance, error) - client.Instances.Get(ctx context.Context, id string) (\*hypeman.Instance, error) - client.Instances.Logs(ctx context.Context, id string, query hypeman.InstanceLogsParams) (\*string, error) - client.Instances.Restore(ctx context.Context, id string) (\*hypeman.Instance, error) -- client.Instances.Standby(ctx context.Context, id string) (\*hypeman.Instance, error) +- client.Instances.Standby(ctx context.Context, id string, body hypeman.InstanceStandbyParams) (\*hypeman.Instance, error) - client.Instances.Start(ctx context.Context, id string, body hypeman.InstanceStartParams) (\*hypeman.Instance, error) - client.Instances.Stat(ctx context.Context, id string, query hypeman.InstanceStatParams) (\*hypeman.PathInfo, error) - client.Instances.Stats(ctx context.Context, id string) (\*hypeman.InstanceStats, error) @@ -56,6 +67,31 @@ Methods: - client.Instances.Volumes.Attach(ctx context.Context, volumeID string, params hypeman.InstanceVolumeAttachParams) (\*hypeman.Instance, error) - client.Instances.Volumes.Detach(ctx context.Context, volumeID string, body hypeman.InstanceVolumeDetachParams) (\*hypeman.Instance, error) +## Snapshots + +Methods: + +- client.Instances.Snapshots.New(ctx context.Context, id string, body hypeman.InstanceSnapshotNewParams) (\*hypeman.Snapshot, error) +- client.Instances.Snapshots.Restore(ctx context.Context, snapshotID string, params hypeman.InstanceSnapshotRestoreParams) (\*hypeman.Instance, error) + +# Snapshots + +Params Types: + +- hypeman.SnapshotKind + +Response Types: + +- hypeman.Snapshot +- hypeman.SnapshotKind + +Methods: + +- client.Snapshots.List(ctx context.Context, query hypeman.SnapshotListParams) (\*[]hypeman.Snapshot, error) +- client.Snapshots.Delete(ctx context.Context, snapshotID string) error +- client.Snapshots.Fork(ctx context.Context, snapshotID string, body hypeman.SnapshotForkParams) (\*hypeman.Instance, error) +- client.Snapshots.Get(ctx context.Context, snapshotID string) (\*hypeman.Snapshot, error) + # Volumes Response Types: @@ -66,7 +102,7 @@ Response Types: Methods: - client.Volumes.New(ctx context.Context, body hypeman.VolumeNewParams) (\*hypeman.Volume, error) -- client.Volumes.List(ctx context.Context) (\*[]hypeman.Volume, error) +- client.Volumes.List(ctx context.Context, query hypeman.VolumeListParams) (\*[]hypeman.Volume, error) - client.Volumes.Delete(ctx context.Context, id string) error - client.Volumes.NewFromArchive(ctx context.Context, body io.Reader, params hypeman.VolumeNewFromArchiveParams) (\*hypeman.Volume, error) - client.Volumes.Get(ctx context.Context, id string) (\*hypeman.Volume, error) @@ -83,7 +119,7 @@ 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.List(ctx context.Context, query hypeman.DeviceListParams) (\*[]hypeman.Device, error) - client.Devices.Delete(ctx context.Context, id string) error - client.Devices.ListAvailable(ctx context.Context) (\*[]hypeman.AvailableDevice, error) @@ -105,17 +141,23 @@ Response Types: Methods: - client.Ingresses.New(ctx context.Context, body hypeman.IngressNewParams) (\*hypeman.Ingress, error) -- client.Ingresses.List(ctx context.Context) (\*[]hypeman.Ingress, error) +- client.Ingresses.List(ctx context.Context, query hypeman.IngressListParams) (\*[]hypeman.Ingress, error) - client.Ingresses.Delete(ctx context.Context, id string) error - client.Ingresses.Get(ctx context.Context, id string) (\*hypeman.Ingress, error) # Resources +Params Types: + +- hypeman.MemoryReclaimRequestParam + Response Types: - hypeman.DiskBreakdown - hypeman.GPUProfile - hypeman.GPUResourceStatus +- hypeman.MemoryReclaimAction +- hypeman.MemoryReclaimResponse - hypeman.PassthroughDevice - hypeman.ResourceAllocation - hypeman.ResourceStatus @@ -124,6 +166,7 @@ Response Types: Methods: - client.Resources.Get(ctx context.Context) (\*hypeman.Resources, error) +- client.Resources.ReclaimMemory(ctx context.Context, body hypeman.ResourceReclaimMemoryParams) (\*hypeman.MemoryReclaimResponse, error) # Builds @@ -137,7 +180,7 @@ Response Types: Methods: - client.Builds.New(ctx context.Context, body hypeman.BuildNewParams) (\*hypeman.Build, error) -- client.Builds.List(ctx context.Context) (\*[]hypeman.Build, error) +- client.Builds.List(ctx context.Context, query hypeman.BuildListParams) (\*[]hypeman.Build, error) - client.Builds.Cancel(ctx context.Context, id string) error - client.Builds.Events(ctx context.Context, id string, query hypeman.BuildEventsParams) (\*hypeman.BuildEvent, error) - client.Builds.Get(ctx context.Context, id string) (\*hypeman.Build, error) diff --git a/build.go b/build.go index 48de63b..ad536ea 100644 --- a/build.go +++ b/build.go @@ -49,15 +49,15 @@ func (r *BuildService) New(ctx context.Context, body BuildNewParams, opts ...opt opts = slices.Concat(r.Options, opts) path := "builds" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // List builds -func (r *BuildService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Build, err error) { +func (r *BuildService) List(ctx context.Context, query BuildListParams, opts ...option.RequestOption) (res *[]Build, err error) { opts = slices.Concat(r.Options, opts) path := "builds" - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err } // Cancel build @@ -66,11 +66,11 @@ func (r *BuildService) Cancel(ctx context.Context, id string, opts ...option.Req opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return err } path := fmt.Sprintf("builds/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } // Streams build events as Server-Sent Events. Events include: @@ -89,7 +89,7 @@ func (r *BuildService) EventsStreaming(ctx context.Context, id string, query Bui opts = append([]option.RequestOption{option.WithHeader("Accept", "text/event-stream")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return ssestream.NewStream[BuildEvent](nil, err) } path := fmt.Sprintf("builds/%s/events", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &raw, opts...) @@ -101,11 +101,11 @@ func (r *BuildService) Get(ctx context.Context, id string, opts ...option.Reques opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("builds/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type Build struct { @@ -134,6 +134,8 @@ type Build struct { QueuePosition int64 `json:"queue_position" api:"nullable"` // Build start timestamp StartedAt time.Time `json:"started_at" api:"nullable" format:"date-time"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ID respjson.Field @@ -148,6 +150,7 @@ type Build struct { Provenance respjson.Field QueuePosition respjson.Field StartedAt respjson.Field + Tags respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -266,6 +269,8 @@ type BuildNewParams struct { // (required) for use with --mount=type=secret,id=... Example: [{"id": // "npm_token"}, {"id": "github_token"}] Secrets param.Opt[string] `json:"secrets,omitzero"` + // JSON object of tags. Example: {"team":"backend","env":"staging"} + Tags param.Opt[string] `json:"tags,omitzero"` // Build timeout (default 600) TimeoutSeconds param.Opt[int64] `json:"timeout_seconds,omitzero"` paramObj @@ -289,6 +294,20 @@ func (r BuildNewParams) MarshalMultipart() (data []byte, contentType string, err return buf.Bytes(), writer.FormDataContentType(), nil } +type BuildListParams struct { + // Filter builds by tag key-value pairs. + Tags map[string]string `query:"tags,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [BuildListParams]'s query parameters as `url.Values`. +func (r BuildListParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + type BuildEventsParams struct { // Continue streaming new events after initial output Follow param.Opt[bool] `query:"follow,omitzero" json:"-"` diff --git a/build_test.go b/build_test.go index 250c1e3..c7b7139 100644 --- a/build_test.go +++ b/build_test.go @@ -29,7 +29,7 @@ func TestBuildNewWithOptionalParams(t *testing.T) { option.WithAPIKey("My API Key"), ) _, err := client.Builds.New(context.TODO(), hypeman.BuildNewParams{ - Source: io.Reader(bytes.NewBuffer([]byte("some file contents"))), + Source: io.Reader(bytes.NewBuffer([]byte("Example data"))), BaseImageDigest: hypeman.String("base_image_digest"), CacheScope: hypeman.String("cache_scope"), CPUs: hypeman.Int(0), @@ -39,6 +39,7 @@ func TestBuildNewWithOptionalParams(t *testing.T) { IsAdminBuild: hypeman.String("is_admin_build"), MemoryMB: hypeman.Int(0), Secrets: hypeman.String("secrets"), + Tags: hypeman.String("tags"), TimeoutSeconds: hypeman.Int(0), }) if err != nil { @@ -50,7 +51,7 @@ func TestBuildNewWithOptionalParams(t *testing.T) { } } -func TestBuildList(t *testing.T) { +func TestBuildListWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -63,7 +64,12 @@ func TestBuildList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Builds.List(context.TODO()) + _, err := client.Builds.List(context.TODO(), hypeman.BuildListParams{ + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/client.go b/client.go index eeef91e..6105b35 100644 --- a/client.go +++ b/client.go @@ -20,6 +20,7 @@ type Client struct { Health HealthService Images ImageService Instances InstanceService + Snapshots SnapshotService Volumes VolumeService Devices DeviceService Ingresses IngressService @@ -52,6 +53,7 @@ func NewClient(opts ...option.RequestOption) (r Client) { r.Health = NewHealthService(opts...) r.Images = NewImageService(opts...) r.Instances = NewInstanceService(opts...) + r.Snapshots = NewSnapshotService(opts...) r.Volumes = NewVolumeService(opts...) r.Devices = NewDeviceService(opts...) r.Ingresses = NewIngressService(opts...) diff --git a/client_test.go b/client_test.go index cd17cef..997045e 100644 --- a/client_test.go +++ b/client_test.go @@ -39,7 +39,7 @@ func TestUserAgentHeader(t *testing.T) { }, }), ) - client.Health.Check(context.Background()) + _, _ = client.Health.Check(context.Background()) if userAgent != fmt.Sprintf("Hypeman/Go %s", internal.PackageVersion) { t.Errorf("Expected User-Agent to be correct, but got: %#v", userAgent) } diff --git a/device.go b/device.go index c754587..7b69256 100644 --- a/device.go +++ b/device.go @@ -7,10 +7,12 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "time" "github.com/kernel/hypeman-go/internal/apijson" + "github.com/kernel/hypeman-go/internal/apiquery" "github.com/kernel/hypeman-go/internal/requestconfig" "github.com/kernel/hypeman-go/option" "github.com/kernel/hypeman-go/packages/param" @@ -41,7 +43,7 @@ func (r *DeviceService) New(ctx context.Context, body DeviceNewParams, opts ...o opts = slices.Concat(r.Options, opts) path := "devices" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Get device details @@ -49,19 +51,19 @@ func (r *DeviceService) Get(ctx context.Context, id string, opts ...option.Reque opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("devices/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } // List registered devices -func (r *DeviceService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Device, err error) { +func (r *DeviceService) List(ctx context.Context, query DeviceListParams, 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 + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err } // Unregister device @@ -70,11 +72,11 @@ func (r *DeviceService) Delete(ctx context.Context, id string, opts ...option.Re opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return err } path := fmt.Sprintf("devices/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } // Discover passthrough-capable devices on host @@ -82,7 +84,7 @@ func (r *DeviceService) ListAvailable(ctx context.Context, opts ...option.Reques opts = slices.Concat(r.Options, opts) path := "devices/available" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type AvailableDevice struct { @@ -149,6 +151,8 @@ type Device struct { AttachedTo string `json:"attached_to" api:"nullable"` // Device name (user-provided or auto-generated from PCI address) Name string `json:"name"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ID respjson.Field @@ -161,6 +165,7 @@ type Device struct { VendorID respjson.Field AttachedTo respjson.Field Name respjson.Field + Tags respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -186,6 +191,8 @@ type DeviceNewParams struct { // 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"` + // User-defined key-value tags. + Tags map[string]string `json:"tags,omitzero"` paramObj } @@ -196,3 +203,17 @@ func (r DeviceNewParams) MarshalJSON() (data []byte, err error) { func (r *DeviceNewParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type DeviceListParams struct { + // Filter devices by tag key-value pairs. + Tags map[string]string `query:"tags,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [DeviceListParams]'s query parameters as `url.Values`. +func (r DeviceListParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} diff --git a/device_test.go b/device_test.go index 1c09257..48d0d79 100644 --- a/device_test.go +++ b/device_test.go @@ -29,6 +29,10 @@ func TestDeviceNewWithOptionalParams(t *testing.T) { _, err := client.Devices.New(context.TODO(), hypeman.DeviceNewParams{ PciAddress: "0000:a2:00.0", Name: hypeman.String("l4-gpu"), + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, }) if err != nil { var apierr *hypeman.Error @@ -62,7 +66,7 @@ func TestDeviceGet(t *testing.T) { } } -func TestDeviceList(t *testing.T) { +func TestDeviceListWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -75,7 +79,12 @@ func TestDeviceList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Devices.List(context.TODO()) + _, err := client.Devices.List(context.TODO(), hypeman.DeviceListParams{ + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/health.go b/health.go index 9e6cc0f..5532bf4 100644 --- a/health.go +++ b/health.go @@ -37,7 +37,7 @@ func (r *HealthService) Check(ctx context.Context, opts ...option.RequestOption) opts = slices.Concat(r.Options, opts) path := "health" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type HealthCheckResponse struct { diff --git a/image.go b/image.go index 5c11648..babf0db 100644 --- a/image.go +++ b/image.go @@ -7,10 +7,12 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "time" "github.com/kernel/hypeman-go/internal/apijson" + "github.com/kernel/hypeman-go/internal/apiquery" "github.com/kernel/hypeman-go/internal/requestconfig" "github.com/kernel/hypeman-go/option" "github.com/kernel/hypeman-go/packages/param" @@ -41,15 +43,15 @@ func (r *ImageService) New(ctx context.Context, body ImageNewParams, opts ...opt opts = slices.Concat(r.Options, opts) path := "images" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // List images -func (r *ImageService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Image, err error) { +func (r *ImageService) List(ctx context.Context, query ImageListParams, opts ...option.RequestOption) (res *[]Image, err error) { opts = slices.Concat(r.Options, opts) path := "images" - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err } // Delete image @@ -58,11 +60,11 @@ func (r *ImageService) Delete(ctx context.Context, name string, opts ...option.R opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if name == "" { err = errors.New("missing required name parameter") - return + return err } path := fmt.Sprintf("images/%s", name) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } // Get image details @@ -70,11 +72,11 @@ func (r *ImageService) Get(ctx context.Context, name string, opts ...option.Requ opts = slices.Concat(r.Options, opts) if name == "" { err = errors.New("missing required name parameter") - return + return nil, err } path := fmt.Sprintf("images/%s", name) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type Image struct { @@ -100,6 +102,8 @@ type Image struct { QueuePosition int64 `json:"queue_position" api:"nullable"` // Disk size in bytes (null until ready) SizeBytes int64 `json:"size_bytes" api:"nullable"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` // Working directory from container metadata WorkingDir string `json:"working_dir" api:"nullable"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. @@ -114,6 +118,7 @@ type Image struct { Error respjson.Field QueuePosition respjson.Field SizeBytes respjson.Field + Tags respjson.Field WorkingDir respjson.Field ExtraFields map[string]respjson.Field raw string @@ -140,6 +145,8 @@ const ( type ImageNewParams struct { // OCI image reference (e.g., docker.io/library/nginx:latest) Name string `json:"name" api:"required"` + // User-defined key-value tags. + Tags map[string]string `json:"tags,omitzero"` paramObj } @@ -150,3 +157,17 @@ func (r ImageNewParams) MarshalJSON() (data []byte, err error) { func (r *ImageNewParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type ImageListParams struct { + // Filter images by tag key-value pairs. + Tags map[string]string `query:"tags,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [ImageListParams]'s query parameters as `url.Values`. +func (r ImageListParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} diff --git a/image_test.go b/image_test.go index e799946..9631e75 100644 --- a/image_test.go +++ b/image_test.go @@ -13,7 +13,7 @@ import ( "github.com/kernel/hypeman-go/option" ) -func TestImageNew(t *testing.T) { +func TestImageNewWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -28,6 +28,10 @@ func TestImageNew(t *testing.T) { ) _, err := client.Images.New(context.TODO(), hypeman.ImageNewParams{ Name: "docker.io/library/nginx:latest", + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, }) if err != nil { var apierr *hypeman.Error @@ -38,7 +42,7 @@ func TestImageNew(t *testing.T) { } } -func TestImageList(t *testing.T) { +func TestImageListWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -51,7 +55,12 @@ func TestImageList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Images.List(context.TODO()) + _, err := client.Images.List(context.TODO(), hypeman.ImageListParams{ + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/ingress.go b/ingress.go index 860f31c..8d4393d 100644 --- a/ingress.go +++ b/ingress.go @@ -8,10 +8,12 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "time" "github.com/kernel/hypeman-go/internal/apijson" + "github.com/kernel/hypeman-go/internal/apiquery" "github.com/kernel/hypeman-go/internal/requestconfig" "github.com/kernel/hypeman-go/option" "github.com/kernel/hypeman-go/packages/param" @@ -42,15 +44,15 @@ func (r *IngressService) New(ctx context.Context, body IngressNewParams, opts .. opts = slices.Concat(r.Options, opts) path := "ingresses" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // List ingresses -func (r *IngressService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Ingress, err error) { +func (r *IngressService) List(ctx context.Context, query IngressListParams, opts ...option.RequestOption) (res *[]Ingress, err error) { opts = slices.Concat(r.Options, opts) path := "ingresses" - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err } // Delete ingress @@ -59,11 +61,11 @@ func (r *IngressService) Delete(ctx context.Context, id string, opts ...option.R opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return err } path := fmt.Sprintf("ingresses/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } // Get ingress details @@ -71,11 +73,11 @@ func (r *IngressService) Get(ctx context.Context, id string, opts ...option.Requ opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("ingresses/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type Ingress struct { @@ -87,12 +89,15 @@ type Ingress struct { Name string `json:"name" api:"required"` // Routing rules for this ingress Rules []IngressRule `json:"rules" api:"required"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ID respjson.Field CreatedAt respjson.Field Name respjson.Field Rules respjson.Field + Tags respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -284,6 +289,8 @@ type IngressNewParams struct { Name string `json:"name" api:"required"` // Routing rules for this ingress Rules []IngressRuleParam `json:"rules,omitzero" api:"required"` + // User-defined key-value tags. + Tags map[string]string `json:"tags,omitzero"` paramObj } @@ -294,3 +301,17 @@ func (r IngressNewParams) MarshalJSON() (data []byte, err error) { func (r *IngressNewParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type IngressListParams struct { + // Filter ingresses by tag key-value pairs. + Tags map[string]string `query:"tags,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [IngressListParams]'s query parameters as `url.Values`. +func (r IngressListParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} diff --git a/ingress_test.go b/ingress_test.go index 28bbab5..68693c9 100644 --- a/ingress_test.go +++ b/ingress_test.go @@ -13,7 +13,7 @@ import ( "github.com/kernel/hypeman-go/option" ) -func TestIngressNew(t *testing.T) { +func TestIngressNewWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -40,6 +40,10 @@ func TestIngressNew(t *testing.T) { RedirectHTTP: hypeman.Bool(true), Tls: hypeman.Bool(true), }}, + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, }) if err != nil { var apierr *hypeman.Error @@ -50,7 +54,7 @@ func TestIngressNew(t *testing.T) { } } -func TestIngressList(t *testing.T) { +func TestIngressListWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -63,7 +67,12 @@ func TestIngressList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Ingresses.List(context.TODO()) + _, err := client.Ingresses.List(context.TODO(), hypeman.IngressListParams{ + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/instance.go b/instance.go index cfe3b54..a2c146f 100644 --- a/instance.go +++ b/instance.go @@ -19,6 +19,7 @@ import ( "github.com/kernel/hypeman-go/packages/param" "github.com/kernel/hypeman-go/packages/respjson" "github.com/kernel/hypeman-go/packages/ssestream" + "github.com/kernel/hypeman-go/shared" ) // InstanceService contains methods and other services that help with interacting @@ -28,8 +29,9 @@ import ( // automatically. You should not instantiate this service directly, and instead use // the [NewInstanceService] method instead. type InstanceService struct { - Options []option.RequestOption - Volumes InstanceVolumeService + Options []option.RequestOption + Volumes InstanceVolumeService + Snapshots InstanceSnapshotService } // NewInstanceService generates a new service that applies the given options to @@ -39,6 +41,7 @@ func NewInstanceService(opts ...option.RequestOption) (r InstanceService) { r = InstanceService{} r.Options = opts r.Volumes = NewInstanceVolumeService(opts...) + r.Snapshots = NewInstanceSnapshotService(opts...) return } @@ -47,7 +50,21 @@ func (r *InstanceService) New(ctx context.Context, body InstanceNewParams, opts opts = slices.Concat(r.Options, opts) path := "instances" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err +} + +// Update mutable properties of a running instance. Currently supports updating +// only the environment variables referenced by existing credential policies, +// enabling secret/key rotation without instance restart. +func (r *InstanceService) Update(ctx context.Context, id string, body InstanceUpdateParams, opts ...option.RequestOption) (res *Instance, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return nil, err + } + path := fmt.Sprintf("instances/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...) + return res, err } // List instances @@ -55,7 +72,7 @@ func (r *InstanceService) List(ctx context.Context, query InstanceListParams, op opts = slices.Concat(r.Options, opts) path := "instances" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) - return + return res, err } // Stop and delete instance @@ -64,11 +81,11 @@ func (r *InstanceService) Delete(ctx context.Context, id string, opts ...option. opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return err } path := fmt.Sprintf("instances/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } // Fork an instance from stopped, standby, or running (with from_running=true) @@ -76,11 +93,11 @@ func (r *InstanceService) Fork(ctx context.Context, id string, body InstanceFork opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/fork", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Get instance details @@ -88,11 +105,11 @@ func (r *InstanceService) Get(ctx context.Context, id string, opts ...option.Req opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } // Streams instance logs as Server-Sent Events. Use the `source` parameter to @@ -113,7 +130,7 @@ func (r *InstanceService) LogsStreaming(ctx context.Context, id string, query In opts = append([]option.RequestOption{option.WithHeader("Accept", "text/event-stream")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return ssestream.NewStream[string](nil, err) } path := fmt.Sprintf("instances/%s/logs", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &raw, opts...) @@ -125,23 +142,23 @@ func (r *InstanceService) Restore(ctx context.Context, id string, opts ...option opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/restore", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) - return + return res, err } // Put instance in standby (pause, snapshot, delete VMM) -func (r *InstanceService) Standby(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { +func (r *InstanceService) Standby(ctx context.Context, id string, body InstanceStandbyParams, opts ...option.RequestOption) (res *Instance, err error) { opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/standby", id) - err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) - return + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err } // Start a stopped instance @@ -149,11 +166,11 @@ func (r *InstanceService) Start(ctx context.Context, id string, body InstanceSta opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/start", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // Returns information about a path in the guest filesystem. Useful for checking if @@ -162,11 +179,11 @@ func (r *InstanceService) Stat(ctx context.Context, id string, query InstanceSta opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/stat", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) - return + return res, err } // Returns real-time resource utilization statistics for a running VM instance. @@ -176,11 +193,11 @@ func (r *InstanceService) Stats(ctx context.Context, id string, opts ...option.R opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/stats", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } // Stop instance (graceful shutdown) @@ -188,11 +205,11 @@ func (r *InstanceService) Stop(ctx context.Context, id string, opts ...option.Re opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/stop", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) - return + return res, err } type Instance struct { @@ -207,15 +224,16 @@ type Instance struct { // Instance state: // // - Created: VMM created but not started (Cloud Hypervisor native) - // - Running: VM is actively running (Cloud Hypervisor native) + // - Initializing: VM is running while guest init is still in progress + // - Running: Guest program has started and instance is ready // - Paused: VM is paused (Cloud Hypervisor native) // - Shutdown: VM shut down but VMM exists (Cloud Hypervisor native) // - Stopped: No VMM running, no snapshot exists // - Standby: No VMM running, snapshot exists (can be restored) // - Unknown: Failed to determine state (see state_error for details) // - // Any of "Created", "Running", "Paused", "Shutdown", "Stopped", "Standby", - // "Unknown". + // Any of "Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", + // "Standby", "Unknown". State InstanceState `json:"state" api:"required"` // Disk I/O rate limit (human-readable, e.g., "100MB/s") DiskIoBps string `json:"disk_io_bps"` @@ -236,50 +254,52 @@ type Instance struct { // // Any of "cloud-hypervisor", "firecracker", "qemu", "vz". Hypervisor InstanceHypervisor `json:"hypervisor"` - // User-defined key-value metadata - Metadata map[string]string `json:"metadata"` // Network configuration of the instance Network InstanceNetwork `json:"network"` // Writable overlay disk size (human-readable) OverlaySize string `json:"overlay_size"` // Base memory size (human-readable) - Size string `json:"size"` + Size string `json:"size"` + SnapshotPolicy SnapshotPolicy `json:"snapshot_policy"` // Start timestamp (RFC3339) StartedAt time.Time `json:"started_at" api:"nullable" format:"date-time"` // Error message if state couldn't be determined (only set when state is Unknown) StateError string `json:"state_error" api:"nullable"` // Stop timestamp (RFC3339) StoppedAt time.Time `json:"stopped_at" api:"nullable" format:"date-time"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` // Number of virtual CPUs Vcpus int64 `json:"vcpus"` // Volumes attached to the instance Volumes []VolumeMount `json:"volumes"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { - ID respjson.Field - CreatedAt respjson.Field - Image respjson.Field - Name respjson.Field - State respjson.Field - DiskIoBps respjson.Field - Env respjson.Field - ExitCode respjson.Field - ExitMessage respjson.Field - GPU respjson.Field - HasSnapshot respjson.Field - HotplugSize respjson.Field - Hypervisor respjson.Field - Metadata respjson.Field - Network respjson.Field - OverlaySize respjson.Field - Size respjson.Field - StartedAt respjson.Field - StateError respjson.Field - StoppedAt respjson.Field - Vcpus respjson.Field - Volumes respjson.Field - ExtraFields map[string]respjson.Field - raw string + ID respjson.Field + CreatedAt respjson.Field + Image respjson.Field + Name respjson.Field + State respjson.Field + DiskIoBps respjson.Field + Env respjson.Field + ExitCode respjson.Field + ExitMessage respjson.Field + GPU respjson.Field + HasSnapshot respjson.Field + HotplugSize respjson.Field + Hypervisor respjson.Field + Network respjson.Field + OverlaySize respjson.Field + Size respjson.Field + SnapshotPolicy respjson.Field + StartedAt respjson.Field + StateError respjson.Field + StoppedAt respjson.Field + Tags respjson.Field + Vcpus respjson.Field + Volumes respjson.Field + ExtraFields map[string]respjson.Field + raw string } `json:"-"` } @@ -292,7 +312,8 @@ func (r *Instance) UnmarshalJSON(data []byte) error { // Instance state: // // - Created: VMM created but not started (Cloud Hypervisor native) -// - Running: VM is actively running (Cloud Hypervisor native) +// - Initializing: VM is running while guest init is still in progress +// - Running: Guest program has started and instance is ready // - Paused: VM is paused (Cloud Hypervisor native) // - Shutdown: VM shut down but VMM exists (Cloud Hypervisor native) // - Stopped: No VMM running, no snapshot exists @@ -301,13 +322,14 @@ func (r *Instance) UnmarshalJSON(data []byte) error { type InstanceState string const ( - InstanceStateCreated InstanceState = "Created" - InstanceStateRunning InstanceState = "Running" - InstanceStatePaused InstanceState = "Paused" - InstanceStateShutdown InstanceState = "Shutdown" - InstanceStateStopped InstanceState = "Stopped" - InstanceStateStandby InstanceState = "Standby" - InstanceStateUnknown InstanceState = "Unknown" + InstanceStateCreated InstanceState = "Created" + InstanceStateInitializing InstanceState = "Initializing" + InstanceStateRunning InstanceState = "Running" + InstanceStatePaused InstanceState = "Paused" + InstanceStateShutdown InstanceState = "Shutdown" + InstanceStateStopped InstanceState = "Stopped" + InstanceStateStandby InstanceState = "Standby" + InstanceStateUnknown InstanceState = "Unknown" ) // GPU information attached to the instance @@ -459,6 +481,44 @@ func (r *PathInfo) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type SnapshotPolicy struct { + Compression shared.SnapshotCompressionConfig `json:"compression"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Compression respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r SnapshotPolicy) RawJSON() string { return r.JSON.raw } +func (r *SnapshotPolicy) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// ToParam converts this SnapshotPolicy to a SnapshotPolicyParam. +// +// Warning: the fields of the param type will not be present. ToParam should only +// be used at the last possible moment before sending a request. Test for this with +// SnapshotPolicyParam.Overrides() +func (r SnapshotPolicy) ToParam() SnapshotPolicyParam { + return param.Override[SnapshotPolicyParam](json.RawMessage(r.RawJSON())) +} + +type SnapshotPolicyParam struct { + Compression shared.SnapshotCompressionConfigParam `json:"compression,omitzero"` + paramObj +} + +func (r SnapshotPolicyParam) MarshalJSON() (data []byte, err error) { + type shadow SnapshotPolicyParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *SnapshotPolicyParam) 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" api:"required"` @@ -552,6 +612,11 @@ type InstanceNewParams struct { // Override image CMD (like docker run ). Omit to use image // default. Cmd []string `json:"cmd,omitzero"` + // Host-managed credential brokering policies keyed by guest-visible env var name. + // Those guest env vars receive mock placeholder values, while the real values + // remain host-scoped in the request `env` map and are only materialized on the + // mediated egress path according to each credential's `source` and `inject` rules. + Credentials map[string]InstanceNewParamsCredential `json:"credentials,omitzero"` // Device IDs or names to attach for GPU/PCI passthrough Devices []string `json:"devices,omitzero"` // Override image entrypoint (like docker run --entrypoint). Omit to use image @@ -565,10 +630,13 @@ type InstanceNewParams struct { // // Any of "cloud-hypervisor", "firecracker", "qemu", "vz". Hypervisor InstanceNewParamsHypervisor `json:"hypervisor,omitzero"` - // User-defined key-value metadata for the instance - Metadata map[string]string `json:"metadata,omitzero"` // Network configuration for the instance Network InstanceNewParamsNetwork `json:"network,omitzero"` + // Snapshot compression policy for this instance. Controls compression settings + // applied when creating snapshots or entering standby. + SnapshotPolicy SnapshotPolicyParam `json:"snapshot_policy,omitzero"` + // User-defined key-value tags. + Tags map[string]string `json:"tags,omitzero"` // Volumes to attach to the instance at creation time Volumes []VolumeMountParam `json:"volumes,omitzero"` paramObj @@ -582,6 +650,77 @@ func (r *InstanceNewParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// The properties Inject, Source are required. +type InstanceNewParamsCredential struct { + Inject []InstanceNewParamsCredentialInject `json:"inject,omitzero" api:"required"` + Source InstanceNewParamsCredentialSource `json:"source,omitzero" api:"required"` + paramObj +} + +func (r InstanceNewParamsCredential) MarshalJSON() (data []byte, err error) { + type shadow InstanceNewParamsCredential + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceNewParamsCredential) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// The property As is required. +type InstanceNewParamsCredentialInject struct { + // Current v1 transform shape. Header templating is supported now; other transform + // types (for example request signing) can be added in future revisions. + As InstanceNewParamsCredentialInjectAs `json:"as,omitzero" api:"required"` + // Optional destination host patterns (`api.example.com`, `*.example.com`). Omit to + // allow injection on all destinations. + Hosts []string `json:"hosts,omitzero"` + paramObj +} + +func (r InstanceNewParamsCredentialInject) MarshalJSON() (data []byte, err error) { + type shadow InstanceNewParamsCredentialInject + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceNewParamsCredentialInject) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Current v1 transform shape. Header templating is supported now; other transform +// types (for example request signing) can be added in future revisions. +// +// The properties Format, Header are required. +type InstanceNewParamsCredentialInjectAs struct { + // Template that must include `${value}`. + Format string `json:"format" api:"required"` + // Header name to set/mutate for matching outbound requests. + Header string `json:"header" api:"required"` + paramObj +} + +func (r InstanceNewParamsCredentialInjectAs) MarshalJSON() (data []byte, err error) { + type shadow InstanceNewParamsCredentialInjectAs + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceNewParamsCredentialInjectAs) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// The property Env is required. +type InstanceNewParamsCredentialSource struct { + // Name of the real credential in the request `env` map. The guest-visible env var + // key can receive a mock placeholder, while the mediated egress path resolves that + // placeholder back to this real value only on the host. + Env string `json:"env" api:"required"` + paramObj +} + +func (r InstanceNewParamsCredentialSource) MarshalJSON() (data []byte, err error) { + type shadow InstanceNewParamsCredentialSource + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceNewParamsCredentialSource) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + // GPU configuration for the instance type InstanceNewParamsGPU struct { // vGPU profile name (e.g., "L40S-1Q"). Only used in vGPU mode. @@ -617,6 +756,10 @@ type InstanceNewParamsNetwork struct { BandwidthUpload param.Opt[string] `json:"bandwidth_upload,omitzero"` // Whether to attach instance to the default network Enabled param.Opt[bool] `json:"enabled,omitzero"` + // Host-mediated outbound network policy. Omit this object, or set + // `enabled: false`, to preserve normal direct outbound networking when + // `network.enabled` is true. + Egress InstanceNewParamsNetworkEgress `json:"egress,omitzero"` paramObj } @@ -628,16 +771,77 @@ func (r *InstanceNewParamsNetwork) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +// Host-mediated outbound network policy. Omit this object, or set +// `enabled: false`, to preserve normal direct outbound networking when +// `network.enabled` is true. +type InstanceNewParamsNetworkEgress struct { + // Whether to enable the mediated egress path. When false or omitted, the instance + // keeps normal direct outbound networking and host-managed credential rewriting is + // disabled. + Enabled param.Opt[bool] `json:"enabled,omitzero"` + // Egress enforcement policy applied when mediation is enabled. + Enforcement InstanceNewParamsNetworkEgressEnforcement `json:"enforcement,omitzero"` + paramObj +} + +func (r InstanceNewParamsNetworkEgress) MarshalJSON() (data []byte, err error) { + type shadow InstanceNewParamsNetworkEgress + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceNewParamsNetworkEgress) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Egress enforcement policy applied when mediation is enabled. +type InstanceNewParamsNetworkEgressEnforcement struct { + // `all` (default) rejects direct non-mediated TCP egress from the VM, while + // `http_https_only` rejects direct egress only on TCP ports 80 and 443. + // + // Any of "all", "http_https_only". + Mode string `json:"mode,omitzero"` + paramObj +} + +func (r InstanceNewParamsNetworkEgressEnforcement) MarshalJSON() (data []byte, err error) { + type shadow InstanceNewParamsNetworkEgressEnforcement + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceNewParamsNetworkEgressEnforcement) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +func init() { + apijson.RegisterFieldValidator[InstanceNewParamsNetworkEgressEnforcement]( + "mode", "all", "http_https_only", + ) +} + +type InstanceUpdateParams struct { + // Environment variables to update (merged with existing). Only keys referenced by + // the instance's existing credential `source.env` bindings are accepted. Use this + // to rotate real credential values without restarting the VM. + Env map[string]string `json:"env,omitzero"` + paramObj +} + +func (r InstanceUpdateParams) MarshalJSON() (data []byte, err error) { + type shadow InstanceUpdateParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceUpdateParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type InstanceListParams struct { - // Filter instances by metadata key-value pairs. Uses deepObject style: - // ?metadata[team]=backend&metadata[env]=staging Multiple entries are ANDed - // together. All specified key-value pairs must match. - Metadata map[string]string `query:"metadata,omitzero" json:"-"` // Filter instances by state (e.g., Running, Stopped) // - // Any of "Created", "Running", "Paused", "Shutdown", "Stopped", "Standby", - // "Unknown". + // Any of "Created", "Initializing", "Running", "Paused", "Shutdown", "Stopped", + // "Standby", "Unknown". State InstanceListParamsState `query:"state,omitzero" json:"-"` + // Filter instances by tag key-value pairs. Uses deepObject style: + // ?tags[team]=backend&tags[env]=staging Multiple entries are ANDed together. All + // specified key-value pairs must match. + Tags map[string]string `query:"tags,omitzero" json:"-"` paramObj } @@ -653,13 +857,14 @@ func (r InstanceListParams) URLQuery() (v url.Values, err error) { type InstanceListParamsState string const ( - InstanceListParamsStateCreated InstanceListParamsState = "Created" - InstanceListParamsStateRunning InstanceListParamsState = "Running" - InstanceListParamsStatePaused InstanceListParamsState = "Paused" - InstanceListParamsStateShutdown InstanceListParamsState = "Shutdown" - InstanceListParamsStateStopped InstanceListParamsState = "Stopped" - InstanceListParamsStateStandby InstanceListParamsState = "Standby" - InstanceListParamsStateUnknown InstanceListParamsState = "Unknown" + InstanceListParamsStateCreated InstanceListParamsState = "Created" + InstanceListParamsStateInitializing InstanceListParamsState = "Initializing" + InstanceListParamsStateRunning InstanceListParamsState = "Running" + InstanceListParamsStatePaused InstanceListParamsState = "Paused" + InstanceListParamsStateShutdown InstanceListParamsState = "Shutdown" + InstanceListParamsStateStopped InstanceListParamsState = "Stopped" + InstanceListParamsStateStandby InstanceListParamsState = "Standby" + InstanceListParamsStateUnknown InstanceListParamsState = "Unknown" ) type InstanceForkParams struct { @@ -734,6 +939,19 @@ const ( InstanceLogsParamsSourceHypeman InstanceLogsParamsSource = "hypeman" ) +type InstanceStandbyParams struct { + Compression shared.SnapshotCompressionConfigParam `json:"compression,omitzero"` + paramObj +} + +func (r InstanceStandbyParams) MarshalJSON() (data []byte, err error) { + type shadow InstanceStandbyParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceStandbyParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type InstanceStartParams struct { // Override image CMD for this run. Omit to keep previous value. Cmd []string `json:"cmd,omitzero"` diff --git a/instance_test.go b/instance_test.go index 92f99ae..be89cd7 100644 --- a/instance_test.go +++ b/instance_test.go @@ -11,6 +11,7 @@ import ( "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/internal/testutil" "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/shared" ) func TestInstanceNewWithOptionalParams(t *testing.T) { @@ -27,9 +28,23 @@ 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", - Cmd: []string{"echo", "hello"}, + Image: "docker.io/library/alpine:latest", + Name: "my-workload-1", + Cmd: []string{"echo", "hello"}, + Credentials: map[string]hypeman.InstanceNewParamsCredential{ + "OUTBOUND_OPENAI_KEY": { + Inject: []hypeman.InstanceNewParamsCredentialInject{{ + As: hypeman.InstanceNewParamsCredentialInjectAs{ + Format: "Bearer ${value}", + Header: "Authorization", + }, + Hosts: []string{"api.openai.com", "*.openai.com"}, + }}, + Source: hypeman.InstanceNewParamsCredentialSource{ + Env: "OUTBOUND_OPENAI_KEY", + }, + }, + }, Devices: []string{"l4-gpu"}, DiskIoBps: hypeman.String("100MB/s"), Entrypoint: []string{"/bin/sh", "-c"}, @@ -42,20 +57,33 @@ func TestInstanceNewWithOptionalParams(t *testing.T) { }, HotplugSize: hypeman.String("2GB"), Hypervisor: hypeman.InstanceNewParamsHypervisorCloudHypervisor, - Metadata: map[string]string{ - "team": "backend", - "purpose": "staging", - }, Network: hypeman.InstanceNewParamsNetwork{ BandwidthDownload: hypeman.String("1Gbps"), BandwidthUpload: hypeman.String("1Gbps"), - Enabled: hypeman.Bool(true), + Egress: hypeman.InstanceNewParamsNetworkEgress{ + Enabled: hypeman.Bool(true), + Enforcement: hypeman.InstanceNewParamsNetworkEgressEnforcement{ + Mode: "all", + }, + }, + Enabled: hypeman.Bool(true), }, OverlaySize: hypeman.String("20GB"), Size: hypeman.String("2GB"), SkipGuestAgent: hypeman.Bool(false), SkipKernelHeaders: hypeman.Bool(true), - Vcpus: hypeman.Int(2), + SnapshotPolicy: hypeman.SnapshotPolicyParam{ + Compression: shared.SnapshotCompressionConfigParam{ + Enabled: true, + Algorithm: shared.SnapshotCompressionConfigAlgorithmZstd, + Level: hypeman.Int(1), + }, + }, + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + Vcpus: hypeman.Int(2), Volumes: []hypeman.VolumeMountParam{{ MountPath: "/mnt/data", VolumeID: "vol-abc123", @@ -73,6 +101,37 @@ func TestInstanceNewWithOptionalParams(t *testing.T) { } } +func TestInstanceUpdateWithOptionalParams(t *testing.T) { + t.Skip("Mock server 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.Update( + context.TODO(), + "id", + hypeman.InstanceUpdateParams{ + Env: map[string]string{ + "OUTBOUND_OPENAI_KEY": "new-rotated-key-456", + }, + }, + ) + 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 TestInstanceListWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" @@ -87,10 +146,11 @@ func TestInstanceListWithOptionalParams(t *testing.T) { option.WithAPIKey("My API Key"), ) _, err := client.Instances.List(context.TODO(), hypeman.InstanceListParams{ - Metadata: map[string]string{ - "foo": "string", - }, State: hypeman.InstanceListParamsStateCreated, + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, }) if err != nil { var apierr *hypeman.Error @@ -201,7 +261,7 @@ func TestInstanceRestore(t *testing.T) { } } -func TestInstanceStandby(t *testing.T) { +func TestInstanceStandbyWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -214,7 +274,17 @@ func TestInstanceStandby(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Instances.Standby(context.TODO(), "id") + _, err := client.Instances.Standby( + context.TODO(), + "id", + hypeman.InstanceStandbyParams{ + Compression: shared.SnapshotCompressionConfigParam{ + Enabled: true, + Algorithm: shared.SnapshotCompressionConfigAlgorithmZstd, + Level: hypeman.Int(1), + }, + }, + ) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/instancesnapshot.go b/instancesnapshot.go new file mode 100644 index 0000000..d0aed99 --- /dev/null +++ b/instancesnapshot.go @@ -0,0 +1,136 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + + "github.com/kernel/hypeman-go/internal/apijson" + "github.com/kernel/hypeman-go/internal/requestconfig" + "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/packages/param" + "github.com/kernel/hypeman-go/shared" +) + +// InstanceSnapshotService 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 [NewInstanceSnapshotService] method instead. +type InstanceSnapshotService struct { + Options []option.RequestOption +} + +// NewInstanceSnapshotService 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 NewInstanceSnapshotService(opts ...option.RequestOption) (r InstanceSnapshotService) { + r = InstanceSnapshotService{} + r.Options = opts + return +} + +// Create a snapshot for an instance +func (r *InstanceSnapshotService) New(ctx context.Context, id string, body InstanceSnapshotNewParams, opts ...option.RequestOption) (res *Snapshot, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return nil, err + } + path := fmt.Sprintf("instances/%s/snapshots", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err +} + +// Restore an instance from a snapshot in-place +func (r *InstanceSnapshotService) Restore(ctx context.Context, snapshotID string, params InstanceSnapshotRestoreParams, opts ...option.RequestOption) (res *Instance, err error) { + opts = slices.Concat(r.Options, opts) + if params.ID == "" { + err = errors.New("missing required id parameter") + return nil, err + } + if snapshotID == "" { + err = errors.New("missing required snapshotId parameter") + return nil, err + } + path := fmt.Sprintf("instances/%s/snapshots/%s/restore", params.ID, snapshotID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) + return res, err +} + +type InstanceSnapshotNewParams struct { + // Snapshot capture kind + // + // Any of "Standby", "Stopped". + Kind SnapshotKind `json:"kind,omitzero" api:"required"` + // Optional snapshot name (lowercase letters, digits, and dashes only; cannot start + // or end with a dash) + Name param.Opt[string] `json:"name,omitzero"` + // Compression settings to use for this snapshot. Overrides instance and server + // defaults. + Compression shared.SnapshotCompressionConfigParam `json:"compression,omitzero"` + // User-defined key-value tags. + Tags map[string]string `json:"tags,omitzero"` + paramObj +} + +func (r InstanceSnapshotNewParams) MarshalJSON() (data []byte, err error) { + type shadow InstanceSnapshotNewParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceSnapshotNewParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type InstanceSnapshotRestoreParams struct { + ID string `path:"id" api:"required" json:"-"` + // Optional hypervisor override. Allowed only when restoring from a Stopped + // snapshot. Standby snapshots must restore with their original hypervisor. + // + // Any of "cloud-hypervisor", "firecracker", "qemu", "vz". + TargetHypervisor InstanceSnapshotRestoreParamsTargetHypervisor `json:"target_hypervisor,omitzero"` + // Optional final state after restore. Defaults by snapshot kind: + // + // - Standby snapshot defaults to Running + // - Stopped snapshot defaults to Stopped + // + // Any of "Stopped", "Standby", "Running". + TargetState InstanceSnapshotRestoreParamsTargetState `json:"target_state,omitzero"` + paramObj +} + +func (r InstanceSnapshotRestoreParams) MarshalJSON() (data []byte, err error) { + type shadow InstanceSnapshotRestoreParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *InstanceSnapshotRestoreParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Optional hypervisor override. Allowed only when restoring from a Stopped +// snapshot. Standby snapshots must restore with their original hypervisor. +type InstanceSnapshotRestoreParamsTargetHypervisor string + +const ( + InstanceSnapshotRestoreParamsTargetHypervisorCloudHypervisor InstanceSnapshotRestoreParamsTargetHypervisor = "cloud-hypervisor" + InstanceSnapshotRestoreParamsTargetHypervisorFirecracker InstanceSnapshotRestoreParamsTargetHypervisor = "firecracker" + InstanceSnapshotRestoreParamsTargetHypervisorQemu InstanceSnapshotRestoreParamsTargetHypervisor = "qemu" + InstanceSnapshotRestoreParamsTargetHypervisorVz InstanceSnapshotRestoreParamsTargetHypervisor = "vz" +) + +// Optional final state after restore. Defaults by snapshot kind: +// +// - Standby snapshot defaults to Running +// - Stopped snapshot defaults to Stopped +type InstanceSnapshotRestoreParamsTargetState string + +const ( + InstanceSnapshotRestoreParamsTargetStateStopped InstanceSnapshotRestoreParamsTargetState = "Stopped" + InstanceSnapshotRestoreParamsTargetStateStandby InstanceSnapshotRestoreParamsTargetState = "Standby" + InstanceSnapshotRestoreParamsTargetStateRunning InstanceSnapshotRestoreParamsTargetState = "Running" +) diff --git a/instancesnapshot_test.go b/instancesnapshot_test.go new file mode 100644 index 0000000..8424616 --- /dev/null +++ b/instancesnapshot_test.go @@ -0,0 +1,85 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/internal/testutil" + "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/shared" +) + +func TestInstanceSnapshotNewWithOptionalParams(t *testing.T) { + t.Skip("Mock server 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.Snapshots.New( + context.TODO(), + "id", + hypeman.InstanceSnapshotNewParams{ + Kind: hypeman.SnapshotKindStandby, + Compression: shared.SnapshotCompressionConfigParam{ + Enabled: true, + Algorithm: shared.SnapshotCompressionConfigAlgorithmZstd, + Level: hypeman.Int(1), + }, + Name: hypeman.String("pre-upgrade"), + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }, + ) + 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 TestInstanceSnapshotRestoreWithOptionalParams(t *testing.T) { + t.Skip("Mock server 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.Snapshots.Restore( + context.TODO(), + "snapshotId", + hypeman.InstanceSnapshotRestoreParams{ + ID: "id", + TargetHypervisor: hypeman.InstanceSnapshotRestoreParamsTargetHypervisorQemu, + TargetState: hypeman.InstanceSnapshotRestoreParamsTargetStateRunning, + }, + ) + 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/instancevolume.go b/instancevolume.go index 2bc78d5..5ebb8e0 100644 --- a/instancevolume.go +++ b/instancevolume.go @@ -39,15 +39,15 @@ func (r *InstanceVolumeService) Attach(ctx context.Context, volumeID string, par opts = slices.Concat(r.Options, opts) if params.ID == "" { err = errors.New("missing required id parameter") - return + return nil, err } if volumeID == "" { err = errors.New("missing required volumeId parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/volumes/%s", params.ID, volumeID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) - return + return res, err } // Detach volume from instance @@ -55,15 +55,15 @@ func (r *InstanceVolumeService) Detach(ctx context.Context, volumeID string, bod opts = slices.Concat(r.Options, opts) if body.ID == "" { err = errors.New("missing required id parameter") - return + return nil, err } if volumeID == "" { err = errors.New("missing required volumeId parameter") - return + return nil, err } path := fmt.Sprintf("instances/%s/volumes/%s", body.ID, volumeID) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, &res, opts...) - return + return res, err } type InstanceVolumeAttachParams struct { diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go index a2f1516..61effd1 100644 --- a/internal/apiform/encoder.go +++ b/internal/apiform/encoder.go @@ -469,5 +469,5 @@ func WriteExtras(writer *multipart.Writer, extras map[string]any) (err error) { break } } - return + return err } diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index 2884602..737af2c 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -585,14 +585,17 @@ func TestEncode(t *testing.T) { t.Run(name, func(t *testing.T) { buf := bytes.NewBuffer(nil) writer := multipart.NewWriter(buf) - writer.SetBoundary("xxx") + err := writer.SetBoundary("xxx") + if err != nil { + t.Errorf("setting boundary for %v failed with error %v", test.val, err) + } - var arrayFmt string = "indices:dots" + arrayFmt := "indices:dots" if tags := strings.Split(name, ","); len(tags) > 1 { arrayFmt = tags[1] } - err := MarshalWithSettings(test.val, writer, arrayFmt) + err = MarshalWithSettings(test.val, writer, arrayFmt) if err != nil { t.Errorf("serialization of %v failed with error %v", test.val, err) } diff --git a/internal/apiform/tag.go b/internal/apiform/tag.go index b353617..d9915d4 100644 --- a/internal/apiform/tag.go +++ b/internal/apiform/tag.go @@ -24,7 +24,7 @@ func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool raw, ok = field.Tag.Lookup(jsonStructTag) } if !ok { - return + return tag, ok } parts := strings.Split(raw, ",") if len(parts) == 0 { @@ -45,7 +45,7 @@ func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool } parseApiStructTag(field, &tag) - return + return tag, ok } func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { @@ -60,11 +60,13 @@ func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { tag.extras = true case "required": tag.required = true + case "metadata": + tag.metadata = true } } } func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { format, ok = field.Tag.Lookup(formatStructTag) - return + return format, ok } diff --git a/internal/apijson/decoder.go b/internal/apijson/decoder.go index 95c9c28..0225b9f 100644 --- a/internal/apijson/decoder.go +++ b/internal/apijson/decoder.go @@ -393,7 +393,7 @@ func (d *decoderBuilder) newStructTypeDecoder(t reflect.Type) decoderFunc { for _, decoder := range anonymousDecoders { // ignore errors - decoder.fn(node, value.FieldByIndex(decoder.idx), state) + _ = decoder.fn(node, value.FieldByIndex(decoder.idx), state) } for _, inlineDecoder := range inlineDecoders { @@ -462,7 +462,7 @@ func (d *decoderBuilder) newStructTypeDecoder(t reflect.Type) decoderFunc { // Handle null [param.Opt] if itemNode.Type == gjson.Null && dest.IsValid() && dest.Type().Implements(reflect.TypeOf((*param.Optional)(nil)).Elem()) { - dest.Addr().Interface().(json.Unmarshaler).UnmarshalJSON([]byte(itemNode.Raw)) + _ = dest.Addr().Interface().(json.Unmarshaler).UnmarshalJSON([]byte(itemNode.Raw)) continue } @@ -684,8 +684,5 @@ func guardUnknown(state *decoderState, v reflect.Value) bool { constantString, ok := v.Interface().(interface{ Default() string }) named := v.Type() != stringType - if guardStrict(state, ok && named && v.Equal(reflect.ValueOf(constantString.Default()))) { - return true - } - return false + return guardStrict(state, ok && named && v.Equal(reflect.ValueOf(constantString.Default()))) } diff --git a/internal/apijson/encoder.go b/internal/apijson/encoder.go index ab7a3c1..0decb73 100644 --- a/internal/apijson/encoder.go +++ b/internal/apijson/encoder.go @@ -286,28 +286,7 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { return nil, err } } - return - } -} - -func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { - f, _ := t.FieldByName("Value") - enc := e.typeEncoder(f.Type) - - return func(value reflect.Value) (json []byte, err error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return []byte("null"), nil - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.typeEncoder(raw.Type())(raw) - } - return enc(value.FieldByName("Value")) + return json, err } } diff --git a/internal/apijson/enum.go b/internal/apijson/enum.go index 5bef11c..a1626a5 100644 --- a/internal/apijson/enum.go +++ b/internal/apijson/enum.go @@ -4,7 +4,6 @@ import ( "fmt" "reflect" "slices" - "sync" "github.com/tidwall/gjson" ) @@ -15,7 +14,6 @@ import ( type validationEntry struct { field reflect.StructField - required bool legalValues struct { strings []string // 1 represents true, 0 represents false, -1 represents either @@ -24,9 +22,6 @@ type validationEntry struct { } } -type validatorFunc func(reflect.Value) exactness - -var validators sync.Map var validationRegistry = map[reflect.Type][]validationEntry{} func RegisterFieldValidator[T any, V string | bool | int | float64](fieldName string, values ...V) { @@ -111,9 +106,9 @@ func (state *decoderState) validateBool(v reflect.Value) { return } b := v.Bool() - if state.validator.legalValues.bools == 1 && b == false { + if state.validator.legalValues.bools == 1 && !b { state.exactness = loose - } else if state.validator.legalValues.bools == 0 && b == true { + } else if state.validator.legalValues.bools == 0 && b { state.exactness = loose } } diff --git a/internal/apijson/json_test.go b/internal/apijson/json_test.go index fac9fcc..19b3614 100644 --- a/internal/apijson/json_test.go +++ b/internal/apijson/json_test.go @@ -87,7 +87,7 @@ type JSONFieldStruct struct { C string `json:"c"` D string `json:"d"` ExtraFields map[string]int64 `json:"" api:"extrafields"` - JSON JSONFieldStructJSON `json:",metadata"` + JSON JSONFieldStructJSON `json:"-" api:"metadata"` } type JSONFieldStructJSON struct { @@ -113,12 +113,12 @@ type Union interface { type Inline struct { InlineField Primitives `json:",inline"` - JSON InlineJSON `json:",metadata"` + JSON InlineJSON `json:"-" api:"metadata"` } type InlineArray struct { InlineField []string `json:",inline"` - JSON InlineJSON `json:",metadata"` + JSON InlineJSON `json:"-" api:"metadata"` } type InlineJSON struct { @@ -268,7 +268,7 @@ type MarshallingUnionStruct struct { func (r *MarshallingUnionStruct) UnmarshalJSON(data []byte) (err error) { *r = MarshallingUnionStruct{} err = UnmarshalRoot(data, &r.Union) - return + return err } func (r MarshallingUnionStruct) MarshalJSON() (data []byte, err error) { diff --git a/internal/apijson/tag.go b/internal/apijson/tag.go index 49731b8..17b2130 100644 --- a/internal/apijson/tag.go +++ b/internal/apijson/tag.go @@ -20,7 +20,7 @@ type parsedStructTag struct { func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { raw, ok := field.Tag.Lookup(jsonStructTag) if !ok { - return + return tag, ok } parts := strings.Split(raw, ",") if len(parts) == 0 { @@ -42,7 +42,7 @@ func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool // the `api` struct tag is only used alongside `json` for custom behaviour parseApiStructTag(field, &tag) - return + return tag, ok } func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { @@ -57,11 +57,13 @@ func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) { tag.extras = true case "required": tag.required = true + case "metadata": + tag.metadata = true } } } func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { format, ok = field.Tag.Lookup(formatStructTag) - return + return format, ok } diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go index a98c29c..ac31823 100644 --- a/internal/apiquery/encoder.go +++ b/internal/apiquery/encoder.go @@ -103,7 +103,7 @@ func (e *encoder) newTypeEncoder(t reflect.Type) encoderFunc { encoder := e.typeEncoder(t.Elem()) return func(key string, value reflect.Value) (pairs []Pair, err error) { if !value.IsValid() || value.IsNil() { - return + return pairs, err } return encoder(key, value.Elem()) } @@ -193,7 +193,7 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { return func(key string, value reflect.Value) (pairs []Pair, err error) { for _, ef := range encoderFields { - var subkey string = e.renderKeyPath(key, ef.tag.name) + subkey := e.renderKeyPath(key, ef.tag.name) if ef.tag.inline { subkey = key } @@ -205,7 +205,7 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } } @@ -256,7 +256,7 @@ func (e *encoder) newMapEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } } @@ -300,7 +300,7 @@ func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } case ArrayQueryFormatIndices: panic("The array indices format is not supported yet") @@ -315,7 +315,7 @@ func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc { } pairs = append(pairs, subpairs...) } - return + return pairs, err } default: panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) @@ -372,27 +372,6 @@ func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc { } } -func (e *encoder) newFieldTypeEncoder(t reflect.Type) encoderFunc { - f, _ := t.FieldByName("Value") - enc := e.typeEncoder(f.Type) - - return func(key string, value reflect.Value) ([]Pair, error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return nil, fmt.Errorf("apiquery: field cannot be null") - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.typeEncoder(raw.Type())(key, raw) - } - return enc(key, value.FieldByName("Value")) - } -} - func (e *encoder) newTimeTypeEncoder(_ reflect.Type) encoderFunc { format := e.dateFormat return func(key string, value reflect.Value) ([]Pair, error) { diff --git a/internal/apiquery/tag.go b/internal/apiquery/tag.go index 772c40e..9e413ad 100644 --- a/internal/apiquery/tag.go +++ b/internal/apiquery/tag.go @@ -18,7 +18,7 @@ type parsedStructTag struct { func parseQueryStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) { raw, ok := field.Tag.Lookup(queryStructTag) if !ok { - return + return tag, ok } parts := strings.Split(raw, ",") if len(parts) == 0 { @@ -35,10 +35,10 @@ func parseQueryStructTag(field reflect.StructField) (tag parsedStructTag, ok boo tag.inline = true } } - return + return tag, ok } func parseFormatStructTag(field reflect.StructField) (format string, ok bool) { format, ok = field.Tag.Lookup(formatStructTag) - return + return format, ok } diff --git a/internal/requestconfig/requestconfig.go b/internal/requestconfig/requestconfig.go index 595c464..b578df8 100644 --- a/internal/requestconfig/requestconfig.go +++ b/internal/requestconfig/requestconfig.go @@ -355,11 +355,9 @@ func (b *bodyWithTimeout) Close() error { } func retryDelay(res *http.Response, retryCount int) time.Duration { - // If the API asks us to wait a certain amount of time (and it's a reasonable amount), - // just do what it says. - - if retryAfterDelay, ok := parseRetryAfterHeader(res); ok && 0 <= retryAfterDelay && retryAfterDelay < time.Minute { - return retryAfterDelay + // If the backend tells us to wait a certain amount of time, use that value + if retryAfterDelay, ok := parseRetryAfterHeader(res); ok { + return max(0, retryAfterDelay) } maxDelay := 8 * time.Second @@ -463,7 +461,7 @@ func (cfg *RequestConfig) Execute() (err error) { // Close the response body before retrying to prevent connection leaks if res != nil && res.Body != nil { - res.Body.Close() + _ = res.Body.Close() } select { @@ -491,7 +489,7 @@ func (cfg *RequestConfig) Execute() (err error) { if res.StatusCode >= 400 { contents, err := io.ReadAll(res.Body) - res.Body.Close() + _ = res.Body.Close() if err != nil { return err } @@ -522,7 +520,7 @@ func (cfg *RequestConfig) Execute() (err error) { } contents, err := io.ReadAll(res.Body) - res.Body.Close() + _ = res.Body.Close() if err != nil { return fmt.Errorf("error reading response body: %w", err) } diff --git a/internal/version.go b/internal/version.go index 1f338c3..16d4c3d 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.15.0" // x-release-please-version +const PackageVersion = "0.16.0" // x-release-please-version diff --git a/resource.go b/resource.go index 6ee51a1..661648a 100644 --- a/resource.go +++ b/resource.go @@ -4,12 +4,16 @@ package hypeman import ( "context" + "encoding/json" "net/http" "slices" + "time" "github.com/kernel/hypeman-go/internal/apijson" + shimjson "github.com/kernel/hypeman-go/internal/encoding/json" "github.com/kernel/hypeman-go/internal/requestconfig" "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/packages/param" "github.com/kernel/hypeman-go/packages/respjson" ) @@ -39,7 +43,17 @@ func (r *ResourceService) Get(ctx context.Context, opts ...option.RequestOption) opts = slices.Concat(r.Options, opts) path := "resources" err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err +} + +// Requests runtime balloon inflation across reclaim-eligible guests. The same +// planner used by host-pressure reclaim is applied, including protected floors and +// per-VM step limits. +func (r *ResourceService) ReclaimMemory(ctx context.Context, body ResourceReclaimMemoryParams, opts ...option.RequestOption) (res *MemoryReclaimResponse, err error) { + opts = slices.Concat(r.Options, opts) + path := "resources/memory/reclaim" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err } type DiskBreakdown struct { @@ -132,6 +146,113 @@ const ( GPUResourceStatusModePassthrough GPUResourceStatusMode = "passthrough" ) +type MemoryReclaimAction struct { + AppliedReclaimBytes int64 `json:"applied_reclaim_bytes" api:"required"` + AssignedMemoryBytes int64 `json:"assigned_memory_bytes" api:"required"` + // Any of "cloud-hypervisor", "firecracker", "qemu", "vz". + Hypervisor MemoryReclaimActionHypervisor `json:"hypervisor" api:"required"` + InstanceID string `json:"instance_id" api:"required"` + InstanceName string `json:"instance_name" api:"required"` + PlannedTargetGuestMemoryBytes int64 `json:"planned_target_guest_memory_bytes" api:"required"` + PreviousTargetGuestMemoryBytes int64 `json:"previous_target_guest_memory_bytes" api:"required"` + ProtectedFloorBytes int64 `json:"protected_floor_bytes" api:"required"` + // Result of this VM's reclaim step. + Status string `json:"status" api:"required"` + TargetGuestMemoryBytes int64 `json:"target_guest_memory_bytes" api:"required"` + // Error message when status is error or unsupported. + Error string `json:"error"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + AppliedReclaimBytes respjson.Field + AssignedMemoryBytes respjson.Field + Hypervisor respjson.Field + InstanceID respjson.Field + InstanceName respjson.Field + PlannedTargetGuestMemoryBytes respjson.Field + PreviousTargetGuestMemoryBytes respjson.Field + ProtectedFloorBytes respjson.Field + Status respjson.Field + TargetGuestMemoryBytes respjson.Field + Error respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r MemoryReclaimAction) RawJSON() string { return r.JSON.raw } +func (r *MemoryReclaimAction) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type MemoryReclaimActionHypervisor string + +const ( + MemoryReclaimActionHypervisorCloudHypervisor MemoryReclaimActionHypervisor = "cloud-hypervisor" + MemoryReclaimActionHypervisorFirecracker MemoryReclaimActionHypervisor = "firecracker" + MemoryReclaimActionHypervisorQemu MemoryReclaimActionHypervisor = "qemu" + MemoryReclaimActionHypervisorVz MemoryReclaimActionHypervisor = "vz" +) + +// The property ReclaimBytes is required. +type MemoryReclaimRequestParam struct { + // Total bytes of guest memory to reclaim across eligible VMs. + ReclaimBytes int64 `json:"reclaim_bytes" api:"required"` + // Calculate a reclaim plan without applying balloon changes or creating a hold. + DryRun param.Opt[bool] `json:"dry_run,omitzero"` + // How long to keep the reclaim hold active (Go duration string). Defaults to 5m + // when omitted. + HoldFor param.Opt[string] `json:"hold_for,omitzero"` + // Optional operator-provided reason attached to logs and traces. + Reason param.Opt[string] `json:"reason,omitzero"` + paramObj +} + +func (r MemoryReclaimRequestParam) MarshalJSON() (data []byte, err error) { + type shadow MemoryReclaimRequestParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *MemoryReclaimRequestParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type MemoryReclaimResponse struct { + Actions []MemoryReclaimAction `json:"actions" api:"required"` + AppliedReclaimBytes int64 `json:"applied_reclaim_bytes" api:"required"` + HostAvailableBytes int64 `json:"host_available_bytes" api:"required"` + // Any of "healthy", "pressure". + HostPressureState MemoryReclaimResponseHostPressureState `json:"host_pressure_state" api:"required"` + PlannedReclaimBytes int64 `json:"planned_reclaim_bytes" api:"required"` + RequestedReclaimBytes int64 `json:"requested_reclaim_bytes" api:"required"` + // When the current manual reclaim hold expires. + HoldUntil time.Time `json:"hold_until" format:"date-time"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Actions respjson.Field + AppliedReclaimBytes respjson.Field + HostAvailableBytes respjson.Field + HostPressureState respjson.Field + PlannedReclaimBytes respjson.Field + RequestedReclaimBytes respjson.Field + HoldUntil respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r MemoryReclaimResponse) RawJSON() string { return r.JSON.raw } +func (r *MemoryReclaimResponse) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type MemoryReclaimResponseHostPressureState string + +const ( + MemoryReclaimResponseHostPressureStateHealthy MemoryReclaimResponseHostPressureState = "healthy" + MemoryReclaimResponseHostPressureStatePressure MemoryReclaimResponseHostPressureState = "pressure" +) + // Physical GPU available for passthrough type PassthroughDevice struct { // Whether this GPU is available (not attached to an instance) @@ -256,3 +377,15 @@ func (r Resources) RawJSON() string { return r.JSON.raw } func (r *Resources) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type ResourceReclaimMemoryParams struct { + MemoryReclaimRequest MemoryReclaimRequestParam + paramObj +} + +func (r ResourceReclaimMemoryParams) MarshalJSON() (data []byte, err error) { + return shimjson.Marshal(r.MemoryReclaimRequest) +} +func (r *ResourceReclaimMemoryParams) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &r.MemoryReclaimRequest) +} diff --git a/resource_test.go b/resource_test.go index 47a0603..97d280f 100644 --- a/resource_test.go +++ b/resource_test.go @@ -35,3 +35,33 @@ func TestResourceGet(t *testing.T) { t.Fatalf("err should be nil: %s", err.Error()) } } + +func TestResourceReclaimMemoryWithOptionalParams(t *testing.T) { + t.Skip("Mock server 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.Resources.ReclaimMemory(context.TODO(), hypeman.ResourceReclaimMemoryParams{ + MemoryReclaimRequest: hypeman.MemoryReclaimRequestParam{ + ReclaimBytes: 536870912, + DryRun: hypeman.Bool(true), + HoldFor: hypeman.String("5m"), + Reason: hypeman.String("prepare for another vm start"), + }, + }) + 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/shared/shared.go b/shared/shared.go new file mode 100644 index 0000000..4b9e9eb --- /dev/null +++ b/shared/shared.go @@ -0,0 +1,86 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package shared + +import ( + "encoding/json" + + "github.com/kernel/hypeman-go/internal/apijson" + "github.com/kernel/hypeman-go/packages/param" + "github.com/kernel/hypeman-go/packages/respjson" +) + +// aliased to make [param.APIUnion] private when embedding +type paramUnion = param.APIUnion + +// aliased to make [param.APIObject] private when embedding +type paramObj = param.APIObject + +type SnapshotCompressionConfig struct { + // Enable snapshot memory compression + Enabled bool `json:"enabled" api:"required"` + // Compression algorithm (defaults to zstd when enabled). Ignored when enabled is + // false. + // + // Any of "zstd", "lz4". + Algorithm SnapshotCompressionConfigAlgorithm `json:"algorithm"` + // Compression level. Allowed ranges are zstd=1-19 and lz4=0-9. When omitted, zstd + // defaults to 1 and lz4 defaults to 0. Ignored when enabled is false. + Level int64 `json:"level"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Enabled respjson.Field + Algorithm respjson.Field + Level respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r SnapshotCompressionConfig) RawJSON() string { return r.JSON.raw } +func (r *SnapshotCompressionConfig) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// ToParam converts this SnapshotCompressionConfig to a +// SnapshotCompressionConfigParam. +// +// Warning: the fields of the param type will not be present. ToParam should only +// be used at the last possible moment before sending a request. Test for this with +// SnapshotCompressionConfigParam.Overrides() +func (r SnapshotCompressionConfig) ToParam() SnapshotCompressionConfigParam { + return param.Override[SnapshotCompressionConfigParam](json.RawMessage(r.RawJSON())) +} + +// Compression algorithm (defaults to zstd when enabled). Ignored when enabled is +// false. +type SnapshotCompressionConfigAlgorithm string + +const ( + SnapshotCompressionConfigAlgorithmZstd SnapshotCompressionConfigAlgorithm = "zstd" + SnapshotCompressionConfigAlgorithmLz4 SnapshotCompressionConfigAlgorithm = "lz4" +) + +// The property Enabled is required. +type SnapshotCompressionConfigParam struct { + // Enable snapshot memory compression + Enabled bool `json:"enabled" api:"required"` + // Compression level. Allowed ranges are zstd=1-19 and lz4=0-9. When omitted, zstd + // defaults to 1 and lz4 defaults to 0. Ignored when enabled is false. + Level param.Opt[int64] `json:"level,omitzero"` + // Compression algorithm (defaults to zstd when enabled). Ignored when enabled is + // false. + // + // Any of "zstd", "lz4". + Algorithm SnapshotCompressionConfigAlgorithm `json:"algorithm,omitzero"` + paramObj +} + +func (r SnapshotCompressionConfigParam) MarshalJSON() (data []byte, err error) { + type shadow SnapshotCompressionConfigParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *SnapshotCompressionConfigParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/snapshot.go b/snapshot.go new file mode 100644 index 0000000..f615439 --- /dev/null +++ b/snapshot.go @@ -0,0 +1,246 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "time" + + "github.com/kernel/hypeman-go/internal/apijson" + "github.com/kernel/hypeman-go/internal/apiquery" + "github.com/kernel/hypeman-go/internal/requestconfig" + "github.com/kernel/hypeman-go/option" + "github.com/kernel/hypeman-go/packages/param" + "github.com/kernel/hypeman-go/packages/respjson" + "github.com/kernel/hypeman-go/shared" +) + +// SnapshotService 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 [NewSnapshotService] method instead. +type SnapshotService struct { + Options []option.RequestOption +} + +// NewSnapshotService 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 NewSnapshotService(opts ...option.RequestOption) (r SnapshotService) { + r = SnapshotService{} + r.Options = opts + return +} + +// List snapshots +func (r *SnapshotService) List(ctx context.Context, query SnapshotListParams, opts ...option.RequestOption) (res *[]Snapshot, err error) { + opts = slices.Concat(r.Options, opts) + path := "snapshots" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err +} + +// Delete a snapshot +func (r *SnapshotService) Delete(ctx context.Context, snapshotID string, opts ...option.RequestOption) (err error) { + opts = slices.Concat(r.Options, opts) + opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) + if snapshotID == "" { + err = errors.New("missing required snapshotId parameter") + return err + } + path := fmt.Sprintf("snapshots/%s", snapshotID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) + return err +} + +// Fork a new instance from a snapshot +func (r *SnapshotService) Fork(ctx context.Context, snapshotID string, body SnapshotForkParams, opts ...option.RequestOption) (res *Instance, err error) { + opts = slices.Concat(r.Options, opts) + if snapshotID == "" { + err = errors.New("missing required snapshotId parameter") + return nil, err + } + path := fmt.Sprintf("snapshots/%s/fork", snapshotID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return res, err +} + +// Get snapshot details +func (r *SnapshotService) Get(ctx context.Context, snapshotID string, opts ...option.RequestOption) (res *Snapshot, err error) { + opts = slices.Concat(r.Options, opts) + if snapshotID == "" { + err = errors.New("missing required snapshotId parameter") + return nil, err + } + path := fmt.Sprintf("snapshots/%s", snapshotID) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return res, err +} + +type Snapshot struct { + // Auto-generated unique snapshot identifier + ID string `json:"id" api:"required"` + // Snapshot creation timestamp + CreatedAt time.Time `json:"created_at" api:"required" format:"date-time"` + // Snapshot capture kind + // + // Any of "Standby", "Stopped". + Kind SnapshotKind `json:"kind" api:"required"` + // Total payload size in bytes + SizeBytes int64 `json:"size_bytes" api:"required"` + // Source instance hypervisor at snapshot creation time + // + // Any of "cloud-hypervisor", "firecracker", "qemu", "vz". + SourceHypervisor SnapshotSourceHypervisor `json:"source_hypervisor" api:"required"` + // Source instance ID at snapshot creation time + SourceInstanceID string `json:"source_instance_id" api:"required"` + // Source instance name at snapshot creation time + SourceInstanceName string `json:"source_instance_name" api:"required"` + // Compressed memory payload size in bytes + CompressedSizeBytes int64 `json:"compressed_size_bytes" api:"nullable"` + Compression shared.SnapshotCompressionConfig `json:"compression"` + // Compression error message when compression_state is error + CompressionError string `json:"compression_error" api:"nullable"` + // Compression status of the snapshot payload memory file + // + // Any of "none", "compressing", "compressed", "error". + CompressionState SnapshotCompressionState `json:"compression_state"` + // Optional human-readable snapshot name (unique per source instance) + Name string `json:"name" api:"nullable"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` + // Uncompressed memory payload size in bytes + UncompressedSizeBytes int64 `json:"uncompressed_size_bytes" api:"nullable"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + ID respjson.Field + CreatedAt respjson.Field + Kind respjson.Field + SizeBytes respjson.Field + SourceHypervisor respjson.Field + SourceInstanceID respjson.Field + SourceInstanceName respjson.Field + CompressedSizeBytes respjson.Field + Compression respjson.Field + CompressionError respjson.Field + CompressionState respjson.Field + Name respjson.Field + Tags respjson.Field + UncompressedSizeBytes respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r Snapshot) RawJSON() string { return r.JSON.raw } +func (r *Snapshot) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Source instance hypervisor at snapshot creation time +type SnapshotSourceHypervisor string + +const ( + SnapshotSourceHypervisorCloudHypervisor SnapshotSourceHypervisor = "cloud-hypervisor" + SnapshotSourceHypervisorFirecracker SnapshotSourceHypervisor = "firecracker" + SnapshotSourceHypervisorQemu SnapshotSourceHypervisor = "qemu" + SnapshotSourceHypervisorVz SnapshotSourceHypervisor = "vz" +) + +// Compression status of the snapshot payload memory file +type SnapshotCompressionState string + +const ( + SnapshotCompressionStateNone SnapshotCompressionState = "none" + SnapshotCompressionStateCompressing SnapshotCompressionState = "compressing" + SnapshotCompressionStateCompressed SnapshotCompressionState = "compressed" + SnapshotCompressionStateError SnapshotCompressionState = "error" +) + +// Snapshot capture kind +type SnapshotKind string + +const ( + SnapshotKindStandby SnapshotKind = "Standby" + SnapshotKindStopped SnapshotKind = "Stopped" +) + +type SnapshotListParams struct { + // Filter snapshots by snapshot name + Name param.Opt[string] `query:"name,omitzero" json:"-"` + // Filter snapshots by source instance ID + SourceInstanceID param.Opt[string] `query:"source_instance_id,omitzero" json:"-"` + // Filter snapshots by kind + // + // Any of "Standby", "Stopped". + Kind SnapshotKind `query:"kind,omitzero" json:"-"` + // Filter snapshots by tag key-value pairs. + Tags map[string]string `query:"tags,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [SnapshotListParams]'s query parameters as `url.Values`. +func (r SnapshotListParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + +type SnapshotForkParams struct { + // Name for the new instance (lowercase letters, digits, and dashes only; cannot + // start or end with a dash) + Name string `json:"name" api:"required"` + // Optional hypervisor override. Allowed only when forking from a Stopped snapshot. + // Standby snapshots must fork with their original hypervisor. + // + // Any of "cloud-hypervisor", "firecracker", "qemu", "vz". + TargetHypervisor SnapshotForkParamsTargetHypervisor `json:"target_hypervisor,omitzero"` + // Optional final state for the forked instance. Defaults by snapshot kind: + // + // - Standby snapshot defaults to Running + // - Stopped snapshot defaults to Stopped + // + // Any of "Stopped", "Standby", "Running". + TargetState SnapshotForkParamsTargetState `json:"target_state,omitzero"` + paramObj +} + +func (r SnapshotForkParams) MarshalJSON() (data []byte, err error) { + type shadow SnapshotForkParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *SnapshotForkParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// Optional hypervisor override. Allowed only when forking from a Stopped snapshot. +// Standby snapshots must fork with their original hypervisor. +type SnapshotForkParamsTargetHypervisor string + +const ( + SnapshotForkParamsTargetHypervisorCloudHypervisor SnapshotForkParamsTargetHypervisor = "cloud-hypervisor" + SnapshotForkParamsTargetHypervisorFirecracker SnapshotForkParamsTargetHypervisor = "firecracker" + SnapshotForkParamsTargetHypervisorQemu SnapshotForkParamsTargetHypervisor = "qemu" + SnapshotForkParamsTargetHypervisorVz SnapshotForkParamsTargetHypervisor = "vz" +) + +// Optional final state for the forked instance. Defaults by snapshot kind: +// +// - Standby snapshot defaults to Running +// - Stopped snapshot defaults to Stopped +type SnapshotForkParamsTargetState string + +const ( + SnapshotForkParamsTargetStateStopped SnapshotForkParamsTargetState = "Stopped" + SnapshotForkParamsTargetStateStandby SnapshotForkParamsTargetState = "Standby" + SnapshotForkParamsTargetStateRunning SnapshotForkParamsTargetState = "Running" +) diff --git a/snapshot_test.go b/snapshot_test.go new file mode 100644 index 0000000..dfbb194 --- /dev/null +++ b/snapshot_test.go @@ -0,0 +1,122 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/internal/testutil" + "github.com/kernel/hypeman-go/option" +) + +func TestSnapshotListWithOptionalParams(t *testing.T) { + t.Skip("Mock server 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.Snapshots.List(context.TODO(), hypeman.SnapshotListParams{ + Kind: hypeman.SnapshotKindStandby, + Name: hypeman.String("name"), + SourceInstanceID: hypeman.String("source_instance_id"), + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }) + 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 TestSnapshotDelete(t *testing.T) { + t.Skip("Mock server 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.Snapshots.Delete(context.TODO(), "snapshotId") + 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 TestSnapshotForkWithOptionalParams(t *testing.T) { + t.Skip("Mock server 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.Snapshots.Fork( + context.TODO(), + "snapshotId", + hypeman.SnapshotForkParams{ + Name: "nginx-from-snap", + TargetHypervisor: hypeman.SnapshotForkParamsTargetHypervisorCloudHypervisor, + TargetState: hypeman.SnapshotForkParamsTargetStateRunning, + }, + ) + 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 TestSnapshotGet(t *testing.T) { + t.Skip("Mock server 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.Snapshots.Get(context.TODO(), "snapshotId") + 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/usage_test.go b/usage_test.go index 436721b..195a051 100644 --- a/usage_test.go +++ b/usage_test.go @@ -13,6 +13,7 @@ import ( ) func TestUsage(t *testing.T) { + t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { baseURL = envURL @@ -24,7 +25,6 @@ func TestUsage(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - t.Skip("Mock server tests are disabled") response, err := client.Health.Check(context.TODO()) if err != nil { t.Fatalf("err should be nil: %s", err.Error()) diff --git a/volume.go b/volume.go index 4cbf2a0..127ccf0 100644 --- a/volume.go +++ b/volume.go @@ -47,15 +47,15 @@ func (r *VolumeService) New(ctx context.Context, body VolumeNewParams, opts ...o opts = slices.Concat(r.Options, opts) path := "volumes" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) - return + return res, err } // List volumes -func (r *VolumeService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Volume, err error) { +func (r *VolumeService) List(ctx context.Context, query VolumeListParams, opts ...option.RequestOption) (res *[]Volume, err error) { opts = slices.Concat(r.Options, opts) path := "volumes" - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + return res, err } // Delete volume @@ -64,11 +64,11 @@ func (r *VolumeService) Delete(ctx context.Context, id string, opts ...option.Re opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) if id == "" { err = errors.New("missing required id parameter") - return + return err } path := fmt.Sprintf("volumes/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) - return + return err } // Creates a new volume pre-populated with content from a tar.gz archive. The @@ -78,7 +78,7 @@ func (r *VolumeService) NewFromArchive(ctx context.Context, body io.Reader, para opts = append([]option.RequestOption{option.WithRequestBody("application/gzip", body)}, opts...) path := "volumes/from-archive" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) - return + return res, err } // Get volume details @@ -86,11 +86,11 @@ func (r *VolumeService) Get(ctx context.Context, id string, opts ...option.Reque opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") - return + return nil, err } path := fmt.Sprintf("volumes/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return + return res, err } type Volume struct { @@ -104,6 +104,8 @@ type Volume struct { SizeGB int64 `json:"size_gb" api:"required"` // List of current attachments (empty if not attached) Attachments []VolumeAttachment `json:"attachments"` + // User-defined key-value tags. + Tags map[string]string `json:"tags"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ID respjson.Field @@ -111,6 +113,7 @@ type Volume struct { Name respjson.Field SizeGB respjson.Field Attachments respjson.Field + Tags respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -152,6 +155,8 @@ type VolumeNewParams struct { SizeGB int64 `json:"size_gb" api:"required"` // Optional custom identifier (auto-generated if not provided) ID param.Opt[string] `json:"id,omitzero"` + // User-defined key-value tags. + Tags map[string]string `json:"tags,omitzero"` paramObj } @@ -163,6 +168,20 @@ func (r *VolumeNewParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type VolumeListParams struct { + // Filter volumes by tag key-value pairs. + Tags map[string]string `query:"tags,omitzero" json:"-"` + paramObj +} + +// URLQuery serializes [VolumeListParams]'s query parameters as `url.Values`. +func (r VolumeListParams) URLQuery() (v url.Values, err error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ + ArrayFormat: apiquery.ArrayQueryFormatComma, + NestedFormat: apiquery.NestedQueryFormatBrackets, + }) +} + type VolumeNewFromArchiveParams struct { // Volume name Name string `query:"name" api:"required" json:"-"` @@ -170,6 +189,8 @@ type VolumeNewFromArchiveParams struct { SizeGB int64 `query:"size_gb" api:"required" json:"-"` // Optional custom volume ID (auto-generated if not provided) ID param.Opt[string] `query:"id,omitzero" json:"-"` + // Tags for the created volume. + Tags map[string]string `query:"tags,omitzero" json:"-"` paramObj } diff --git a/volume_test.go b/volume_test.go index 2f90508..aca90e7 100644 --- a/volume_test.go +++ b/volume_test.go @@ -32,6 +32,10 @@ func TestVolumeNewWithOptionalParams(t *testing.T) { Name: "my-data-volume", SizeGB: 10, ID: hypeman.String("vol-data-1"), + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, }) if err != nil { var apierr *hypeman.Error @@ -42,7 +46,7 @@ func TestVolumeNewWithOptionalParams(t *testing.T) { } } -func TestVolumeList(t *testing.T) { +func TestVolumeListWithOptionalParams(t *testing.T) { t.Skip("Mock server tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -55,7 +59,12 @@ func TestVolumeList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Volumes.List(context.TODO()) + _, err := client.Volumes.List(context.TODO(), hypeman.VolumeListParams{ + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, + }) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -103,11 +112,15 @@ func TestVolumeNewFromArchiveWithOptionalParams(t *testing.T) { ) _, err := client.Volumes.NewFromArchive( context.TODO(), - io.Reader(bytes.NewBuffer([]byte("some file contents"))), + io.Reader(bytes.NewBuffer([]byte("Example data"))), hypeman.VolumeNewFromArchiveParams{ Name: "name", SizeGB: 0, ID: hypeman.String("id"), + Tags: map[string]string{ + "team": "backend", + "env": "staging", + }, }, ) if err != nil {