From 3d4c2dbb209a7919e70bd367159be9ce8dd269c5 Mon Sep 17 00:00:00 2001 From: shiran Date: Sat, 22 Aug 2026 22:14:08 +0800 Subject: [PATCH] feat: initial AWS Server Go SDK --- .gitea/workflows/test.yml | 15 +++ .gitignore | 3 + README.md | 100 ++++++++++++++++ access.go | 75 ++++++++++++ agent.go | 73 ++++++++++++ catalog.go | 67 +++++++++++ client.go | 238 +++++++++++++++++++++++++++++++++++++ client_test.go | 108 +++++++++++++++++ errors.go | 44 +++++++ examples/lifecycle/main.go | 45 +++++++ go.mod | 3 + instances.go | 112 +++++++++++++++++ resources.go | 117 ++++++++++++++++++ secret.go | 14 +++ tasks.go | 75 ++++++++++++ types.go | 183 ++++++++++++++++++++++++++++ 16 files changed, 1272 insertions(+) create mode 100644 .gitea/workflows/test.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 access.go create mode 100644 agent.go create mode 100644 catalog.go create mode 100644 client.go create mode 100644 client_test.go create mode 100644 errors.go create mode 100644 examples/lifecycle/main.go create mode 100644 go.mod create mode 100644 instances.go create mode 100644 resources.go create mode 100644 secret.go create mode 100644 tasks.go create mode 100644 types.go diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..76276a9 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,15 @@ +name: test +on: + push: + pull_request: +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24.x' + - run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go test -race ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a94ecc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/coverage.out +/.idea/ +/.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5897419 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# AWS Server Go SDK + +供其他 Go 应用通过应用密钥调用 AWS Server 平台,完成 EC2 创建、价格预估和完整生命周期管理。SDK 只使用稳定的 `/api/sdk/v1` 接口,不需要也不会接触管理员 Token、AWS 账号 ID 或 AWS 凭证。 + +## 安装 + +```bash +go get gitea.s1f.ren/shiran/aws-server-sdk@v0.1.0 +``` + +## 初始化 + +```go +client, err := awsserversdk.NewClient( + "https://aws.example.com", + os.Getenv("AWS_SERVER_APP_TOKEN"), +) +``` + +管理员需要先在平台创建客户端应用,为应用配置允许的 Region、权限范围和网络位置,并创建一次性显示的应用 Token。 + +## 完整创建流程 + +```go +ctx := context.Background() + +options, err := client.Catalog.GetCreateOptions(ctx, "ap-northeast-1") +if err != nil { + log.Fatal(err) +} + +estimate, err := client.Pricing.EstimateInstance(ctx, "ap-northeast-1", awsserversdk.CreateInstanceRequest{ + PlacementID: options.Placements[0].PlacementID, + ImageID: options.Images[0].ImageID, + InstanceType: options.InstanceTypes[0].InstanceType, + RootVolume: &awsserversdk.VolumeSpec{SizeGiB: 30, Type: "gp3"}, + AssociateEIP: true, +}) +if err != nil { + log.Fatal(err) +} +log.Printf("hourly=%f calendar_month=%f %s", estimate.HourlyPrice, estimate.CalendarMonthPrice, estimate.Currency) + +operation, err := client.Instances.Create(ctx, "ap-northeast-1", awsserversdk.CreateInstanceRequest{ + PlacementID: options.Placements[0].PlacementID, + ImageID: options.Images[0].ImageID, + InstanceType: options.InstanceTypes[0].InstanceType, + Name: "sdk-created", + RootVolume: &awsserversdk.VolumeSpec{SizeGiB: 30, Type: "gp3"}, + SecurityGroupMode: "common_ports", + AssociateEIP: true, +}) +if err != nil { + log.Fatal(err) +} +task, err := operation.Wait(ctx) +``` + +创建接口不接受 `account_id` 和 `subnet_id`。可以传管理员配置的 `placement_id`;省略时由平台选择可用网络位置。 + +## 实例管理 + +```go +instances, _ := client.Instances.List(ctx, region, awsserversdk.InstanceListOptions{Limit: 50}) +details, _ := client.Instances.GetDetails(ctx, region, instanceID) + +stop, _ := client.Instances.Stop(ctx, region, instanceID) +_, _ = stop.Wait(ctx) +resize, _ := client.Instances.Resize(ctx, region, instanceID, awsserversdk.ResizeInstanceRequest{InstanceType: "t3.small"}) +_, _ = resize.Wait(ctx) +rebuild, _ := client.Instances.Rebuild(ctx, region, instanceID, awsserversdk.RebuildInstanceRequest{ImageID: "ami-..."}) +_, _ = rebuild.Wait(ctx) +``` + +其他服务入口: + +- `SecurityGroups`:查看、添加和删除安全组规则。 +- `Volumes`:创建、挂载、卸载、扩容和删除数据卷。 +- `EIPs`:申请或更换 EIP、设置 PTR 反向解析。 +- `Traffic`:查询和修改实例出站流量额度。 +- `Access`:登录信息、密码轮换和 SSH Key;需要敏感权限。 +- `Agent`:Agent 状态、命令、升级、MOTD 和 SSH 审计。 +- `Tasks`:应用自身任务的分页查询和等待。 + +所有修改方法接受可选的 `WithIdempotencyKey`。省略时 SDK 自动生成,并在网络重试中始终复用同一个值。 + +## 错误与敏感信息 + +```go +if awsserversdk.IsBudgetExceeded(err) { /* 提示额度不足 */ } +if awsserversdk.IsUnauthorized(err) { /* 检查 Token、权限范围或 Region */ } +``` + +密码和私钥使用 `Secret` 类型,日志和 JSON 序列化默认显示 `[REDACTED]`。只有显式调用 `Reveal()` 才能取得明文。 + +## 兼容性 + +- SDK `v0.x` 对应平台 `/api/sdk/v1`。 +- 服务端可以增加响应字段;SDK 会忽略未知字段。 +- 首版异步结果使用任务轮询,不提供 Webhook。 diff --git a/access.go b/access.go new file mode 100644 index 0000000..e0f2a5f --- /dev/null +++ b/access.go @@ -0,0 +1,75 @@ +package awsserversdk + +import ( + "context" + "net/http" + "net/url" +) + +type AccessService struct{ client *Client } +type LoginInfo struct { + Users []LoginUser `json:"users"` +} +type LoginUser struct { + Username string `json:"username"` + Methods []LoginMethod `json:"methods"` +} +type LoginMethod struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Credential LoginCredential `json:"credential"` +} +type LoginCredential struct { + Kind string `json:"kind"` + Available bool `json:"available"` + Status string `json:"status"` + Error string `json:"error"` + Password Secret `json:"password"` + PrivateKey Secret `json:"private_key"` + KeyID string `json:"key_id"` + KeyName string `json:"key_name"` + Fingerprint string `json:"fingerprint"` + Source string `json:"source"` +} +type Credential struct { + Username string `json:"username"` + Password Secret `json:"password"` + Version int `json:"version"` + Warning string `json:"warning"` +} +type SSHKeyAssignment map[string]any +type AddSSHKeyRequest struct { + SSHKeyID string `json:"ssh_key_id"` + Username string `json:"username"` +} + +func (s *AccessService) GetLoginInfo(ctx context.Context, region, instanceID string) (*LoginInfo, error) { + var result LoginInfo + err := s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/login-info", map[string]any{}, &result, newID()) + return &result, err +} +func (s *AccessService) RevealCredential(ctx context.Context, region, instanceID string) (*Credential, error) { + var result Credential + err := s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/credentials/reveal", map[string]any{}, &result, newID()) + return &result, err +} +func (s *AccessService) RotateCredential(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/credentials/rotate", map[string]any{}, options...) +} +func (s *AccessService) ListSSHKeys(ctx context.Context, region, instanceID string) (*Page[SSHKeyAssignment], error) { + var result Page[SSHKeyAssignment] + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/ssh-keys", nil, &result, "") + return &result, err +} +func (s *AccessService) AddSSHKey(ctx context.Context, region, instanceID string, input AddSSHKeyRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys", input, options...) +} +func (s *AccessService) RemoveSSHKey(ctx context.Context, region, instanceID, keyID, username string, options ...RequestOption) (*Operation, error) { + path := instancesPath(region, instanceID) + "/ssh-keys/" + escaped(keyID) + if username != "" { + path += "?username=" + url.QueryEscape(username) + } + return (&InstancesService{client: s.client}).operation(ctx, http.MethodDelete, path, nil, options...) +} diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..f4ca7f6 --- /dev/null +++ b/agent.go @@ -0,0 +1,73 @@ +package awsserversdk + +import ( + "context" + "net/http" +) + +type AgentService struct{ client *Client } +type AgentStatus map[string]any +type AgentCommandRequest struct { + Action string `json:"action"` + Service string `json:"service,omitempty"` +} +type MOTDPolicyRequest struct { + Mode string `json:"mode"` + Template string `json:"template,omitempty"` +} +type SSHAuditPolicyRequest struct { + Mode string `json:"mode"` +} + +func (s *AgentService) Get(ctx context.Context, region, instanceID string) (AgentStatus, error) { + result := AgentStatus{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/agent", nil, &result, "") + return result, err +} +func (s *AgentService) GetTraffic(ctx context.Context, region, instanceID string) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/agent/traffic", nil, &result, "") + return result, err +} +func (s *AgentService) GetMOTD(ctx context.Context, region, instanceID string) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/agent/motd", nil, &result, "") + return result, err +} +func (s *AgentService) UpdateMOTD(ctx context.Context, region, instanceID string, input MOTDPolicyRequest) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/agent/motd", input, &result, newID()) + return result, err +} +func (s *AgentService) GetSSHAudit(ctx context.Context, region, instanceID string) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/agent/ssh-audit", nil, &result, "") + return result, err +} +func (s *AgentService) UpdateSSHAudit(ctx context.Context, region, instanceID string, input SSHAuditPolicyRequest) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/agent/ssh-audit", input, &result, newID()) + return result, err +} +func (s *AgentService) ListSSHConnections(ctx context.Context, region, instanceID string) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/agent/ssh-connections", nil, &result, "") + return result, err +} +func (s *AgentService) Command(ctx context.Context, region, instanceID string, input AgentCommandRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/agent/commands", input, options...) +} +func (s *AgentService) GetCommand(ctx context.Context, region, instanceID, commandID string) (map[string]any, error) { + result := map[string]any{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/agent/commands/"+escaped(commandID), nil, &result, "") + return result, err +} +func (s *AgentService) Upgrade(ctx context.Context, region, instanceID, version string, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/agent/upgrade", map[string]string{"version": version}, options...) +} +func (s *AgentService) RetryInstall(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/agent/retry-install", map[string]any{}, options...) +} +func (s *AgentService) Revoke(ctx context.Context, region, instanceID string) error { + return s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/agent/revoke", map[string]any{}, nil, newID()) +} diff --git a/catalog.go b/catalog.go new file mode 100644 index 0000000..65300c4 --- /dev/null +++ b/catalog.go @@ -0,0 +1,67 @@ +package awsserversdk + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +type CatalogService struct{ client *Client } +type PricingService struct{ client *Client } + +func (s *CatalogService) ListRegions(ctx context.Context) ([]Region, error) { + var result struct { + Items []Region `json:"items"` + } + err := s.client.do(ctx, http.MethodGet, "/regions", nil, &result, "") + return result.Items, err +} +func (s *CatalogService) GetCreateOptions(ctx context.Context, region string) (*CreateOptions, error) { + var result CreateOptions + err := s.client.do(ctx, http.MethodGet, "/regions/"+escaped(region)+"/create-options", nil, &result, "") + return &result, err +} +func (s *CatalogService) ListImages(ctx context.Context, region string, options ListOptions) (*Page[Image], error) { + var result Page[Image] + err := s.client.do(ctx, http.MethodGet, catalogPath(region, "images", options), nil, &result, "") + return &result, err +} +func (s *CatalogService) ListInstanceTypes(ctx context.Context, region string, options ListOptions) (*Page[InstanceType], error) { + var result Page[InstanceType] + err := s.client.do(ctx, http.MethodGet, catalogPath(region, "instance-types", options), nil, &result, "") + return &result, err +} +func (s *CatalogService) ListPlacements(ctx context.Context, region string) ([]Placement, error) { + var result struct { + Items []Placement `json:"items"` + } + err := s.client.do(ctx, http.MethodGet, "/regions/"+escaped(region)+"/placements", nil, &result, "") + return result.Items, err +} +func (s *PricingService) EstimateInstance(ctx context.Context, region string, input CreateInstanceRequest) (*PriceEstimate, error) { + var result PriceEstimate + err := s.client.do(ctx, http.MethodPost, "/regions/"+escaped(region)+"/price-estimate", input, &result, newID()) + return &result, err +} +func catalogPath(region, resource string, options ListOptions) string { + values := url.Values{} + if options.Query != "" { + values.Set("q", options.Query) + } + if options.CategoryID != "" { + values.Set("category_id", options.CategoryID) + } + if options.Limit > 0 { + values.Set("limit", strconv.Itoa(options.Limit)) + } + if options.Offset > 0 { + values.Set("offset", strconv.Itoa(options.Offset)) + } + path := fmt.Sprintf("/regions/%s/%s", escaped(region), resource) + if encoded := values.Encode(); encoded != "" { + path += "?" + encoded + } + return path +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..e15df00 --- /dev/null +++ b/client.go @@ -0,0 +1,238 @@ +package awsserversdk + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const Version = "0.1.0" + +type Logger interface { + Printf(format string, args ...any) +} +type Option func(*Client) + +func WithHTTPClient(value *http.Client) Option { + return func(c *Client) { + if value != nil { + c.httpClient = value + } + } +} +func WithUserAgent(value string) Option { + return func(c *Client) { + if strings.TrimSpace(value) != "" { + c.userAgent = value + } + } +} +func WithMaxRetries(value int) Option { + return func(c *Client) { + if value >= 0 { + c.maxRetries = value + } + } +} +func WithLogger(value Logger) Option { return func(c *Client) { c.logger = value } } + +type Client struct { + baseURL *url.URL + token string + httpClient *http.Client + userAgent string + maxRetries int + logger Logger + Catalog *CatalogService + Pricing *PricingService + Instances *InstancesService + SecurityGroups *SecurityGroupsService + EIPs *EIPService + Volumes *VolumeService + Traffic *TrafficService + Access *AccessService + Agent *AgentService + Tasks *TaskService +} + +func NewClient(baseURL, applicationToken string, options ...Option) (*Client, error) { + parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/")) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, errors.New("awsserversdk: invalid base URL") + } + if !strings.HasPrefix(applicationToken, "awsapp_") { + return nil, errors.New("awsserversdk: invalid application token") + } + c := &Client{baseURL: parsed, token: applicationToken, httpClient: &http.Client{Timeout: 30 * time.Second}, userAgent: "aws-server-sdk-go/" + Version, maxRetries: 3} + for _, option := range options { + option(c) + } + c.Catalog = &CatalogService{client: c} + c.Pricing = &PricingService{client: c} + c.Instances = &InstancesService{client: c} + c.SecurityGroups = &SecurityGroupsService{client: c} + c.EIPs = &EIPService{client: c} + c.Volumes = &VolumeService{client: c} + c.Traffic = &TrafficService{client: c} + c.Access = &AccessService{client: c} + c.Agent = &AgentService{client: c} + c.Tasks = &TaskService{client: c} + return c, nil +} + +type RequestOption func(*requestOptions) +type requestOptions struct{ idempotencyKey string } + +func WithIdempotencyKey(value string) RequestOption { + return func(v *requestOptions) { v.idempotencyKey = strings.TrimSpace(value) } +} +func mutationOptions(options []RequestOption) requestOptions { + v := requestOptions{} + for _, option := range options { + option(&v) + } + if v.idempotencyKey == "" { + v.idempotencyKey = newID() + } + return v +} + +type envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func (c *Client) do(ctx context.Context, method, path string, input, output any, idempotencyKey string) error { + var payload []byte + var err error + if input != nil { + payload, err = json.Marshal(input) + if err != nil { + return fmt.Errorf("awsserversdk: encode request: %w", err) + } + } + endpoint := *c.baseURL + relative, parseErr := url.Parse("/api/sdk/v1" + path) + if parseErr != nil { + return fmt.Errorf("awsserversdk: invalid request path: %w", parseErr) + } + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + relative.Path + endpoint.RawQuery = relative.RawQuery + for attempt := 0; ; attempt++ { + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + req, requestErr := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if requestErr != nil { + return requestErr + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.userAgent) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } + response, requestErr := c.httpClient.Do(req) + if requestErr != nil { + if attempt < c.maxRetries && retryableMethod(method, idempotencyKey) { + if waitErr := sleepContext(ctx, retryDelay(attempt)); waitErr != nil { + return waitErr + } + continue + } + return requestErr + } + raw, readErr := io.ReadAll(io.LimitReader(response.Body, 8<<20)) + response.Body.Close() + if readErr != nil { + return readErr + } + if retryableStatus(response.StatusCode) && attempt < c.maxRetries && retryableMethod(method, idempotencyKey) { + delay := retryAfter(response.Header.Get("Retry-After"), attempt) + if c.logger != nil { + c.logger.Printf("awsserversdk: retrying %s %s after HTTP %d", method, path, response.StatusCode) + } + if waitErr := sleepContext(ctx, delay); waitErr != nil { + return waitErr + } + continue + } + var wrapped envelope + if err := json.Unmarshal(raw, &wrapped); err != nil { + return &APIError{StatusCode: response.StatusCode, Code: response.StatusCode, Message: "invalid API response", Body: string(raw)} + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &APIError{StatusCode: response.StatusCode, Code: wrapped.Code, Message: wrapped.Message, Data: wrapped.Data} + } + if output != nil && len(wrapped.Data) > 0 && string(wrapped.Data) != "null" { + if err := json.Unmarshal(wrapped.Data, output); err != nil { + return fmt.Errorf("awsserversdk: decode response: %w", err) + } + } + return nil + } +} + +func retryableMethod(method, key string) bool { + return method == http.MethodGet || method == http.MethodHead || key != "" +} +func retryableStatus(code int) bool { + return code == http.StatusTooManyRequests || code == http.StatusBadGateway || code == http.StatusServiceUnavailable || code == http.StatusGatewayTimeout +} +func retryDelay(attempt int) time.Duration { + delay := 200 * time.Millisecond * time.Duration(1<= 0 { + return time.Duration(seconds) * time.Second + } + if at, err := http.ParseTime(value); err == nil && at.After(time.Now()) { + return time.Until(at) + } + return retryDelay(attempt) +} +func sleepContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} +func newID() string { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return fmt.Sprintf("request-%d", time.Now().UnixNano()) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + raw := hex.EncodeToString(value[:]) + return raw[:8] + "-" + raw[8:12] + "-" + raw[12:16] + "-" + raw[16:20] + "-" + raw[20:] +} +func escaped(values ...string) string { + parts := make([]string, len(values)) + for i, value := range values { + parts[i] = url.PathEscape(value) + } + return strings.Join(parts, "/") +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..08f81d5 --- /dev/null +++ b/client_test.go @@ -0,0 +1,108 @@ +package awsserversdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := NewClient(server.URL, "awsapp_test_secret", WithMaxRetries(2)) + if err != nil { + t.Fatal(err) + } + return client, server +} + +func TestClientSendsAuthAndStableIdempotencyOnRetry(t *testing.T) { + var mutex sync.Mutex + attempts := 0 + keys := []string{} + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + mutex.Lock() + defer mutex.Unlock() + attempts++ + keys = append(keys, r.Header.Get("Idempotency-Key")) + if r.Header.Get("Authorization") != "Bearer awsapp_test_secret" { + t.Errorf("missing auth header") + } + w.Header().Set("Content-Type", "application/json") + if attempts == 1 { + w.WriteHeader(503) + _, _ = w.Write([]byte(`{"code":503,"message":"retry"}`)) + return + } + _, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"id":"local-1","task_id":"task-1","status":"queued"}}`)) + }) + operation, err := client.Instances.Create(context.Background(), "us-east-1", CreateInstanceRequest{ImageID: "ami-1", InstanceType: "t3.nano", PlacementID: "default"}) + if err != nil { + t.Fatal(err) + } + if operation.TaskID != "task-1" || attempts != 2 { + t.Fatalf("unexpected operation %#v attempts=%d", operation, attempts) + } + if keys[0] == "" || keys[0] != keys[1] { + t.Fatalf("idempotency key changed: %#v", keys) + } +} + +func TestAPIErrorAndHelpers(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + _, _ = w.Write([]byte(`{"code":409,"message":"budget","data":{"shortfall":1.25}}`)) + }) + _, err := client.Instances.Create(context.Background(), "us-east-1", CreateInstanceRequest{}) + if err == nil || !IsConflict(err) || !IsBudgetExceeded(err) { + t.Fatalf("unexpected error %v", err) + } +} + +func TestTaskWait(t *testing.T) { + calls := 0 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + status := "running" + if calls > 1 { + status = "succeeded" + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "message": "Success", "data": map[string]any{"id": "task-1", "status": status, "progress": 100}}) + }) + task, err := client.Tasks.Wait(context.Background(), "task-1", WithPollInterval(time.Millisecond)) + if err != nil || task.Status != "succeeded" || calls != 2 { + t.Fatalf("task=%#v err=%v calls=%d", task, err, calls) + } +} + +func TestSecretIsRedacted(t *testing.T) { + var secret Secret + if err := json.Unmarshal([]byte(`"very-secret"`), &secret); err != nil { + t.Fatal(err) + } + if secret.Reveal() != "very-secret" || secret.String() != "[REDACTED]" { + t.Fatal("secret behavior invalid") + } + raw, _ := json.Marshal(secret) + if string(raw) != `"[REDACTED]"` { + t.Fatalf("secret marshaled as %s", raw) + } +} + +func TestListQueryParametersAreNotEscapedIntoPath(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/sdk/v1/regions/us-east-1/images" || r.URL.Query().Get("q") != "ubuntu 24" || r.URL.Query().Get("limit") != "10" { + t.Errorf("unexpected URL %s", r.URL.String()) + } + _, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"items":[],"total":0,"limit":10,"offset":0}}`)) + }) + if _, err := client.Catalog.ListImages(context.Background(), "us-east-1", ListOptions{Query: "ubuntu 24", Limit: 10}); err != nil { + t.Fatal(err) + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..19538ca --- /dev/null +++ b/errors.go @@ -0,0 +1,44 @@ +package awsserversdk + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type APIError struct { + StatusCode int + Code int + Message string + Data json.RawMessage + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("aws server API: HTTP %d code %d: %s", e.StatusCode, e.Code, e.Message) +} +func IsUnauthorized(err error) bool { + value, ok := err.(*APIError) + return ok && (value.StatusCode == http.StatusUnauthorized || value.StatusCode == http.StatusForbidden) +} +func IsConflict(err error) bool { + value, ok := err.(*APIError) + return ok && value.StatusCode == http.StatusConflict +} +func IsBudgetExceeded(err error) bool { + value, ok := err.(*APIError) + if !ok || value.StatusCode != http.StatusConflict { + return false + } + var data map[string]any + return json.Unmarshal(value.Data, &data) == nil && data["shortfall"] != nil +} + +type TaskError struct{ Task Task } + +func (e *TaskError) Error() string { + if e.Task.ErrorMessage != "" { + return e.Task.ErrorMessage + } + return "operation task failed: " + e.Task.Status +} diff --git a/examples/lifecycle/main.go b/examples/lifecycle/main.go new file mode 100644 index 0000000..7dc0df8 --- /dev/null +++ b/examples/lifecycle/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + awsserversdk "gitea.s1f.ren/shiran/aws-server-sdk" +) + +func main() { + client, err := awsserversdk.NewClient(os.Getenv("AWS_SERVER_BASE_URL"), os.Getenv("AWS_SERVER_APP_TOKEN")) + if err != nil { + log.Fatal(err) + } + region := os.Getenv("AWS_SERVER_REGION") + options, err := client.Catalog.GetCreateOptions(context.Background(), region) + if err != nil { + log.Fatal(err) + } + if len(options.Images) == 0 || len(options.InstanceTypes) == 0 { + log.Fatal("Region has no published image or instance type") + } + request := awsserversdk.CreateInstanceRequest{ImageID: options.Images[0].ImageID, InstanceType: options.InstanceTypes[0].InstanceType, Name: "sdk-example", AssociateEIP: true, RootVolume: &awsserversdk.VolumeSpec{SizeGiB: 30, Type: "gp3"}} + if len(options.Placements) > 0 { + request.PlacementID = options.Placements[0].PlacementID + } + estimate, err := client.Pricing.EstimateInstance(context.Background(), region, request) + if err != nil { + log.Fatal(err) + } + log.Printf("estimated hourly %.6f %s", estimate.HourlyPrice, estimate.Currency) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + operation, err := client.Instances.Create(ctx, region, request) + if err != nil { + log.Fatal(err) + } + task, err := operation.Wait(ctx) + if err != nil { + log.Fatal(err) + } + log.Printf("instance %s created by task %s", operation.ID, task.ID) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21cc2f7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.s1f.ren/shiran/aws-server-sdk + +go 1.24.0 diff --git a/instances.go b/instances.go new file mode 100644 index 0000000..2d3cbee --- /dev/null +++ b/instances.go @@ -0,0 +1,112 @@ +package awsserversdk + +import ( + "context" + "net/http" + "net/url" + "strconv" +) + +type InstancesService struct{ client *Client } +type InstanceListOptions struct { + State string + Name string + Limit int + Offset int +} +type ResizeInstanceRequest struct { + InstanceType string `json:"instance_type"` +} +type RebuildInstanceRequest struct { + ImageID string `json:"image_id"` + InstanceType string `json:"instance_type,omitempty"` + AssociateEIP bool `json:"associate_eip,omitempty"` + InstallAgent *bool `json:"install_agent,omitempty"` +} +type RefreshDetailsRequest struct { + Sections []string `json:"sections,omitempty"` +} + +func (s *InstancesService) Create(ctx context.Context, region string, input CreateInstanceRequest, options ...RequestOption) (*Operation, error) { + requestOptions := mutationOptions(options) + var result Operation + err := s.client.do(ctx, http.MethodPost, instancesPath(region, ""), input, &result, requestOptions.idempotencyKey) + result.client = s.client + return &result, err +} +func (s *InstancesService) List(ctx context.Context, region string, options InstanceListOptions) (*Page[Instance], error) { + values := url.Values{} + if options.State != "" { + values.Set("state", options.State) + } + if options.Name != "" { + values.Set("name", options.Name) + } + if options.Limit > 0 { + values.Set("limit", strconv.Itoa(options.Limit)) + } + if options.Offset > 0 { + values.Set("offset", strconv.Itoa(options.Offset)) + } + path := instancesPath(region, "") + if encoded := values.Encode(); encoded != "" { + path += "?" + encoded + } + var result Page[Instance] + err := s.client.do(ctx, http.MethodGet, path, nil, &result, "") + return &result, err +} +func (s *InstancesService) Get(ctx context.Context, region, instanceID string) (*Instance, error) { + var result Instance + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID), nil, &result, "") + return &result, err +} +func (s *InstancesService) GetDetails(ctx context.Context, region, instanceID string) (InstanceDetails, error) { + result := InstanceDetails{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/details", nil, &result, "") + return result, err +} +func (s *InstancesService) RefreshDetails(ctx context.Context, region, instanceID string, input RefreshDetailsRequest, options ...RequestOption) (*Operation, error) { + return s.operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/details/refresh", input, options...) +} +func (s *InstancesService) Start(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return s.action(ctx, region, instanceID, "start", options...) +} +func (s *InstancesService) Stop(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return s.action(ctx, region, instanceID, "stop", options...) +} +func (s *InstancesService) Reboot(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return s.action(ctx, region, instanceID, "reboot", options...) +} +func (s *InstancesService) Delete(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return s.operation(ctx, http.MethodDelete, instancesPath(region, instanceID), nil, options...) +} +func (s *InstancesService) RetryCreate(ctx context.Context, region, instanceID string, options ...RequestOption) (*Operation, error) { + return s.operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/retry", map[string]any{}, options...) +} +func (s *InstancesService) Resize(ctx context.Context, region, instanceID string, input ResizeInstanceRequest, options ...RequestOption) (*Operation, error) { + return s.operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/resize", input, options...) +} +func (s *InstancesService) Rebuild(ctx context.Context, region, instanceID string, input RebuildInstanceRequest, options ...RequestOption) (*Operation, error) { + return s.operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/rebuild", input, options...) +} +func (s *InstancesService) action(ctx context.Context, region, instanceID, action string, options ...RequestOption) (*Operation, error) { + return s.operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/actions", map[string]string{"action": action}, options...) +} +func (s *InstancesService) operation(ctx context.Context, method, path string, input any, options ...RequestOption) (*Operation, error) { + requestOptions := mutationOptions(options) + var result Operation + err := s.client.do(ctx, method, path, input, &result, requestOptions.idempotencyKey) + if result.TaskID == "" { + result.TaskID = result.CommandID + } + result.client = s.client + return &result, err +} +func instancesPath(region, instanceID string) string { + path := "/regions/" + escaped(region) + "/instances" + if instanceID != "" { + path += "/" + escaped(instanceID) + } + return path +} diff --git a/resources.go b/resources.go new file mode 100644 index 0000000..b7fa839 --- /dev/null +++ b/resources.go @@ -0,0 +1,117 @@ +package awsserversdk + +import ( + "context" + "net/http" +) + +type SecurityGroupsService struct{ client *Client } +type EIPService struct{ client *Client } +type VolumeService struct{ client *Client } +type TrafficService struct{ client *Client } + +type SecurityGroupRule struct { + Direction string `json:"direction,omitempty"` + Protocol string `json:"protocol"` + FromPort int32 `json:"from_port"` + ToPort int32 `json:"to_port"` + CIDR string `json:"cidr"` + Description string `json:"description,omitempty"` +} +type SecurityGroupView map[string]any + +func (s *SecurityGroupsService) Get(ctx context.Context, region, instanceID string) (SecurityGroupView, error) { + result := SecurityGroupView{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/security-group", nil, &result, "") + return result, err +} +func (s *SecurityGroupsService) AddRule(ctx context.Context, region, instanceID string, input SecurityGroupRule, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/security-group/rules", input, options...) +} +func (s *SecurityGroupsService) DeleteRule(ctx context.Context, region, instanceID string, input SecurityGroupRule, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodDelete, instancesPath(region, instanceID)+"/security-group/rules", input, options...) +} + +type EIPInfo struct { + Current map[string]any `json:"current"` + Available []map[string]any `json:"available"` +} +type EnsureEIPRequest struct { + AllocationID string `json:"allocation_id,omitempty"` +} +type ReverseDNSRequest struct { + DomainName string `json:"domain_name,omitempty"` +} + +func (s *EIPService) Get(ctx context.Context, region, instanceID string) (*EIPInfo, error) { + var result EIPInfo + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/eip", nil, &result, "") + return &result, err +} +func (s *EIPService) Ensure(ctx context.Context, region, instanceID string, input EnsureEIPRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/eip", input, options...) +} +func (s *EIPService) SetReverseDNS(ctx context.Context, region, instanceID string, input ReverseDNSRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/eip/reverse-dns", input, options...) +} + +type Volume map[string]any +type CreateVolumeRequest struct { + Name string `json:"name,omitempty"` + DeviceName string `json:"device_name"` + SizeGiB int32 `json:"size_gib"` + Type string `json:"type,omitempty"` + IOPS int32 `json:"iops,omitempty"` + Throughput int32 `json:"throughput,omitempty"` + Encrypted bool `json:"encrypted"` + KMSKeyID string `json:"kms_key_id,omitempty"` + DeleteOnTermination bool `json:"delete_on_termination"` +} +type AttachVolumeRequest struct { + DeviceName string `json:"device_name"` + DeleteOnTermination bool `json:"delete_on_termination"` +} +type ModifyVolumeRequest struct { + SizeGiB int32 `json:"size_gib,omitempty"` + Type string `json:"type,omitempty"` + IOPS int32 `json:"iops,omitempty"` + Throughput int32 `json:"throughput,omitempty"` +} + +func (s *VolumeService) List(ctx context.Context, region, instanceID string) ([]Volume, error) { + var result []Volume + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/volumes", nil, &result, "") + return result, err +} +func (s *VolumeService) Create(ctx context.Context, region, instanceID string, input CreateVolumeRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/volumes", input, options...) +} +func (s *VolumeService) Attach(ctx context.Context, region, instanceID, volumeID string, input AttachVolumeRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/volumes/"+escaped(volumeID)+"/attach", input, options...) +} +func (s *VolumeService) Detach(ctx context.Context, region, instanceID, volumeID string, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/volumes/"+escaped(volumeID)+"/detach", map[string]any{}, options...) +} +func (s *VolumeService) Modify(ctx context.Context, region, instanceID, volumeID string, input ModifyVolumeRequest, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/volumes/"+escaped(volumeID), input, options...) +} +func (s *VolumeService) Delete(ctx context.Context, region, instanceID, volumeID string, force bool, options ...RequestOption) (*Operation, error) { + return (&InstancesService{client: s.client}).operation(ctx, http.MethodDelete, instancesPath(region, instanceID)+"/volumes/"+escaped(volumeID), map[string]bool{"force": force}, options...) +} + +type TrafficLimit map[string]any +type TrafficLimitRequest struct { + Enabled *bool `json:"enabled,omitempty"` + LimitGiB *float64 `json:"limit_gib,omitempty"` +} + +func (s *TrafficService) Get(ctx context.Context, region, instanceID string) (TrafficLimit, error) { + result := TrafficLimit{} + err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/traffic-limit", nil, &result, "") + return result, err +} +func (s *TrafficService) Update(ctx context.Context, region, instanceID string, input TrafficLimitRequest) (TrafficLimit, error) { + result := TrafficLimit{} + err := s.client.do(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/traffic-limit", input, &result, newID()) + return result, err +} diff --git a/secret.go b/secret.go new file mode 100644 index 0000000..f7fda1e --- /dev/null +++ b/secret.go @@ -0,0 +1,14 @@ +package awsserversdk + +import ( + "encoding/json" +) + +// Secret redacts credentials when formatted or marshaled. Reveal must be called explicitly. +type Secret struct{ value string } + +func (s Secret) Reveal() string { return s.value } +func (Secret) String() string { return "[REDACTED]" } +func (Secret) GoString() string { return "[REDACTED]" } +func (Secret) MarshalJSON() ([]byte, error) { return json.Marshal("[REDACTED]") } +func (s *Secret) UnmarshalJSON(raw []byte) error { return json.Unmarshal(raw, &s.value) } diff --git a/tasks.go b/tasks.go new file mode 100644 index 0000000..e75651d --- /dev/null +++ b/tasks.go @@ -0,0 +1,75 @@ +package awsserversdk + +import ( + "context" + "errors" + "net/http" + "net/url" + "strconv" + "time" +) + +type TaskService struct{ client *Client } +type TaskListOptions struct { + Status string + Limit int + Offset int +} +type WaitOption func(*waitOptions) +type waitOptions struct{ pollInterval time.Duration } + +func WithPollInterval(value time.Duration) WaitOption { + return func(v *waitOptions) { + if value > 0 { + v.pollInterval = value + } + } +} +func (s *TaskService) Get(ctx context.Context, taskID string) (*Task, error) { + if taskID == "" { + return nil, errors.New("awsserversdk: empty task ID") + } + var result Task + err := s.client.do(ctx, http.MethodGet, "/tasks/"+escaped(taskID), nil, &result, "") + return &result, err +} +func (s *TaskService) List(ctx context.Context, options TaskListOptions) (*Page[Task], error) { + values := url.Values{} + if options.Status != "" { + values.Set("status", options.Status) + } + if options.Limit > 0 { + values.Set("limit", strconv.Itoa(options.Limit)) + } + if options.Offset > 0 { + values.Set("offset", strconv.Itoa(options.Offset)) + } + path := "/tasks" + if encoded := values.Encode(); encoded != "" { + path += "?" + encoded + } + var result Page[Task] + err := s.client.do(ctx, http.MethodGet, path, nil, &result, "") + return &result, err +} +func (s *TaskService) Wait(ctx context.Context, taskID string, options ...WaitOption) (*Task, error) { + settings := waitOptions{pollInterval: 2 * time.Second} + for _, option := range options { + option(&settings) + } + for { + task, err := s.Get(ctx, taskID) + if err != nil { + return nil, err + } + switch task.Status { + case "succeeded": + return task, nil + case "failed", "cancelled", "expired": + return task, &TaskError{Task: *task} + } + if err := sleepContext(ctx, settings.pollInterval); err != nil { + return nil, err + } + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..fa36f2b --- /dev/null +++ b/types.go @@ -0,0 +1,183 @@ +package awsserversdk + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +type Page[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} +type ListOptions struct { + Query string + CategoryID string + Limit int + Offset int +} +type Region struct { + ID string `json:"id"` + NameEN string `json:"name_en"` + NameZH string `json:"name_zh"` + PlacementCount int `json:"placement_count"` + Ready bool `json:"ready"` +} +type Placement struct { + ID string `json:"id"` + PlacementID string `json:"placement_id"` + Name string `json:"name"` + Region string `json:"region"` + AvailabilityZone string `json:"availability_zone"` + CIDR string `json:"cidr"` + MapPublicIPOnLaunch bool `json:"map_public_ip_on_launch"` + SortOrder int `json:"sort_order"` + Enabled bool `json:"enabled"` +} +type Category struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + SortOrder int `json:"sort_order"` + Enabled bool `json:"enabled"` +} +type Image struct { + CatalogID string `json:"catalog_id"` + Region string `json:"region"` + CategoryID string `json:"category_id"` + ImageID string `json:"image_id"` + SourceScope string `json:"source_scope"` + SourceName string `json:"source_name"` + DisplayName string `json:"display_name"` + DisplayIntro string `json:"display_intro"` + AvailabilityStatus string `json:"availability_status"` + Metadata map[string]any `json:"metadata"` +} +type InstanceType struct { + ID string `json:"id"` + Region string `json:"region"` + CategoryID string `json:"category_id"` + InstanceType string `json:"instance_type"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + VCPUs int32 `json:"vcpus"` + MemoryMiB int64 `json:"memory_mib"` + Architectures []string `json:"architectures"` + NetworkPerformance string `json:"network_performance"` + GPUCount float64 `json:"gpu_count"` + OSPrices map[string]float64 `json:"os_prices"` + HourlyRate float64 `json:"hourly_rate"` + Currency string `json:"currency"` + PriceMode string `json:"price_mode"` + PriceValue float64 `json:"price_value"` +} +type CreateOptions struct { + Region string `json:"region"` + ImageCategories []Category `json:"image_categories"` + Images []Image `json:"images"` + InstanceTypeCategories []Category `json:"instance_type_categories"` + InstanceTypes []InstanceType `json:"instance_types"` + Placements []Placement `json:"placements"` +} +type VolumeSpec struct { + DeviceName string `json:"device_name,omitempty"` + SizeGiB int32 `json:"size_gib,omitempty"` + Type string `json:"type,omitempty"` + IOPS int32 `json:"iops,omitempty"` + Throughput int32 `json:"throughput,omitempty"` + Encrypted bool `json:"encrypted,omitempty"` + KMSKeyID string `json:"kms_key_id,omitempty"` + DeleteOnTermination *bool `json:"delete_on_termination,omitempty"` +} +type CreateInstanceRequest struct { + PlacementID string `json:"placement_id,omitempty"` + ImageID string `json:"image_id"` + InstanceType string `json:"instance_type"` + Name string `json:"name,omitempty"` + SecurityGroupMode string `json:"security_group_mode,omitempty"` + IAMInstanceProfileARN string `json:"iam_instance_profile_arn,omitempty"` + UserData string `json:"user_data,omitempty"` + PrivateIPAddress string `json:"private_ip_address,omitempty"` + AssociateEIP bool `json:"associate_eip,omitempty"` + Monitoring bool `json:"monitoring,omitempty"` + EBSOptimized bool `json:"ebs_optimized,omitempty"` + ShutdownBehavior string `json:"shutdown_behavior,omitempty"` + RootVolume *VolumeSpec `json:"root_volume,omitempty"` + DataVolumes []VolumeSpec `json:"data_volumes,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + InstallAgent *bool `json:"install_agent,omitempty"` + TrafficLimitGiB *float64 `json:"traffic_limit_gib,omitempty"` +} +type PriceEstimate struct { + Currency string `json:"currency"` + HourlyPrice float64 `json:"hourly_price"` + MonthlyPrice float64 `json:"monthly_price"` + CalendarMonthPrice float64 `json:"calendar_month_price"` + Breakdown map[string]any `json:"breakdown"` + Raw map[string]any `json:"-"` +} +type Instance struct { + ID string `json:"id"` + InstanceID string `json:"instance_id,omitempty"` + Name string `json:"name"` + Region string `json:"region"` + State string `json:"state"` + ProvisionStatus string `json:"provision_status"` + ImageID string `json:"image_id"` + InstanceType string `json:"instance_type"` + AvailabilityZone string `json:"availability_zone"` + PrivateIPAddress string `json:"private_ip_address"` + PublicIPAddress string `json:"public_ip_address"` + HourlyRate float64 `json:"hourly_rate"` + MonthCost float64 `json:"month_cost"` + ProjectedCost float64 `json:"projected_month_cost"` + FullMonthCost float64 `json:"full_month_cost"` + CostCurrency string `json:"cost_currency"` + LastTaskID string `json:"last_task_id"` + LastError string `json:"last_error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +type InstanceDetails map[string]any +type Operation struct { + client *Client + ID string `json:"id"` + TaskID string `json:"task_id"` + CommandID string `json:"command_id,omitempty"` + Status string `json:"status"` + PlacementID string `json:"placement_id,omitempty"` +} + +func (o *Operation) Wait(ctx context.Context, options ...WaitOption) (*Task, error) { + if o == nil || o.client == nil { + return nil, errors.New("awsserversdk: invalid operation") + } + if o.TaskID == "" { + if o.Status == "deleted" || o.Status == "succeeded" { + return &Task{ID: o.ID, ResourceID: o.ID, Status: "succeeded", Progress: 100}, nil + } + return nil, errors.New("awsserversdk: operation has no task ID") + } + return o.client.Tasks.Wait(ctx, o.TaskID, options...) +} + +type Task struct { + ID string `json:"id"` + Type string `json:"type"` + QueueName string `json:"queue_name"` + Region string `json:"region"` + ResourceID string `json:"resource_id"` + Status string `json:"status"` + Progress int `json:"progress"` + Attempts int `json:"attempts"` + State json.RawMessage `json:"state"` + Result json.RawMessage `json:"result"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + FinishedAt *time.Time `json:"finished_at"` +}