-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.go
More file actions
269 lines (241 loc) · 8.23 KB
/
Copy pathclient.go
File metadata and controls
269 lines (241 loc) · 8.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
package xshellz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// DefaultAPIURL is the production control-plane base URL, used when neither
// the APIURL option nor the XSHELLZ_API_URL environment variable is set.
const DefaultAPIURL = "https://api.xshellz.com/v1"
// Sandbox status values as reported by the control plane.
const (
// StatusCreating means the box is being provisioned.
StatusCreating = "creating"
// StatusRunning means the box is up and reachable over SSH.
StatusRunning = "running"
// StatusStopped means the box is idle-stopped (resume it with Start).
StatusStopped = "stopped"
)
// SandboxInfo is the control-plane view of one sandbox, as returned by List
// and carried inside a Sandbox. Field names mirror the snake_case wire shape.
type SandboxInfo struct {
// UUID uniquely identifies the sandbox.
UUID string `json:"uuid"`
// Name is the user-supplied (or defaulted) box name.
Name string `json:"name"`
// Status is one of the Status* constants (plus transitional states).
Status string `json:"status"`
// SSHCommand is a ready-to-copy "ssh -p PORT root@HOST" one-liner; empty
// while the box is not reachable.
SSHCommand string `json:"ssh_command"`
// SSHHost is the public SSH hostname; empty while not reachable.
SSHHost string `json:"ssh_host"`
// SSHPort is the public SSH port; zero while not reachable.
SSHPort int `json:"ssh_port"`
// WebTerminalReady reports whether the browser terminal can be opened.
WebTerminalReady bool `json:"web_terminal_ready"`
// AlwaysOn reports whether the box stays up 24/7 (paid plans).
AlwaysOn bool `json:"always_on"`
// TrialHoursRemaining is the metered always-on trial pool left on the
// account (0 for paid plans or once drained).
TrialHoursRemaining float64 `json:"trial_hours_remaining"`
// SpawnedAt is the RFC 3339 time the box last started running; empty if
// it never ran.
SpawnedAt string `json:"spawned_at"`
// CreatedAt is the RFC 3339 creation time.
CreatedAt string `json:"created_at"`
// Isolation is the effective OCI runtime ("runsc" = gVisor, "runc" =
// shared host kernel).
Isolation string `json:"isolation"`
// Gvisor reports whether the box booted under gVisor kernel isolation.
Gvisor bool `json:"gvisor"`
}
// apiClient is the minimal control-plane HTTP client.
type apiClient struct {
baseURL string
apiKey string
http *http.Client
}
// newAPIClient resolves configuration with the documented precedence
// (explicit option > environment variable > default) and returns a client,
// or ErrNoAPIKey when no key can be found.
func newAPIClient(apiKey, apiURL string) (*apiClient, error) {
if apiKey == "" {
apiKey = os.Getenv("XSHELLZ_API_KEY")
}
if apiKey == "" {
return nil, ErrNoAPIKey
}
if apiURL == "" {
apiURL = os.Getenv("XSHELLZ_API_URL")
}
if apiURL == "" {
apiURL = DefaultAPIURL
}
return &apiClient{
baseURL: strings.TrimRight(apiURL, "/"),
apiKey: apiKey,
http: &http.Client{Timeout: 120 * time.Second},
}, nil
}
// do performs one JSON request against the control plane. A non-nil body is
// JSON-encoded; a non-nil out receives the decoded 2xx response. Non-2xx
// responses are mapped to *APIError wrapping the matching sentinel.
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
var reqBody io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("xshellz: encode request: %w", err)
}
reqBody = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("xshellz: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("xshellz: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("xshellz: read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return newAPIError(resp.StatusCode, raw)
}
if out != nil {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("xshellz: decode response: %w", err)
}
}
return nil
}
// newAPIError builds an *APIError from a non-2xx response, attaching the
// sentinel that best matches the status code and payload.
func newAPIError(status int, raw []byte) error {
var payload struct {
Message string `json:"message"`
Code string `json:"error"`
}
// Best effort — some bodies (gateway errors) are not JSON at all.
_ = json.Unmarshal(raw, &payload)
apiErr := &APIError{
StatusCode: status,
Code: payload.Code,
Message: payload.Message,
Body: string(raw),
}
switch {
case status == http.StatusUnauthorized:
apiErr.sentinel = ErrAuth
case status == http.StatusForbidden && strings.Contains(strings.ToLower(payload.Message), "agent shell limit"):
apiErr.sentinel = ErrQuota
case status == http.StatusForbidden:
apiErr.sentinel = ErrAuth
case status == http.StatusNotFound:
apiErr.sentinel = ErrNotFound
}
return apiErr
}
// createRequest is the POST /shells/agent body (snake_case wire).
type createRequest struct {
SSHPublicKey string `json:"ssh_public_key"`
Name string `json:"name,omitempty"`
}
func (c *apiClient) createShell(ctx context.Context, publicKey, name string) (SandboxInfo, error) {
var info SandboxInfo
err := c.do(ctx, http.MethodPost, "/shells/agent", createRequest{SSHPublicKey: publicKey, Name: name}, &info)
return info, err
}
// listShells returns the account's active sandboxes. The wire shape is a
// bare JSON array of sandbox objects (no envelope).
func (c *apiClient) listShells(ctx context.Context) ([]SandboxInfo, error) {
var infos []SandboxInfo
err := c.do(ctx, http.MethodGet, "/shells/agent", nil, &infos)
return infos, err
}
func (c *apiClient) startShell(ctx context.Context, uuid string) (SandboxInfo, error) {
var info SandboxInfo
err := c.do(ctx, http.MethodPost, "/shells/agent/"+uuid+"/start", nil, &info)
return info, err
}
func (c *apiClient) deleteShell(ctx context.Context, uuid string) error {
var resp struct {
Deleted bool `json:"deleted"`
}
return c.do(ctx, http.MethodDelete, "/shells/agent/"+uuid, nil, &resp)
}
func (c *apiClient) restartShell(ctx context.Context, uuid string) (SandboxInfo, error) {
var info SandboxInfo
err := c.do(ctx, http.MethodPost, "/shells/agent/"+uuid+"/restart", nil, &info)
return info, err
}
func (c *apiClient) shellStats(ctx context.Context, uuid string) (*SandboxStats, error) {
var stats SandboxStats
if err := c.do(ctx, http.MethodGet, "/shells/agent/"+uuid+"/stats", nil, &stats); err != nil {
return nil, err
}
return &stats, nil
}
func (c *apiClient) shellProcs(ctx context.Context, uuid string) (*SandboxProcs, error) {
var procs SandboxProcs
if err := c.do(ctx, http.MethodGet, "/shells/agent/"+uuid+"/procs", nil, &procs); err != nil {
return nil, err
}
return &procs, nil
}
func (c *apiClient) terminalURL(ctx context.Context, uuid string) (string, error) {
var resp struct {
URL string `json:"url"`
}
err := c.do(ctx, http.MethodGet, "/shells/agent/"+uuid+"/terminal", nil, &resp)
return resp.URL, err
}
// boxfileRequest is the PUT /shells/agent/boxfile body; a null manifest
// clears the saved template.
type boxfileRequest struct {
Manifest *string `json:"manifest"`
}
// boxfileResponse is the GET/PUT /shells/agent/boxfile response; manifest is
// null when nothing is saved.
type boxfileResponse struct {
Manifest *string `json:"manifest"`
}
func (c *apiClient) getBoxfile(ctx context.Context) (string, error) {
var resp boxfileResponse
if err := c.do(ctx, http.MethodGet, "/shells/agent/boxfile", nil, &resp); err != nil {
return "", err
}
if resp.Manifest == nil {
return "", nil
}
return *resp.Manifest, nil
}
func (c *apiClient) saveBoxfile(ctx context.Context, manifest string) (string, error) {
body := boxfileRequest{}
if manifest != "" {
body.Manifest = &manifest
}
var resp boxfileResponse
if err := c.do(ctx, http.MethodPut, "/shells/agent/boxfile", body, &resp); err != nil {
return "", err
}
if resp.Manifest == nil {
return "", nil
}
return *resp.Manifest, nil
}