diff --git a/.release-please-manifest.json b/.release-please-manifest.json index da59f99..2aca35a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.4.0" + ".": "0.5.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2410f40..96e35f6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-ce51f144a3d2de556750203edbaa5bfeefe874660737c35a4fc37dfb30057dd5.yml -openapi_spec_hash: 27663b6503056317abcb578ac7b67c06 -config_hash: b4e65d240d7bca1ba6162ee2098c8ac2 +configured_endpoints: 22 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-da3f4038bb544acae375f44527f515dc58308f67822905258b155192041e65ed.yml +openapi_spec_hash: 4c7f6f453c20eda7fd8689e8917c65f9 +config_hash: a7d0557c72de54fd6baded5b189777c3 diff --git a/CHANGELOG.md b/CHANGELOG.md index b85f65e..2feb3ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.5.0 (2025-12-05) + +Full Changelog: [v0.4.0...v0.5.0](https://github.com/onkernel/hypeman-go/compare/v0.4.0...v0.5.0) + +### Features + +* add Push and PushImage functions for OCI registry push ([7417cc8](https://github.com/onkernel/hypeman-go/commit/7417cc8a56c7d11c535ac7ab9a7b3d21d80bd2b4)) +* Ingress ([c751d1a](https://github.com/onkernel/hypeman-go/commit/c751d1a6bba5ca619c03f833f27251c6d3b855a7)) +* Initialize volume with data ([32d4047](https://github.com/onkernel/hypeman-go/commit/32d404746df0a3e9d83e7651105e6c6daa16476f)) +* try to fix name collision in codegen ([8173a73](https://github.com/onkernel/hypeman-go/commit/8173a73d0317d35870d5a3cec8f3fdec56fcf362)) +* Volume readonly multi-attach ([bac3fd2](https://github.com/onkernel/hypeman-go/commit/bac3fd2cee3325dc3d1b31e6077ad1f1ce13340c)) +* Volumes ([099f9b8](https://github.com/onkernel/hypeman-go/commit/099f9b8a2553087e117c8c8a9731900081d713f0)) + ## 0.4.0 (2025-11-26) Full Changelog: [v0.3.0...v0.4.0](https://github.com/onkernel/hypeman-go/compare/v0.3.0...v0.4.0) diff --git a/README.md b/README.md index 7171142..a5c9ecb 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Or to pin the version: ```sh -go get -u 'github.com/onkernel/hypeman-go@v0.4.0' +go get -u 'github.com/onkernel/hypeman-go@v0.5.0' ``` diff --git a/api.md b/api.md index aa4b3c7..81727b9 100644 --- a/api.md +++ b/api.md @@ -17,25 +17,30 @@ Response Types: Methods: - client.Images.New(ctx context.Context, body hypeman.ImageNewParams) (hypeman.Image, error) -- client.Images.Get(ctx context.Context, name string) (hypeman.Image, error) - client.Images.List(ctx context.Context) ([]hypeman.Image, error) - client.Images.Delete(ctx context.Context, name string) error +- client.Images.Get(ctx context.Context, name string) (hypeman.Image, error) # Instances +Params Types: + +- hypeman.VolumeMountParam + Response Types: - hypeman.Instance +- hypeman.VolumeMount Methods: - client.Instances.New(ctx context.Context, body hypeman.InstanceNewParams) (hypeman.Instance, error) -- client.Instances.Get(ctx context.Context, id string) (hypeman.Instance, error) - client.Instances.List(ctx context.Context) ([]hypeman.Instance, error) - client.Instances.Delete(ctx context.Context, id string) 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.PutInStandby(ctx context.Context, id string) (hypeman.Instance, error) -- client.Instances.RestoreFromStandby(ctx context.Context, id string) (hypeman.Instance, error) +- client.Instances.Restore(ctx context.Context, id string) (hypeman.Instance, error) +- client.Instances.Standby(ctx context.Context, id string) (hypeman.Instance, error) ## Volumes @@ -49,10 +54,33 @@ Methods: Response Types: - hypeman.Volume +- hypeman.VolumeAttachment Methods: - client.Volumes.New(ctx context.Context, body hypeman.VolumeNewParams) (hypeman.Volume, error) -- client.Volumes.Get(ctx context.Context, id string) (hypeman.Volume, error) - client.Volumes.List(ctx context.Context) ([]hypeman.Volume, error) - client.Volumes.Delete(ctx context.Context, id string) error +- client.Volumes.Get(ctx context.Context, id string) (hypeman.Volume, error) + +# Ingresses + +Params Types: + +- hypeman.IngressMatchParam +- hypeman.IngressRuleParam +- hypeman.IngressTargetParam + +Response Types: + +- hypeman.Ingress +- hypeman.IngressMatch +- hypeman.IngressRule +- hypeman.IngressTarget + +Methods: + +- client.Ingresses.New(ctx context.Context, body hypeman.IngressNewParams) (hypeman.Ingress, error) +- client.Ingresses.List(ctx context.Context) ([]hypeman.Ingress, error) +- client.Ingresses.Delete(ctx context.Context, id string) error +- client.Ingresses.Get(ctx context.Context, id string) (hypeman.Ingress, error) diff --git a/client.go b/client.go index 3f05529..70aefcc 100644 --- a/client.go +++ b/client.go @@ -21,6 +21,7 @@ type Client struct { Images ImageService Instances InstanceService Volumes VolumeService + Ingresses IngressService } // DefaultClientOptions read from the environment (HYPEMAN_API_KEY, @@ -49,6 +50,7 @@ func NewClient(opts ...option.RequestOption) (r Client) { r.Images = NewImageService(opts...) r.Instances = NewInstanceService(opts...) r.Volumes = NewVolumeService(opts...) + r.Ingresses = NewIngressService(opts...) return } diff --git a/image.go b/image.go index 3026a2c..410478a 100644 --- a/image.go +++ b/image.go @@ -44,18 +44,6 @@ func (r *ImageService) New(ctx context.Context, body ImageNewParams, opts ...opt return } -// Get image details -func (r *ImageService) Get(ctx context.Context, name string, opts ...option.RequestOption) (res *Image, err error) { - opts = slices.Concat(r.Options, opts) - if name == "" { - err = errors.New("missing required name parameter") - return - } - path := fmt.Sprintf("images/%s", name) - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return -} - // List images func (r *ImageService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Image, err error) { opts = slices.Concat(r.Options, opts) @@ -77,6 +65,18 @@ func (r *ImageService) Delete(ctx context.Context, name string, opts ...option.R return } +// Get image details +func (r *ImageService) Get(ctx context.Context, name string, opts ...option.RequestOption) (res *Image, err error) { + opts = slices.Concat(r.Options, opts) + if name == "" { + err = errors.New("missing required name parameter") + return + } + path := fmt.Sprintf("images/%s", name) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + type Image struct { // Creation timestamp (RFC3339) CreatedAt time.Time `json:"created_at,required" format:"date-time"` diff --git a/image_test.go b/image_test.go index cd7536b..facbfba 100644 --- a/image_test.go +++ b/image_test.go @@ -38,7 +38,7 @@ func TestImageNew(t *testing.T) { } } -func TestImageGet(t *testing.T) { +func TestImageList(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -51,7 +51,7 @@ func TestImageGet(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Images.Get(context.TODO(), "name") + _, err := client.Images.List(context.TODO()) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -61,7 +61,7 @@ func TestImageGet(t *testing.T) { } } -func TestImageList(t *testing.T) { +func TestImageDelete(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -74,7 +74,7 @@ func TestImageList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Images.List(context.TODO()) + err := client.Images.Delete(context.TODO(), "name") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -84,7 +84,7 @@ func TestImageList(t *testing.T) { } } -func TestImageDelete(t *testing.T) { +func TestImageGet(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -97,7 +97,7 @@ func TestImageDelete(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - err := client.Images.Delete(context.TODO(), "name") + _, err := client.Images.Get(context.TODO(), "name") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/ingress.go b/ingress.go new file mode 100644 index 0000000..68a4e1d --- /dev/null +++ b/ingress.go @@ -0,0 +1,256 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "time" + + "github.com/onkernel/hypeman-go/internal/apijson" + "github.com/onkernel/hypeman-go/internal/requestconfig" + "github.com/onkernel/hypeman-go/option" + "github.com/onkernel/hypeman-go/packages/param" + "github.com/onkernel/hypeman-go/packages/respjson" +) + +// IngressService 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 [NewIngressService] method instead. +type IngressService struct { + Options []option.RequestOption +} + +// NewIngressService 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 NewIngressService(opts ...option.RequestOption) (r IngressService) { + r = IngressService{} + r.Options = opts + return +} + +// Create ingress +func (r *IngressService) New(ctx context.Context, body IngressNewParams, opts ...option.RequestOption) (res *Ingress, err error) { + opts = slices.Concat(r.Options, opts) + path := "ingresses" + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + return +} + +// List ingresses +func (r *IngressService) List(ctx context.Context, 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 +} + +// Delete ingress +func (r *IngressService) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) { + opts = slices.Concat(r.Options, opts) + opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("ingresses/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) + return +} + +// Get ingress details +func (r *IngressService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Ingress, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("ingresses/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + +type Ingress struct { + // Auto-generated unique identifier + ID string `json:"id,required"` + // Creation timestamp (RFC3339) + CreatedAt time.Time `json:"created_at,required" format:"date-time"` + // Human-readable name + Name string `json:"name,required"` + // Routing rules for this ingress + Rules []IngressRule `json:"rules,required"` + // 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 + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r Ingress) RawJSON() string { return r.JSON.raw } +func (r *Ingress) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type IngressMatch struct { + // Hostname to match (exact match on Host header) + Hostname string `json:"hostname,required"` + // Host port to listen on for this rule (default 80) + Port int64 `json:"port"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Hostname respjson.Field + Port respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r IngressMatch) RawJSON() string { return r.JSON.raw } +func (r *IngressMatch) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// ToParam converts this IngressMatch to a IngressMatchParam. +// +// 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 +// IngressMatchParam.Overrides() +func (r IngressMatch) ToParam() IngressMatchParam { + return param.Override[IngressMatchParam](json.RawMessage(r.RawJSON())) +} + +// The property Hostname is required. +type IngressMatchParam struct { + // Hostname to match (exact match on Host header) + Hostname string `json:"hostname,required"` + // Host port to listen on for this rule (default 80) + Port param.Opt[int64] `json:"port,omitzero"` + paramObj +} + +func (r IngressMatchParam) MarshalJSON() (data []byte, err error) { + type shadow IngressMatchParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *IngressMatchParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type IngressRule struct { + Match IngressMatch `json:"match,required"` + Target IngressTarget `json:"target,required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Match respjson.Field + Target respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r IngressRule) RawJSON() string { return r.JSON.raw } +func (r *IngressRule) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// ToParam converts this IngressRule to a IngressRuleParam. +// +// 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 +// IngressRuleParam.Overrides() +func (r IngressRule) ToParam() IngressRuleParam { + return param.Override[IngressRuleParam](json.RawMessage(r.RawJSON())) +} + +// The properties Match, Target are required. +type IngressRuleParam struct { + Match IngressMatchParam `json:"match,omitzero,required"` + Target IngressTargetParam `json:"target,omitzero,required"` + paramObj +} + +func (r IngressRuleParam) MarshalJSON() (data []byte, err error) { + type shadow IngressRuleParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *IngressRuleParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type IngressTarget struct { + // Target instance name or ID + Instance string `json:"instance,required"` + // Target port on the instance + Port int64 `json:"port,required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + Instance respjson.Field + Port respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r IngressTarget) RawJSON() string { return r.JSON.raw } +func (r *IngressTarget) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// ToParam converts this IngressTarget to a IngressTargetParam. +// +// 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 +// IngressTargetParam.Overrides() +func (r IngressTarget) ToParam() IngressTargetParam { + return param.Override[IngressTargetParam](json.RawMessage(r.RawJSON())) +} + +// The properties Instance, Port are required. +type IngressTargetParam struct { + // Target instance name or ID + Instance string `json:"instance,required"` + // Target port on the instance + Port int64 `json:"port,required"` + paramObj +} + +func (r IngressTargetParam) MarshalJSON() (data []byte, err error) { + type shadow IngressTargetParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *IngressTargetParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type IngressNewParams struct { + // Human-readable name (lowercase letters, digits, and dashes only; cannot start or + // end with a dash) + Name string `json:"name,required"` + // Routing rules for this ingress + Rules []IngressRuleParam `json:"rules,omitzero,required"` + paramObj +} + +func (r IngressNewParams) MarshalJSON() (data []byte, err error) { + type shadow IngressNewParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *IngressNewParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/ingress_test.go b/ingress_test.go new file mode 100644 index 0000000..ae44a04 --- /dev/null +++ b/ingress_test.go @@ -0,0 +1,118 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +package hypeman_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/onkernel/hypeman-go" + "github.com/onkernel/hypeman-go/internal/testutil" + "github.com/onkernel/hypeman-go/option" +) + +func TestIngressNew(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Ingresses.New(context.TODO(), hypeman.IngressNewParams{ + Name: "my-api-ingress", + Rules: []hypeman.IngressRuleParam{{ + Match: hypeman.IngressMatchParam{ + Hostname: "api.example.com", + Port: hypeman.Int(8080), + }, + Target: hypeman.IngressTargetParam{ + Instance: "my-api", + Port: 8080, + }, + }}, + }) + 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 TestIngressList(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Ingresses.List(context.TODO()) + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestIngressDelete(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + err := client.Ingresses.Delete(context.TODO(), "id") + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} + +func TestIngressGet(t *testing.T) { + t.Skip("Prism tests are disabled") + baseURL := "http://localhost:4010" + if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { + baseURL = envURL + } + if !testutil.CheckTestServer(t, baseURL) { + return + } + client := hypeman.NewClient( + option.WithBaseURL(baseURL), + option.WithAPIKey("My API Key"), + ) + _, err := client.Ingresses.Get(context.TODO(), "id") + if err != nil { + var apierr *hypeman.Error + if errors.As(err, &apierr) { + t.Log(string(apierr.DumpRequest(true))) + } + t.Fatalf("err should be nil: %s", err.Error()) + } +} diff --git a/instance.go b/instance.go index 69a15d7..2f9d571 100644 --- a/instance.go +++ b/instance.go @@ -4,6 +4,7 @@ package hypeman import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -49,18 +50,6 @@ func (r *InstanceService) New(ctx context.Context, body InstanceNewParams, opts return } -// Get instance details -func (r *InstanceService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { - opts = slices.Concat(r.Options, opts) - if id == "" { - err = errors.New("missing required id parameter") - return - } - path := fmt.Sprintf("instances/%s", id) - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return -} - // List instances func (r *InstanceService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Instance, err error) { opts = slices.Concat(r.Options, opts) @@ -82,6 +71,18 @@ func (r *InstanceService) Delete(ctx context.Context, id string, opts ...option. return } +// Get instance details +func (r *InstanceService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("instances/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + // Streams instance console logs as Server-Sent Events. Returns the last N lines // (controlled by `tail` parameter), then optionally continues streaming new lines // if `follow=true`. @@ -101,26 +102,26 @@ func (r *InstanceService) LogsStreaming(ctx context.Context, id string, query In return ssestream.NewStream[string](ssestream.NewDecoder(raw), err) } -// Put instance in standby (pause, snapshot, delete VMM) -func (r *InstanceService) PutInStandby(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { +// Restore instance from standby +func (r *InstanceService) Restore(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") return } - path := fmt.Sprintf("instances/%s/standby", id) + path := fmt.Sprintf("instances/%s/restore", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) return } -// Restore instance from standby -func (r *InstanceService) RestoreFromStandby(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { +// Put instance in standby (pause, snapshot, delete VMM) +func (r *InstanceService) Standby(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) { opts = slices.Concat(r.Options, opts) if id == "" { err = errors.New("missing required id parameter") return } - path := fmt.Sprintf("instances/%s/restore", id) + path := fmt.Sprintf("instances/%s/standby", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) return } @@ -163,6 +164,8 @@ type Instance struct { StoppedAt time.Time `json:"stopped_at,nullable" format:"date-time"` // 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 @@ -179,6 +182,7 @@ type Instance struct { StartedAt respjson.Field StoppedAt respjson.Field Vcpus respjson.Field + Volumes respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -236,6 +240,69 @@ func (r *InstanceNetwork) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type VolumeMount struct { + // Path where volume is mounted in the guest + MountPath string `json:"mount_path,required"` + // Volume identifier + VolumeID string `json:"volume_id,required"` + // Create per-instance overlay for writes (requires readonly=true) + Overlay bool `json:"overlay"` + // Max overlay size as human-readable string (e.g., "1GB"). Required if + // overlay=true. + OverlaySize string `json:"overlay_size"` + // Whether volume is mounted read-only + Readonly bool `json:"readonly"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + MountPath respjson.Field + VolumeID respjson.Field + Overlay respjson.Field + OverlaySize respjson.Field + Readonly respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r VolumeMount) RawJSON() string { return r.JSON.raw } +func (r *VolumeMount) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +// ToParam converts this VolumeMount to a VolumeMountParam. +// +// 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 +// VolumeMountParam.Overrides() +func (r VolumeMount) ToParam() VolumeMountParam { + return param.Override[VolumeMountParam](json.RawMessage(r.RawJSON())) +} + +// The properties MountPath, VolumeID are required. +type VolumeMountParam struct { + // Path where volume is mounted in the guest + MountPath string `json:"mount_path,required"` + // Volume identifier + VolumeID string `json:"volume_id,required"` + // Create per-instance overlay for writes (requires readonly=true) + Overlay param.Opt[bool] `json:"overlay,omitzero"` + // Max overlay size as human-readable string (e.g., "1GB"). Required if + // overlay=true. + OverlaySize param.Opt[string] `json:"overlay_size,omitzero"` + // Whether volume is mounted read-only + Readonly param.Opt[bool] `json:"readonly,omitzero"` + paramObj +} + +func (r VolumeMountParam) MarshalJSON() (data []byte, err error) { + type shadow VolumeMountParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *VolumeMountParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type InstanceNewParams struct { // OCI image reference Image string `json:"image,required"` @@ -254,6 +321,8 @@ type InstanceNewParams struct { Env map[string]string `json:"env,omitzero"` // Network configuration for the instance Network InstanceNewParamsNetwork `json:"network,omitzero"` + // Volumes to attach to the instance at creation time + Volumes []VolumeMountParam `json:"volumes,omitzero"` paramObj } diff --git a/instance_test.go b/instance_test.go index d88c1a6..d8ba3a7 100644 --- a/instance_test.go +++ b/instance_test.go @@ -40,6 +40,13 @@ func TestInstanceNewWithOptionalParams(t *testing.T) { OverlaySize: hypeman.String("20GB"), Size: hypeman.String("2GB"), Vcpus: hypeman.Int(2), + Volumes: []hypeman.VolumeMountParam{{ + MountPath: "/mnt/data", + VolumeID: "vol-abc123", + Overlay: hypeman.Bool(true), + OverlaySize: hypeman.String("1GB"), + Readonly: hypeman.Bool(true), + }}, }) if err != nil { var apierr *hypeman.Error @@ -50,7 +57,7 @@ func TestInstanceNewWithOptionalParams(t *testing.T) { } } -func TestInstanceGet(t *testing.T) { +func TestInstanceList(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -63,7 +70,7 @@ func TestInstanceGet(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Instances.Get(context.TODO(), "id") + _, err := client.Instances.List(context.TODO()) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -73,7 +80,7 @@ func TestInstanceGet(t *testing.T) { } } -func TestInstanceList(t *testing.T) { +func TestInstanceDelete(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -86,7 +93,7 @@ func TestInstanceList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Instances.List(context.TODO()) + err := client.Instances.Delete(context.TODO(), "id") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -96,7 +103,7 @@ func TestInstanceList(t *testing.T) { } } -func TestInstanceDelete(t *testing.T) { +func TestInstanceGet(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -109,7 +116,7 @@ func TestInstanceDelete(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - err := client.Instances.Delete(context.TODO(), "id") + _, err := client.Instances.Get(context.TODO(), "id") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -119,7 +126,7 @@ func TestInstanceDelete(t *testing.T) { } } -func TestInstancePutInStandby(t *testing.T) { +func TestInstanceRestore(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -132,7 +139,7 @@ func TestInstancePutInStandby(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Instances.PutInStandby(context.TODO(), "id") + _, err := client.Instances.Restore(context.TODO(), "id") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -142,7 +149,7 @@ func TestInstancePutInStandby(t *testing.T) { } } -func TestInstanceRestoreFromStandby(t *testing.T) { +func TestInstanceStandby(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -155,7 +162,7 @@ func TestInstanceRestoreFromStandby(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Instances.RestoreFromStandby(context.TODO(), "id") + _, err := client.Instances.Standby(context.TODO(), "id") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { diff --git a/internal/version.go b/internal/version.go index 5c62cac..67c4d40 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.4.0" // x-release-please-version +const PackageVersion = "0.5.0" // x-release-please-version diff --git a/volume.go b/volume.go index e99cbb5..e68abff 100644 --- a/volume.go +++ b/volume.go @@ -36,7 +36,11 @@ func NewVolumeService(opts ...option.RequestOption) (r VolumeService) { return } -// Create volume +// Creates a new volume. Supports two modes: +// +// - JSON body: Creates an empty volume of the specified size +// - Multipart form: Creates a volume pre-populated with content from a tar.gz +// archive func (r *VolumeService) New(ctx context.Context, body VolumeNewParams, opts ...option.RequestOption) (res *Volume, err error) { opts = slices.Concat(r.Options, opts) path := "volumes" @@ -44,18 +48,6 @@ func (r *VolumeService) New(ctx context.Context, body VolumeNewParams, opts ...o return } -// Get volume details -func (r *VolumeService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Volume, err error) { - opts = slices.Concat(r.Options, opts) - if id == "" { - err = errors.New("missing required id parameter") - return - } - path := fmt.Sprintf("volumes/%s", id) - err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) - return -} - // List volumes func (r *VolumeService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Volume, err error) { opts = slices.Concat(r.Options, opts) @@ -77,6 +69,18 @@ func (r *VolumeService) Delete(ctx context.Context, id string, opts ...option.Re return } +// Get volume details +func (r *VolumeService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Volume, err error) { + opts = slices.Concat(r.Options, opts) + if id == "" { + err = errors.New("missing required id parameter") + return + } + path := fmt.Sprintf("volumes/%s", id) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return +} + type Volume struct { // Unique identifier ID string `json:"id,required"` @@ -86,18 +90,15 @@ type Volume struct { Name string `json:"name,required"` // Size in gigabytes SizeGB int64 `json:"size_gb,required"` - // Instance ID if attached - AttachedTo string `json:"attached_to,nullable"` - // Mount path if attached - MountPath string `json:"mount_path,nullable"` + // List of current attachments (empty if not attached) + Attachments []VolumeAttachment `json:"attachments"` // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. JSON struct { ID respjson.Field CreatedAt respjson.Field Name respjson.Field SizeGB respjson.Field - AttachedTo respjson.Field - MountPath respjson.Field + Attachments respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -109,6 +110,29 @@ func (r *Volume) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +type VolumeAttachment struct { + // ID of the instance this volume is attached to + InstanceID string `json:"instance_id,required"` + // Mount path in the guest + MountPath string `json:"mount_path,required"` + // Whether the attachment is read-only + Readonly bool `json:"readonly,required"` + // JSON contains metadata for fields, check presence with [respjson.Field.Valid]. + JSON struct { + InstanceID respjson.Field + MountPath respjson.Field + Readonly respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +// Returns the unmodified JSON received from the API +func (r VolumeAttachment) RawJSON() string { return r.JSON.raw } +func (r *VolumeAttachment) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + type VolumeNewParams struct { // Volume name Name string `json:"name,required"` diff --git a/volume_test.go b/volume_test.go index 26f2106..2528263 100644 --- a/volume_test.go +++ b/volume_test.go @@ -40,7 +40,7 @@ func TestVolumeNewWithOptionalParams(t *testing.T) { } } -func TestVolumeGet(t *testing.T) { +func TestVolumeList(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -53,7 +53,7 @@ func TestVolumeGet(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Volumes.Get(context.TODO(), "id") + _, err := client.Volumes.List(context.TODO()) if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -63,7 +63,7 @@ func TestVolumeGet(t *testing.T) { } } -func TestVolumeList(t *testing.T) { +func TestVolumeDelete(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -76,7 +76,7 @@ func TestVolumeList(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - _, err := client.Volumes.List(context.TODO()) + err := client.Volumes.Delete(context.TODO(), "id") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) { @@ -86,7 +86,7 @@ func TestVolumeList(t *testing.T) { } } -func TestVolumeDelete(t *testing.T) { +func TestVolumeGet(t *testing.T) { t.Skip("Prism tests are disabled") baseURL := "http://localhost:4010" if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok { @@ -99,7 +99,7 @@ func TestVolumeDelete(t *testing.T) { option.WithBaseURL(baseURL), option.WithAPIKey("My API Key"), ) - err := client.Volumes.Delete(context.TODO(), "id") + _, err := client.Volumes.Get(context.TODO(), "id") if err != nil { var apierr *hypeman.Error if errors.As(err, &apierr) {