feat: initial AWS Server Go SDK
test / go (push) Successful in 1m7s

This commit is contained in:
shiran
2026-08-22 22:14:08 +08:00
commit 3d4c2dbb20
16 changed files with 1272 additions and 0 deletions
+15
View File
@@ -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 ./...
+3
View File
@@ -0,0 +1,3 @@
/coverage.out
/.idea/
/.vscode/
+100
View File
@@ -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。
+75
View File
@@ -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...)
}
+73
View File
@@ -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
View File
@@ -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
}
+238
View File
@@ -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
View File
@@ -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)
}
}
+44
View File
@@ -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
}
+45
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
module gitea.s1f.ren/shiran/aws-server-sdk
go 1.24.0
+112
View File
@@ -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
View File
@@ -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
}
+14
View File
@@ -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) }
+75
View File
@@ -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
}
}
}
+183
View File
@@ -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"`
}