@@ -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 ./...
|
||||
@@ -0,0 +1,3 @@
|
||||
/coverage.out
|
||||
/.idea/
|
||||
/.vscode/
|
||||
@@ -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。
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
+67
@@ -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
|
||||
}
|
||||
@@ -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<<min(attempt, 5))
|
||||
var jitter [1]byte
|
||||
_, _ = rand.Read(jitter[:])
|
||||
return delay + time.Duration(jitter[0])*time.Millisecond
|
||||
}
|
||||
func retryAfter(value string, attempt int) time.Duration {
|
||||
if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 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, "/")
|
||||
}
|
||||
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+112
@@ -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
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user