2 Commits
Author SHA1 Message Date
shiran 6069f64a3e feat(access): add SSH key provisioning and rotation
test / go (push) Successful in 47s
2026-09-02 14:47:38 +08:00
shiran 06b11d8121 feat(storage): add root volume expansion
test / go (push) Successful in 4m59s
2026-09-01 15:02:21 +08:00
5 changed files with 122 additions and 7 deletions
+31
View File
@@ -44,6 +44,25 @@ type AddSSHKeyRequest struct {
SSHKeyID string `json:"ssh_key_id"`
Username string `json:"username"`
}
type ProvisionSSHKeyRequest struct {
Name string `json:"name,omitempty"`
PublicKey string `json:"public_key,omitempty"`
Username string `json:"username"`
}
type RotateSSHKeyRequest struct {
Name string `json:"name,omitempty"`
PublicKey string `json:"public_key,omitempty"`
Username string `json:"username"`
ReplaceKeyID string `json:"replace_key_id,omitempty"`
}
type SSHPrivateKey struct {
KeyID string `json:"key_id"`
KeyName string `json:"key_name"`
Fingerprint string `json:"fingerprint"`
Algorithm string `json:"algorithm"`
Username string `json:"username"`
PrivateKey Secret `json:"private_key"`
}
func (s *AccessService) GetLoginInfo(ctx context.Context, region, instanceID string) (*LoginInfo, error) {
var result LoginInfo
@@ -63,9 +82,21 @@ func (s *AccessService) ListSSHKeys(ctx context.Context, region, instanceID stri
err := s.client.do(ctx, http.MethodGet, instancesPath(region, instanceID)+"/ssh-keys", nil, &result, "")
return &result, err
}
func (s *AccessService) RevealSSHKey(ctx context.Context, region, instanceID, keyID string) (*SSHPrivateKey, error) {
var result SSHPrivateKey
err := s.client.do(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys/"+url.PathEscape(keyID)+"/reveal", map[string]any{}, &result, newID())
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) ProvisionSSHKey(ctx context.Context, region, instanceID string, input ProvisionSSHKeyRequest, options ...RequestOption) (*Operation, error) {
return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys/provision", input, options...)
}
func (s *AccessService) RotateSSHKey(ctx context.Context, region, instanceID string, input RotateSSHKeyRequest, options ...RequestOption) (*Operation, error) {
return (&InstancesService{client: s.client}).operation(ctx, http.MethodPost, instancesPath(region, instanceID)+"/ssh-keys/rotate", 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 != "" {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"time"
)
const Version = "0.4.0"
const Version = "0.6.0"
type Logger interface {
Printf(format string, args ...any)
+76
View File
@@ -71,6 +71,65 @@ func TestClientSendsAuthAndStableIdempotencyOnRetry(t *testing.T) {
}
}
func TestProvisionAndRotateSSHKeyRequests(t *testing.T) {
tests := []struct {
path string
call func(*Client) (*Operation, error)
want map[string]string
}{
{path: "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/provision", call: func(client *Client) (*Operation, error) {
return client.Access.ProvisionSSHKey(context.Background(), "us-east-1", "instance-1", ProvisionSSHKeyRequest{Name: "uploaded", PublicKey: "ssh-ed25519 AAAA", Username: "ec2-user"}, WithIdempotencyKey("add-key"))
}, want: map[string]string{"name": "uploaded", "public_key": "ssh-ed25519 AAAA", "username": "ec2-user"}},
{path: "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/rotate", call: func(client *Client) (*Operation, error) {
return client.Access.RotateSSHKey(context.Background(), "us-east-1", "instance-1", RotateSSHKeyRequest{Username: "ec2-user", ReplaceKeyID: "old-key"}, WithIdempotencyKey("rotate-key"))
}, want: map[string]string{"username": "ec2-user", "replace_key_id": "old-key"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != test.path {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
for key, value := range test.want {
if body[key] != value {
t.Fatalf("body[%s] = %q, want %q", key, body[key], value)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"command_id":"command-1","status":"queued"}}`))
})
operation, err := test.call(client)
if err != nil {
t.Fatal(err)
}
if operation.TaskID != "command-1" {
t.Fatalf("task ID = %q", operation.TaskID)
}
})
}
}
func TestRevealSSHKeyRequestAndSecret(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/key-1/reveal" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"key_id":"key-1","key_name":"generated","username":"ec2-user","private_key":"secret-pem"}}`))
})
key, err := client.Access.RevealSSHKey(context.Background(), "us-east-1", "instance-1", "key-1")
if err != nil {
t.Fatal(err)
}
if key.KeyID != "key-1" || key.PrivateKey.Reveal() != "secret-pem" || key.PrivateKey.String() != "[REDACTED]" {
t.Fatalf("unexpected private key response: %#v", key)
}
}
func TestAPIErrorAndHelpers(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -190,6 +249,23 @@ func TestInstanceChangePricingAndAsyncTraffic(t *testing.T) {
}
}
func TestExpandRootVolumePathAndBody(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.Path != "/api/sdk/v1/regions/us-east-1/instances/local-1/root-volume" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
var body ExpandRootVolumeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SizeGiB != 80 || body.ExpectedConfigurationFingerprint != "fp" {
t.Fatalf("unexpected body %#v err=%v", body, err)
}
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"task_id":"task-root","status":"queued"}}`))
})
operation, err := client.Volumes.ExpandRoot(context.Background(), "us-east-1", "local-1", ExpandRootVolumeRequest{SizeGiB: 80, ExpectedConfigurationFingerprint: "fp"}, WithIdempotencyKey("root-expand"))
if err != nil || operation.TaskID != "task-root" {
t.Fatalf("operation=%#v err=%v", operation, err)
}
}
func TestInstanceDetailsExposeCatalogMetadata(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+7
View File
@@ -86,6 +86,10 @@ type ModifyVolumeRequest struct {
Throughput int32 `json:"throughput,omitempty"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
type ExpandRootVolumeRequest struct {
SizeGiB int32 `json:"size_gib"`
ExpectedConfigurationFingerprint string `json:"expected_configuration_fingerprint,omitempty"`
}
func (s *VolumeService) List(ctx context.Context, region, instanceID string) ([]Volume, error) {
var result struct {
@@ -106,6 +110,9 @@ func (s *VolumeService) Detach(ctx context.Context, region, instanceID, volumeID
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) ExpandRoot(ctx context.Context, region, instanceID string, input ExpandRootVolumeRequest, options ...RequestOption) (*Operation, error) {
return (&InstancesService{client: s.client}).operation(ctx, http.MethodPatch, instancesPath(region, instanceID)+"/root-volume", 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...)
}
+7 -6
View File
@@ -164,12 +164,13 @@ type PriceEstimate struct {
}
const (
InstanceChangeEIPAdd = "eip_add"
InstanceChangeEIPReplace = "eip_replace"
InstanceChangeVolumeCreate = "volume_create"
InstanceChangeVolumeExpand = "volume_expand"
InstanceChangeRebuildImage = "rebuild_image"
InstanceChangeTrafficLimit = "traffic_limit"
InstanceChangeEIPAdd = "eip_add"
InstanceChangeEIPReplace = "eip_replace"
InstanceChangeVolumeCreate = "volume_create"
InstanceChangeVolumeExpand = "volume_expand"
InstanceChangeRootVolumeExpand = "root_volume_expand"
InstanceChangeRebuildImage = "rebuild_image"
InstanceChangeTrafficLimit = "traffic_limit"
)
type InstanceChangePriceRequest struct {