diff --git a/go/apps.go b/go/apps.go index 9ad68e5e..f1a69e4f 100644 --- a/go/apps.go +++ b/go/apps.go @@ -33,6 +33,18 @@ func (c *Client) GetAppCVMs(ctx context.Context, appID string) ([]GenericObject, return result, nil } +// ReplicateAppCVM creates a replica of a CVM within an application context. +// This uses the app-scoped endpoint POST /apps/{appID}/cvms/{vmUUID}/replicas +// to ensure the new replica is associated with the correct app. +func (c *Client) ReplicateAppCVM(ctx context.Context, appID, vmUUID string, opts *ReplicateCVMOptions) (*CVMActionResponse, error) { + var result CVMActionResponse + path := "/apps/" + appID + "/cvms/" + vmUUID + "/replicas" + if err := c.doJSON(ctx, "POST", path, opts, &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" diff --git a/go/cvms_compose.go b/go/cvms_compose.go index 798597c6..024eb8f3 100644 --- a/go/cvms_compose.go +++ b/go/cvms_compose.go @@ -27,11 +27,17 @@ func (c *Client) GetCVMPreLaunchScript(ctx context.Context, cvmID string) (strin // 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"` + 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"` + AllowedEnvs []string `json:"allowed_envs,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` + UpdateEnvVars *bool `json:"update_env_vars,omitempty"` } // ProvisionCVMComposeFileUpdate provisions a compose file update. @@ -48,10 +54,10 @@ func (c *Client) ProvisionCVMComposeFileUpdate(ctx context.Context, cvmID string // 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"` + 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. diff --git a/go/cvms_config.go b/go/cvms_config.go index 8829fc51..5d3e4203 100644 --- a/go/cvms_config.go +++ b/go/cvms_config.go @@ -107,10 +107,10 @@ func (c *Client) RefreshCVMInstanceIDs(ctx context.Context, req *RefreshInstance // 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"` + 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. diff --git a/go/dstack_helpers.go b/go/dstack_helpers.go new file mode 100644 index 00000000..87f898fc --- /dev/null +++ b/go/dstack_helpers.go @@ -0,0 +1,50 @@ +package phala + +import dstacksdk "github.com/Dstack-TEE/dstack/sdk/go/dstack" + +// EnvVar represents an environment variable key-value pair. +type EnvVar = dstacksdk.EnvVar + +// AppCompose represents the application composition structure used for compose hashing. +type AppCompose = dstacksdk.AppCompose + +// DockerConfig represents Docker registry credentials used by AppCompose. +type DockerConfig = dstacksdk.DockerConfig + +// KeyProviderKind represents the key provider type used by AppCompose. +type KeyProviderKind = dstacksdk.KeyProviderKind + +const ( + KeyProviderNone = dstacksdk.KeyProviderNone + KeyProviderKMS = dstacksdk.KeyProviderKMS + KeyProviderLocal = dstacksdk.KeyProviderLocal +) + +// VerifyEnvEncryptPublicKeyOptions configures timestamp validation for signature verification. +type VerifyEnvEncryptPublicKeyOptions = dstacksdk.VerifyEnvEncryptPublicKeyOptions + +// EncryptEnvVars encrypts environment variables using the upstream dstack Go SDK implementation. +func EncryptEnvVars(envs []EnvVar, publicKeyHex string) (string, error) { + return dstacksdk.EncryptEnvVars(envs, publicKeyHex) +} + +// GetComposeHash computes the compose hash using the upstream dstack Go SDK implementation. +func GetComposeHash(appCompose AppCompose, normalize ...bool) (string, error) { + return dstacksdk.GetComposeHash(appCompose, normalize...) +} + +// VerifyEnvEncryptPublicKey verifies the signature of an env encryption public key. +func VerifyEnvEncryptPublicKey(publicKey []byte, signature []byte, appID string) ([]byte, error) { + return dstacksdk.VerifyEnvEncryptPublicKey(publicKey, signature, appID) +} + +// VerifyEnvEncryptPublicKeyWithTimestamp verifies a timestamped env encryption public-key signature. +func VerifyEnvEncryptPublicKeyWithTimestamp( + publicKey []byte, + signature []byte, + appID string, + timestamp uint64, + opts *VerifyEnvEncryptPublicKeyOptions, +) ([]byte, error) { + return dstacksdk.VerifyEnvEncryptPublicKeyWithTimestamp(publicKey, signature, appID, timestamp, opts) +} diff --git a/go/dstack_helpers_test.go b/go/dstack_helpers_test.go new file mode 100644 index 00000000..66fda505 --- /dev/null +++ b/go/dstack_helpers_test.go @@ -0,0 +1,94 @@ +package phala + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "encoding/json" + "testing" + + "golang.org/x/crypto/curve25519" +) + +func TestEncryptEnvVars(t *testing.T) { + remotePriv := make([]byte, 32) + if _, err := rand.Read(remotePriv); err != nil { + t.Fatal(err) + } + remotePub, err := curve25519.X25519(remotePriv, curve25519.Basepoint) + if err != nil { + t.Fatal(err) + } + + envs := []EnvVar{ + {Key: "NODE_ENV", Value: "production"}, + {Key: "MESSAGE", Value: "Hello world"}, + } + + encryptedHex, err := EncryptEnvVars(envs, hex.EncodeToString(remotePub)) + if err != nil { + t.Fatal(err) + } + + encrypted, err := hex.DecodeString(encryptedHex) + if err != nil { + t.Fatal(err) + } + if len(encrypted) <= 44 { + t.Fatalf("expected encrypted payload > 44 bytes, got %d", len(encrypted)) + } + + ephemeralPub := encrypted[:32] + iv := encrypted[32:44] + ciphertext := encrypted[44:] + + sharedSecret, err := curve25519.X25519(remotePriv, ephemeralPub) + if err != nil { + t.Fatal(err) + } + + block, err := aes.NewCipher(sharedSecret) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + + plaintext, err := gcm.Open(nil, iv, ciphertext, nil) + if err != nil { + t.Fatal(err) + } + + var payload struct { + Env []EnvVar `json:"env"` + } + if err := json.Unmarshal(plaintext, &payload); err != nil { + t.Fatal(err) + } + + if len(payload.Env) != len(envs) { + t.Fatalf("expected %d env vars, got %d", len(envs), len(payload.Env)) + } + for i := range envs { + if payload.Env[i] != envs[i] { + t.Fatalf("env var mismatch at %d: expected %+v, got %+v", i, envs[i], payload.Env[i]) + } + } +} + +func TestGetComposeHash(t *testing.T) { + got, err := GetComposeHash(AppCompose{ + Runner: "docker-compose", + DockerComposeFile: "services:\n app:\n image: nginx:latest\n", + AllowedEnvs: []string{"API_KEY"}, + }, true) + if err != nil { + t.Fatal(err) + } + if len(got) != 64 { + t.Fatalf("expected 64-char sha256 hex, got %q", got) + } +} diff --git a/go/error_codes.go b/go/error_codes.go new file mode 100644 index 00000000..a5ea0465 --- /dev/null +++ b/go/error_codes.go @@ -0,0 +1,67 @@ +package phala + +// Structured error codes returned by the Phala Cloud API. +// Use with APIError.HasErrorCode() to match specific errors. +// +// Error codes follow the format ERR-{MODULE}-{CODE} where MODULE is a +// two-digit module identifier and CODE is a three-digit sequential number. + +// Module 01: CVM Preflight & Compose Hash +const ( + ErrNodeNotFound = "ERR-01-001" + ErrComposeFileRequired = "ERR-01-002" + ErrInvalidComposeFile = "ERR-01-003" + ErrDuplicateCvmName = "ERR-01-004" + ErrHashRegistration = "ERR-01-005" + ErrHashInvalidExpired = "ERR-01-006" + ErrTxVerifyFailed = "ERR-01-007" + ErrHashNotAllowed = "ERR-01-008" +) + +// Module 02: Inventory +const ( + ErrInstanceTypeNotFound = "ERR-02-001" + ErrResourceNotAvailable = "ERR-02-002" + ErrInsufficientVcpu = "ERR-02-003" + ErrInsufficientMemory = "ERR-02-004" + ErrInsufficientSlots = "ERR-02-005" + ErrGpuAllocation = "ERR-02-006" + ErrInsufficientGpu = "ERR-02-007" + ErrInvalidRequest = "ERR-02-008" + ErrIncompatibleConfig = "ERR-02-009" + ErrImageNotFound = "ERR-02-010" + ErrKmsNotFound = "ERR-02-011" + ErrTeepodNotAccessible = "ERR-02-012" + ErrOsImageNotCompatible = "ERR-02-013" + ErrNodeCapacityNotConfig = "ERR-02-014" + ErrQuotaExceeded = "ERR-02-015" +) + +// Module 03: CVM Operations +const ( + ErrCvmNotFound = "ERR-03-001" + ErrMultipleCvmsSameName = "ERR-03-002" + // ERR-03-003 and ERR-03-004 are CvmNotInWorkspaceError variants (reveal/hide existence). + ErrCvmNotInWorkspace = "ERR-03-003" + ErrCvmAccessDenied = "ERR-03-005" + ErrReplicaImageNotAvail = "ERR-03-006" + ErrCvmAppIdConflict = "ERR-03-007" +) + +// Module 04: Workspace +const ( + ErrInsufficientBalance = "ERR-04-001" + ErrMaxCvmLimit = "ERR-04-002" + ErrResourceLimitExceed = "ERR-04-003" +) + +// Module 05: Credentials +const ( + ErrTokenLimitExceeded = "ERR-05-001" + ErrTokenRateLimit = "ERR-05-002" +) + +// Module 06: Auth +const ( + ErrOAuthEmailInvalid = "ERR-06-001" +) diff --git a/go/errors.go b/go/errors.go index a960d6e4..cfd363b3 100644 --- a/go/errors.go +++ b/go/errors.go @@ -1,20 +1,38 @@ package phala import ( + "encoding/json" "fmt" "net/http" "strconv" + "strings" "time" ) +// ErrorDetail represents a field-level validation error detail. +type ErrorDetail struct { + Field string `json:"field"` + Value any `json:"value"` + Message string `json:"message"` +} + +// ErrorLink represents a reference link attached to an API error. +type ErrorLink struct { + URL string `json:"url"` + Label string `json:"label"` +} + // 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 + StatusCode int + Message string + Detail any + Body string + Headers http.Header + ErrorCode string + Details []ErrorDetail + Suggestions []string + Links []ErrorLink } func (e *APIError) Error() string { @@ -45,12 +63,22 @@ func (e *APIError) IsServer() bool { } // IsRetryable returns true if the error is retryable (409/429/503). +// A 409 Conflict with a structured ErrorCode is a deterministic business error +// and is not retryable. func (e *APIError) IsRetryable() bool { + if e.StatusCode == http.StatusConflict && e.ErrorCode != "" { + return false + } return e.StatusCode == http.StatusConflict || e.StatusCode == http.StatusTooManyRequests || e.StatusCode == http.StatusServiceUnavailable } +// IsConflict returns true if the error is a 409 Conflict. +func (e *APIError) IsConflict() bool { + return e.StatusCode == http.StatusConflict +} + // IsComposePrecondition returns true if the error is a compose hash precondition failure (465). func (e *APIError) IsComposePrecondition() bool { return e.StatusCode == 465 @@ -79,3 +107,72 @@ func (e *APIError) RetryAfter() time.Duration { } return 0 } + +// IsStructured returns true when the error includes a structured ErrorCode. +func (e *APIError) IsStructured() bool { + return e.ErrorCode != "" +} + +// HasErrorCode returns true if the error has the given error code. +func (e *APIError) HasErrorCode(code string) bool { + return e.ErrorCode == code +} + +// FormatError returns a human-readable formatted representation of the error, +// including details, suggestions, and links when available. +func (e *APIError) FormatError() string { + var b strings.Builder + if e.ErrorCode != "" { + fmt.Fprintf(&b, "[%s] ", e.ErrorCode) + } + b.WriteString(e.Message) + + if len(e.Details) > 0 { + b.WriteString("\n\nDetails:") + for _, d := range e.Details { + fmt.Fprintf(&b, "\n - %s", d.Message) + if d.Field != "" { + fmt.Fprintf(&b, " (field: %s", d.Field) + if rendered := formatErrorDetailValue(d.Value); rendered != "" { + fmt.Fprintf(&b, ", value: %s", rendered) + } + b.WriteString(")") + } + } + } + + if len(e.Suggestions) > 0 { + b.WriteString("\n\nSuggestions:") + for _, s := range e.Suggestions { + fmt.Fprintf(&b, "\n - %s", s) + } + } + + if len(e.Links) > 0 { + b.WriteString("\n\nReferences:") + for _, l := range e.Links { + if l.Label != "" { + fmt.Fprintf(&b, "\n - %s: %s", l.Label, l.URL) + } else { + fmt.Fprintf(&b, "\n - %s", l.URL) + } + } + } + + return b.String() +} + +func formatErrorDetailValue(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) + } +} diff --git a/go/go.mod b/go/go.mod index a135793e..3e823f1a 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,6 +3,14 @@ module github.com/Phala-Network/phala-cloud/sdks/go go 1.25.0 require ( - golang.org/x/crypto v0.49.0 // indirect + github.com/Dstack-TEE/dstack/sdk/go v0.0.0-20260318210907-bad8c975c30e + golang.org/x/crypto v0.49.0 +) + +require ( + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/ethereum/go-ethereum v1.17.1 // indirect + github.com/holiman/uint256 v1.3.2 // indirect golang.org/x/sys v0.42.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index bb3d09ba..82b51f11 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,3 +1,15 @@ +github.com/Dstack-TEE/dstack/sdk/go v0.0.0-20260318210907-bad8c975c30e h1:Ci04RC8GOLdt6fxQHS4bhGlK87Lqx0eHJztGTncD2fQ= +github.com/Dstack-TEE/dstack/sdk/go v0.0.0-20260318210907-bad8c975c30e/go.mod h1:KvaSdZnBZzvbvCZbDF/3EVMpa7FNyRV8ENKPHG/crrI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho= +github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= 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= diff --git a/go/request.go b/go/request.go index abd21a38..0ec6308f 100644 --- a/go/request.go +++ b/go/request.go @@ -67,6 +67,45 @@ func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, err if code, ok := parsed["error_code"].(string); ok { apiErr.ErrorCode = code } + // Parse structured error fields. + if details, ok := parsed["details"].([]any); ok { + for _, d := range details { + if dm, ok := d.(map[string]any); ok { + ed := ErrorDetail{} + if f, ok := dm["field"].(string); ok { + ed.Field = f + } + if v, ok := dm["value"]; ok { + ed.Value = v + } + if m, ok := dm["message"].(string); ok { + ed.Message = m + } + apiErr.Details = append(apiErr.Details, ed) + } + } + } + if suggestions, ok := parsed["suggestions"].([]any); ok { + for _, s := range suggestions { + if str, ok := s.(string); ok { + apiErr.Suggestions = append(apiErr.Suggestions, str) + } + } + } + if links, ok := parsed["links"].([]any); ok { + for _, l := range links { + if lm, ok := l.(map[string]any); ok { + el := ErrorLink{} + if u, ok := lm["url"].(string); ok { + el.URL = u + } + if lb, ok := lm["label"].(string); ok { + el.Label = lb + } + apiErr.Links = append(apiErr.Links, el) + } + } + } } if apiErr.Message == "" { diff --git a/go/request_compat_test.go b/go/request_compat_test.go new file mode 100644 index 00000000..ec38f46b --- /dev/null +++ b/go/request_compat_test.go @@ -0,0 +1,102 @@ +package phala + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCVMInfo_UnmarshalPreservesEndpointInstance(t *testing.T) { + var info CVMInfo + err := json.Unmarshal([]byte(`{ + "id":"1", + "name":"test", + "resource":{}, + "status":"running", + "listed":false, + "endpoints":[{"app":"https://app.example","instance":"https://instance.example"}], + "public_urls":[{"app":"https://public-app.example","instance":"https://public-instance.example"}] + }`), &info) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if len(info.Endpoints) != 1 || info.Endpoints[0].Instance != "https://instance.example" { + t.Fatalf("endpoints = %#v", info.Endpoints) + } + if len(info.PublicURLs) != 1 || info.PublicURLs[0].Instance != "https://public-instance.example" { + t.Fatalf("public_urls = %#v", info.PublicURLs) + } +} + +func TestDo_PreservesStructuredErrorDetailValues(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(465) + _, _ = io.WriteString(w, `{ + "message":"compose hash precondition failed", + "error_code":"ERR-03-006", + "details":[ + { + "field":"compose_hash", + "value":{"app_id":"app_123","kms_info":{"chain_id":1}}, + "message":"use returned metadata" + }, + { + "field":"memory", + "value":512, + "message":"too small" + } + ] + }`) + })) + defer srv.Close() + + client, err := NewClient(WithAPIKey("k"), WithBaseURL(srv.URL)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + req, err := client.newRequest(context.Background(), "GET", "/test", nil) + if err != nil { + t.Fatalf("newRequest: %v", err) + } + + _, err = client.do(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if len(apiErr.Details) != 2 { + t.Fatalf("details = %#v", apiErr.Details) + } + + value0, ok := apiErr.Details[0].Value.(map[string]any) + if !ok { + t.Fatalf("detail value type = %T, want map[string]any", apiErr.Details[0].Value) + } + if value0["app_id"] != "app_123" { + t.Fatalf("detail value = %#v", value0) + } + + value1, ok := apiErr.Details[1].Value.(float64) + if !ok || value1 != 512 { + t.Fatalf("numeric detail value = %#v", apiErr.Details[1].Value) + } + + formatted := apiErr.FormatError() + if !strings.Contains(formatted, `"kms_info":{"chain_id":1}`) { + t.Fatalf("FormatError() missing object value: %s", formatted) + } + if !strings.Contains(formatted, "value: 512") { + t.Fatalf("FormatError() missing numeric value: %s", formatted) + } +} diff --git a/go/types_auth.go b/go/types_auth.go index 92b0b661..beca5120 100644 --- a/go/types_auth.go +++ b/go/types_auth.go @@ -9,14 +9,14 @@ type CurrentUser struct { // 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"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Avatar *string `json:"avatar,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + TOTPEnabled *bool `json:"totp_enabled,omitempty"` + HasBackupCodes *bool `json:"has_backup_codes,omitempty"` + FlagHasPassword *bool `json:"flag_has_password,omitempty"` } // WorkspaceInfo contains workspace information for the current user. @@ -31,8 +31,8 @@ type WorkspaceInfo struct { // 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"` + Balance *string `json:"balance,omitempty"` + GrantedBalance *string `json:"granted_balance,omitempty"` + IsPostPaid *bool `json:"is_post_paid,omitempty"` + OutstandingAmount *string `json:"outstanding_amount,omitempty"` } diff --git a/go/types_cvms.go b/go/types_cvms.go index 64f4ec2a..87d46ee9 100644 --- a/go/types_cvms.go +++ b/go/types_cvms.go @@ -36,6 +36,19 @@ type CVMInfo struct { Runner *string `json:"runner,omitempty"` ManifestVer *string `json:"manifest_version,omitempty"` ComposeFile any `json:"compose_file,omitempty"` + + // Additional fields used by the Terraform provider. + InProgress bool `json:"in_progress,omitempty"` + EncryptedEnvPubkey *string `json:"encrypted_env_pubkey,omitempty"` + DiskSize *int64 `json:"disk_size,omitempty"` + Endpoints []CVMEndpoint `json:"endpoints,omitempty"` + PublicURLs []CVMEndpoint `json:"public_urls,omitempty"` +} + +// CVMEndpoint represents a CVM endpoint URL. +type CVMEndpoint struct { + App string `json:"app"` + Instance string `json:"instance"` } // CvmResource holds CVM resource allocation. @@ -134,25 +147,35 @@ type ProvisionCVMRequest struct { 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"` + 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"` + Region *string `json:"region,omitempty"` + KMSType *string `json:"kms_type,omitempty"` + Listed *bool `json:"listed,omitempty"` + Encrypted *bool `json:"encrypted,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` + SSHAuthorizedKeys []string `json:"ssh_authorized_keys,omitempty"` + CustomAppID *string `json:"custom_app_id,omitempty"` + Nonce *int64 `json:"nonce,omitempty"` + StorageFS *string `json:"storage_fs,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"` + 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"` + AllowedEnvs []string `json:"allowed_envs,omitempty"` + PublicLogs *bool `json:"public_logs,omitempty"` + PublicSysinfo *bool `json:"public_sysinfo,omitempty"` + PublicTcbinfo *bool `json:"public_tcbinfo,omitempty"` + SecureTime *bool `json:"secure_time,omitempty"` + StorageFS *string `json:"storage_fs,omitempty"` } // ProvisionCVMResponse is the response from provisioning a CVM. @@ -171,9 +194,11 @@ type ProvisionCVMResponse struct { // 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"` + AppID string `json:"app_id"` + ComposeHash string `json:"compose_hash"` + TransactionHash *string `json:"transaction_hash,omitempty"` + EncryptedEnv *string `json:"encrypted_env,omitempty"` + EnvKeys []string `json:"env_keys,omitempty"` } // CommitCVMProvisionResponse is the response from committing a CVM provision. @@ -220,22 +245,22 @@ type ReplicateCVMOptions struct { // 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"` + DockerComposeFile *string `json:"docker_compose_file,omitempty"` + PreLaunchScript *string `json:"pre_launch_script,omitempty"` + EncryptedEnv *string `json:"encrypted_env,omitempty"` + AllowedEnvs []string `json:"allowed_envs,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. diff --git a/go/types_ssh_keys.go b/go/types_ssh_keys.go index d1ccb084..772638bf 100644 --- a/go/types_ssh_keys.go +++ b/go/types_ssh_keys.go @@ -2,10 +2,14 @@ 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"` + ID string `json:"id"` + Name string `json:"name"` + PublicKey string `json:"public_key"` + Fingerprint *string `json:"fingerprint,omitempty"` + KeyType *string `json:"key_type,omitempty"` + Source *string `json:"source,omitempty"` + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` } // CreateSSHKeyRequest is the request for creating an SSH key. diff --git a/js/src/actions/cvms/commit_cvm_provision.ts b/js/src/actions/cvms/commit_cvm_provision.ts index e726136d..958b79a5 100644 --- a/js/src/actions/cvms/commit_cvm_provision.ts +++ b/js/src/actions/cvms/commit_cvm_provision.ts @@ -86,6 +86,25 @@ import { defineAction } from "../../utils/define-action"; * - **contract_address**: On-chain KMS contract address (required for ETHEREUM/BASE KMS) * - **deployer_address**: Deployer address for on-chain verification (required for ETHEREUM/BASE KMS) * + * ## Idempotency + * + * This endpoint is idempotent: submitting the same `app_id` + `compose_hash` + * again returns the existing CVM (safe retry after network timeout). A different + * `compose_hash` for the same `app_id` throws a `ResourceError` with error code + * `ERR-03-007` (`CVM_APP_ID_CONFLICT`, HTTP 409). + * + * ```typescript + * import { isAppIdConflictError } from '@phala/cloud' + * + * try { + * const cvm = await commitCvmProvision(client, payload) + * } catch (error) { + * if (isAppIdConflictError(error)) { + * // app_id already used with a different compose_hash + * } + * } + * ``` + * * ## Schema Parameter * * - **Type:** `ZodSchema | false` diff --git a/js/src/utils/error_codes.ts b/js/src/utils/error_codes.ts new file mode 100644 index 00000000..6b105f49 --- /dev/null +++ b/js/src/utils/error_codes.ts @@ -0,0 +1,60 @@ +/** + * Phala Cloud API structured error codes. + * + * Use with ResourceError.errorCode to match specific errors: + * ```typescript + * if (error instanceof ResourceError && error.errorCode === ErrorCodes.CVM_APP_ID_CONFLICT) { + * // handle idempotency conflict + * } + * ``` + */ +export const ErrorCodes = { + // Module 01: CVM Preflight & Compose Hash + NODE_NOT_FOUND: "ERR-01-001", + COMPOSE_FILE_REQUIRED: "ERR-01-002", + INVALID_COMPOSE_FILE: "ERR-01-003", + DUPLICATE_CVM_NAME: "ERR-01-004", + HASH_REGISTRATION_REQUIRED: "ERR-01-005", + HASH_INVALID_OR_EXPIRED: "ERR-01-006", + TX_VERIFICATION_FAILED: "ERR-01-007", + HASH_NOT_ALLOWED: "ERR-01-008", + + // Module 02: Inventory + INSTANCE_TYPE_NOT_FOUND: "ERR-02-001", + RESOURCE_NOT_AVAILABLE: "ERR-02-002", + INSUFFICIENT_VCPU: "ERR-02-003", + INSUFFICIENT_MEMORY: "ERR-02-004", + INSUFFICIENT_SLOTS: "ERR-02-005", + GPU_ALLOCATION_ERROR: "ERR-02-006", + INSUFFICIENT_GPU: "ERR-02-007", + INVALID_REQUEST: "ERR-02-008", + INCOMPATIBLE_CONFIG: "ERR-02-009", + IMAGE_NOT_FOUND: "ERR-02-010", + KMS_NOT_FOUND: "ERR-02-011", + TEEPOD_NOT_ACCESSIBLE: "ERR-02-012", + OS_IMAGE_NOT_COMPATIBLE: "ERR-02-013", + NODE_CAPACITY_NOT_CONFIGURED: "ERR-02-014", + QUOTA_EXCEEDED: "ERR-02-015", + + // Module 03: CVM Operations + CVM_NOT_FOUND: "ERR-03-001", + MULTIPLE_CVMS_SAME_NAME: "ERR-03-002", + CVM_NOT_IN_WORKSPACE: "ERR-03-003", + CVM_ACCESS_DENIED: "ERR-03-005", + REPLICA_IMAGE_NOT_AVAILABLE: "ERR-03-006", + CVM_APP_ID_CONFLICT: "ERR-03-007", + + // Module 04: Workspace + INSUFFICIENT_BALANCE: "ERR-04-001", + MAX_CVM_LIMIT: "ERR-04-002", + RESOURCE_LIMIT_EXCEEDED: "ERR-04-003", + + // Module 05: Credentials + TOKEN_LIMIT_EXCEEDED: "ERR-05-001", + TOKEN_RATE_LIMIT: "ERR-05-002", + + // Module 06: Auth + OAUTH_EMAIL_INVALID: "ERR-06-001", +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; diff --git a/js/src/utils/errors.ts b/js/src/utils/errors.ts index 2b4e15fc..d99e0bae 100644 --- a/js/src/utils/errors.ts +++ b/js/src/utils/errors.ts @@ -796,3 +796,18 @@ export function formatStructuredError( return parts.join("\n"); } + +/** + * Check if an error is a conflict error (409). + */ +export function isConflictError(error: PhalaCloudError): boolean { + return error.status === 409; +} + +/** + * Check if an error is an app_id conflict from idempotent CVM creation. + * This means the app_id already has an active CVM with a different compose_hash. + */ +export function isAppIdConflictError(error: PhalaCloudError): error is ResourceError { + return error instanceof ResourceError && error.errorCode === "ERR-03-007"; +} diff --git a/js/src/utils/index.ts b/js/src/utils/index.ts index 644d980b..36283944 100644 --- a/js/src/utils/index.ts +++ b/js/src/utils/index.ts @@ -38,6 +38,9 @@ export { formatErrorMessage, formatStructuredError, getErrorMessage, + // Conflict helpers + isConflictError, + isAppIdConflictError, // Deprecated utilities getValidationFields, // Types @@ -49,6 +52,9 @@ export { type ApiError, } from "./errors"; +// Error code constants +export { ErrorCodes, type ErrorCode } from "./error_codes"; + // Network utilities export { // Core network functions diff --git a/python/src/phala_cloud/__init__.py b/python/src/phala_cloud/__init__.py index da6244a9..643ca342 100644 --- a/python/src/phala_cloud/__init__.py +++ b/python/src/phala_cloud/__init__.py @@ -1,3 +1,4 @@ +from . import error_codes from .actions import * # noqa: F403 from .blockchains import add_compose_hash, deploy_app_auth from .client import DEFAULT_API_VERSION, SUPPORTED_API_VERSIONS, ApiVersion @@ -5,6 +6,7 @@ ApiError, AuthError, BusinessError, + ConflictError, PhalaCloudError, RequestError, ResourceError, @@ -28,6 +30,7 @@ "AsyncPhalaCloud", "AuthError", "BusinessError", + "ConflictError", "DEFAULT_API_VERSION", "PhalaCloud", "PhalaCloudError", @@ -40,6 +43,7 @@ "add_compose_hash", "deploy_app_auth", "encrypt_env_vars", + "error_codes", "get_compose_hash", "parse_env", "parse_env_vars", diff --git a/python/src/phala_cloud/client.py b/python/src/phala_cloud/client.py index 7ab57ca4..6d02003b 100644 --- a/python/src/phala_cloud/client.py +++ b/python/src/phala_cloud/client.py @@ -11,6 +11,7 @@ ApiError, AuthError, BusinessError, + ConflictError, PhalaCloudError, RequestError, ResourceError, @@ -262,6 +263,8 @@ def _to_api_error(self, response: httpx.Response) -> ApiError: return AuthError(**base_kwargs) if status >= 500: return ServerError(**base_kwargs) + if status == 409: + return ConflictError(**base_kwargs) if status >= 400: return BusinessError(**base_kwargs) @@ -479,6 +482,8 @@ def _to_api_error(self, response: httpx.Response) -> ApiError: return AuthError(**base_kwargs) if status >= 500: return ServerError(**base_kwargs) + if status == 409: + return ConflictError(**base_kwargs) if status >= 400: return BusinessError(**base_kwargs) diff --git a/python/src/phala_cloud/error_codes.py b/python/src/phala_cloud/error_codes.py new file mode 100644 index 00000000..b03a074f --- /dev/null +++ b/python/src/phala_cloud/error_codes.py @@ -0,0 +1,60 @@ +"""Phala Cloud API structured error codes. + +Use with ResourceError.error_code to match specific errors:: + + from phala_cloud.error_codes import CVM_APP_ID_CONFLICT + + try: + client.commit_cvm_provision(...) + except ResourceError as e: + if e.error_code == CVM_APP_ID_CONFLICT: + # handle idempotency conflict + ... +""" + +# Module 01: CVM Preflight & Compose Hash +NODE_NOT_FOUND = "ERR-01-001" +COMPOSE_FILE_REQUIRED = "ERR-01-002" +INVALID_COMPOSE_FILE = "ERR-01-003" +DUPLICATE_CVM_NAME = "ERR-01-004" +HASH_REGISTRATION_REQUIRED = "ERR-01-005" +HASH_INVALID_OR_EXPIRED = "ERR-01-006" +TX_VERIFICATION_FAILED = "ERR-01-007" +HASH_NOT_ALLOWED = "ERR-01-008" + +# Module 02: Inventory +INSTANCE_TYPE_NOT_FOUND = "ERR-02-001" +RESOURCE_NOT_AVAILABLE = "ERR-02-002" +INSUFFICIENT_VCPU = "ERR-02-003" +INSUFFICIENT_MEMORY = "ERR-02-004" +INSUFFICIENT_SLOTS = "ERR-02-005" +GPU_ALLOCATION_ERROR = "ERR-02-006" +INSUFFICIENT_GPU = "ERR-02-007" +INVALID_REQUEST = "ERR-02-008" +INCOMPATIBLE_CONFIG = "ERR-02-009" +IMAGE_NOT_FOUND = "ERR-02-010" +KMS_NOT_FOUND = "ERR-02-011" +TEEPOD_NOT_ACCESSIBLE = "ERR-02-012" +OS_IMAGE_NOT_COMPATIBLE = "ERR-02-013" +NODE_CAPACITY_NOT_CONFIGURED = "ERR-02-014" +QUOTA_EXCEEDED = "ERR-02-015" + +# Module 03: CVM Operations +CVM_NOT_FOUND = "ERR-03-001" +MULTIPLE_CVMS_SAME_NAME = "ERR-03-002" +CVM_NOT_IN_WORKSPACE = "ERR-03-003" +CVM_ACCESS_DENIED = "ERR-03-005" +REPLICA_IMAGE_NOT_AVAILABLE = "ERR-03-006" +CVM_APP_ID_CONFLICT = "ERR-03-007" + +# Module 04: Workspace +INSUFFICIENT_BALANCE = "ERR-04-001" +MAX_CVM_LIMIT = "ERR-04-002" +RESOURCE_LIMIT_EXCEEDED = "ERR-04-003" + +# Module 05: Credentials +TOKEN_LIMIT_EXCEEDED = "ERR-05-001" +TOKEN_RATE_LIMIT = "ERR-05-002" + +# Module 06: Auth +OAUTH_EMAIL_INVALID = "ERR-06-001" diff --git a/python/src/phala_cloud/errors.py b/python/src/phala_cloud/errors.py index 7329ea0a..ae041f2f 100644 --- a/python/src/phala_cloud/errors.py +++ b/python/src/phala_cloud/errors.py @@ -36,6 +36,15 @@ class BusinessError(ApiError): """Business logic error (400, 409, etc.).""" +class ConflictError(BusinessError): + """409 Conflict — transient, may be retryable. + + A 409 without a structured error_code typically indicates a transient + conflict (e.g., operation in progress). A 409 WITH an error_code + (returned as ResourceError) is a deterministic business error. + """ + + class ServerError(ApiError): """Server-side error (500+).""" diff --git a/python/src/phala_cloud/full_client.py b/python/src/phala_cloud/full_client.py index fc0ee61e..2d070be0 100644 --- a/python/src/phala_cloud/full_client.py +++ b/python/src/phala_cloud/full_client.py @@ -494,6 +494,12 @@ def safe_provision_cvm(self, request: Mapping[str, Any]) -> SafeResult[Any]: return self.safe(self.provision_cvm, request) def commit_cvm_provision(self, request: Mapping[str, Any]) -> Any: + """Commit a provisioned CVM, creating the actual instance. + + This endpoint is idempotent: submitting the same app_id + compose_hash + again returns the existing CVM. A different compose_hash for the same + app_id raises ResourceError with error_code CVM_APP_ID_CONFLICT (409). + """ return self._loose_validate(self.post("/cvms", json=dict(request))) def safe_commit_cvm_provision(self, request: Mapping[str, Any]) -> SafeResult[Any]: @@ -1251,6 +1257,12 @@ async def safe_provision_cvm(self, request: Mapping[str, Any]) -> SafeResult[Any return await self.safe(self.provision_cvm, request) async def commit_cvm_provision(self, request: Mapping[str, Any]) -> Any: + """Commit a provisioned CVM, creating the actual instance. + + This endpoint is idempotent: submitting the same app_id + compose_hash + again returns the existing CVM. A different compose_hash for the same + app_id raises ResourceError with error_code CVM_APP_ID_CONFLICT (409). + """ return self._loose_validate(await self.post("/cvms", json=dict(request))) async def safe_commit_cvm_provision(self, request: Mapping[str, Any]) -> SafeResult[Any]: diff --git a/terraform b/terraform index 28846aaa..47dcd998 160000 --- a/terraform +++ b/terraform @@ -1 +1 @@ -Subproject commit 28846aaab58bac9e23385c78f5ec468c6bab5805 +Subproject commit 47dcd9981a7ea17b83fd230a77ca3b54965cf96a