From fe2a730c669384e011da1fd057d5f32e109ac578 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 17:37:20 +0800 Subject: [PATCH 1/8] feat(go): add Go SDK for Phala Cloud API Flat package `phala` with functional options client, covering all non-on-chain-KMS API surface: - Auth, CVMs (CRUD + lifecycle + patch), compose/config management - KMS, SSH keys, workspaces, apps, nodes, instance types, OS images - SSE streaming (WatchCVMState), retry with exponential backoff - CVM ID resolution (int, UUID, app_id, hashed, name) - X25519+AES-GCM env encryption for UpdateCVMEnvs/PatchCVM - Full E2E test suite (go:build e2e) matching Python SDK coverage --- go/apps.go | 93 +++++ go/auth.go | 12 + go/client.go | 56 +++ go/client_options.go | 49 +++ go/cvms.go | 216 +++++++++++ go/cvms_compose.go | 112 ++++++ go/cvms_config.go | 100 ++++++ go/cvms_watch.go | 135 +++++++ go/doc.go | 16 + go/e2e_test.go | 709 +++++++++++++++++++++++++++++++++++++ go/errors.go | 81 +++++ go/go.mod | 8 + go/go.sum | 4 + go/helpers.go | 49 +++ go/instance_types.go | 21 ++ go/kms.go | 52 +++ go/nodes.go | 12 + go/os_images.go | 12 + go/request.go | 173 +++++++++ go/retry.go | 46 +++ go/ssh_keys.go | 45 +++ go/status.go | 13 + go/types_apps.go | 97 +++++ go/types_auth.go | 38 ++ go/types_common.go | 50 +++ go/types_cvms.go | 338 ++++++++++++++++++ go/types_instance_types.go | 7 + go/types_kms.go | 52 +++ go/types_nodes.go | 43 +++ go/types_os_images.go | 4 + go/types_ssh_keys.go | 21 ++ go/types_workspaces.go | 19 + go/version.go | 12 + go/workspaces.go | 56 +++ 34 files changed, 2751 insertions(+) create mode 100644 go/apps.go create mode 100644 go/auth.go create mode 100644 go/client.go create mode 100644 go/client_options.go create mode 100644 go/cvms.go create mode 100644 go/cvms_compose.go create mode 100644 go/cvms_config.go create mode 100644 go/cvms_watch.go create mode 100644 go/doc.go create mode 100644 go/e2e_test.go create mode 100644 go/errors.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/helpers.go create mode 100644 go/instance_types.go create mode 100644 go/kms.go create mode 100644 go/nodes.go create mode 100644 go/os_images.go create mode 100644 go/request.go create mode 100644 go/retry.go create mode 100644 go/ssh_keys.go create mode 100644 go/status.go create mode 100644 go/types_apps.go create mode 100644 go/types_auth.go create mode 100644 go/types_common.go create mode 100644 go/types_cvms.go create mode 100644 go/types_instance_types.go create mode 100644 go/types_kms.go create mode 100644 go/types_nodes.go create mode 100644 go/types_os_images.go create mode 100644 go/types_ssh_keys.go create mode 100644 go/types_workspaces.go create mode 100644 go/version.go create mode 100644 go/workspaces.go diff --git a/go/apps.go b/go/apps.go new file mode 100644 index 00000000..9ad68e5e --- /dev/null +++ b/go/apps.go @@ -0,0 +1,93 @@ +package phala + +import ( + "context" + "fmt" + "net/url" +) + +// GetAppList returns the list of applications. +func (c *Client) GetAppList(ctx context.Context) (*GetAppListResponse, error) { + var result GetAppListResponse + if err := c.doJSON(ctx, "GET", "/apps", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppInfo returns information about a specific application. +func (c *Client) GetAppInfo(ctx context.Context, appID string) (*AppInfo, error) { + var result AppInfo + if err := c.doJSON(ctx, "GET", "/apps/"+appID, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppCVMs returns CVMs associated with an application. +func (c *Client) GetAppCVMs(ctx context.Context, appID string) ([]GenericObject, error) { + var result []GenericObject + if err := c.doJSON(ctx, "GET", "/apps/"+appID+"/cvms", nil, &result); err != nil { + return nil, err + } + return result, nil +} + +// GetAppRevisions returns revisions for an application. +func (c *Client) GetAppRevisions(ctx context.Context, appID string, opts *PaginationOptions) (*AppRevisionsResponse, error) { + path := "/apps/" + appID + "/revisions" + if opts != nil { + q := url.Values{} + if opts.Page != nil { + q.Set("page", fmt.Sprintf("%d", *opts.Page)) + } + if opts.PageSize != nil { + q.Set("page_size", fmt.Sprintf("%d", *opts.PageSize)) + } + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + } + var result AppRevisionsResponse + if err := c.doJSON(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppRevisionDetail returns detailed information about a specific revision. +func (c *Client) GetAppRevisionDetail(ctx context.Context, appID, revisionID string) (*AppRevisionDetail, error) { + var result AppRevisionDetail + path := fmt.Sprintf("/apps/%s/revisions/%s", appID, revisionID) + if err := c.doJSON(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppAttestation returns attestation data for an application. +func (c *Client) GetAppAttestation(ctx context.Context, appID string) (*AppAttestationResponse, error) { + var result AppAttestationResponse + if err := c.doJSON(ctx, "GET", "/apps/"+appID+"/attestations", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppDeviceAllowlist returns the device allowlist for an application. +func (c *Client) GetAppDeviceAllowlist(ctx context.Context, appID string) (*DeviceAllowlistResponse, error) { + var result DeviceAllowlistResponse + if err := c.doJSON(ctx, "GET", "/apps/"+appID+"/device-allowlist", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppFilterOptions returns filter options for application listings. +func (c *Client) GetAppFilterOptions(ctx context.Context) (*AppFilterOptions, error) { + var result AppFilterOptions + if err := c.doJSON(ctx, "GET", "/apps/filter-options", nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/auth.go b/go/auth.go new file mode 100644 index 00000000..7da1bf47 --- /dev/null +++ b/go/auth.go @@ -0,0 +1,12 @@ +package phala + +import "context" + +// GetCurrentUser returns the currently authenticated user. +func (c *Client) GetCurrentUser(ctx context.Context) (*CurrentUser, error) { + var result CurrentUser + if err := c.doJSON(ctx, "GET", "/auth/me", nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/client.go b/go/client.go new file mode 100644 index 00000000..7d762408 --- /dev/null +++ b/go/client.go @@ -0,0 +1,56 @@ +package phala + +import ( + "fmt" + "net/http" + "os" + "strings" +) + +// Client is the Phala Cloud API client. +type Client struct { + baseURL string + apiKey string + apiVersion string + httpClient *http.Client + userAgent string + headers map[string]string + maxRetries int +} + +// NewClient creates a new Phala Cloud API client with the given options. +// API key is resolved from options or the PHALA_CLOUD_API_KEY environment variable. +// Base URL is resolved from options or the PHALA_CLOUD_API_PREFIX environment variable. +func NewClient(opts ...Option) (*Client, error) { + c := &Client{ + baseURL: DefaultBaseURL, + apiVersion: DefaultAPIVersion, + httpClient: &http.Client{}, + userAgent: "phala-cloud-sdk-go/" + sdkVersion, + headers: make(map[string]string), + maxRetries: 30, + } + + for _, opt := range opts { + opt(c) + } + + // Environment variable fallbacks. + if c.apiKey == "" { + c.apiKey = os.Getenv("PHALA_CLOUD_API_KEY") + } + if envURL := os.Getenv("PHALA_CLOUD_API_PREFIX"); envURL != "" { + // Only use env URL if no explicit option was set (check if still default). + if c.baseURL == DefaultBaseURL { + c.baseURL = envURL + } + } + + if c.apiKey == "" { + return nil, fmt.Errorf("phala: API key is required (set via WithAPIKey or PHALA_CLOUD_API_KEY)") + } + + c.baseURL = strings.TrimRight(c.baseURL, "/") + + return c, nil +} diff --git a/go/client_options.go b/go/client_options.go new file mode 100644 index 00000000..39ae152f --- /dev/null +++ b/go/client_options.go @@ -0,0 +1,49 @@ +package phala + +import ( + "net/http" + "time" +) + +// Option configures the Client. +type Option func(*Client) + +// WithAPIKey sets the API key for authentication. +func WithAPIKey(key string) Option { + return func(c *Client) { c.apiKey = key } +} + +// WithBaseURL sets the base URL for the API. +func WithBaseURL(url string) Option { + return func(c *Client) { c.baseURL = url } +} + +// WithAPIVersion sets the API version header value. +func WithAPIVersion(v string) Option { + return func(c *Client) { c.apiVersion = v } +} + +// WithHTTPClient sets a custom HTTP client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { c.httpClient = hc } +} + +// WithTimeout sets the HTTP client timeout. +func WithTimeout(d time.Duration) Option { + return func(c *Client) { c.httpClient.Timeout = d } +} + +// WithUserAgent sets a custom User-Agent header. +func WithUserAgent(ua string) Option { + return func(c *Client) { c.userAgent = ua } +} + +// WithHeader adds a custom header to all requests. +func WithHeader(key, value string) Option { + return func(c *Client) { c.headers[key] = value } +} + +// WithMaxRetries sets the maximum number of retries for retryable errors. +func WithMaxRetries(n int) Option { + return func(c *Client) { c.maxRetries = n } +} diff --git a/go/cvms.go b/go/cvms.go new file mode 100644 index 00000000..b5dafb3f --- /dev/null +++ b/go/cvms.go @@ -0,0 +1,216 @@ +package phala + +import ( + "context" + "fmt" + "net/url" +) + +// cvmPath returns the API path for a CVM, resolving the ID format. +func cvmPath(cvmID string, subpath ...string) string { + p := "/cvms/" + ResolveCVMID(cvmID) + for _, s := range subpath { + p += "/" + s + } + return p +} + +// ProvisionCVM provisions a new CVM. +func (c *Client) ProvisionCVM(ctx context.Context, req *ProvisionCVMRequest) (*ProvisionCVMResponse, error) { + var result ProvisionCVMResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "POST", "/cvms/provision", req, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// CommitCVMProvision commits a provisioned CVM. +func (c *Client) CommitCVMProvision(ctx context.Context, req *CommitCVMProvisionRequest) (*CommitCVMProvisionResponse, error) { + var result CommitCVMProvisionResponse + if err := c.doJSON(ctx, "POST", "/cvms", req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMList returns a paginated list of CVMs. +func (c *Client) GetCVMList(ctx context.Context, opts *GetCVMListOptions) (*PaginatedCVMInfos, error) { + path := "/cvms/paginated" + if opts != nil { + q := url.Values{} + if opts.Page != nil { + q.Set("page", fmt.Sprintf("%d", *opts.Page)) + } + if opts.PageSize != nil { + q.Set("page_size", fmt.Sprintf("%d", *opts.PageSize)) + } + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + } + var result PaginatedCVMInfos + if err := c.doJSON(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMInfo returns detailed information about a CVM. +func (c *Client) GetCVMInfo(ctx context.Context, cvmID string) (*CVMInfo, error) { + var result CVMInfo + if err := c.doJSON(ctx, "GET", cvmPath(cvmID), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMState returns the current state of a CVM. +func (c *Client) GetCVMState(ctx context.Context, cvmID string) (*CVMState, error) { + var result CVMState + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "state"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMStats returns statistics for a CVM. +func (c *Client) GetCVMStats(ctx context.Context, cvmID string) (*CVMStats, error) { + var result CVMStats + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "stats"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMNetwork returns network information for a CVM. +func (c *Client) GetCVMNetwork(ctx context.Context, cvmID string) (*CVMNetwork, error) { + var result CVMNetwork + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "network"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMContainersStats returns container statistics for a CVM. +func (c *Client) GetCVMContainersStats(ctx context.Context, cvmID string) (*CVMContainersStats, error) { + var result CVMContainersStats + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "composition"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMAttestation returns attestation data for a CVM. +func (c *Client) GetCVMAttestation(ctx context.Context, cvmID string) (*CVMAttestation, error) { + var result CVMAttestation + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "attestation"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMUserConfig returns user configuration for a CVM. +func (c *Client) GetCVMUserConfig(ctx context.Context, cvmID string) (*CVMUserConfig, error) { + var result CVMUserConfig + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "user_config"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAvailableOSImages returns available OS images for a CVM. +func (c *Client) GetAvailableOSImages(ctx context.Context, cvmID string) ([]GenericObject, error) { + var result []GenericObject + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "available-os-images"), nil, &result); err != nil { + return nil, err + } + return result, nil +} + +// StartCVM starts a CVM. +func (c *Client) StartCVM(ctx context.Context, cvmID string) (*CVMActionResponse, error) { + var result CVMActionResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "POST", cvmPath(cvmID, "start"), nil, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// StopCVM stops a CVM. +func (c *Client) StopCVM(ctx context.Context, cvmID string) (*CVMActionResponse, error) { + var result CVMActionResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "POST", cvmPath(cvmID, "stop"), nil, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// ShutdownCVM gracefully shuts down a CVM. +func (c *Client) ShutdownCVM(ctx context.Context, cvmID string) (*CVMActionResponse, error) { + var result CVMActionResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "POST", cvmPath(cvmID, "shutdown"), nil, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// RestartCVM restarts a CVM. +func (c *Client) RestartCVM(ctx context.Context, cvmID string) (*CVMActionResponse, error) { + var result CVMActionResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "POST", cvmPath(cvmID, "restart"), map[string]bool{"force": true}, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// DeleteCVM deletes a CVM. +func (c *Client) DeleteCVM(ctx context.Context, cvmID string) error { + return c.doWithRetry(ctx, func() error { + return c.doEmpty(ctx, "DELETE", cvmPath(cvmID)) + }) +} + +// ReplicateCVM replicates a CVM to another node. +func (c *Client) ReplicateCVM(ctx context.Context, cvmID string, opts *ReplicateCVMOptions) (*CVMActionResponse, error) { + var result CVMActionResponse + if err := c.doJSON(ctx, "POST", cvmPath(cvmID, "replicas"), opts, &result); err != nil { + return nil, err + } + return &result, nil +} + +// PatchCVM applies a multi-field patch to a CVM. +func (c *Client) PatchCVM(ctx context.Context, cvmID string, req *PatchCVMRequest) (*PatchCVMResponse, error) { + var result PatchCVMResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID), req, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// ConfirmCVMPatch confirms a CVM patch with on-chain transaction hash. +func (c *Client) ConfirmCVMPatch(ctx context.Context, cvmID string, req *ConfirmCVMPatchRequest) (*CVMActionResponse, error) { + var result CVMActionResponse + if err := c.doJSON(ctx, "PATCH", cvmPath(cvmID), req, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/cvms_compose.go b/go/cvms_compose.go new file mode 100644 index 00000000..798597c6 --- /dev/null +++ b/go/cvms_compose.go @@ -0,0 +1,112 @@ +package phala + +import "context" + +// GetCVMComposeFile returns the compose file for a CVM. +func (c *Client) GetCVMComposeFile(ctx context.Context, cvmID string) (*ComposeFile, error) { + var result ComposeFile + if err := c.doJSON(ctx, "GET", cvmPath(cvmID, "compose_file"), nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetCVMDockerCompose returns the raw docker-compose YAML for a CVM. +func (c *Client) GetCVMDockerCompose(ctx context.Context, cvmID string) (string, error) { + var result string + err := c.doText(ctx, "GET", cvmPath(cvmID, "docker-compose.yml"), "", "", nil, &result) + return result, err +} + +// GetCVMPreLaunchScript returns the pre-launch script for a CVM. +func (c *Client) GetCVMPreLaunchScript(ctx context.Context, cvmID string) (string, error) { + var result string + err := c.doText(ctx, "GET", cvmPath(cvmID, "pre-launch-script"), "", "", nil, &result) + return result, err +} + +// ProvisionComposeUpdateRequest is the request for provisioning a compose file update. +type ProvisionComposeUpdateRequest struct { + DockerComposeFile string `json:"docker_compose_file"` + GatewayEnabled *bool `json:"gateway_enabled,omitempty"` + PreLaunchScript *string `json:"pre_launch_script,omitempty"` + EncryptedEnv *string `json:"encrypted_env,omitempty"` + EnvKeys *string `json:"env_keys,omitempty"` +} + +// ProvisionCVMComposeFileUpdate provisions a compose file update. +func (c *Client) ProvisionCVMComposeFileUpdate(ctx context.Context, cvmID string, req *ProvisionComposeUpdateRequest) (*ProvisionCVMResponse, error) { + var result ProvisionCVMResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "POST", cvmPath(cvmID, "compose_file", "provision"), req, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// CommitComposeUpdateRequest is the request for committing a compose file update. +type CommitComposeUpdateRequest struct { + ComposeHash string `json:"compose_hash"` + EncryptedEnv *string `json:"encrypted_env,omitempty"` + EnvKeys *string `json:"env_keys,omitempty"` + UpdateEnvVars *bool `json:"update_env_vars,omitempty"` +} + +// CommitCVMComposeFileUpdate commits a compose file update. +func (c *Client) CommitCVMComposeFileUpdate(ctx context.Context, cvmID string, req *CommitComposeUpdateRequest) error { + return c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "compose_file"), req, nil) + }) +} + +// ComposeUpdateOptions holds optional headers for compose update operations. +type ComposeUpdateOptions struct { + ComposeHash string + TransactionHash string +} + +// UpdateDockerCompose updates the docker-compose YAML for a CVM. +func (c *Client) UpdateDockerCompose(ctx context.Context, cvmID, compose string, opts *ComposeUpdateOptions) (*UpdateResult, error) { + headers := map[string]string{} + if opts != nil { + if opts.ComposeHash != "" { + headers["X-Compose-Hash"] = opts.ComposeHash + } + if opts.TransactionHash != "" { + headers["X-Transaction-Hash"] = opts.TransactionHash + } + } + + var result UpdateResult + err := c.doWithRetry(ctx, func() error { + return c.doText(ctx, "PATCH", cvmPath(cvmID, "docker-compose"), "text/yaml", compose, headers, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// UpdatePreLaunchScript updates the pre-launch script for a CVM. +func (c *Client) UpdatePreLaunchScript(ctx context.Context, cvmID, script string, opts *ComposeUpdateOptions) (*UpdateResult, error) { + headers := map[string]string{} + if opts != nil { + if opts.ComposeHash != "" { + headers["X-Compose-Hash"] = opts.ComposeHash + } + if opts.TransactionHash != "" { + headers["X-Transaction-Hash"] = opts.TransactionHash + } + } + + var result UpdateResult + err := c.doWithRetry(ctx, func() error { + return c.doText(ctx, "PATCH", cvmPath(cvmID, "pre-launch-script"), "text/plain", script, headers, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/cvms_config.go b/go/cvms_config.go new file mode 100644 index 00000000..14619284 --- /dev/null +++ b/go/cvms_config.go @@ -0,0 +1,100 @@ +package phala + +import "context" + +// UpdateResourcesRequest is the request for updating CVM resources. +type UpdateResourcesRequest struct { + VCPU *int `json:"vcpu,omitempty"` + Memory *int `json:"memory,omitempty"` + DiskSize *int `json:"disk_size,omitempty"` + InstanceType *string `json:"instance_type,omitempty"` + AllowRestart *bool `json:"allow_restart,omitempty"` +} + +// UpdateCVMResources updates the resources for a CVM. +func (c *Client) UpdateCVMResources(ctx context.Context, cvmID string, req *UpdateResourcesRequest) error { + return c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "resources"), req, nil) + }) +} + +// UpdateVisibilityRequest is the request for updating CVM visibility. +type UpdateVisibilityRequest struct { + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` +} + +// UpdateCVMVisibility updates the visibility settings for a CVM. +func (c *Client) UpdateCVMVisibility(ctx context.Context, cvmID string, req *UpdateVisibilityRequest) (*CVMVisibility, error) { + var result CVMVisibility + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "visibility"), req, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// UpdateOSImageRequest is the request for updating CVM OS image. +type UpdateOSImageRequest struct { + OSImageName string `json:"os_image_name"` +} + +// UpdateOSImage updates the OS image for a CVM. +func (c *Client) UpdateOSImage(ctx context.Context, cvmID string, req *UpdateOSImageRequest) error { + return c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "os-image"), req, nil) + }) +} + +// RefreshInstanceIDResponse is the response for refreshing a CVM instance ID. +type RefreshInstanceIDResponse = GenericObject + +// RefreshCVMInstanceID refreshes the instance ID for a CVM. +func (c *Client) RefreshCVMInstanceID(ctx context.Context, cvmID string) (*RefreshInstanceIDResponse, error) { + var result RefreshInstanceIDResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "instance-id"), nil, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// RefreshInstanceIDsResponse is the response for refreshing all CVM instance IDs. +type RefreshInstanceIDsResponse = GenericObject + +// RefreshCVMInstanceIDs refreshes instance IDs for all CVMs. +func (c *Client) RefreshCVMInstanceIDs(ctx context.Context) (*RefreshInstanceIDsResponse, error) { + var result RefreshInstanceIDsResponse + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", "/cvms/instance-ids", map[string]any{}, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// UpdateEnvsRequest is the request for updating CVM environment variables. +type UpdateEnvsRequest struct { + EncryptedEnv string `json:"encrypted_env"` + EnvKeys *string `json:"env_keys,omitempty"` + ComposeHash *string `json:"compose_hash,omitempty"` + TransactionHash *string `json:"transaction_hash,omitempty"` +} + +// UpdateCVMEnvs updates the encrypted environment variables for a CVM. +func (c *Client) UpdateCVMEnvs(ctx context.Context, cvmID string, req *UpdateEnvsRequest) (*UpdateResult, error) { + var result UpdateResult + err := c.doWithRetry(ctx, func() error { + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "envs"), req, &result) + }) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/cvms_watch.go b/go/cvms_watch.go new file mode 100644 index 00000000..7aa58f3e --- /dev/null +++ b/go/cvms_watch.go @@ -0,0 +1,135 @@ +package phala + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// CVMStateEvent represents an event from the CVM state SSE stream. +type CVMStateEvent struct { + Event string `json:"event"` + Data GenericObject `json:"data,omitempty"` + Error error `json:"-"` +} + +// WatchCVMStateOptions holds options for watching CVM state. +type WatchCVMStateOptions struct { + Target string + Interval int // 5-30 seconds + Timeout int // 10-600 seconds + MaxRetries int + RetryDelay time.Duration +} + +// WatchCVMState watches CVM state changes via SSE. Returns a channel that emits state events. +// The channel is closed when the stream ends or context is cancelled. +func (c *Client) WatchCVMState(ctx context.Context, cvmID string, opts *WatchCVMStateOptions) (<-chan CVMStateEvent, error) { + if opts == nil { + opts = &WatchCVMStateOptions{} + } + if opts.Interval == 0 { + opts.Interval = 5 + } + if opts.Timeout == 0 { + opts.Timeout = 60 + } + if opts.RetryDelay == 0 { + opts.RetryDelay = 5 * time.Second + } + + path := fmt.Sprintf("%s?interval=%d&timeout=%d", cvmPath(cvmID, "state"), opts.Interval, opts.Timeout) + if opts.Target != "" { + path += "&target=" + opts.Target + } + + ch := make(chan CVMStateEvent, 16) + + go func() { + defer close(ch) + retries := 0 + + for { + err := c.streamSSE(ctx, path, ch) + if err == nil || ctx.Err() != nil { + return + } + + if opts.MaxRetries >= 0 && retries >= opts.MaxRetries { + ch <- CVMStateEvent{Event: "error", Error: err} + return + } + retries++ + + select { + case <-ctx.Done(): + return + case <-time.After(opts.RetryDelay): + } + } + }() + + return ch, nil +} + +func (c *Client) streamSSE(ctx context.Context, path string, ch chan<- CVMStateEvent) error { + req, err := c.newRequest(ctx, "GET", path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("SSE stream returned status %d", resp.StatusCode) + } + + scanner := bufio.NewScanner(resp.Body) + var eventType, data string + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + // Empty line = end of event. + if eventType != "" || data != "" { + evt := CVMStateEvent{Event: eventType} + if data != "" { + var obj GenericObject + if json.Unmarshal([]byte(data), &obj) == nil { + evt.Data = obj + } + } + select { + case ch <- evt: + case <-ctx.Done(): + return ctx.Err() + } + + if eventType == "complete" || eventType == "error" || eventType == "timeout" { + return nil + } + } + eventType = "" + data = "" + continue + } + + if strings.HasPrefix(line, "event:") { + eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + } else if strings.HasPrefix(line, "data:") { + data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + } + + return scanner.Err() +} diff --git a/go/doc.go b/go/doc.go new file mode 100644 index 00000000..57caba80 --- /dev/null +++ b/go/doc.go @@ -0,0 +1,16 @@ +// Package phala provides a Go client for the Phala Cloud API. +// +// Usage: +// +// client, err := phala.NewClient( +// phala.WithAPIKey("your-api-key"), +// ) +// if err != nil { +// log.Fatal(err) +// } +// +// user, err := client.GetCurrentUser(context.Background()) +// if err != nil { +// log.Fatal(err) +// } +package phala diff --git a/go/e2e_test.go b/go/e2e_test.go new file mode 100644 index 00000000..020bc267 --- /dev/null +++ b/go/e2e_test.go @@ -0,0 +1,709 @@ +//go:build e2e + +package phala + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +const testCompose = `services: + app: + image: ghcr.io/phala-network/phala-cloud-bun-starter:latest + restart: unless-stopped + ports: + - "80:3000" + volumes: + - /var/run/tappd.sock:/var/run/tappd.sock + - /var/run/dstack.sock:/var/run/dstack.sock +` + +// transientStates are CVM states that indicate an operation is in progress. +var transientStates = map[string]bool{ + "starting": true, + "stopping": true, + "restarting": true, + "shutting_down": true, + "provisioning": true, + "in_progress": true, + "updating": true, +} + +func mustEnv(t *testing.T, key, fallback string) string { + t.Helper() + v := os.Getenv(key) + if v == "" { + v = fallback + } + if v == "" { + t.Fatalf("environment variable %s is required", key) + } + if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") && key == "PHALA_CLOUD_E2E_BASE_URL" { + v = "https://" + v + } + return v +} + +func genCVMName() string { + b := make([]byte, 4) + rand.Read(b) + return "e2e-test-" + hex.EncodeToString(b) +} + +// encryptEnvVars encrypts environment variables using X25519 + AES-256-GCM, +// matching dstack_sdk.encrypt_env_vars format. +// Returns hex(ephemeral_pubkey || iv || ciphertext). +func encryptEnvVars(t *testing.T, serverPubkeyHex string, envs map[string]string) string { + t.Helper() + + serverPubkeyBytes, err := hex.DecodeString(serverPubkeyHex) + if err != nil { + t.Fatalf("decode server pubkey: %v", err) + } + + serverPubkey, err := ecdh.X25519().NewPublicKey(serverPubkeyBytes) + if err != nil { + t.Fatalf("parse server pubkey: %v", err) + } + + // Generate ephemeral X25519 keypair. + ephemeralPriv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ephemeral key: %v", err) + } + + // X25519 key exchange. + sharedSecret, err := ephemeralPriv.ECDH(serverPubkey) + if err != nil { + t.Fatalf("ECDH: %v", err) + } + + // Build JSON payload: {"env": [{"key": "...", "value": "..."}, ...]} + type envVar struct { + Key string `json:"key"` + Value string `json:"value"` + } + var envList []envVar + for k, v := range envs { + envList = append(envList, envVar{Key: k, Value: v}) + } + payload, err := json.Marshal(map[string][]envVar{"env": envList}) + if err != nil { + t.Fatalf("marshal env payload: %v", err) + } + + // AES-256-GCM encrypt. + block, err := aes.NewCipher(sharedSecret) + if err != nil { + t.Fatalf("new AES cipher: %v", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatalf("new GCM: %v", err) + } + + iv := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(iv); err != nil { + t.Fatalf("generate IV: %v", err) + } + + ciphertext := gcm.Seal(nil, iv, payload, nil) + + // Concatenate: ephemeral_pubkey || iv || ciphertext + result := make([]byte, 0, len(ephemeralPriv.PublicKey().Bytes())+len(iv)+len(ciphertext)) + result = append(result, ephemeralPriv.PublicKey().Bytes()...) + result = append(result, iv...) + result = append(result, ciphertext...) + + return hex.EncodeToString(result) +} + +func newE2EClient(t *testing.T) *Client { + t.Helper() + apiKey := mustEnv(t, "PHALA_CLOUD_E2E_API_KEY", "") + baseURL := mustEnv(t, "PHALA_CLOUD_E2E_BASE_URL", "https://cloud.phala.network/api/v1") + client, err := NewClient( + WithAPIKey(apiKey), + WithBaseURL(baseURL), + ) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + return client +} + +func waitIdle(t *testing.T, client *Client, cvmID string, timeout time.Duration) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + start := time.Now() + lastLog := start + + for { + info, err := client.GetCVMInfo(ctx, cvmID) + if err != nil { + t.Logf("waitIdle: GetCVMInfo error: %v", err) + } else { + status := info.Status + hasProgress := info.Progress != nil && info.Progress.Target != nil && *info.Progress.Target != "" + + if now := time.Now(); now.Sub(lastLog) >= 30*time.Second { + target := "" + if info.Progress != nil && info.Progress.Target != nil { + target = *info.Progress.Target + } + t.Logf("waitIdle: status=%s progress_target=%s elapsed=%s", status, target, now.Sub(start).Round(time.Second)) + lastLog = now + } + + if !transientStates[status] && !hasProgress { + return + } + } + + select { + case <-ctx.Done(): + t.Fatalf("waitIdle: timed out after %s", timeout) + case <-time.After(5 * time.Second): + } + } +} + +func assertIdle(t *testing.T, client *Client, cvmID, label string) { + t.Helper() + ctx := context.Background() + info, err := client.GetCVMInfo(ctx, cvmID) + if err != nil { + t.Fatalf("%s: GetCVMInfo error: %v", label, err) + } + if transientStates[info.Status] { + t.Fatalf("%s: expected idle state, got %s", label, info.Status) + } +} + +func deploy(t *testing.T, client *Client) (cvmID, appID, encryptPubkey string) { + t.Helper() + ctx := context.Background() + name := genCVMName() + t.Logf("deploying CVM: %s", name) + + provResp, err := client.ProvisionCVM(ctx, &ProvisionCVMRequest{ + Name: name, + InstanceType: "tdx.small", + ComposeFile: &ComposeFile{ + DockerComposeFile: testCompose, + GatewayEnabled: Bool(true), + }, + }) + if err != nil { + t.Fatalf("ProvisionCVM: %v", err) + } + t.Logf("provisioned: app_id=%s encrypt_pubkey=%v", provResp.AppID, provResp.AppEnvEncryptPubkey != "") + + commitResp, err := client.CommitCVMProvision(ctx, &CommitCVMProvisionRequest{ + AppID: provResp.AppID, + ComposeHash: provResp.ComposeHash, + }) + if err != nil { + t.Fatalf("CommitCVMProvision: %v", err) + } + cvmID = commitResp.CvmID() + if cvmID == "" { + cvmID = provResp.AppID + } + t.Logf("committed: cvm_id=%s", cvmID) + + waitIdle(t, client, cvmID, 10*time.Minute) + return cvmID, provResp.AppID, provResp.AppEnvEncryptPubkey +} + +func TestE2EAllInterfaces(t *testing.T) { + client := newE2EClient(t) + ctx := context.Background() + + // ── 1. Read-only APIs ── + + t.Run("get_current_user", func(t *testing.T) { + user, err := client.GetCurrentUser(ctx) + if err != nil { + t.Fatalf("GetCurrentUser: %v", err) + } + t.Logf("user: %s", user.User.Username) + }) + + t.Run("get_available_nodes", func(t *testing.T) { + nodes, err := client.GetAvailableNodes(ctx) + if err != nil { + t.Fatalf("GetAvailableNodes: %v", err) + } + t.Logf("tier=%s nodes=%d", nodes.Tier, len(nodes.Nodes)) + }) + + t.Run("get_cvm_list", func(t *testing.T) { + list, err := client.GetCVMList(ctx, nil) + if err != nil { + t.Fatalf("GetCVMList: %v", err) + } + t.Logf("total CVMs: %d", list.Total) + }) + + var kmsID string + t.Run("kms", func(t *testing.T) { + kmsList, err := client.GetKMSList(ctx) + if err != nil { + t.Fatalf("GetKMSList: %v", err) + } + t.Logf("kms count: %d", len(kmsList.Items)) + + if len(kmsList.Items) > 0 { + kmsID = kmsList.Items[0].ID + info, err := client.GetKMSInfo(ctx, kmsID) + if err != nil { + t.Fatalf("GetKMSInfo: %v", err) + } + t.Logf("kms: id=%s url=%s", info.ID, info.URL) + } + + _, err = client.NextAppIDs(ctx) + if err != nil { + t.Fatalf("NextAppIDs: %v", err) + } + + // safe call — on-chain detail may not be available + _, _ = client.GetKMSOnChainDetail(ctx, "base") + }) + + var workspaceSlug string + t.Run("workspaces", func(t *testing.T) { + ws, err := client.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + t.Logf("workspaces: %v", ws) + + // Try to extract slug from the response. + // Response format: {data: [...], pagination: {...}} + if data, ok := (*ws)["data"]; ok { + if items, ok := data.([]any); ok && len(items) > 0 { + if item, ok := items[0].(map[string]any); ok { + if slug, ok := item["slug"].(string); ok && slug != "" { + workspaceSlug = slug + } + } + } + } + + if workspaceSlug != "" { + _, err = client.GetWorkspace(ctx, workspaceSlug) + if err != nil { + t.Logf("GetWorkspace: %v (may be expected)", err) + } + _, _ = client.GetWorkspaceNodes(ctx, workspaceSlug, nil) + _, _ = client.GetWorkspaceQuotas(ctx, workspaceSlug) + } + }) + + t.Run("instance_types", func(t *testing.T) { + families, err := client.ListAllInstanceTypeFamilies(ctx) + if err != nil { + t.Fatalf("ListAllInstanceTypeFamilies: %v", err) + } + t.Logf("instance type families: %v", families) + + _, _ = client.ListFamilyInstanceTypes(ctx, "tdx") + }) + + t.Run("ssh_keys", func(t *testing.T) { + keys, err := client.ListSSHKeys(ctx) + if err != nil { + t.Fatalf("ListSSHKeys: %v", err) + } + t.Logf("ssh keys: %d", len(keys)) + + // Create and delete a test key (generate a real ed25519 key). + _, privKey, _ := ed25519.GenerateKey(rand.Reader) + sshPub, _ := ssh.NewPublicKey(privKey.Public()) + pubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) + " e2e-test" + created, err := client.CreateSSHKey(ctx, &CreateSSHKeyRequest{ + Name: "e2e-test-" + hex.EncodeToString([]byte(genCVMName())[:4]), + PublicKey: pubKeyStr, + }) + if err != nil { + t.Logf("CreateSSHKey: %v (may already exist)", err) + } else { + t.Logf("created ssh key: %s", created.ID) + if err := client.DeleteSSHKey(ctx, created.ID); err != nil { + t.Logf("DeleteSSHKey: %v", err) + } + } + }) + + t.Run("os_images", func(t *testing.T) { + imgs, err := client.GetOSImages(ctx) + if err != nil { + t.Fatalf("GetOSImages: %v", err) + } + t.Logf("os images: %d", len(imgs.Items)) + }) + + t.Run("app_list", func(t *testing.T) { + apps, err := client.GetAppList(ctx) + if err != nil { + t.Fatalf("GetAppList: %v", err) + } + t.Logf("apps: %d", apps.Total) + + _, _ = client.GetAppFilterOptions(ctx) + }) + + // ── 2. Deploy CVM ── + + cvmID, appID, encryptPubkey := deploy(t, client) + defer func() { + t.Log("cleaning up: deleting CVM") + _ = client.DeleteCVM(context.Background(), cvmID) + }() + + // ── 3. CVM reads ── + + t.Run("cvm_reads", func(t *testing.T) { + info, err := client.GetCVMInfo(ctx, cvmID) + if err != nil { + t.Fatalf("GetCVMInfo: %v", err) + } + t.Logf("cvm status: %s", info.Status) + + _, err = client.GetCVMComposeFile(ctx, cvmID) + if err != nil { + t.Logf("GetCVMComposeFile: %v", err) + } + + _, err = client.GetCVMPreLaunchScript(ctx, cvmID) + if err != nil { + t.Logf("GetCVMPreLaunchScript: %v", err) + } + + _, _ = client.GetCVMState(ctx, cvmID) + _, _ = client.GetCVMStats(ctx, cvmID) + _, _ = client.GetCVMNetwork(ctx, cvmID) + _, _ = client.GetCVMDockerCompose(ctx, cvmID) + _, _ = client.GetCVMContainersStats(ctx, cvmID) + _, _ = client.GetCVMAttestation(ctx, cvmID) + _, _ = client.GetCVMUserConfig(ctx, cvmID) + _, _ = client.GetAvailableOSImages(ctx, cvmID) + + if info.VMUUID != nil { + _, _ = client.GetCVMStatusBatch(ctx, []string{*info.VMUUID}) + } + }) + + // ── 4. App reads ── + + t.Run("app_reads", func(t *testing.T) { + if appID == "" { + t.Skip("no app_id") + } + + appInfo, err := client.GetAppInfo(ctx, appID) + if err != nil { + t.Fatalf("GetAppInfo: %v", err) + } + t.Logf("app: %s", appInfo.Name) + + _, _ = client.GetAppCVMs(ctx, appID) + + revisions, err := client.GetAppRevisions(ctx, appID, nil) + if err != nil { + t.Logf("GetAppRevisions: %v", err) + } else if len(revisions.Revisions) > 0 { + revID := revisions.Revisions[0].RevisionID + _, _ = client.GetAppRevisionDetail(ctx, appID, revID) + } + + _, _ = client.GetAppAttestation(ctx, appID) + _, _ = client.GetAppDeviceAllowlist(ctx, appID) + _, _ = client.GetAppEnvEncryptPubKey(ctx, "phala", appID) + }) + + // ── 5. Watch SSE ── + + t.Run("watch_cvm_state", func(t *testing.T) { + state, err := client.GetCVMState(ctx, cvmID) + if err != nil { + t.Fatalf("GetCVMState: %v", err) + } + status, _ := (*state)["status"].(string) + if status == "" { + t.Skip("no status available") + } + + watchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + ch, err := client.WatchCVMState(watchCtx, cvmID, &WatchCVMStateOptions{ + Target: status, + Timeout: 20, + MaxRetries: 0, + }) + if err != nil { + t.Fatalf("WatchCVMState: %v", err) + } + + for evt := range ch { + t.Logf("SSE event: %s", evt.Event) + if evt.Error != nil { + t.Logf("SSE error: %v", evt.Error) + } + } + }) + + // ── 6. CVM mutations ── + + t.Run("mutations", func(t *testing.T) { + assertIdle(t, client, cvmID, "before mutations") + + // Visibility + _, err := client.UpdateCVMVisibility(ctx, cvmID, &UpdateVisibilityRequest{ + PublicSysinfo: Bool(true), + PublicLogs: Bool(true), + }) + if err != nil { + t.Fatalf("UpdateCVMVisibility: %v", err) + } + waitIdle(t, client, cvmID, 5*time.Minute) + + // Docker compose + _, err = client.UpdateDockerCompose(ctx, cvmID, testCompose, nil) + if err != nil { + t.Logf("UpdateDockerCompose: %v", err) + } + waitIdle(t, client, cvmID, 5*time.Minute) + + // Pre-launch script + _, err = client.UpdatePreLaunchScript(ctx, cvmID, "#!/bin/sh\ntrue", nil) + if err != nil { + t.Logf("UpdatePreLaunchScript: %v", err) + } + waitIdle(t, client, cvmID, 5*time.Minute) + + // Refresh instance ID + _, err = client.RefreshCVMInstanceID(ctx, cvmID) + if err != nil { + t.Logf("RefreshCVMInstanceID: %v", err) + } + + _, err = client.RefreshCVMInstanceIDs(ctx) + if err != nil { + t.Logf("RefreshCVMInstanceIDs: %v", err) + } + + // UpdateCVMEnvs (requires encryption) + if encryptPubkey != "" { + assertIdle(t, client, cvmID, "before update_cvm_envs") + encrypted := encryptEnvVars(t, encryptPubkey, map[string]string{"E2E_TEST": "1"}) + _, err = client.UpdateCVMEnvs(ctx, cvmID, &UpdateEnvsRequest{ + EncryptedEnv: encrypted, + }) + if err != nil { + t.Logf("UpdateCVMEnvs: %v", err) + } + waitIdle(t, client, cvmID, 5*time.Minute) + } else { + t.Log("[skip] UpdateCVMEnvs: no encrypt_pubkey from provision") + } + }) + + // ── 7. Lifecycle ── + + t.Run("lifecycle", func(t *testing.T) { + assertIdle(t, client, cvmID, "before lifecycle") + + // Restart + _, err := client.RestartCVM(ctx, cvmID) + if err != nil { + t.Fatalf("RestartCVM: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + info, _ := client.GetCVMInfo(ctx, cvmID) + t.Logf("after restart: status=%s", info.Status) + + // Stop + _, err = client.StopCVM(ctx, cvmID) + if err != nil { + t.Fatalf("StopCVM: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + + // Start + _, err = client.StartCVM(ctx, cvmID) + if err != nil { + t.Fatalf("StartCVM: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + info, _ = client.GetCVMInfo(ctx, cvmID) + t.Logf("after start: status=%s", info.Status) + + // Shutdown + _, err = client.ShutdownCVM(ctx, cvmID) + if err != nil { + t.Fatalf("ShutdownCVM: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + }) + + // ── 8. Patch CVM ── + + t.Run("patch_cvm", func(t *testing.T) { + // Start CVM back up for patching. + _, err := client.StartCVM(ctx, cvmID) + if err != nil { + t.Logf("StartCVM for patch: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + + // Visibility-only patch + _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ + PublicSysinfo: Bool(true), + PublicLogs: Bool(true), + }) + if err != nil { + t.Logf("PatchCVM visibility: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + + // Docker compose patch + compose := testCompose + _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ + DockerComposeFile: &compose, + }) + if err != nil { + t.Logf("PatchCVM docker_compose: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + + // Pre-launch script patch + script := "#!/bin/sh\ntrue" + _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ + PreLaunchScript: &script, + }) + if err != nil { + t.Logf("PatchCVM pre_launch_script: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + + // Multi-field patch + _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ + PublicSysinfo: Bool(true), + DockerComposeFile: &compose, + }) + if err != nil { + t.Logf("PatchCVM multi-field: %v", err) + } + waitIdle(t, client, cvmID, 10*time.Minute) + + // Verify + info, err := client.GetCVMInfo(ctx, cvmID) + if err != nil { + t.Fatalf("GetCVMInfo after patch: %v", err) + } + t.Logf("after patches: status=%s public_sysinfo=%v", info.Status, info.PublicSysinfo) + }) +} + +func TestE2EPatchCVM(t *testing.T) { + client := newE2EClient(t) + ctx := context.Background() + + cvmID, _, encryptPubkey := deploy(t, client) + defer func() { + _ = client.DeleteCVM(context.Background(), cvmID) + }() + + tests := []struct { + name string + patch *PatchCVMRequest + skip bool + }{ + { + name: "visibility_only", + patch: &PatchCVMRequest{ + PublicSysinfo: Bool(true), + PublicLogs: Bool(true), + }, + }, + { + name: "docker_compose", + patch: func() *PatchCVMRequest { + c := testCompose + return &PatchCVMRequest{DockerComposeFile: &c} + }(), + }, + { + name: "pre_launch_script", + patch: func() *PatchCVMRequest { + s := "#!/bin/sh\ntrue" + return &PatchCVMRequest{PreLaunchScript: &s} + }(), + }, + { + name: "encrypted_env", + patch: func() *PatchCVMRequest { + if encryptPubkey == "" { + return nil + } + // Can't call t.Helper() from here; encryption is done in test body. + return nil + }(), + skip: encryptPubkey == "", + }, + { + name: "multi_field", + patch: func() *PatchCVMRequest { + c := testCompose + return &PatchCVMRequest{ + PublicSysinfo: Bool(true), + DockerComposeFile: &c, + } + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.skip { + t.Skip("no encrypt_pubkey from provision") + } + + assertIdle(t, client, cvmID, fmt.Sprintf("before %s", tt.name)) + + patch := tt.patch + // Build encrypted_env patch dynamically (needs *testing.T for encryptEnvVars). + if tt.name == "encrypted_env" { + encrypted := encryptEnvVars(t, encryptPubkey, map[string]string{"E2E_TEST": "1"}) + patch = &PatchCVMRequest{EncryptedEnv: &encrypted} + } + + resp, err := client.PatchCVM(ctx, cvmID, patch) + if err != nil { + t.Logf("PatchCVM %s: %v", tt.name, err) + return + } + t.Logf("PatchCVM %s: requires_on_chain=%v", tt.name, resp.RequiresOnChainHash) + waitIdle(t, client, cvmID, 5*time.Minute) + }) + } +} diff --git a/go/errors.go b/go/errors.go new file mode 100644 index 00000000..a960d6e4 --- /dev/null +++ b/go/errors.go @@ -0,0 +1,81 @@ +package phala + +import ( + "fmt" + "net/http" + "strconv" + "time" +) + +// APIError represents an error response from the Phala Cloud API. +type APIError struct { + StatusCode int + Message string + Detail any + Body string + Headers http.Header + ErrorCode string +} + +func (e *APIError) Error() string { + if e.Message != "" { + return fmt.Sprintf("phala api error (status %d): %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("phala api error (status %d): %s", e.StatusCode, http.StatusText(e.StatusCode)) +} + +// IsAuth returns true if the error is an authentication/authorization error (401/403). +func (e *APIError) IsAuth() bool { + return e.StatusCode == http.StatusUnauthorized || e.StatusCode == http.StatusForbidden +} + +// IsValidation returns true if the error is a validation error (422). +func (e *APIError) IsValidation() bool { + return e.StatusCode == http.StatusUnprocessableEntity +} + +// IsBusiness returns true if the error is a business logic error (4xx, non-auth, non-validation). +func (e *APIError) IsBusiness() bool { + return e.StatusCode >= 400 && e.StatusCode < 500 && !e.IsAuth() && !e.IsValidation() +} + +// IsServer returns true if the error is a server error (500+). +func (e *APIError) IsServer() bool { + return e.StatusCode >= 500 +} + +// IsRetryable returns true if the error is retryable (409/429/503). +func (e *APIError) IsRetryable() bool { + return e.StatusCode == http.StatusConflict || + e.StatusCode == http.StatusTooManyRequests || + e.StatusCode == http.StatusServiceUnavailable +} + +// IsComposePrecondition returns true if the error is a compose hash precondition failure (465). +func (e *APIError) IsComposePrecondition() bool { + return e.StatusCode == 465 +} + +// RetryAfter returns the duration to wait before retrying, based on the Retry-After header. +// Returns 0 if the header is not present or cannot be parsed. +func (e *APIError) RetryAfter() time.Duration { + if e.Headers == nil { + return 0 + } + ra := e.Headers.Get("Retry-After") + if ra == "" { + return 0 + } + // Try parsing as seconds first. + if secs, err := strconv.Atoi(ra); err == nil { + return time.Duration(secs) * time.Second + } + // Try parsing as HTTP date. + if t, err := http.ParseTime(ra); err == nil { + d := time.Until(t) + if d > 0 { + return d + } + } + return 0 +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 00000000..01981216 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,8 @@ +module github.com/Phala-Network/phala-cloud-sdk-go + +go 1.25.0 + +require ( + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 00000000..bb3d09ba --- /dev/null +++ b/go/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/go/helpers.go b/go/helpers.go new file mode 100644 index 00000000..81bfbb33 --- /dev/null +++ b/go/helpers.go @@ -0,0 +1,49 @@ +package phala + +import ( + "regexp" + "strings" +) + +var ( + // UUID v4 pattern (with or without dashes). + uuidRegex = regexp.MustCompile( + `(?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$`, + ) + // 40-char hex string (unprefixed app_id). + appIDRegex = regexp.MustCompile(`(?i)^[0-9a-f]{40}$`) +) + +// ResolveCVMID normalizes a CVM identifier to the format expected by the API. +// +// Supported input formats: +// - UUID (with or without dashes): dashes are removed +// - 40-char hex app_id: "app_" prefix is added +// - Integer ID, hashed ID, name, or already-prefixed ID: used as-is +func ResolveCVMID(id string) string { + if id == "" { + return "" + } + if uuidRegex.MatchString(id) { + return strings.ReplaceAll(id, "-", "") + } + if appIDRegex.MatchString(id) { + return "app_" + id + } + return id +} + +// String returns a pointer to the given string value. +func String(v string) *string { return &v } + +// Int returns a pointer to the given int value. +func Int(v int) *int { return &v } + +// Int64 returns a pointer to the given int64 value. +func Int64(v int64) *int64 { return &v } + +// Float64 returns a pointer to the given float64 value. +func Float64(v float64) *float64 { return &v } + +// Bool returns a pointer to the given bool value. +func Bool(v bool) *bool { return &v } diff --git a/go/instance_types.go b/go/instance_types.go new file mode 100644 index 00000000..27530426 --- /dev/null +++ b/go/instance_types.go @@ -0,0 +1,21 @@ +package phala + +import "context" + +// ListAllInstanceTypeFamilies returns all instance type families. +func (c *Client) ListAllInstanceTypeFamilies(ctx context.Context) (*InstanceTypeFamiliesResponse, error) { + var result InstanceTypeFamiliesResponse + if err := c.doJSON(ctx, "GET", "/instance-types", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ListFamilyInstanceTypes returns instance types in a specific family. +func (c *Client) ListFamilyInstanceTypes(ctx context.Context, family string) (*FamilyInstanceTypesResponse, error) { + var result FamilyInstanceTypesResponse + if err := c.doJSON(ctx, "GET", "/instance-types/"+family, nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/kms.go b/go/kms.go new file mode 100644 index 00000000..168338e7 --- /dev/null +++ b/go/kms.go @@ -0,0 +1,52 @@ +package phala + +import ( + "context" + "fmt" +) + +// GetKMSList returns the list of KMS servers. +func (c *Client) GetKMSList(ctx context.Context) (*GetKMSListResponse, error) { + var result GetKMSListResponse + if err := c.doJSON(ctx, "GET", "/kms", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetKMSInfo returns information about a specific KMS server. +func (c *Client) GetKMSInfo(ctx context.Context, kmsID string) (*KMSInfo, error) { + var result KMSInfo + if err := c.doJSON(ctx, "GET", "/kms/"+kmsID, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// NextAppIDs returns the next available app IDs. +func (c *Client) NextAppIDs(ctx context.Context) (*NextAppIDsResponse, error) { + var result NextAppIDsResponse + if err := c.doJSON(ctx, "GET", "/kms/phala/next_app_id", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetAppEnvEncryptPubKey returns the environment encryption public key for an app. +func (c *Client) GetAppEnvEncryptPubKey(ctx context.Context, kmsType, appID string) (*AppEnvPubKeyResponse, error) { + var result AppEnvPubKeyResponse + path := fmt.Sprintf("/kms/%s/pubkey/%s", kmsType, appID) + if err := c.doJSON(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetKMSOnChainDetail returns KMS on-chain detail for a given chain. +func (c *Client) GetKMSOnChainDetail(ctx context.Context, chain string) (*KMSOnChainDetail, error) { + var result KMSOnChainDetail + if err := c.doJSON(ctx, "GET", "/kms/on-chain/"+chain, nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/nodes.go b/go/nodes.go new file mode 100644 index 00000000..67cb88fd --- /dev/null +++ b/go/nodes.go @@ -0,0 +1,12 @@ +package phala + +import "context" + +// GetAvailableNodes returns available nodes for CVM deployment. +func (c *Client) GetAvailableNodes(ctx context.Context) (*AvailableNodes, error) { + var result AvailableNodes + if err := c.doJSON(ctx, "GET", "/teepods/available", nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/os_images.go b/go/os_images.go new file mode 100644 index 00000000..2b551335 --- /dev/null +++ b/go/os_images.go @@ -0,0 +1,12 @@ +package phala + +import "context" + +// GetOSImages returns the list of available OS images. +func (c *Client) GetOSImages(ctx context.Context) (*OSImagesResponse, error) { + var result OSImagesResponse + if err := c.doJSON(ctx, "GET", "/os-images", nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/request.go b/go/request.go new file mode 100644 index 00000000..abd21a38 --- /dev/null +++ b/go/request.go @@ -0,0 +1,173 @@ +package phala + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// newRequest creates a new HTTP request with standard headers. +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + url := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("X-Phala-Version", c.apiVersion) + req.Header.Set("User-Agent", c.userAgent) + + for k, v := range c.headers { + req.Header.Set(k, v) + } + + return req, nil +} + +// do executes an HTTP request and returns the response. Non-2xx responses return *APIError. +func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + + apiErr := &APIError{ + StatusCode: resp.StatusCode, + Body: string(bodyBytes), + Headers: resp.Header, + } + + // Try to parse error message from JSON response. + var parsed map[string]any + if json.Unmarshal(bodyBytes, &parsed) == nil { + if msg, ok := parsed["message"].(string); ok { + apiErr.Message = msg + } else if detail, ok := parsed["detail"]; ok { + switch v := detail.(type) { + case string: + apiErr.Message = v + default: + apiErr.Detail = detail + b, _ := json.Marshal(detail) + apiErr.Message = string(b) + } + } + if code, ok := parsed["error_code"].(string); ok { + apiErr.ErrorCode = code + } + } + + if apiErr.Message == "" { + apiErr.Message = http.StatusText(resp.StatusCode) + } + + // Strip sensitive headers from error. + if apiErr.Headers != nil { + sanitized := apiErr.Headers.Clone() + sanitized.Del("X-API-Key") + sanitized.Del("Authorization") + apiErr.Headers = sanitized + } + + return nil, apiErr +} + +// doJSON sends a JSON request and decodes the JSON response into result. +func (c *Client) doJSON(ctx context.Context, method, path string, reqBody, result any) error { + var body io.Reader + if reqBody != nil { + b, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("phala: marshal request: %w", err) + } + body = bytes.NewReader(b) + } + + req, err := c.newRequest(ctx, method, path, body) + if err != nil { + return err + } + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.do(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if result == nil { + return nil + } + + return json.NewDecoder(resp.Body).Decode(result) +} + +// doText sends a request with a text body (YAML, plain text, etc.) and optional extra headers. +func (c *Client) doText(ctx context.Context, method, path, contentType, body string, extraHeaders map[string]string, result any) error { + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + + req, err := c.newRequest(ctx, method, path, reader) + if err != nil { + return err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + + resp, err := c.do(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + if result == nil { + return nil + } + + // If result is *string, read as text. + if s, ok := result.(*string); ok { + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + *s = string(b) + return nil + } + + return json.NewDecoder(resp.Body).Decode(result) +} + +// doEmpty sends a request expecting no response body. +func (c *Client) doEmpty(ctx context.Context, method, path string) error { + req, err := c.newRequest(ctx, method, path, nil) + if err != nil { + return err + } + + resp, err := c.do(ctx, req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/go/retry.go b/go/retry.go new file mode 100644 index 00000000..b7f6e1eb --- /dev/null +++ b/go/retry.go @@ -0,0 +1,46 @@ +package phala + +import ( + "context" + "errors" + "math" + "time" +) + +const ( + retryBaseDelay = 1 * time.Second + retryMaxDelay = 20 * time.Second +) + +// doWithRetry executes fn with exponential backoff retry on retryable errors (409/429/503). +func (c *Client) doWithRetry(ctx context.Context, fn func() error) error { + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + err := fn() + if err == nil { + return nil + } + + var apiErr *APIError + if !errors.As(err, &apiErr) || !apiErr.IsRetryable() { + return err + } + lastErr = err + + // Calculate delay. + delay := apiErr.RetryAfter() + if delay == 0 { + delay = time.Duration(math.Min( + float64(retryBaseDelay)*math.Pow(2, float64(attempt)), + float64(retryMaxDelay), + )) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } + return lastErr +} diff --git a/go/ssh_keys.go b/go/ssh_keys.go new file mode 100644 index 00000000..f4c549ee --- /dev/null +++ b/go/ssh_keys.go @@ -0,0 +1,45 @@ +package phala + +import "context" + +// ListSSHKeys returns the list of SSH keys for the current user. +func (c *Client) ListSSHKeys(ctx context.Context) ([]SSHKey, error) { + var result []SSHKey + if err := c.doJSON(ctx, "GET", "/user/ssh-keys", nil, &result); err != nil { + return nil, err + } + return result, nil +} + +// CreateSSHKey creates a new SSH key. +func (c *Client) CreateSSHKey(ctx context.Context, req *CreateSSHKeyRequest) (*SSHKey, error) { + var result SSHKey + if err := c.doJSON(ctx, "POST", "/user/ssh-keys", req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DeleteSSHKey deletes an SSH key by ID. +func (c *Client) DeleteSSHKey(ctx context.Context, keyID string) error { + return c.doEmpty(ctx, "DELETE", "/user/ssh-keys/"+keyID) +} + +// ImportGithubProfileSSHKeys imports SSH keys from a GitHub user profile. +func (c *Client) ImportGithubProfileSSHKeys(ctx context.Context, username string) (*ImportGithubResponse, error) { + var result ImportGithubResponse + body := map[string]string{"github_username": username} + if err := c.doJSON(ctx, "POST", "/user/ssh-keys/github-profile", body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SyncGithubSSHKeys syncs SSH keys from GitHub. +func (c *Client) SyncGithubSSHKeys(ctx context.Context) (*SyncGithubResponse, error) { + var result SyncGithubResponse + if err := c.doJSON(ctx, "POST", "/user/ssh-keys/github-sync", nil, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/go/status.go b/go/status.go new file mode 100644 index 00000000..3590f869 --- /dev/null +++ b/go/status.go @@ -0,0 +1,13 @@ +package phala + +import "context" + +// GetCVMStatusBatch returns status information for multiple CVMs. +func (c *Client) GetCVMStatusBatch(ctx context.Context, vmUUIDs []string) (GenericObject, error) { + var result GenericObject + body := map[string][]string{"vm_uuids": vmUUIDs} + if err := c.doJSON(ctx, "POST", "/status/batch", body, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/go/types_apps.go b/go/types_apps.go new file mode 100644 index 00000000..b54cbfc3 --- /dev/null +++ b/go/types_apps.go @@ -0,0 +1,97 @@ +package phala + +// AppInfo represents application information. +type AppInfo struct { + ID string `json:"id"` + Name string `json:"name"` + AppID string `json:"app_id"` + AppProvisionType *string `json:"app_provision_type,omitempty"` + AppIconURL *string `json:"app_icon_url,omitempty"` + CreatedAt string `json:"created_at"` + KMSType string `json:"kms_type"` + Profile *AppProfile `json:"profile,omitempty"` + CurrentCVM *CVMInfo `json:"current_cvm,omitempty"` + CVMs []CVMInfo `json:"cvms,omitempty"` + CVMCount int `json:"cvm_count"` +} + +// AppProfile represents an app profile. +type AppProfile struct { + DisplayName *string `json:"display_name,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Description *string `json:"description,omitempty"` + CustomDomain *string `json:"custom_domain,omitempty"` +} + +// GetAppListResponse is the response for listing apps. +type GetAppListResponse struct { + DstackApps []AppInfo `json:"dstack_apps"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +// AppRevision represents an app revision. +type AppRevision struct { + RevisionID string `json:"revision_id"` + AppID string `json:"app_id"` + VMUUID string `json:"vm_uuid"` + ComposeHash string `json:"compose_hash"` + CreatedAt string `json:"created_at"` + TraceID *string `json:"trace_id,omitempty"` + OperationType string `json:"operation_type"` + TriggeredBy *UserRef `json:"triggered_by,omitempty"` + CVM *CvmRef `json:"cvm,omitempty"` + Workspace *WorkspaceRef `json:"workspace,omitempty"` +} + +// AppRevisionDetail represents detailed app revision information. +type AppRevisionDetail struct { + RevisionID string `json:"revision_id"` + AppID string `json:"app_id"` + VMUUID string `json:"vm_uuid"` + ComposeHash string `json:"compose_hash"` + ComposeFile any `json:"compose_file,omitempty"` + EncryptedEnv string `json:"encrypted_env"` + UserConfig string `json:"user_config"` + CreatedAt string `json:"created_at"` + TraceID *string `json:"trace_id,omitempty"` + OperationType string `json:"operation_type"` + TriggeredBy *UserRef `json:"triggered_by,omitempty"` + CVM *CvmRef `json:"cvm,omitempty"` + Workspace *WorkspaceRef `json:"workspace,omitempty"` +} + +// AppRevisionsResponse is the response for listing app revisions. +type AppRevisionsResponse struct { + Revisions []AppRevision `json:"revisions"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// AppAttestationResponse is the response for getting app attestation. +type AppAttestationResponse = GenericObject + +// DeviceAllowlistItem represents an item in the device allowlist. +type DeviceAllowlistItem struct { + DeviceID string `json:"device_id"` + NodeName *string `json:"node_name,omitempty"` + CVMIDs []int `json:"cvm_ids,omitempty"` + AllowedOnchain bool `json:"allowed_onchain"` + Status string `json:"status"` +} + +// DeviceAllowlistResponse is the response for getting app device allowlist. +type DeviceAllowlistResponse struct { + IsOnchainKMS bool `json:"is_onchain_kms"` + AllowAnyDevice *bool `json:"allow_any_device,omitempty"` + ChainID *int `json:"chain_id,omitempty"` + AppContractAddress *string `json:"app_contract_address,omitempty"` + Devices []DeviceAllowlistItem `json:"devices,omitempty"` +} + +// AppFilterOptions is the response for getting app filter options. +type AppFilterOptions = GenericObject diff --git a/go/types_auth.go b/go/types_auth.go new file mode 100644 index 00000000..92b0b661 --- /dev/null +++ b/go/types_auth.go @@ -0,0 +1,38 @@ +package phala + +// CurrentUser represents the current authenticated user (API version 2026-01-21). +type CurrentUser struct { + User UserInfo `json:"user"` + Workspace WorkspaceInfo `json:"workspace"` + Credits CreditsInfo `json:"credits"` +} + +// UserInfo contains user profile information. +type UserInfo struct { + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Avatar string `json:"avatar"` + EmailVerified bool `json:"email_verified"` + TOTPEnabled bool `json:"totp_enabled"` + HasBackupCodes bool `json:"has_backup_codes"` + FlagHasPassword bool `json:"flag_has_password"` +} + +// WorkspaceInfo contains workspace information for the current user. +type WorkspaceInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + Tier string `json:"tier"` + Role string `json:"role"` + Avatar *string `json:"avatar,omitempty"` +} + +// CreditsInfo contains credit balance information. +type CreditsInfo struct { + Balance any `json:"balance"` + GrantedBalance any `json:"granted_balance"` + IsPostPaid bool `json:"is_post_paid"` + OutstandingAmount any `json:"outstanding_amount,omitempty"` +} diff --git a/go/types_common.go b/go/types_common.go new file mode 100644 index 00000000..7926fce7 --- /dev/null +++ b/go/types_common.go @@ -0,0 +1,50 @@ +package phala + +// GenericObject is a generic JSON object for responses that don't have a defined type. +type GenericObject = map[string]any + +// PaginationOptions holds common pagination parameters. +type PaginationOptions struct { + Page *int `json:"page,omitempty"` + PageSize *int `json:"page_size,omitempty"` +} + +// Paginated is a generic paginated response. +type Paginated[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Pages int `json:"pages"` +} + +// UpdateResult represents the result of an update operation that may require on-chain confirmation. +type UpdateResult struct { + // Normal success fields. + RequiresOnChainHash *bool `json:"requires_on_chain_hash,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + + // Compose precondition fields (status 465). + ComposeHash string `json:"compose_hash,omitempty"` + AppID string `json:"app_id,omitempty"` + DeviceID string `json:"device_id,omitempty"` + Message string `json:"message,omitempty"` + + // KMS info for on-chain operations. + KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` +} + +// InProgressResponse is returned for operations that are in progress. +type InProgressResponse struct { + RequiresOnChainHash bool `json:"requires_on_chain_hash"` + CorrelationID string `json:"correlation_id,omitempty"` +} + +// ComposeHashPreconditionResponse is returned when compose hash precondition fails (465). +type ComposeHashPreconditionResponse struct { + Message string `json:"message"` + ComposeHash string `json:"compose_hash"` + AppID string `json:"app_id"` + DeviceID string `json:"device_id"` + KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` +} diff --git a/go/types_cvms.go b/go/types_cvms.go new file mode 100644 index 00000000..c8f359ac --- /dev/null +++ b/go/types_cvms.go @@ -0,0 +1,338 @@ +package phala + +import "fmt" + +// CVMInfo represents CVM information (API version 2026-01-21). +type CVMInfo struct { + ID string `json:"id"` + Name string `json:"name"` + AppID *string `json:"app_id,omitempty"` + VMUUID *string `json:"vm_uuid,omitempty"` + InstanceID *string `json:"instance_id,omitempty"` + Resource CvmResource `json:"resource"` + NodeInfo *NodeRef `json:"node_info,omitempty"` + OS *CvmOsInfo `json:"os,omitempty"` + KMSType *string `json:"kms_type,omitempty"` + KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` + Status string `json:"status"` + Progress *CvmProgressInfo `json:"progress,omitempty"` + ComposeHash *string `json:"compose_hash,omitempty"` + Gateway *CvmGatewayInfo `json:"gateway,omitempty"` + Services []any `json:"services,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` + GatewayEnabled *bool `json:"gateway_enabled,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` + Listed bool `json:"listed"` + StorageFS *string `json:"storage_fs,omitempty"` + Workspace *WorkspaceRef `json:"workspace,omitempty"` + Creator *UserRef `json:"creator,omitempty"` + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + AppURL *string `json:"app_url,omitempty"` + BaseImage *string `json:"base_image,omitempty"` + Features []string `json:"features,omitempty"` + Runner *string `json:"runner,omitempty"` + ManifestVer *string `json:"manifest_version,omitempty"` + ComposeFile any `json:"compose_file,omitempty"` +} + +// CvmResource holds CVM resource allocation. +type CvmResource struct { + InstanceType *string `json:"instance_type,omitempty"` + VCPU *int `json:"vcpu,omitempty"` + MemoryInGB *float64 `json:"memory_in_gb,omitempty"` + DiskInGB *int `json:"disk_in_gb,omitempty"` + GPUs *int `json:"gpus,omitempty"` + ComputeBillingPrice *string `json:"compute_billing_price,omitempty"` + BillingPeriod *string `json:"billing_period,omitempty"` +} + +// CvmOsInfo holds CVM OS information. +type CvmOsInfo struct { + Name *string `json:"name,omitempty"` + Version *string `json:"version,omitempty"` + IsDev *bool `json:"is_dev,omitempty"` + OSImageHash *string `json:"os_image_hash,omitempty"` +} + +// CvmKmsInfo holds CVM KMS information. +type CvmKmsInfo struct { + ChainID *int `json:"chain_id,omitempty"` + DstackKmsAddress *string `json:"dstack_kms_address,omitempty"` + DstackAppAddress *string `json:"dstack_app_address,omitempty"` + DeployerAddress *string `json:"deployer_address,omitempty"` + RPCEndpoint *string `json:"rpc_endpoint,omitempty"` + EncryptedEnvPubkey *string `json:"encrypted_env_pubkey,omitempty"` +} + +// CvmProgressInfo holds CVM progress information. +type CvmProgressInfo struct { + Target *string `json:"target,omitempty"` + StartedAt *string `json:"started_at,omitempty"` + CorrelationID *string `json:"correlation_id,omitempty"` +} + +// CvmGatewayInfo holds CVM gateway information. +type CvmGatewayInfo struct { + BaseDomain *string `json:"base_domain,omitempty"` + CNAME *string `json:"cname,omitempty"` +} + +// NodeRef is a reference to a node. +type NodeRef struct { + ObjectType string `json:"object_type"` + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Region *string `json:"region,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + PPID *string `json:"ppid,omitempty"` + Status *string `json:"status,omitempty"` + Version *string `json:"version,omitempty"` +} + +// UserRef is a reference to a user. +type UserRef struct { + ObjectType string `json:"object_type"` + ID *string `json:"id,omitempty"` + Username *string `json:"username,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` +} + +// WorkspaceRef is a reference to a workspace. +type WorkspaceRef struct { + ObjectType string `json:"object_type"` + ID string `json:"id"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` +} + +// CvmRef is a reference to a CVM. +type CvmRef struct { + ObjectType string `json:"object_type"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + AppID *string `json:"app_id,omitempty"` + VMUUID *string `json:"vm_uuid,omitempty"` +} + +// PaginatedCVMInfos is a paginated list of CVM infos. +type PaginatedCVMInfos = Paginated[CVMInfo] + +// GetCVMListOptions holds query parameters for listing CVMs. +type GetCVMListOptions struct { + Page *int `json:"page,omitempty"` + PageSize *int `json:"page_size,omitempty"` +} + +// ProvisionCVMRequest is the request body for provisioning a CVM. +type ProvisionCVMRequest struct { + Name string `json:"name"` + InstanceType string `json:"instance_type"` + ComposeFile *ComposeFile `json:"compose_file,omitempty"` + + // Optional fields. + VCPU *int `json:"vcpu,omitempty"` + Memory *int `json:"memory,omitempty"` + DiskSize *int `json:"disk_size,omitempty"` + TeepodID *int `json:"teepod_id,omitempty"` + Image *string `json:"image,omitempty"` + KMSType *string `json:"kms_type,omitempty"` + Listed *bool `json:"listed,omitempty"` + Encrypted *bool `json:"encrypted,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` +} + +// ComposeFile represents a compose file configuration. +type ComposeFile struct { + Name string `json:"name"` + DockerComposeFile string `json:"docker_compose_file"` + GatewayEnabled *bool `json:"gateway_enabled,omitempty"` + PreLaunchScript *string `json:"pre_launch_script,omitempty"` + EncryptedEnv *string `json:"encrypted_env,omitempty"` + EnvKeys *string `json:"env_keys,omitempty"` +} + +// ProvisionCVMResponse is the response from provisioning a CVM. +type ProvisionCVMResponse struct { + AppID string `json:"app_id"` + ComposeHash string `json:"compose_hash"` + AppEnvEncryptPubkey string `json:"app_env_encrypt_pubkey,omitempty"` + KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` + FMSPC string `json:"fmspc,omitempty"` + DeviceID string `json:"device_id,omitempty"` + OSImageHash string `json:"os_image_hash,omitempty"` + InstanceType string `json:"instance_type,omitempty"` + NodeID *int `json:"node_id,omitempty"` + KMSID string `json:"kms_id,omitempty"` +} + +// CommitCVMProvisionRequest is the request for committing a CVM provision. +type CommitCVMProvisionRequest struct { + AppID string `json:"app_id"` + ComposeHash string `json:"compose_hash"` + TransactionHash *string `json:"transaction_hash,omitempty"` +} + +// CommitCVMProvisionResponse is the response from committing a CVM provision. +type CommitCVMProvisionResponse struct { + ID any `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +// CvmID returns the CVM ID as a string. +func (r *CommitCVMProvisionResponse) CvmID() string { + if r.ID == nil { + return "" + } + return fmt.Sprintf("%v", r.ID) +} + +// CVMState represents the state of a CVM. +type CVMState = GenericObject + +// CVMStats represents CVM statistics. +type CVMStats = GenericObject + +// CVMNetwork represents CVM network information. +type CVMNetwork = GenericObject + +// CVMContainersStats represents CVM container statistics. +type CVMContainersStats = GenericObject + +// CVMUserConfig represents CVM user configuration. +type CVMUserConfig = GenericObject + +// CVMActionResponse is the generic response for CVM lifecycle actions. +type CVMActionResponse struct { + ID any `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` +} + +// ReplicateCVMOptions holds options for replicating a CVM. +type ReplicateCVMOptions struct { + NodeID *int `json:"node_id,omitempty"` +} + +// PatchCVMRequest is the request for patching a CVM (multi-field update). +type PatchCVMRequest struct { + DockerComposeFile *string `json:"docker_compose_file,omitempty"` + PreLaunchScript *string `json:"pre_launch_script,omitempty"` + EncryptedEnv *string `json:"encrypted_env,omitempty"` + EnvKeys *string `json:"env_keys,omitempty"` + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` + GatewayEnabled *bool `json:"gateway_enabled,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` + Listed *bool `json:"listed,omitempty"` + VCPU *int `json:"vcpu,omitempty"` + Memory *int `json:"memory,omitempty"` + DiskSize *int `json:"disk_size,omitempty"` + InstanceType *string `json:"instance_type,omitempty"` + OSImageName *string `json:"os_image_name,omitempty"` + AllowRestart *bool `json:"allow_restart,omitempty"` +} + +// PatchCVMResponse is the response from patching a CVM. +type PatchCVMResponse struct { + RequiresOnChainHash bool `json:"requires_on_chain_hash"` + CorrelationID string `json:"correlation_id,omitempty"` + ComposeHash string `json:"compose_hash,omitempty"` + AppID string `json:"app_id,omitempty"` + DeviceID string `json:"device_id,omitempty"` + KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` +} + +// ConfirmCVMPatchRequest is the request for confirming a CVM patch with on-chain hash. +type ConfirmCVMPatchRequest struct { + ComposeHash string `json:"compose_hash"` + TransactionHash string `json:"transaction_hash"` +} + +// CVMAttestation represents CVM attestation data. +type CVMAttestation struct { + Name *string `json:"name,omitempty"` + IsOnline bool `json:"is_online"` + IsPublic bool `json:"is_public"` + Error *string `json:"error,omitempty"` + AppCertificates []Certificate `json:"app_certificates,omitempty"` + TCBInfo *TcbInfo `json:"tcb_info,omitempty"` + ComposeFile *string `json:"compose_file,omitempty"` +} + +// Certificate represents a TLS certificate. +type Certificate struct { + Subject CertificateSubject `json:"subject"` + Issuer CertificateIssuer `json:"issuer"` + SerialNumber string `json:"serial_number"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint"` + SignatureAlgorithm string `json:"signature_algorithm"` + SANs []string `json:"sans,omitempty"` + IsCA bool `json:"is_ca"` + PositionInChain *int `json:"position_in_chain,omitempty"` + Quote *string `json:"quote,omitempty"` + AppID *string `json:"app_id,omitempty"` + CertUsage *string `json:"cert_usage,omitempty"` +} + +// CertificateSubject holds certificate subject fields. +type CertificateSubject struct { + CommonName *string `json:"common_name,omitempty"` + Organization *string `json:"organization,omitempty"` + Country *string `json:"country,omitempty"` + State *string `json:"state,omitempty"` + Locality *string `json:"locality,omitempty"` +} + +// CertificateIssuer holds certificate issuer fields. +type CertificateIssuer struct { + CommonName *string `json:"common_name,omitempty"` + Organization *string `json:"organization,omitempty"` + Country *string `json:"country,omitempty"` +} + +// TcbInfo holds TCB info fields. +type TcbInfo struct { + MRTD string `json:"mrtd"` + RootfsHash *string `json:"rootfs_hash,omitempty"` + RTMR0 string `json:"rtmr0"` + RTMR1 string `json:"rtmr1"` + RTMR2 string `json:"rtmr2"` + RTMR3 string `json:"rtmr3"` + EventLog []EventLog `json:"event_log,omitempty"` + AppCompose string `json:"app_compose"` +} + +// EventLog holds an event log entry. +type EventLog struct { + IMR int `json:"imr"` + EventType int `json:"event_type"` + Digest string `json:"digest"` + Event string `json:"event"` + EventPayload string `json:"event_payload"` +} + +// CVMVisibility holds CVM visibility settings. +type CVMVisibility struct { + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` +} + +// OSImage represents an available OS image. +type OSImage struct { + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + Version string `json:"version,omitempty"` + OSImageHash *string `json:"os_image_hash,omitempty"` + IsDev bool `json:"is_dev,omitempty"` + RequiresGPU bool `json:"requires_gpu,omitempty"` +} diff --git a/go/types_instance_types.go b/go/types_instance_types.go new file mode 100644 index 00000000..356eda7a --- /dev/null +++ b/go/types_instance_types.go @@ -0,0 +1,7 @@ +package phala + +// InstanceTypeFamiliesResponse is the response for listing all instance type families. +type InstanceTypeFamiliesResponse = GenericObject + +// FamilyInstanceTypesResponse is the response for listing instance types in a family. +type FamilyInstanceTypesResponse = GenericObject diff --git a/go/types_kms.go b/go/types_kms.go new file mode 100644 index 00000000..6719953b --- /dev/null +++ b/go/types_kms.go @@ -0,0 +1,52 @@ +package phala + +// KMSInfo represents KMS server information. +type KMSInfo struct { + ID string `json:"id"` + Slug *string `json:"slug,omitempty"` + URL string `json:"url"` + Version string `json:"version"` + ChainID *int `json:"chain_id,omitempty"` + KMSContractAddress *string `json:"kms_contract_address,omitempty"` + GatewayAppID *string `json:"gateway_app_id,omitempty"` +} + +// GetKMSListResponse is the paginated list of KMS servers. +type GetKMSListResponse = Paginated[KMSInfo] + +// NextAppIDsResponse is the response for getting next app IDs. +type NextAppIDsResponse = GenericObject + +// AppEnvPubKeyResponse is the response for getting app env encryption public key. +type AppEnvPubKeyResponse = GenericObject + +// KMSOnChainDetail represents KMS on-chain detail. +type KMSOnChainDetail struct { + ChainName string `json:"chain_name"` + ChainID int `json:"chain_id"` + Contracts []OnChainKMSContract `json:"contracts,omitempty"` +} + +// OnChainKMSContract represents an on-chain KMS contract. +type OnChainKMSContract struct { + ContractAddress string `json:"contract_address"` + ChainID int `json:"chain_id"` + ChainName string `json:"chain_name"` + Devices []OnChainDevice `json:"devices,omitempty"` + OSImages []OnChainOSImage `json:"os_images,omitempty"` +} + +// OnChainDevice represents an on-chain device. +type OnChainDevice struct { + DeviceID string `json:"device_id"` + NodeName *string `json:"node_name,omitempty"` + OnChainAllowed *bool `json:"on_chain_allowed,omitempty"` +} + +// OnChainOSImage represents an on-chain OS image. +type OnChainOSImage struct { + Name string `json:"name"` + Version string `json:"version"` + OSImageHash *string `json:"os_image_hash,omitempty"` + OnChainAllowed *bool `json:"on_chain_allowed,omitempty"` +} diff --git a/go/types_nodes.go b/go/types_nodes.go new file mode 100644 index 00000000..367cefd0 --- /dev/null +++ b/go/types_nodes.go @@ -0,0 +1,43 @@ +package phala + +// AvailableNodes is the response for listing available nodes. +type AvailableNodes struct { + Tier string `json:"tier"` + Capacity ResourceThreshold `json:"capacity"` + Nodes []TeepodCapacity `json:"nodes"` + KMSList []KMSInfo `json:"kms_list"` +} + +// ResourceThreshold holds resource threshold limits. +type ResourceThreshold struct { + MaxInstances *int `json:"max_instances,omitempty"` + MaxVCPU *int `json:"max_vcpu,omitempty"` + MaxMemory *int `json:"max_memory,omitempty"` + MaxDisk *int `json:"max_disk,omitempty"` +} + +// TeepodCapacity represents a node's capacity. +type TeepodCapacity struct { + TeepodID int `json:"teepod_id"` + Name string `json:"name"` + Listed bool `json:"listed"` + ResourceScore float64 `json:"resource_score"` + RemainingVCPU float64 `json:"remaining_vcpu"` + RemainingMemory float64 `json:"remaining_memory"` + RemainingCVMSlots float64 `json:"remaining_cvm_slots"` + Images []AvailableImage `json:"images"` + SupportOnchainKMS *bool `json:"support_onchain_kms,omitempty"` + FMSPC *string `json:"fmspc,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + RegionIdentifier *string `json:"region_identifier,omitempty"` + DefaultKMS *string `json:"default_kms,omitempty"` + KMSList []string `json:"kms_list,omitempty"` +} + +// AvailableImage represents an available OS image on a node. +type AvailableImage struct { + Name string `json:"name"` + IsDev bool `json:"is_dev"` + Version any `json:"version"` + OSImageHash *string `json:"os_image_hash,omitempty"` +} diff --git a/go/types_os_images.go b/go/types_os_images.go new file mode 100644 index 00000000..41ea072b --- /dev/null +++ b/go/types_os_images.go @@ -0,0 +1,4 @@ +package phala + +// OSImagesResponse is the paginated list of OS images. +type OSImagesResponse = Paginated[OSImage] diff --git a/go/types_ssh_keys.go b/go/types_ssh_keys.go new file mode 100644 index 00000000..d1ccb084 --- /dev/null +++ b/go/types_ssh_keys.go @@ -0,0 +1,21 @@ +package phala + +// SSHKey represents an SSH key. +type SSHKey struct { + ID string `json:"id"` + Name string `json:"name"` + PublicKey string `json:"public_key"` + CreatedAt *string `json:"created_at,omitempty"` +} + +// CreateSSHKeyRequest is the request for creating an SSH key. +type CreateSSHKeyRequest struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` +} + +// ImportGithubResponse is the response for importing GitHub SSH keys. +type ImportGithubResponse = GenericObject + +// SyncGithubResponse is the response for syncing GitHub SSH keys. +type SyncGithubResponse = GenericObject diff --git a/go/types_workspaces.go b/go/types_workspaces.go new file mode 100644 index 00000000..3603b8aa --- /dev/null +++ b/go/types_workspaces.go @@ -0,0 +1,19 @@ +package phala + +// Workspace represents workspace information. +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + Tier string `json:"tier"` + Avatar *string `json:"avatar,omitempty"` +} + +// ListWorkspacesResponse is the response for listing workspaces. +type ListWorkspacesResponse = GenericObject + +// WorkspaceNodes is the response for workspace nodes. +type WorkspaceNodes = GenericObject + +// WorkspaceQuotas is the response for workspace quotas. +type WorkspaceQuotas = GenericObject diff --git a/go/version.go b/go/version.go new file mode 100644 index 00000000..11cc4588 --- /dev/null +++ b/go/version.go @@ -0,0 +1,12 @@ +package phala + +const ( + // DefaultAPIVersion is the default Phala Cloud API version. + DefaultAPIVersion = "2026-01-21" + + // DefaultBaseURL is the default Phala Cloud API base URL. + DefaultBaseURL = "https://cloud.phala.network/api/v1" + + // sdkVersion is the version of this SDK, used in User-Agent. + sdkVersion = "0.1.0" +) diff --git a/go/workspaces.go b/go/workspaces.go new file mode 100644 index 00000000..0e7093ce --- /dev/null +++ b/go/workspaces.go @@ -0,0 +1,56 @@ +package phala + +import ( + "context" + "fmt" + "net/url" +) + +// ListWorkspaces returns the list of workspaces. +func (c *Client) ListWorkspaces(ctx context.Context) (*ListWorkspacesResponse, error) { + var result ListWorkspacesResponse + if err := c.doJSON(ctx, "GET", "/workspaces", nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetWorkspace returns information about a workspace. +func (c *Client) GetWorkspace(ctx context.Context, slug string) (*Workspace, error) { + var result Workspace + if err := c.doJSON(ctx, "GET", "/workspaces/"+slug, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetWorkspaceNodes returns nodes for a workspace. +func (c *Client) GetWorkspaceNodes(ctx context.Context, slug string, opts *PaginationOptions) (*WorkspaceNodes, error) { + path := "/workspaces/" + slug + "/nodes" + if opts != nil { + q := url.Values{} + if opts.Page != nil { + q.Set("page", fmt.Sprintf("%d", *opts.Page)) + } + if opts.PageSize != nil { + q.Set("page_size", fmt.Sprintf("%d", *opts.PageSize)) + } + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + } + var result WorkspaceNodes + if err := c.doJSON(ctx, "GET", path, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetWorkspaceQuotas returns quotas for a workspace. +func (c *Client) GetWorkspaceQuotas(ctx context.Context, slug string) (*WorkspaceQuotas, error) { + var result WorkspaceQuotas + if err := c.doJSON(ctx, "GET", "/workspaces/"+slug+"/quotas", nil, &result); err != nil { + return nil, err + } + return &result, nil +} From d53ba4aacb724633b9b74c8453bcb9857f0926d8 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 19:16:26 +0800 Subject: [PATCH 2/8] fix(go): skip delay after final retry attempt Avoid unnecessary sleep (up to 20s) when the last retry fails and no more attempts will be made. --- go/retry.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/go/retry.go b/go/retry.go index b7f6e1eb..ff1ccbd1 100644 --- a/go/retry.go +++ b/go/retry.go @@ -27,6 +27,11 @@ func (c *Client) doWithRetry(ctx context.Context, fn func() error) error { } lastErr = err + // Don't delay after the final attempt. + if attempt == c.maxRetries { + break + } + // Calculate delay. delay := apiErr.RetryAfter() if delay == 0 { From c1bec828657407c4a19f693964b4058ef74a88a9 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 19:19:23 +0800 Subject: [PATCH 3/8] fix(go): align Go SDK with JS/Python SDK behavior - Fix default base URL to https://cloud-api.phala.com/api/v1 - Make RestartCVM force parameter configurable (default false) - Add overwrite/dry_run options to RefreshCVMInstanceID - Add full filtering options to RefreshCVMInstanceIDs --- go/cvms.go | 13 +++++++++++-- go/cvms_config.go | 36 +++++++++++++++++++++++++++++++----- go/e2e_test.go | 6 +++--- go/version.go | 2 +- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/go/cvms.go b/go/cvms.go index b5dafb3f..80c1470b 100644 --- a/go/cvms.go +++ b/go/cvms.go @@ -166,11 +166,20 @@ func (c *Client) ShutdownCVM(ctx context.Context, cvmID string) (*CVMActionRespo return &result, nil } +// RestartCVMOptions configures optional parameters for RestartCVM. +type RestartCVMOptions struct { + Force bool `json:"force"` +} + // RestartCVM restarts a CVM. -func (c *Client) RestartCVM(ctx context.Context, cvmID string) (*CVMActionResponse, error) { +func (c *Client) RestartCVM(ctx context.Context, cvmID string, opts *RestartCVMOptions) (*CVMActionResponse, error) { + body := map[string]bool{"force": false} + if opts != nil { + body["force"] = opts.Force + } var result CVMActionResponse err := c.doWithRetry(ctx, func() error { - return c.doJSON(ctx, "POST", cvmPath(cvmID, "restart"), map[string]bool{"force": true}, &result) + return c.doJSON(ctx, "POST", cvmPath(cvmID, "restart"), body, &result) }) if err != nil { return nil, err diff --git a/go/cvms_config.go b/go/cvms_config.go index 14619284..8829fc51 100644 --- a/go/cvms_config.go +++ b/go/cvms_config.go @@ -52,11 +52,21 @@ func (c *Client) UpdateOSImage(ctx context.Context, cvmID string, req *UpdateOSI // RefreshInstanceIDResponse is the response for refreshing a CVM instance ID. type RefreshInstanceIDResponse = GenericObject +// RefreshInstanceIDOptions configures optional parameters for RefreshCVMInstanceID. +type RefreshInstanceIDOptions struct { + Overwrite *bool `json:"overwrite,omitempty"` + DryRun *bool `json:"dry_run,omitempty"` +} + // RefreshCVMInstanceID refreshes the instance ID for a CVM. -func (c *Client) RefreshCVMInstanceID(ctx context.Context, cvmID string) (*RefreshInstanceIDResponse, error) { +func (c *Client) RefreshCVMInstanceID(ctx context.Context, cvmID string, opts *RefreshInstanceIDOptions) (*RefreshInstanceIDResponse, error) { + var body any + if opts != nil { + body = opts + } var result RefreshInstanceIDResponse err := c.doWithRetry(ctx, func() error { - return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "instance-id"), nil, &result) + return c.doJSON(ctx, "PATCH", cvmPath(cvmID, "instance-id"), body, &result) }) if err != nil { return nil, err @@ -67,11 +77,27 @@ func (c *Client) RefreshCVMInstanceID(ctx context.Context, cvmID string) (*Refre // RefreshInstanceIDsResponse is the response for refreshing all CVM instance IDs. type RefreshInstanceIDsResponse = GenericObject -// RefreshCVMInstanceIDs refreshes instance IDs for all CVMs. -func (c *Client) RefreshCVMInstanceIDs(ctx context.Context) (*RefreshInstanceIDsResponse, error) { +// RefreshInstanceIDsRequest configures optional parameters for RefreshCVMInstanceIDs. +type RefreshInstanceIDsRequest struct { + CVMIDs []string `json:"cvm_ids,omitempty"` + RunningOnly *bool `json:"running_only,omitempty"` + MissingOnly *bool `json:"missing_only,omitempty"` + Overwrite *bool `json:"overwrite,omitempty"` + Limit *int `json:"limit,omitempty"` + DryRun *bool `json:"dry_run,omitempty"` +} + +// RefreshCVMInstanceIDs refreshes instance IDs for CVMs. +func (c *Client) RefreshCVMInstanceIDs(ctx context.Context, req *RefreshInstanceIDsRequest) (*RefreshInstanceIDsResponse, error) { + var body any + if req != nil { + body = req + } else { + body = map[string]any{} + } var result RefreshInstanceIDsResponse err := c.doWithRetry(ctx, func() error { - return c.doJSON(ctx, "PATCH", "/cvms/instance-ids", map[string]any{}, &result) + return c.doJSON(ctx, "PATCH", "/cvms/instance-ids", body, &result) }) if err != nil { return nil, err diff --git a/go/e2e_test.go b/go/e2e_test.go index 020bc267..ba18a22e 100644 --- a/go/e2e_test.go +++ b/go/e2e_test.go @@ -501,12 +501,12 @@ func TestE2EAllInterfaces(t *testing.T) { waitIdle(t, client, cvmID, 5*time.Minute) // Refresh instance ID - _, err = client.RefreshCVMInstanceID(ctx, cvmID) + _, err = client.RefreshCVMInstanceID(ctx, cvmID, nil) if err != nil { t.Logf("RefreshCVMInstanceID: %v", err) } - _, err = client.RefreshCVMInstanceIDs(ctx) + _, err = client.RefreshCVMInstanceIDs(ctx, nil) if err != nil { t.Logf("RefreshCVMInstanceIDs: %v", err) } @@ -533,7 +533,7 @@ func TestE2EAllInterfaces(t *testing.T) { assertIdle(t, client, cvmID, "before lifecycle") // Restart - _, err := client.RestartCVM(ctx, cvmID) + _, err := client.RestartCVM(ctx, cvmID, nil) if err != nil { t.Fatalf("RestartCVM: %v", err) } diff --git a/go/version.go b/go/version.go index 11cc4588..7eba9cfd 100644 --- a/go/version.go +++ b/go/version.go @@ -5,7 +5,7 @@ const ( DefaultAPIVersion = "2026-01-21" // DefaultBaseURL is the default Phala Cloud API base URL. - DefaultBaseURL = "https://cloud.phala.network/api/v1" + DefaultBaseURL = "https://cloud-api.phala.com/api/v1" // sdkVersion is the version of this SDK, used in User-Agent. sdkVersion = "0.1.0" From 2b5309569e6cd9d1ecd16584e2a83d55eaf959f9 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 19:24:49 +0800 Subject: [PATCH 4/8] refactor(go): move e2e tests to separate module and add README - Move e2e_test.go to e2e/ subdirectory with its own go.mod - Use external package import pattern for cleaner separation - Add comprehensive README.md with usage examples --- go/README.md | 341 +++++++++++++++++++++++++++++++++++++++ go/{ => e2e}/e2e_test.go | 91 +++++------ go/e2e/go.mod | 12 ++ go/e2e/go.sum | 6 + 4 files changed, 405 insertions(+), 45 deletions(-) create mode 100644 go/README.md rename go/{ => e2e}/e2e_test.go (88%) create mode 100644 go/e2e/go.mod create mode 100644 go/e2e/go.sum diff --git a/go/README.md b/go/README.md new file mode 100644 index 00000000..3ca2e5cd --- /dev/null +++ b/go/README.md @@ -0,0 +1,341 @@ +# Phala Cloud Go SDK + +Go client for the [Phala Cloud](https://cloud.phala.network) API. + +## Installation + +```bash +go get github.com/Phala-Network/phala-cloud-sdk-go +``` + +Requires Go 1.25+. + +## Quick Start + +```go +package main + +import ( + "context" + "fmt" + "log" + + phala "github.com/Phala-Network/phala-cloud-sdk-go" +) + +func main() { + client, err := phala.NewClient( + phala.WithAPIKey("your-api-key"), + ) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + user, err := client.GetCurrentUser(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Hello, %s!\n", user.User.Username) +} +``` + +## Environment Variables + +- `PHALA_CLOUD_API_KEY` — API key for authentication +- `PHALA_CLOUD_API_PREFIX` — Override base URL (default: `https://cloud-api.phala.com/api/v1`) + +```go +// Reads PHALA_CLOUD_API_KEY from environment automatically +client, err := phala.NewClient() +``` + +## Client Options + +```go +client, err := phala.NewClient( + phala.WithAPIKey("your-api-key"), + phala.WithBaseURL("https://custom-api.example.com/api/v1"), + phala.WithTimeout(60 * time.Second), + phala.WithMaxRetries(5), + phala.WithUserAgent("my-app/1.0"), + phala.WithHeader("X-Custom", "value"), + phala.WithHTTPClient(customHTTPClient), + phala.WithAPIVersion("2026-01-21"), +) +``` + +## Usage Examples + +### Deploy a CVM + +```go +ctx := context.Background() + +// Step 1: Provision +provision, err := client.ProvisionCVM(ctx, &phala.ProvisionCVMRequest{ + Name: "my-app", + InstanceType: "tdx.small", + ComposeFile: &phala.ComposeFile{ + DockerComposeFile: `services: + app: + image: nginx:latest + ports: + - "80:80" +`, + GatewayEnabled: phala.Bool(true), + }, +}) +if err != nil { + log.Fatal(err) +} + +// Step 2: Commit +cvm, err := client.CommitCVMProvision(ctx, &phala.CommitCVMProvisionRequest{ + AppID: provision.AppID, + ComposeHash: provision.ComposeHash, +}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("CVM deployed: %s\n", cvm.CvmID()) +``` + +### List and Manage CVMs + +```go +// List CVMs with pagination +list, err := client.GetCVMList(ctx, &phala.PaginationOptions{Page: 1, PageSize: 10}) + +// Get CVM details +info, err := client.GetCVMInfo(ctx, "cvm-id") + +// Lifecycle operations +_, err = client.StartCVM(ctx, "cvm-id") +_, err = client.StopCVM(ctx, "cvm-id") +_, err = client.RestartCVM(ctx, "cvm-id", &phala.RestartCVMOptions{Force: true}) +_, err = client.ShutdownCVM(ctx, "cvm-id") +err = client.DeleteCVM(ctx, "cvm-id") +``` + +### Update CVM Configuration + +```go +// Update docker compose +_, err := client.UpdateDockerCompose(ctx, "cvm-id", composeYAML, nil) + +// Update pre-launch script +_, err = client.UpdatePreLaunchScript(ctx, "cvm-id", "#!/bin/sh\necho hello", nil) + +// Update resources +err = client.UpdateCVMResources(ctx, "cvm-id", &phala.UpdateResourcesRequest{ + VCPU: phala.Int(4), + Memory: phala.Int(8192), + DiskSize: phala.Int(50), + AllowRestart: phala.Bool(true), +}) + +// Update visibility +_, err = client.UpdateCVMVisibility(ctx, "cvm-id", &phala.UpdateVisibilityRequest{ + PublicSysinfo: phala.Bool(true), + PublicLogs: phala.Bool(true), +}) + +// Update environment variables +_, err = client.UpdateCVMEnvs(ctx, "cvm-id", &phala.UpdateEnvsRequest{ + EncryptedEnv: encryptedHex, +}) +``` + +### Patch CVM (Multi-field Update) + +```go +compose := "services:\n app:\n image: nginx:latest\n" +resp, err := client.PatchCVM(ctx, "cvm-id", &phala.PatchCVMRequest{ + DockerComposeFile: &compose, + PublicSysinfo: phala.Bool(true), +}) +if err != nil { + log.Fatal(err) +} +if resp.RequiresOnChainHash { + // Handle on-chain confirmation flow + _, err = client.ConfirmCVMPatch(ctx, "cvm-id", &phala.ConfirmCVMPatchRequest{ + ComposeHash: resp.ComposeHash, + TransactionHash: txHash, + }) +} +``` + +### Watch CVM State (SSE) + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) +defer cancel() + +ch, err := client.WatchCVMState(ctx, "cvm-id", &phala.WatchCVMStateOptions{ + Target: "running", + Interval: 5, + Timeout: 300, + MaxRetries: 3, +}) +if err != nil { + log.Fatal(err) +} + +for event := range ch { + if event.Error != nil { + log.Printf("error: %v", event.Error) + continue + } + fmt.Printf("state: %s\n", event.Event) +} +``` + +### Applications + +```go +apps, err := client.GetAppList(ctx) +appInfo, err := client.GetAppInfo(ctx, "app-id") +cvms, err := client.GetAppCVMs(ctx, "app-id") +revisions, err := client.GetAppRevisions(ctx, "app-id", nil) +attestation, err := client.GetAppAttestation(ctx, "app-id") +allowlist, err := client.GetAppDeviceAllowlist(ctx, "app-id") +filters, err := client.GetAppFilterOptions(ctx) +``` + +### SSH Keys + +```go +keys, err := client.ListSSHKeys(ctx) + +created, err := client.CreateSSHKey(ctx, &phala.CreateSSHKeyRequest{ + Name: "my-key", + PublicKey: "ssh-ed25519 AAAA...", +}) + +err = client.DeleteSSHKey(ctx, "key-id") + +imported, err := client.ImportGithubProfileSSHKeys(ctx, "github-username") +synced, err := client.SyncGithubSSHKeys(ctx) +``` + +### Infrastructure & KMS + +```go +// Available nodes +nodes, err := client.GetAvailableNodes(ctx) + +// Instance types +families, err := client.ListAllInstanceTypeFamilies(ctx) +types, err := client.ListFamilyInstanceTypes(ctx, "tdx") + +// OS images +images, err := client.GetOSImages(ctx) + +// KMS +kmsList, err := client.GetKMSList(ctx) +kmsInfo, err := client.GetKMSInfo(ctx, "kms-id") +pubkey, err := client.GetAppEnvEncryptPubKey(ctx, "phala", "app-id") +onchain, err := client.GetKMSOnChainDetail(ctx, "base") +nextIDs, err := client.NextAppIDs(ctx) + +// Workspaces +workspaces, err := client.ListWorkspaces(ctx) +workspace, err := client.GetWorkspace(ctx, "team-slug") +wsNodes, err := client.GetWorkspaceNodes(ctx, "team-slug", nil) +quotas, err := client.GetWorkspaceQuotas(ctx, "team-slug") +``` + +## Error Handling + +All API errors are returned as `*phala.APIError`: + +```go +info, err := client.GetCVMInfo(ctx, "nonexistent") +if err != nil { + var apiErr *phala.APIError + if errors.As(err, &apiErr) { + fmt.Printf("status: %d\n", apiErr.StatusCode) + fmt.Printf("message: %s\n", apiErr.Message) + + if apiErr.IsAuth() { + // 401 or 403 + } + if apiErr.IsValidation() { + // 422 + } + if apiErr.IsBusiness() { + // 4xx business logic error + } + if apiErr.IsServer() { + // 5xx + } + if apiErr.IsComposePrecondition() { + // 465 — requires on-chain hash confirmation + } + } +} +``` + +## Automatic Retries + +The client automatically retries on `409`, `429`, and `503` responses with exponential backoff (1s base, 20s max). Configure with `WithMaxRetries`: + +```go +client, _ := phala.NewClient( + phala.WithMaxRetries(10), // default: 30 +) +``` + +## Pointer Helpers + +Use `phala.String()`, `phala.Int()`, `phala.Bool()`, etc. for optional pointer fields: + +```go +&phala.PatchCVMRequest{ + PublicSysinfo: phala.Bool(true), + VCPU: phala.Int(4), +} +``` + +## Project Structure + +``` +go/ +├── client.go # Client initialization +├── client_options.go # WithXxx option functions +├── request.go # HTTP request handling +├── retry.go # Retry with exponential backoff +├── errors.go # APIError type and classification +├── helpers.go # CVM ID resolution, pointer helpers +├── version.go # SDK version and defaults +├── doc.go # Package documentation +├── auth.go # GET /auth/me +├── apps.go # App management +├── cvms.go # CVM provisioning and lifecycle +├── cvms_config.go # CVM configuration updates +├── cvms_compose.go # Compose file operations +├── cvms_watch.go # SSE state watching +├── ssh_keys.go # SSH key management +├── workspaces.go # Workspace operations +├── nodes.go # Available nodes +├── os_images.go # OS image listing +├── instance_types.go # Instance type families +├── kms.go # KMS operations +├── status.go # Batch status queries +├── types_*.go # Type definitions +└── e2e/ # E2E integration tests + └── e2e_test.go +``` + +## Running E2E Tests + +```bash +PHALA_CLOUD_E2E_API_KEY=your-key go test -tags e2e -v -timeout 30m ./e2e/ +``` + +## License + +Apache-2.0 diff --git a/go/e2e_test.go b/go/e2e/e2e_test.go similarity index 88% rename from go/e2e_test.go rename to go/e2e/e2e_test.go index ba18a22e..02d864e0 100644 --- a/go/e2e_test.go +++ b/go/e2e/e2e_test.go @@ -1,6 +1,6 @@ //go:build e2e -package phala +package e2e import ( "context" @@ -17,6 +17,7 @@ import ( "testing" "time" + phala "github.com/Phala-Network/phala-cloud-sdk-go" "golang.org/x/crypto/ssh" ) @@ -33,13 +34,13 @@ const testCompose = `services: // transientStates are CVM states that indicate an operation is in progress. var transientStates = map[string]bool{ - "starting": true, - "stopping": true, - "restarting": true, + "starting": true, + "stopping": true, + "restarting": true, "shutting_down": true, - "provisioning": true, - "in_progress": true, - "updating": true, + "provisioning": true, + "in_progress": true, + "updating": true, } func mustEnv(t *testing.T, key, fallback string) string { @@ -131,13 +132,13 @@ func encryptEnvVars(t *testing.T, serverPubkeyHex string, envs map[string]string return hex.EncodeToString(result) } -func newE2EClient(t *testing.T) *Client { +func newE2EClient(t *testing.T) *phala.Client { t.Helper() apiKey := mustEnv(t, "PHALA_CLOUD_E2E_API_KEY", "") - baseURL := mustEnv(t, "PHALA_CLOUD_E2E_BASE_URL", "https://cloud.phala.network/api/v1") - client, err := NewClient( - WithAPIKey(apiKey), - WithBaseURL(baseURL), + baseURL := mustEnv(t, "PHALA_CLOUD_E2E_BASE_URL", "https://cloud-api.phala.com/api/v1") + client, err := phala.NewClient( + phala.WithAPIKey(apiKey), + phala.WithBaseURL(baseURL), ) if err != nil { t.Fatalf("failed to create client: %v", err) @@ -145,7 +146,7 @@ func newE2EClient(t *testing.T) *Client { return client } -func waitIdle(t *testing.T, client *Client, cvmID string, timeout time.Duration) { +func waitIdle(t *testing.T, client *phala.Client, cvmID string, timeout time.Duration) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -183,7 +184,7 @@ func waitIdle(t *testing.T, client *Client, cvmID string, timeout time.Duration) } } -func assertIdle(t *testing.T, client *Client, cvmID, label string) { +func assertIdle(t *testing.T, client *phala.Client, cvmID, label string) { t.Helper() ctx := context.Background() info, err := client.GetCVMInfo(ctx, cvmID) @@ -195,18 +196,18 @@ func assertIdle(t *testing.T, client *Client, cvmID, label string) { } } -func deploy(t *testing.T, client *Client) (cvmID, appID, encryptPubkey string) { +func deploy(t *testing.T, client *phala.Client) (cvmID, appID, encryptPubkey string) { t.Helper() ctx := context.Background() name := genCVMName() t.Logf("deploying CVM: %s", name) - provResp, err := client.ProvisionCVM(ctx, &ProvisionCVMRequest{ + provResp, err := client.ProvisionCVM(ctx, &phala.ProvisionCVMRequest{ Name: name, InstanceType: "tdx.small", - ComposeFile: &ComposeFile{ + ComposeFile: &phala.ComposeFile{ DockerComposeFile: testCompose, - GatewayEnabled: Bool(true), + GatewayEnabled: phala.Bool(true), }, }) if err != nil { @@ -214,7 +215,7 @@ func deploy(t *testing.T, client *Client) (cvmID, appID, encryptPubkey string) { } t.Logf("provisioned: app_id=%s encrypt_pubkey=%v", provResp.AppID, provResp.AppEnvEncryptPubkey != "") - commitResp, err := client.CommitCVMProvision(ctx, &CommitCVMProvisionRequest{ + commitResp, err := client.CommitCVMProvision(ctx, &phala.CommitCVMProvisionRequest{ AppID: provResp.AppID, ComposeHash: provResp.ComposeHash, }) @@ -338,7 +339,7 @@ func TestE2EAllInterfaces(t *testing.T) { _, privKey, _ := ed25519.GenerateKey(rand.Reader) sshPub, _ := ssh.NewPublicKey(privKey.Public()) pubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) + " e2e-test" - created, err := client.CreateSSHKey(ctx, &CreateSSHKeyRequest{ + created, err := client.CreateSSHKey(ctx, &phala.CreateSSHKeyRequest{ Name: "e2e-test-" + hex.EncodeToString([]byte(genCVMName())[:4]), PublicKey: pubKeyStr, }) @@ -454,7 +455,7 @@ func TestE2EAllInterfaces(t *testing.T) { watchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - ch, err := client.WatchCVMState(watchCtx, cvmID, &WatchCVMStateOptions{ + ch, err := client.WatchCVMState(watchCtx, cvmID, &phala.WatchCVMStateOptions{ Target: status, Timeout: 20, MaxRetries: 0, @@ -477,9 +478,9 @@ func TestE2EAllInterfaces(t *testing.T) { assertIdle(t, client, cvmID, "before mutations") // Visibility - _, err := client.UpdateCVMVisibility(ctx, cvmID, &UpdateVisibilityRequest{ - PublicSysinfo: Bool(true), - PublicLogs: Bool(true), + _, err := client.UpdateCVMVisibility(ctx, cvmID, &phala.UpdateVisibilityRequest{ + PublicSysinfo: phala.Bool(true), + PublicLogs: phala.Bool(true), }) if err != nil { t.Fatalf("UpdateCVMVisibility: %v", err) @@ -515,7 +516,7 @@ func TestE2EAllInterfaces(t *testing.T) { if encryptPubkey != "" { assertIdle(t, client, cvmID, "before update_cvm_envs") encrypted := encryptEnvVars(t, encryptPubkey, map[string]string{"E2E_TEST": "1"}) - _, err = client.UpdateCVMEnvs(ctx, cvmID, &UpdateEnvsRequest{ + _, err = client.UpdateCVMEnvs(ctx, cvmID, &phala.UpdateEnvsRequest{ EncryptedEnv: encrypted, }) if err != nil { @@ -576,9 +577,9 @@ func TestE2EAllInterfaces(t *testing.T) { waitIdle(t, client, cvmID, 10*time.Minute) // Visibility-only patch - _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ - PublicSysinfo: Bool(true), - PublicLogs: Bool(true), + _, err = client.PatchCVM(ctx, cvmID, &phala.PatchCVMRequest{ + PublicSysinfo: phala.Bool(true), + PublicLogs: phala.Bool(true), }) if err != nil { t.Logf("PatchCVM visibility: %v", err) @@ -587,7 +588,7 @@ func TestE2EAllInterfaces(t *testing.T) { // Docker compose patch compose := testCompose - _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ + _, err = client.PatchCVM(ctx, cvmID, &phala.PatchCVMRequest{ DockerComposeFile: &compose, }) if err != nil { @@ -597,7 +598,7 @@ func TestE2EAllInterfaces(t *testing.T) { // Pre-launch script patch script := "#!/bin/sh\ntrue" - _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ + _, err = client.PatchCVM(ctx, cvmID, &phala.PatchCVMRequest{ PreLaunchScript: &script, }) if err != nil { @@ -606,8 +607,8 @@ func TestE2EAllInterfaces(t *testing.T) { waitIdle(t, client, cvmID, 10*time.Minute) // Multi-field patch - _, err = client.PatchCVM(ctx, cvmID, &PatchCVMRequest{ - PublicSysinfo: Bool(true), + _, err = client.PatchCVM(ctx, cvmID, &phala.PatchCVMRequest{ + PublicSysinfo: phala.Bool(true), DockerComposeFile: &compose, }) if err != nil { @@ -635,33 +636,33 @@ func TestE2EPatchCVM(t *testing.T) { tests := []struct { name string - patch *PatchCVMRequest + patch *phala.PatchCVMRequest skip bool }{ { name: "visibility_only", - patch: &PatchCVMRequest{ - PublicSysinfo: Bool(true), - PublicLogs: Bool(true), + patch: &phala.PatchCVMRequest{ + PublicSysinfo: phala.Bool(true), + PublicLogs: phala.Bool(true), }, }, { name: "docker_compose", - patch: func() *PatchCVMRequest { + patch: func() *phala.PatchCVMRequest { c := testCompose - return &PatchCVMRequest{DockerComposeFile: &c} + return &phala.PatchCVMRequest{DockerComposeFile: &c} }(), }, { name: "pre_launch_script", - patch: func() *PatchCVMRequest { + patch: func() *phala.PatchCVMRequest { s := "#!/bin/sh\ntrue" - return &PatchCVMRequest{PreLaunchScript: &s} + return &phala.PatchCVMRequest{PreLaunchScript: &s} }(), }, { name: "encrypted_env", - patch: func() *PatchCVMRequest { + patch: func() *phala.PatchCVMRequest { if encryptPubkey == "" { return nil } @@ -672,10 +673,10 @@ func TestE2EPatchCVM(t *testing.T) { }, { name: "multi_field", - patch: func() *PatchCVMRequest { + patch: func() *phala.PatchCVMRequest { c := testCompose - return &PatchCVMRequest{ - PublicSysinfo: Bool(true), + return &phala.PatchCVMRequest{ + PublicSysinfo: phala.Bool(true), DockerComposeFile: &c, } }(), @@ -694,7 +695,7 @@ func TestE2EPatchCVM(t *testing.T) { // Build encrypted_env patch dynamically (needs *testing.T for encryptEnvVars). if tt.name == "encrypted_env" { encrypted := encryptEnvVars(t, encryptPubkey, map[string]string{"E2E_TEST": "1"}) - patch = &PatchCVMRequest{EncryptedEnv: &encrypted} + patch = &phala.PatchCVMRequest{EncryptedEnv: &encrypted} } resp, err := client.PatchCVM(ctx, cvmID, patch) diff --git a/go/e2e/go.mod b/go/e2e/go.mod new file mode 100644 index 00000000..504270a0 --- /dev/null +++ b/go/e2e/go.mod @@ -0,0 +1,12 @@ +module github.com/Phala-Network/phala-cloud-sdk-go/e2e + +go 1.25.0 + +require ( + github.com/Phala-Network/phala-cloud-sdk-go v0.0.0 + golang.org/x/crypto v0.49.0 +) + +require golang.org/x/sys v0.42.0 // indirect + +replace github.com/Phala-Network/phala-cloud-sdk-go => ../ diff --git a/go/e2e/go.sum b/go/e2e/go.sum new file mode 100644 index 00000000..e7defb97 --- /dev/null +++ b/go/e2e/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= From 91b31ab6b791d24fa52735eb66734e61929fa373 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 19:36:11 +0800 Subject: [PATCH 5/8] feat(go): add unit tests, gofmt fixes, and pre-commit hooks - Add unit tests for client, errors, helpers, and retry logic - Fix gofmt formatting in source and test files - Add Go pre-commit hooks (gofmt, go vet, go test) to sdks config --- .pre-commit-config.yaml | 20 +++++ go/client_test.go | 178 ++++++++++++++++++++++++++++++++++++++++ go/cvms_watch.go | 6 +- go/errors_test.go | 133 ++++++++++++++++++++++++++++++ go/helpers_test.go | 68 +++++++++++++++ go/retry_test.go | 137 +++++++++++++++++++++++++++++++ go/types_apps.go | 28 +++---- go/types_cvms.go | 34 ++++---- go/types_kms.go | 4 +- go/types_nodes.go | 28 +++---- 10 files changed, 586 insertions(+), 50 deletions(-) create mode 100644 go/client_test.go create mode 100644 go/errors_test.go create mode 100644 go/helpers_test.go create mode 100644 go/retry_test.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79279888..5455f443 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,3 +8,23 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] files: ^python/ + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: bash -c 'cd go && files=$(gofmt -l .); [ -z "$files" ] || gofmt -l -w $files' + language: system + files: ^go/.*\.go$ + pass_filenames: false + - id: govet + name: go vet + entry: bash -c 'cd go && go vet ./...' + language: system + files: ^go/.*\.go$ + pass_filenames: false + - id: gotest + name: go test + entry: bash -c 'cd go && go test ./...' + language: system + files: ^go/.*\.go$ + pass_filenames: false diff --git a/go/client_test.go b/go/client_test.go new file mode 100644 index 00000000..952007ec --- /dev/null +++ b/go/client_test.go @@ -0,0 +1,178 @@ +package phala + +import ( + "net/http" + "os" + "testing" + "time" +) + +func TestNewClient_RequiresAPIKey(t *testing.T) { + // Ensure env var doesn't interfere. + orig := os.Getenv("PHALA_CLOUD_API_KEY") + os.Unsetenv("PHALA_CLOUD_API_KEY") + defer func() { + if orig != "" { + os.Setenv("PHALA_CLOUD_API_KEY", orig) + } + }() + + _, err := NewClient() + if err == nil { + t.Fatal("expected error when no API key, got nil") + } +} + +func TestNewClient_WithAPIKey(t *testing.T) { + c, err := NewClient(WithAPIKey("test-key")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.apiKey != "test-key" { + t.Errorf("apiKey = %q, want %q", c.apiKey, "test-key") + } +} + +func TestNewClient_Defaults(t *testing.T) { + c, err := NewClient(WithAPIKey("k")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != DefaultBaseURL { + t.Errorf("baseURL = %q, want %q", c.baseURL, DefaultBaseURL) + } + if c.apiVersion != DefaultAPIVersion { + t.Errorf("apiVersion = %q, want %q", c.apiVersion, DefaultAPIVersion) + } + if c.maxRetries != 30 { + t.Errorf("maxRetries = %d, want 30", c.maxRetries) + } +} + +func TestNewClient_EnvFallback(t *testing.T) { + orig := os.Getenv("PHALA_CLOUD_API_KEY") + os.Setenv("PHALA_CLOUD_API_KEY", "env-key") + defer func() { + if orig != "" { + os.Setenv("PHALA_CLOUD_API_KEY", orig) + } else { + os.Unsetenv("PHALA_CLOUD_API_KEY") + } + }() + + c, err := NewClient() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.apiKey != "env-key" { + t.Errorf("apiKey = %q, want %q", c.apiKey, "env-key") + } +} + +func TestNewClient_BaseURLEnvFallback(t *testing.T) { + origKey := os.Getenv("PHALA_CLOUD_API_KEY") + origURL := os.Getenv("PHALA_CLOUD_API_PREFIX") + os.Setenv("PHALA_CLOUD_API_KEY", "k") + os.Setenv("PHALA_CLOUD_API_PREFIX", "https://custom.example.com/api/v1") + defer func() { + if origKey != "" { + os.Setenv("PHALA_CLOUD_API_KEY", origKey) + } else { + os.Unsetenv("PHALA_CLOUD_API_KEY") + } + if origURL != "" { + os.Setenv("PHALA_CLOUD_API_PREFIX", origURL) + } else { + os.Unsetenv("PHALA_CLOUD_API_PREFIX") + } + }() + + c, err := NewClient() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://custom.example.com/api/v1" { + t.Errorf("baseURL = %q, want custom URL", c.baseURL) + } +} + +func TestNewClient_ExplicitBaseURLOverridesEnv(t *testing.T) { + origKey := os.Getenv("PHALA_CLOUD_API_KEY") + origURL := os.Getenv("PHALA_CLOUD_API_PREFIX") + os.Setenv("PHALA_CLOUD_API_KEY", "k") + os.Setenv("PHALA_CLOUD_API_PREFIX", "https://env.example.com") + defer func() { + if origKey != "" { + os.Setenv("PHALA_CLOUD_API_KEY", origKey) + } else { + os.Unsetenv("PHALA_CLOUD_API_KEY") + } + if origURL != "" { + os.Setenv("PHALA_CLOUD_API_PREFIX", origURL) + } else { + os.Unsetenv("PHALA_CLOUD_API_PREFIX") + } + }() + + c, err := NewClient(WithBaseURL("https://explicit.example.com")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://explicit.example.com" { + t.Errorf("baseURL = %q, want explicit URL", c.baseURL) + } +} + +func TestNewClient_TrailingSlashStripped(t *testing.T) { + c, err := NewClient(WithAPIKey("k"), WithBaseURL("https://example.com/api/")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://example.com/api" { + t.Errorf("baseURL = %q, want trailing slash stripped", c.baseURL) + } +} + +func TestOptions(t *testing.T) { + customHTTP := &http.Client{Timeout: 99 * time.Second} + c, err := NewClient( + WithAPIKey("k"), + WithBaseURL("https://test.com"), + WithAPIVersion("2025-01-01"), + WithHTTPClient(customHTTP), + WithUserAgent("my-app/1.0"), + WithHeader("X-Custom", "val"), + WithMaxRetries(5), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "https://test.com" { + t.Errorf("baseURL = %q", c.baseURL) + } + if c.apiVersion != "2025-01-01" { + t.Errorf("apiVersion = %q", c.apiVersion) + } + if c.httpClient != customHTTP { + t.Error("httpClient not set") + } + if c.userAgent != "my-app/1.0" { + t.Errorf("userAgent = %q", c.userAgent) + } + if c.headers["X-Custom"] != "val" { + t.Errorf("headers[X-Custom] = %q", c.headers["X-Custom"]) + } + if c.maxRetries != 5 { + t.Errorf("maxRetries = %d", c.maxRetries) + } +} + +func TestWithTimeout(t *testing.T) { + c, err := NewClient(WithAPIKey("k"), WithTimeout(30*time.Second)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.httpClient.Timeout != 30*time.Second { + t.Errorf("timeout = %v, want 30s", c.httpClient.Timeout) + } +} diff --git a/go/cvms_watch.go b/go/cvms_watch.go index 7aa58f3e..0fb06ff2 100644 --- a/go/cvms_watch.go +++ b/go/cvms_watch.go @@ -11,9 +11,9 @@ import ( // CVMStateEvent represents an event from the CVM state SSE stream. type CVMStateEvent struct { - Event string `json:"event"` - Data GenericObject `json:"data,omitempty"` - Error error `json:"-"` + Event string `json:"event"` + Data GenericObject `json:"data,omitempty"` + Error error `json:"-"` } // WatchCVMStateOptions holds options for watching CVM state. diff --git a/go/errors_test.go b/go/errors_test.go new file mode 100644 index 00000000..a99e3907 --- /dev/null +++ b/go/errors_test.go @@ -0,0 +1,133 @@ +package phala + +import ( + "net/http" + "testing" + "time" +) + +func TestAPIError_Error(t *testing.T) { + tests := []struct { + name string + err APIError + want string + }{ + { + name: "with message", + err: APIError{StatusCode: 400, Message: "bad request"}, + want: "phala api error (status 400): bad request", + }, + { + name: "without message", + err: APIError{StatusCode: 404}, + want: "phala api error (status 404): Not Found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + if got != tt.want { + t.Errorf("Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAPIError_Classification(t *testing.T) { + tests := []struct { + status int + isAuth, isValidation bool + isBusiness, isServer bool + isRetryable bool + isComposePrecond bool + }{ + {401, true, false, false, false, false, false}, + {403, true, false, false, false, false, false}, + {422, false, true, false, false, false, false}, + {400, false, false, true, false, false, false}, + {409, false, false, true, false, true, false}, + {429, false, false, true, false, true, false}, + {465, false, false, true, false, false, true}, + {500, false, false, false, true, false, false}, + {502, false, false, false, true, false, false}, + {503, false, false, false, true, true, false}, + } + + for _, tt := range tests { + e := &APIError{StatusCode: tt.status} + if e.IsAuth() != tt.isAuth { + t.Errorf("status %d: IsAuth() = %v, want %v", tt.status, e.IsAuth(), tt.isAuth) + } + if e.IsValidation() != tt.isValidation { + t.Errorf("status %d: IsValidation() = %v, want %v", tt.status, e.IsValidation(), tt.isValidation) + } + if e.IsBusiness() != tt.isBusiness { + t.Errorf("status %d: IsBusiness() = %v, want %v", tt.status, e.IsBusiness(), tt.isBusiness) + } + if e.IsServer() != tt.isServer { + t.Errorf("status %d: IsServer() = %v, want %v", tt.status, e.IsServer(), tt.isServer) + } + if e.IsRetryable() != tt.isRetryable { + t.Errorf("status %d: IsRetryable() = %v, want %v", tt.status, e.IsRetryable(), tt.isRetryable) + } + if e.IsComposePrecondition() != tt.isComposePrecond { + t.Errorf("status %d: IsComposePrecondition() = %v, want %v", tt.status, e.IsComposePrecondition(), tt.isComposePrecond) + } + } +} + +func TestAPIError_RetryAfter(t *testing.T) { + t.Run("no headers", func(t *testing.T) { + e := &APIError{StatusCode: 429} + if d := e.RetryAfter(); d != 0 { + t.Errorf("RetryAfter() = %v, want 0", d) + } + }) + + t.Run("empty header", func(t *testing.T) { + e := &APIError{StatusCode: 429, Headers: http.Header{}} + if d := e.RetryAfter(); d != 0 { + t.Errorf("RetryAfter() = %v, want 0", d) + } + }) + + t.Run("seconds", func(t *testing.T) { + h := http.Header{} + h.Set("Retry-After", "5") + e := &APIError{StatusCode: 429, Headers: h} + if d := e.RetryAfter(); d != 5*time.Second { + t.Errorf("RetryAfter() = %v, want 5s", d) + } + }) + + t.Run("http date", func(t *testing.T) { + future := time.Now().Add(10 * time.Second) + h := http.Header{} + h.Set("Retry-After", future.UTC().Format(http.TimeFormat)) + e := &APIError{StatusCode: 429, Headers: h} + d := e.RetryAfter() + if d < 8*time.Second || d > 12*time.Second { + t.Errorf("RetryAfter() = %v, want ~10s", d) + } + }) + + t.Run("past date", func(t *testing.T) { + past := time.Now().Add(-10 * time.Second) + h := http.Header{} + h.Set("Retry-After", past.UTC().Format(http.TimeFormat)) + e := &APIError{StatusCode: 429, Headers: h} + if d := e.RetryAfter(); d != 0 { + t.Errorf("RetryAfter() = %v, want 0 for past date", d) + } + }) + + t.Run("unparseable", func(t *testing.T) { + h := http.Header{} + h.Set("Retry-After", "not-a-number-or-date") + e := &APIError{StatusCode: 429, Headers: h} + if d := e.RetryAfter(); d != 0 { + t.Errorf("RetryAfter() = %v, want 0", d) + } + }) +} diff --git a/go/helpers_test.go b/go/helpers_test.go new file mode 100644 index 00000000..9b161a95 --- /dev/null +++ b/go/helpers_test.go @@ -0,0 +1,68 @@ +package phala + +import "testing" + +func TestResolveCVMID(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty", "", ""}, + {"integer id", "123", "123"}, + {"name", "my-cvm", "my-cvm"}, + {"prefixed app_id", "app_abcdef1234567890abcdef1234567890abcdef12", "app_abcdef1234567890abcdef1234567890abcdef12"}, + + // UUID with dashes → dashes removed + {"uuid with dashes", "550e8400-e29b-41d4-a716-446655440000", "550e8400e29b41d4a716446655440000"}, + // UUID without dashes → unchanged + {"uuid without dashes", "550e8400e29b41d4a716446655440000", "550e8400e29b41d4a716446655440000"}, + // UUID uppercase + {"uuid uppercase", "550E8400-E29B-41D4-A716-446655440000", "550E8400E29B41D4A716446655440000"}, + + // 40-char hex app_id → add app_ prefix + {"40 hex app_id", "abcdef1234567890abcdef1234567890abcdef12", "app_abcdef1234567890abcdef1234567890abcdef12"}, + {"40 hex uppercase", "ABCDEF1234567890ABCDEF1234567890ABCDEF12", "app_ABCDEF1234567890ABCDEF1234567890ABCDEF12"}, + + // 39 chars — not a valid app_id, return as-is + {"39 chars", "abcdef1234567890abcdef1234567890abcdef1", "abcdef1234567890abcdef1234567890abcdef1"}, + // 41 chars — not a valid app_id, return as-is + {"41 chars", "abcdef1234567890abcdef1234567890abcdef123", "abcdef1234567890abcdef1234567890abcdef123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveCVMID(tt.input) + if got != tt.want { + t.Errorf("ResolveCVMID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestPointerHelpers(t *testing.T) { + s := String("hello") + if *s != "hello" { + t.Errorf("String() = %q, want %q", *s, "hello") + } + + i := Int(42) + if *i != 42 { + t.Errorf("Int() = %d, want %d", *i, 42) + } + + i64 := Int64(99) + if *i64 != 99 { + t.Errorf("Int64() = %d, want %d", *i64, 99) + } + + f := Float64(3.14) + if *f != 3.14 { + t.Errorf("Float64() = %f, want %f", *f, 3.14) + } + + b := Bool(true) + if *b != true { + t.Errorf("Bool() = %v, want %v", *b, true) + } +} diff --git a/go/retry_test.go b/go/retry_test.go new file mode 100644 index 00000000..eb85ae2f --- /dev/null +++ b/go/retry_test.go @@ -0,0 +1,137 @@ +package phala + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestDoWithRetry_SuccessNoRetry(t *testing.T) { + c := &Client{maxRetries: 3} + calls := 0 + err := c.doWithRetry(context.Background(), func() error { + calls++ + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +func TestDoWithRetry_NonRetryableError(t *testing.T) { + c := &Client{maxRetries: 3} + calls := 0 + err := c.doWithRetry(context.Background(), func() error { + calls++ + return &APIError{StatusCode: 400, Message: "bad request"} + }) + if calls != 1 { + t.Errorf("calls = %d, want 1 (should not retry non-retryable)", calls) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 400 { + t.Errorf("status = %d, want 400", apiErr.StatusCode) + } +} + +func TestDoWithRetry_NonAPIError(t *testing.T) { + c := &Client{maxRetries: 3} + calls := 0 + err := c.doWithRetry(context.Background(), func() error { + calls++ + return errors.New("network error") + }) + if calls != 1 { + t.Errorf("calls = %d, want 1 (should not retry non-API errors)", calls) + } + if err == nil || err.Error() != "network error" { + t.Errorf("unexpected error: %v", err) + } +} + +func TestDoWithRetry_RetryThenSuccess(t *testing.T) { + c := &Client{maxRetries: 5} + calls := 0 + err := c.doWithRetry(context.Background(), func() error { + calls++ + if calls < 3 { + return &APIError{StatusCode: 429, Message: "too many requests"} + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 3 { + t.Errorf("calls = %d, want 3", calls) + } +} + +func TestDoWithRetry_ExhaustsRetries(t *testing.T) { + c := &Client{maxRetries: 2} + calls := 0 + err := c.doWithRetry(context.Background(), func() error { + calls++ + return &APIError{StatusCode: 503, Message: "unavailable"} + }) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + // maxRetries=2 means: attempt 0, 1, 2 = 3 total calls + if calls != 3 { + t.Errorf("calls = %d, want 3", calls) + } +} + +func TestDoWithRetry_ContextCanceled(t *testing.T) { + c := &Client{maxRetries: 100} + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + err := c.doWithRetry(ctx, func() error { + calls++ + if calls == 2 { + cancel() + } + return &APIError{StatusCode: 429, Message: "too many requests"} + }) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", err) + } +} + +func TestDoWithRetry_ZeroRetries(t *testing.T) { + c := &Client{maxRetries: 0} + calls := 0 + err := c.doWithRetry(context.Background(), func() error { + calls++ + return &APIError{StatusCode: 409, Message: "conflict"} + }) + if err == nil { + t.Fatal("expected error with zero retries") + } + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +func TestDoWithRetry_RetryAfterHeader(t *testing.T) { + c := &Client{maxRetries: 1} + start := time.Now() + calls := 0 + _ = c.doWithRetry(context.Background(), func() error { + calls++ + return &APIError{StatusCode: 429, Message: "rate limited"} + }) + elapsed := time.Since(start) + // With maxRetries=1, should have base delay ~1s between attempt 0 and 1. + if elapsed < 500*time.Millisecond { + t.Errorf("expected some delay from retry, elapsed=%v", elapsed) + } +} diff --git a/go/types_apps.go b/go/types_apps.go index b54cbfc3..cd899592 100644 --- a/go/types_apps.go +++ b/go/types_apps.go @@ -2,17 +2,17 @@ package phala // AppInfo represents application information. type AppInfo struct { - ID string `json:"id"` - Name string `json:"name"` - AppID string `json:"app_id"` - AppProvisionType *string `json:"app_provision_type,omitempty"` - AppIconURL *string `json:"app_icon_url,omitempty"` - CreatedAt string `json:"created_at"` - KMSType string `json:"kms_type"` + ID string `json:"id"` + Name string `json:"name"` + AppID string `json:"app_id"` + AppProvisionType *string `json:"app_provision_type,omitempty"` + AppIconURL *string `json:"app_icon_url,omitempty"` + CreatedAt string `json:"created_at"` + KMSType string `json:"kms_type"` Profile *AppProfile `json:"profile,omitempty"` - CurrentCVM *CVMInfo `json:"current_cvm,omitempty"` - CVMs []CVMInfo `json:"cvms,omitempty"` - CVMCount int `json:"cvm_count"` + CurrentCVM *CVMInfo `json:"current_cvm,omitempty"` + CVMs []CVMInfo `json:"cvms,omitempty"` + CVMCount int `json:"cvm_count"` } // AppProfile represents an app profile. @@ -77,11 +77,11 @@ type AppAttestationResponse = GenericObject // DeviceAllowlistItem represents an item in the device allowlist. type DeviceAllowlistItem struct { - DeviceID string `json:"device_id"` + DeviceID string `json:"device_id"` NodeName *string `json:"node_name,omitempty"` - CVMIDs []int `json:"cvm_ids,omitempty"` - AllowedOnchain bool `json:"allowed_onchain"` - Status string `json:"status"` + CVMIDs []int `json:"cvm_ids,omitempty"` + AllowedOnchain bool `json:"allowed_onchain"` + Status string `json:"status"` } // DeviceAllowlistResponse is the response for getting app device allowlist. diff --git a/go/types_cvms.go b/go/types_cvms.go index c8f359ac..64f4ec2a 100644 --- a/go/types_cvms.go +++ b/go/types_cvms.go @@ -157,16 +157,16 @@ type ComposeFile struct { // ProvisionCVMResponse is the response from provisioning a CVM. type ProvisionCVMResponse struct { - AppID string `json:"app_id"` - ComposeHash string `json:"compose_hash"` - AppEnvEncryptPubkey string `json:"app_env_encrypt_pubkey,omitempty"` - KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` - FMSPC string `json:"fmspc,omitempty"` - DeviceID string `json:"device_id,omitempty"` - OSImageHash string `json:"os_image_hash,omitempty"` - InstanceType string `json:"instance_type,omitempty"` - NodeID *int `json:"node_id,omitempty"` - KMSID string `json:"kms_id,omitempty"` + AppID string `json:"app_id"` + ComposeHash string `json:"compose_hash"` + AppEnvEncryptPubkey string `json:"app_env_encrypt_pubkey,omitempty"` + KMSInfo *CvmKmsInfo `json:"kms_info,omitempty"` + FMSPC string `json:"fmspc,omitempty"` + DeviceID string `json:"device_id,omitempty"` + OSImageHash string `json:"os_image_hash,omitempty"` + InstanceType string `json:"instance_type,omitempty"` + NodeID *int `json:"node_id,omitempty"` + KMSID string `json:"kms_id,omitempty"` } // CommitCVMProvisionRequest is the request for committing a CVM provision. @@ -256,13 +256,13 @@ type ConfirmCVMPatchRequest struct { // CVMAttestation represents CVM attestation data. type CVMAttestation struct { - Name *string `json:"name,omitempty"` - IsOnline bool `json:"is_online"` - IsPublic bool `json:"is_public"` - Error *string `json:"error,omitempty"` - AppCertificates []Certificate `json:"app_certificates,omitempty"` - TCBInfo *TcbInfo `json:"tcb_info,omitempty"` - ComposeFile *string `json:"compose_file,omitempty"` + Name *string `json:"name,omitempty"` + IsOnline bool `json:"is_online"` + IsPublic bool `json:"is_public"` + Error *string `json:"error,omitempty"` + AppCertificates []Certificate `json:"app_certificates,omitempty"` + TCBInfo *TcbInfo `json:"tcb_info,omitempty"` + ComposeFile *string `json:"compose_file,omitempty"` } // Certificate represents a TLS certificate. diff --git a/go/types_kms.go b/go/types_kms.go index 6719953b..37c05a58 100644 --- a/go/types_kms.go +++ b/go/types_kms.go @@ -22,8 +22,8 @@ type AppEnvPubKeyResponse = GenericObject // KMSOnChainDetail represents KMS on-chain detail. type KMSOnChainDetail struct { - ChainName string `json:"chain_name"` - ChainID int `json:"chain_id"` + ChainName string `json:"chain_name"` + ChainID int `json:"chain_id"` Contracts []OnChainKMSContract `json:"contracts,omitempty"` } diff --git a/go/types_nodes.go b/go/types_nodes.go index 367cefd0..43503078 100644 --- a/go/types_nodes.go +++ b/go/types_nodes.go @@ -18,20 +18,20 @@ type ResourceThreshold struct { // TeepodCapacity represents a node's capacity. type TeepodCapacity struct { - TeepodID int `json:"teepod_id"` - Name string `json:"name"` - Listed bool `json:"listed"` - ResourceScore float64 `json:"resource_score"` - RemainingVCPU float64 `json:"remaining_vcpu"` - RemainingMemory float64 `json:"remaining_memory"` - RemainingCVMSlots float64 `json:"remaining_cvm_slots"` - Images []AvailableImage `json:"images"` - SupportOnchainKMS *bool `json:"support_onchain_kms,omitempty"` - FMSPC *string `json:"fmspc,omitempty"` - DeviceID *string `json:"device_id,omitempty"` - RegionIdentifier *string `json:"region_identifier,omitempty"` - DefaultKMS *string `json:"default_kms,omitempty"` - KMSList []string `json:"kms_list,omitempty"` + TeepodID int `json:"teepod_id"` + Name string `json:"name"` + Listed bool `json:"listed"` + ResourceScore float64 `json:"resource_score"` + RemainingVCPU float64 `json:"remaining_vcpu"` + RemainingMemory float64 `json:"remaining_memory"` + RemainingCVMSlots float64 `json:"remaining_cvm_slots"` + Images []AvailableImage `json:"images"` + SupportOnchainKMS *bool `json:"support_onchain_kms,omitempty"` + FMSPC *string `json:"fmspc,omitempty"` + DeviceID *string `json:"device_id,omitempty"` + RegionIdentifier *string `json:"region_identifier,omitempty"` + DefaultKMS *string `json:"default_kms,omitempty"` + KMSList []string `json:"kms_list,omitempty"` } // AvailableImage represents an available OS image on a node. From fa7f9a0e13dfa0c92e8c37fbfed096659f0dafd8 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 19:37:45 +0800 Subject: [PATCH 6/8] fix(go): use monorepo module path for Go module publishing Module path: github.com/Phala-Network/phala-cloud/sdks/go Release tag format: sdks/go/v0.x.x --- go/README.md | 4 ++-- go/e2e/e2e_test.go | 2 +- go/e2e/go.mod | 6 +++--- go/go.mod | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go/README.md b/go/README.md index 3ca2e5cd..c4ecb8ea 100644 --- a/go/README.md +++ b/go/README.md @@ -5,7 +5,7 @@ Go client for the [Phala Cloud](https://cloud.phala.network) API. ## Installation ```bash -go get github.com/Phala-Network/phala-cloud-sdk-go +go get github.com/Phala-Network/phala-cloud/sdks/go ``` Requires Go 1.25+. @@ -20,7 +20,7 @@ import ( "fmt" "log" - phala "github.com/Phala-Network/phala-cloud-sdk-go" + phala "github.com/Phala-Network/phala-cloud/sdks/go" ) func main() { diff --git a/go/e2e/e2e_test.go b/go/e2e/e2e_test.go index 02d864e0..275a78a3 100644 --- a/go/e2e/e2e_test.go +++ b/go/e2e/e2e_test.go @@ -17,7 +17,7 @@ import ( "testing" "time" - phala "github.com/Phala-Network/phala-cloud-sdk-go" + phala "github.com/Phala-Network/phala-cloud/sdks/go" "golang.org/x/crypto/ssh" ) diff --git a/go/e2e/go.mod b/go/e2e/go.mod index 504270a0..f577297d 100644 --- a/go/e2e/go.mod +++ b/go/e2e/go.mod @@ -1,12 +1,12 @@ -module github.com/Phala-Network/phala-cloud-sdk-go/e2e +module github.com/Phala-Network/phala-cloud/sdks/go/e2e go 1.25.0 require ( - github.com/Phala-Network/phala-cloud-sdk-go v0.0.0 + github.com/Phala-Network/phala-cloud/sdks/go v0.0.0 golang.org/x/crypto v0.49.0 ) require golang.org/x/sys v0.42.0 // indirect -replace github.com/Phala-Network/phala-cloud-sdk-go => ../ +replace github.com/Phala-Network/phala-cloud/sdks/go => ../ diff --git a/go/go.mod b/go/go.mod index 01981216..a135793e 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,4 +1,4 @@ -module github.com/Phala-Network/phala-cloud-sdk-go +module github.com/Phala-Network/phala-cloud/sdks/go go 1.25.0 From 6b86051caf0db93b193d65188ce0ecae1da8612f Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 19:45:11 +0800 Subject: [PATCH 7/8] feat: add Go SDK release workflow and reorganize release workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename release-command.yml → release-npm.yml (JS & CLI) - Add release-go.yml for Go SDK releases via `!release go ` - Add bump-go-version.sh for semver version bumping - Each workflow silently skips packages it doesn't own - Go releases use git tag `sdks/go/vX.Y.Z` for Go module proxy --- .github/workflows/release-go.yml | 405 ++++++++++++++++++ .../{release-command.yml => release-npm.yml} | 15 +- .github/workflows/release-python.yml | 2 +- scripts/bump-go-version.sh | 71 +++ 4 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release-go.yml rename .github/workflows/{release-command.yml => release-npm.yml} (98%) create mode 100755 scripts/bump-go-version.sh diff --git a/.github/workflows/release-go.yml b/.github/workflows/release-go.yml new file mode 100644 index 00000000..1202b9f6 --- /dev/null +++ b/.github/workflows/release-go.yml @@ -0,0 +1,405 @@ +name: Release Go SDK + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + release_type: + description: 'Release type' + required: true + type: choice + options: + - patch + - minor + - major + prerelease: + description: 'Prerelease tag (leave empty for stable release)' + required: false + type: choice + options: + - '' + - beta + - alpha + - rc + ref: + description: 'Branch/ref to release from (default: main)' + required: false + type: string + default: 'main' + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + parse: + if: > + github.event_name == 'issue_comment' && + (contains(github.event.comment.body, '!release') || + contains(github.event.comment.body, '!Release') || + contains(github.event.comment.body, '!RELEASE')) && + github.event.comment.user.type != 'Bot' + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.parse.outputs.should_release }} + release_type: ${{ steps.parse.outputs.release_type }} + prerelease: ${{ steps.parse.outputs.prerelease }} + ref: ${{ steps.parse.outputs.ref }} + pr_branch: ${{ steps.parse.outputs.pr_branch }} + pr_title: ${{ steps.parse.outputs.pr_title }} + pr_url: ${{ steps.parse.outputs.pr_url }} + issue_number: ${{ steps.parse.outputs.issue_number }} + requested_by: ${{ steps.parse.outputs.requested_by }} + request_url: ${{ steps.parse.outputs.request_url }} + steps: + - name: Parse release command + id: parse + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const allowedPackages = ['go', 'golang']; + const allowedBumps = ['patch', 'minor', 'major']; + const allowedPrereleaseTags = ['beta', 'alpha', 'rc']; + + const body = context.payload.comment.body.trim(); + const match = body.match(/!release\s+(\S+)\s+(\S+)(?:\s+(\S+))?/i); + + if (!match) { + return; + } + + const packageName = match[1].toLowerCase(); + const releaseType = match[2].toLowerCase(); + const prereleaseTag = match[3] ? match[3].toLowerCase() : ''; + + // Only handle go/golang + if (!allowedPackages.includes(packageName)) { + return; + } + + const allowedAssociation = ['OWNER', 'MEMBER', 'COLLABORATOR']; + const association = context.payload.comment.author_association; + const actor = context.payload.comment.user.login; + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.payload.issue.number; + + const invalidBump = !allowedBumps.includes(releaseType); + const invalidPrerelease = prereleaseTag && !allowedPrereleaseTags.includes(prereleaseTag); + + if (invalidBump || invalidPrerelease) { + const message = [ + `⚠️ @${actor} I couldn't parse the release command.`, + '', + 'Expected format: `!release [beta|alpha|rc]`', + '', + 'Examples:', + '- `!release go patch` - stable release', + '- `!release go minor beta` - beta release' + ]; + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: message.join('\n') + }); + return; + } + + if (!allowedAssociation.includes(association)) { + const message = [ + `⛔ @${actor} you do not have permission to trigger releases.`, + '', + 'Please contact the repository maintainers if this is unexpected.' + ]; + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: message.join('\n') + }); + return; + } + + let ref = 'main'; + let prBranch = ''; + let prTitle = ''; + let prUrl = ''; + + if (context.payload.issue.pull_request) { + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: issueNumber + }); + + if (!pr.head.repo || pr.head.repo.full_name !== `${owner}/${repo}`) { + const message = [ + `⛔ @${actor} releases from forked branches are not supported.`, + '', + 'Please push the branch to this repository or run the release manually in Actions.' + ]; + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: message.join('\n') + }); + return; + } + + ref = pr.head.ref; + prBranch = pr.head.ref; + prTitle = pr.title; + prUrl = pr.html_url; + } + + const releaseTypeLabel = prereleaseTag ? `${releaseType} (${prereleaseTag})` : releaseType; + + const lines = [ + `🚀 @${actor} release command accepted: \`go\` \`${releaseTypeLabel}\`.`, + '', + 'The release workflow is queued; results will be posted here.' + ]; + + if (prBranch) { + lines.splice(2, 0, `Target branch: \`${prBranch}\` (open PR). Version commits will be pushed to this branch.`); + } + + if (prereleaseTag) { + lines.splice(2, 0, `📦 Prerelease tag: \`${prereleaseTag}\``); + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: lines.join('\n') + }); + + core.setOutput('should_release', 'true'); + core.setOutput('release_type', releaseType); + core.setOutput('prerelease', prereleaseTag); + core.setOutput('ref', ref); + core.setOutput('pr_branch', prBranch); + core.setOutput('pr_title', prTitle); + core.setOutput('pr_url', prUrl); + core.setOutput('issue_number', issueNumber); + core.setOutput('requested_by', actor); + core.setOutput('request_url', context.payload.comment.html_url); + + release: + needs: [parse] + if: | + always() && + ( + (needs.parse.result == 'success' && needs.parse.outputs.should_release == 'true') || + (github.event_name == 'workflow_dispatch') + ) + concurrency: + group: release-go-${{ github.event_name == 'workflow_dispatch' && inputs.ref || needs.parse.outputs.ref }} + cancel-in-progress: false + runs-on: ubuntu-latest + env: + RELEASE_TYPE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type || needs.parse.outputs.release_type }} + PRERELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || needs.parse.outputs.prerelease }} + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || needs.parse.outputs.ref }} + ISSUE_NUMBER: ${{ needs.parse.outputs.issue_number }} + REQUESTED_BY: ${{ github.event_name == 'workflow_dispatch' && github.actor || needs.parse.outputs.requested_by }} + REQUEST_URL: ${{ needs.parse.outputs.request_url }} + PR_TITLE: ${{ needs.parse.outputs.pr_title }} + PR_URL: ${{ needs.parse.outputs.pr_url }} + PR_BRANCH: ${{ needs.parse.outputs.pr_branch }} + steps: + + - name: Validate context + run: | + set -e + if [ -z "${RELEASE_TYPE:-}" ]; then + echo "RELEASE_TYPE is not set" + exit 1 + fi + case "$RELEASE_TYPE" in + patch|minor|major) ;; + *) + echo "Unsupported release type: $RELEASE_TYPE" + exit 1 + ;; + esac + if [ -z "${SOURCE_REF:-}" ]; then + echo "SOURCE_REF is not set" + exit 1 + fi + + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ env.SOURCE_REF }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + + - name: Bump version + id: bump + run: bash scripts/bump-go-version.sh "$RELEASE_TYPE" "$PRERELEASE" + + - name: Export version metadata + run: | + echo "NEW_VERSION=${{ steps.bump.outputs.version }}" >> "$GITHUB_ENV" + echo "TAG_NAME=sdks/go/v${{ steps.bump.outputs.version }}" >> "$GITHUB_ENV" + + - name: Run checks + working-directory: go + run: | + gofmt -l . | tee /tmp/gofmt_output + if [ -s /tmp/gofmt_output ]; then + echo "gofmt found unformatted files" + exit 1 + fi + go vet ./... + + - name: Run tests + working-directory: go + run: go test -count=1 ./... + + - name: Commit version change + run: | + git add go/version.go + if git diff --cached --quiet; then + echo "No changes to commit" + exit 1 + fi + git commit -m "chore(go): release v${NEW_VERSION}" + + - name: Create tag + run: git tag "$TAG_NAME" + + - name: Push changes + id: push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$SOURCE_REF" = "main" ]; then + RELEASE_BRANCH="release/go-v${NEW_VERSION}" + git checkout -b "$RELEASE_BRANCH" + git push origin "$RELEASE_BRANCH" + git push origin "$TAG_NAME" + + PR_URL=$(gh pr create \ + --base main \ + --head "$RELEASE_BRANCH" \ + --title "chore(go): release v${NEW_VERSION}" \ + --body "Automated release PR for Go SDK v${NEW_VERSION}. Updates version.go. The module is published via git tag \`${TAG_NAME}\`.") + + echo "release_pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + echo "used_release_branch=true" >> "$GITHUB_OUTPUT" + echo "release_branch=$RELEASE_BRANCH" >> "$GITHUB_OUTPUT" + else + git push origin HEAD:${SOURCE_REF} + git push origin "$TAG_NAME" + echo "used_release_branch=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub release + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + MODULE_PATH="github.com/Phala-Network/phala-cloud/sdks/go" + { + echo "Release Go SDK v${NEW_VERSION}" + echo "" + echo "---" + echo "" + echo "**Go module**: [\`${MODULE_PATH}\`](https://pkg.go.dev/${MODULE_PATH}@v${NEW_VERSION})" + echo "" + echo '```bash' + echo "go get ${MODULE_PATH}@v${NEW_VERSION}" + echo '```' + } > release_body.md + + PRERELEASE_FLAG="" + if [ -n "$PRERELEASE" ]; then + PRERELEASE_FLAG="--prerelease" + fi + + RELEASE_URL=$(gh release create "$TAG_NAME" \ + --title "Go SDK v${NEW_VERSION}" \ + --notes-file release_body.md \ + $PRERELEASE_FLAG) + + echo "html_url=$RELEASE_URL" >> "$GITHUB_OUTPUT" + + - name: Comment success + if: ${{ success() && env.ISSUE_NUMBER != '' }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const usedReleaseBranch = '${{ steps.push.outputs.used_release_branch }}' === 'true'; + const releasePrUrl = '${{ steps.push.outputs.release_pr_url }}'; + const modulePath = 'github.com/Phala-Network/phala-cloud/sdks/go'; + + const body = [ + `🎉 Release completed: \`go\` v${process.env.NEW_VERSION}`, + '', + `- Branch: \`${process.env.SOURCE_REF}\``, + `- Go module: [\`${modulePath}\`](https://pkg.go.dev/${modulePath}@v${process.env.NEW_VERSION})`, + `- GitHub Release: [link](${{ steps.release.outputs.html_url }})`, + `- Workflow logs: ${runUrl}`, + ]; + + if (usedReleaseBranch && releasePrUrl) { + body.push(''); + body.push(`> ⚠️ **Action Required**: Please merge the [release PR](${releasePrUrl}) to update \`main\` branch with version changes.`); + } + + body.push( + '', + '### 📦 Install', + '```bash', + `go get ${modulePath}@v${process.env.NEW_VERSION}`, + '```' + ); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.ISSUE_NUMBER), + body: body.join('\n') + }); + + - name: Comment failure + if: ${{ failure() && env.ISSUE_NUMBER != '' }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const actor = process.env.REQUESTED_BY ? `@${process.env.REQUESTED_BY}` : 'Requester'; + const version = process.env.NEW_VERSION || 'unknown version'; + const body = [ + `❌ ${actor} release failed: \`go\` ${version}`, + '', + `Branch: \`${process.env.SOURCE_REF}\``, + `Please review the workflow logs: ${runUrl}` + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.ISSUE_NUMBER), + body + }); diff --git a/.github/workflows/release-command.yml b/.github/workflows/release-npm.yml similarity index 98% rename from .github/workflows/release-command.yml rename to .github/workflows/release-npm.yml index 52cf66f3..514ec052 100644 --- a/.github/workflows/release-command.yml +++ b/.github/workflows/release-npm.yml @@ -1,4 +1,4 @@ -name: Release Command +name: Release npm (JS & CLI) on: issue_comment: @@ -101,6 +101,12 @@ jobs: return; } + // go/golang packages are handled by release-go.yml — skip silently + const goPackages = ['go', 'golang']; + if (goPackages.includes(packageName)) { + return; + } + const invalidPackage = !allowedPackages.includes(packageName); const invalidBump = !allowedBumps.includes(releaseType); const invalidPrerelease = prereleaseTag && !allowedPrereleaseTags.includes(prereleaseTag); @@ -109,12 +115,13 @@ jobs: const message = [ `⚠️ @${actor} I couldn't parse the release command.`, '', - 'Expected format: `!release [beta|alpha|rc]`', + 'Expected format: `!release [beta|alpha|rc]`', '', 'Examples:', '- `!release cli patch` - stable release', '- `!release js minor beta` - beta release', - '- `!release python minor` - Python SDK release' + '- `!release python minor` - Python SDK release', + '- `!release go minor` - Go SDK release' ]; await github.rest.issues.createComment({ owner, @@ -496,7 +503,7 @@ jobs: echo " - Provider: GitHub Actions" echo " - Owner: Phala-Network" echo " - Repository: phala-cloud" - echo " - Workflow: release-command.yml" + echo " - Workflow: release-npm.yml" echo " - Environment: (empty)" echo "" echo " Actual workflow context:" diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index a58748dd..85faa4b6 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -76,7 +76,7 @@ jobs: const releaseType = match[2].toLowerCase(); const prereleaseTag = match[3] ? match[3].toLowerCase() : ''; - // Only handle python/py - let other packages be handled by release-command.yml + // Only handle python/py - let other packages be handled by release-npm.yml / release-go.yml if (!allowedPackages.includes(packageName)) { return; } diff --git a/scripts/bump-go-version.sh b/scripts/bump-go-version.sh new file mode 100755 index 00000000..584b04fc --- /dev/null +++ b/scripts/bump-go-version.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Bump Go SDK version in go/version.go. +# Usage: bump-go-version.sh [prerelease-tag] +# Outputs: version=X.Y.Z (for GitHub Actions) +set -euo pipefail + +RELEASE_TYPE="${1:?Usage: bump-go-version.sh [prerelease-tag]}" +PRERELEASE_TAG="${2:-}" + +VERSION_FILE="$(dirname "$0")/../go/version.go" +VERSION_FILE="$(cd "$(dirname "$VERSION_FILE")" && pwd)/$(basename "$VERSION_FILE")" + +# Extract current version +CURRENT=$(grep 'sdkVersion\s*=' "$VERSION_FILE" | sed -E 's/.*"([^"]+)".*/\1/') +if [ -z "$CURRENT" ]; then + echo "Could not find sdkVersion in $VERSION_FILE" >&2 + exit 1 +fi +echo "Current version: $CURRENT" + +# Parse semver (supports optional -prerelease.N suffix) +if [[ "$CURRENT" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-(alpha|beta|rc)\.([0-9]+))?$ ]]; then + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + CUR_PRE_TAG="${BASH_REMATCH[5]:-}" + CUR_PRE_VER="${BASH_REMATCH[6]:-}" +else + echo "Invalid version format: $CURRENT" >&2 + exit 1 +fi + +# Bump +if [ -n "$PRERELEASE_TAG" ]; then + if [ "$CUR_PRE_TAG" = "$PRERELEASE_TAG" ] && [ -n "$CUR_PRE_VER" ]; then + # Same prerelease tag: increment prerelease version + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-${PRERELEASE_TAG}.$((CUR_PRE_VER + 1))" + else + # Different prerelease tag or no current prerelease: bump base then set prerelease + case "$RELEASE_TYPE" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + *) echo "Invalid release type: $RELEASE_TYPE" >&2; exit 1 ;; + esac + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-${PRERELEASE_TAG}.1" + fi +elif [ -n "$CUR_PRE_TAG" ]; then + # Currently prerelease, stable release: just drop the prerelease suffix + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" +else + case "$RELEASE_TYPE" in + major) NEW_VERSION="$((MAJOR + 1)).0.0" ;; + minor) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;; + patch) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;; + *) echo "Invalid release type: $RELEASE_TYPE" >&2; exit 1 ;; + esac +fi + +# Write new version +sed -i.bak -E "s/sdkVersion = \"[^\"]+\"/sdkVersion = \"${NEW_VERSION}\"/" "$VERSION_FILE" +rm -f "${VERSION_FILE}.bak" + +LABEL="$RELEASE_TYPE" +[ -n "$PRERELEASE_TAG" ] && LABEL="${RELEASE_TYPE} (${PRERELEASE_TAG})" +echo "Bumped go from $CURRENT to $NEW_VERSION [$LABEL]" + +# GitHub Actions output +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT" +fi From c4b7eb447315754c880d8cff210e88aa7dd9e289 Mon Sep 17 00:00:00 2001 From: Leechael Yim Date: Wed, 18 Mar 2026 20:21:22 +0800 Subject: [PATCH 8/8] fix(go): make WatchCVMState MaxRetries default to unlimited MaxRetries was an int defaulting to 0, causing no retries on failure. Changed to *int where nil means unlimited retries, matching JS/Python. --- go/README.md | 2 +- go/cvms_watch.go | 8 ++++---- go/e2e/e2e_test.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go/README.md b/go/README.md index c4ecb8ea..27ef433f 100644 --- a/go/README.md +++ b/go/README.md @@ -178,7 +178,7 @@ ch, err := client.WatchCVMState(ctx, "cvm-id", &phala.WatchCVMStateOptions{ Target: "running", Interval: 5, Timeout: 300, - MaxRetries: 3, + MaxRetries: phala.Int(3), // nil = unlimited retries }) if err != nil { log.Fatal(err) diff --git a/go/cvms_watch.go b/go/cvms_watch.go index 0fb06ff2..adcf5a35 100644 --- a/go/cvms_watch.go +++ b/go/cvms_watch.go @@ -19,9 +19,9 @@ type CVMStateEvent struct { // WatchCVMStateOptions holds options for watching CVM state. type WatchCVMStateOptions struct { Target string - Interval int // 5-30 seconds - Timeout int // 10-600 seconds - MaxRetries int + Interval int // 5-30 seconds + Timeout int // 10-600 seconds + MaxRetries *int // nil = unlimited retries, 0 = no retries RetryDelay time.Duration } @@ -58,7 +58,7 @@ func (c *Client) WatchCVMState(ctx context.Context, cvmID string, opts *WatchCVM return } - if opts.MaxRetries >= 0 && retries >= opts.MaxRetries { + if opts.MaxRetries != nil && retries >= *opts.MaxRetries { ch <- CVMStateEvent{Event: "error", Error: err} return } diff --git a/go/e2e/e2e_test.go b/go/e2e/e2e_test.go index 275a78a3..f57b95cf 100644 --- a/go/e2e/e2e_test.go +++ b/go/e2e/e2e_test.go @@ -458,7 +458,7 @@ func TestE2EAllInterfaces(t *testing.T) { ch, err := client.WatchCVMState(watchCtx, cvmID, &phala.WatchCVMStateOptions{ Target: status, Timeout: 20, - MaxRetries: 0, + MaxRetries: phala.Int(0), }) if err != nil { t.Fatalf("WatchCVMState: %v", err)