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 {