diff options
| author | Xe Iaso <me@xeiaso.net> | 2023-11-03 16:18:09 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2023-11-03 16:18:09 -0400 |
| commit | 7552099904115e6ce9136c7bf8320d6691c9c68c (patch) | |
| tree | df3b8604a22da6217db598147a755f582193452a | |
| parent | fb6c435b59146d0e964461ed8b2636a7874c4f81 (diff) | |
| download | x-7552099904115e6ce9136c7bf8320d6691c9c68c.tar.xz x-7552099904115e6ce9136c7bf8320d6691c9c68c.zip | |
web/fly: add new package flymachines
Signed-off-by: Xe Iaso <me@xeiaso.net>
| -rw-r--r-- | web/fly/flymachines/apps.go | 182 | ||||
| -rw-r--r-- | web/fly/flymachines/apps_test.go | 58 | ||||
| -rw-r--r-- | web/fly/flymachines/client.go | 47 | ||||
| -rw-r--r-- | web/fly/flymachines/doc.go | 9 | ||||
| -rw-r--r-- | web/fly/flymachines/machines.go | 226 |
5 files changed, 522 insertions, 0 deletions
diff --git a/web/fly/flymachines/apps.go b/web/fly/flymachines/apps.go new file mode 100644 index 0000000..3f36c52 --- /dev/null +++ b/web/fly/flymachines/apps.go @@ -0,0 +1,182 @@ +package flymachines + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "within.website/x/web" +) + +// CreateAppArgs are the arguments to the CreateApp call. +type CreateAppArgs struct { + AppName string `json:"app_name"` + Network string `json:"network"` + OrgSlug string `json:"org_slug"` +} + +// CreateAppResponse is the response from the CreateApp call. +type CreateAppResponse struct { + ID string `json:"id"` + CreatedAt MilliTime `json:"created_at"` +} + +// MilliTime is a time.Time that can be marshalled and unmarshalled from milliseconds since the Unix epoch. +type MilliTime struct { + time.Time +} + +func (mt *MilliTime) UnmarshalJSON(b []byte) error { + var millis int64 + err := json.Unmarshal(b, &millis) + if err != nil { + return err + } + + mt.Time = time.UnixMilli(millis) + return nil +} + +func (mt MilliTime) MarshalJSON() ([]byte, error) { + return json.Marshal(mt.Time.UnixMilli()) +} + +// CreateApp creates a single application in the given organization and on the given network. +func (c *Client) CreateApp(ctx context.Context, caa CreateAppArgs) (*CreateAppResponse, error) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(caa); err != nil { + return nil, fmt.Errorf("flymachines: can't encode CreateApp request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/v1/apps", &buf) + if err != nil { + return nil, fmt.Errorf("flymachines: can't create CreateApp request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("flymachines: can't perform CreateApp request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, web.NewError(http.StatusCreated, resp) + } + + var car CreateAppResponse + + if err := json.NewDecoder(resp.Body).Decode(&car); err != nil { + return nil, fmt.Errorf("flymachines: can't decode response body for CreateApp: %w", err) + } + + return &car, nil +} + +// App is a Fly app. Apps are collections of resources such as machines, volumes, and IP addresses. +type App struct { + ID string `json:"id"` // The unique ID of the app + Name string `json:"name"` // The name of the app (also unique but human readable) +} + +// ListApp is a Fly app with extra information that is only shown when you're listing apps with GetApps. +type ListApp struct { + App + MachineCount int `json:"machine_count"` // The number of machines associated with this app + Network string `json:"network"` // The network this app is on +} + +// SingleApp is a Fly app with extra information that is only shown when you're getting a single app with GetApp. +type SingleApp struct { + App + Organization Org `json:"organization"` // The organization this app belongs to + Status string `json:"status"` // The current status of the app +} + +// Org is a Fly organization. An organization is a collection of apps and users that are allowed to manage +// that collection. +type Org struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +// GetApps gets all of the applications in an organization. +func (c *Client) GetApps(ctx context.Context, orgSlug string) ([]ListApp, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL+"/v1/apps", nil) + if err != nil { + return nil, fmt.Errorf("flymachines: can't create GetApps request: %w", err) + } + + q := req.URL.Query() + q.Set("org_slug", orgSlug) + req.URL.RawQuery = q.Encode() + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("flymachines: can't perform GetApps request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, web.NewError(http.StatusOK, resp) + } + + var result struct { + Apps []ListApp `json:"apps"` + TotalApps int `json:"total_apps"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("flymachines: can't decode response body for GetApps: %w", err) + } + + return result.Apps, nil +} + +// GetApp fetches information about one app in particular. +func (c *Client) GetApp(ctx context.Context, appName string) (*SingleApp, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL+"/v1/apps/"+appName, nil) + if err != nil { + return nil, fmt.Errorf("flymachines: can't create GetApp request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("flymachines: can't perform GetApp request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, web.NewError(http.StatusOK, resp) + } + + var app SingleApp + if err := json.NewDecoder(resp.Body).Decode(&app); err != nil { + return nil, fmt.Errorf("flymachines: can't decode response body for GetApp: %w", err) + } + + return &app, nil +} + +func (c *Client) DeleteApp(ctx context.Context, appName string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.apiURL+"/v1/apps/"+appName, nil) + if err != nil { + return fmt.Errorf("flymachines: can't create DeleteApp request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return fmt.Errorf("flymachines: can't perform DeleteApp request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return web.NewError(http.StatusAccepted, resp) + } + + return nil +} diff --git a/web/fly/flymachines/apps_test.go b/web/fly/flymachines/apps_test.go new file mode 100644 index 0000000..52c591d --- /dev/null +++ b/web/fly/flymachines/apps_test.go @@ -0,0 +1,58 @@ +package flymachines + +import ( + "context" + "net/http" + "os" + "testing" + + _ "github.com/joho/godotenv/autoload" + "within.website/x/misc/namegen" +) + +func TestAppLifecycle(t *testing.T) { + token, ok := os.LookupEnv("FLY_API_TOKEN") + if !ok { + t.Skip("no FLY_API_TOKEN") + } + + cli := New(token, http.DefaultClient) + ctx := context.Background() + + name := namegen.Next() + t.Logf("creating app %s", name) + + _, err := cli.CreateApp(ctx, CreateAppArgs{ + AppName: name, + Network: "xtest", + OrgSlug: "personal", + }) + if err != nil { + t.Fatal(err) + } + t.Logf("created app %s", name) + + t.Logf("getting app %s", name) + app, err := cli.GetApp(ctx, name) + if err != nil { + t.Fatal(err) + } + + t.Logf("got app %s: %s", app.Name, app.Status) + + t.Log("listing all apps") + + apps, err := cli.GetApps(ctx, "personal") + if err != nil { + t.Fatal(err) + } + + for _, app := range apps { + t.Logf("app: %s", app.Name) + } + + t.Logf("deleting app %s", name) + if err := cli.DeleteApp(ctx, name); err != nil { + t.Fatal(err) + } +} diff --git a/web/fly/flymachines/client.go b/web/fly/flymachines/client.go new file mode 100644 index 0000000..6fdcae4 --- /dev/null +++ b/web/fly/flymachines/client.go @@ -0,0 +1,47 @@ +package flymachines + +import ( + "net/http" + "os" +) + +const ( + internalURL = "http://_api.internal:4280" + publicURL = "https://api.machines.dev" +) + +type Client struct { + token, apiURL string + cli *http.Client +} + +// NewClient returns a new client for the Fly machines API with the given API token and URL. +// +// This is a fairly low-level operation for if you know what URL you need, you probably want to use New instead. +func NewClient(token, apiURL string, cli *http.Client) *Client { + return &Client{token, apiURL, cli} +} + +// New returns a new client for the Fly machines API with the given API token. +// +// This will automatically detect if you have access to the internal API either by a +// WireGuard tunnel or by being on the Fly network. +func New(token string, cli *http.Client) *Client { + resp, err := http.Get(internalURL) + if err != nil { + return NewClient(token, publicURL, cli) + } + + if resp.StatusCode != http.StatusNotFound { + return NewClient(token, publicURL, cli) + } + + return NewClient(token, internalURL, cli) +} + +// Do performs a HTTP request with the appropriate authentication and user agent headers. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("User-Agent", "within.website/x/web/fly/flymachines in "+os.Args[0]) + return c.cli.Do(req) +} diff --git a/web/fly/flymachines/doc.go b/web/fly/flymachines/doc.go new file mode 100644 index 0000000..63a3d05 --- /dev/null +++ b/web/fly/flymachines/doc.go @@ -0,0 +1,9 @@ +/* +Package flymachines is an API client for Fly's machines API. + +This package is loosely based on the Swagger spec for the Fly Machines API[1], +but with only the important bits implemented. + +[1]: https://docs.machines.dev/swagger/index.html +*/ +package flymachines diff --git a/web/fly/flymachines/machines.go b/web/fly/flymachines/machines.go new file mode 100644 index 0000000..556cc45 --- /dev/null +++ b/web/fly/flymachines/machines.go @@ -0,0 +1,226 @@ +package flymachines + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +func Ptr[T any](t T) *T { + return &t +} + +type MachineConfig struct { + Env map[string]string `json:"env"` + Metadata map[string]string `json:"metadata"` + Mounts []MachineMount `json:"mounts,omitempty"` + Image string `json:"image"` + Restart MachineRestart `json:"restart"` + Guest MachineGuest `json:"guest"` + StopConfig MachineStopConfig `json:"stop_config"` + Processes []MachineProcess `json:"processes,omitempty"` +} + +type MachineProcess struct { + Cmd []string `json:"cmd,omitempty"` + Entrypoint []string `json:"entrypoint,omitempty"` + Env map[string]string `json:"env,omitempty"` + Exec []string `json:"exec,omitempty"` + User string `json:"user,omitempty"` +} + +type MachineMount struct { + Encrypted bool `json:"encrypted"` + Path string `json:"path"` + SizeGB int `json:"size_gb"` + Volume string `json:"volume"` + Name string `json:"name"` +} + +type MachineService struct { + Protocol string `json:"protocol"` + InternalPort int `json:"internal_port"` + ForceInstanceDescription *string `json:"force_instance_description,omitempty"` + ForceInstanceKey *string `json:"force_instance_key,omitempty"` + Ports []MachinePort `json:"ports"` + Checks []MachineCheck `json:"checks"` + MinMachinesRunning int `json:"min_machines_running"` + Concurrency MachineServiceConcurrency `json:"concurrency"` +} + +type MachineGuest struct { + CPUKind string `json:"cpu_kind"` // "shared" or "performance" + CPUs int `json:"cpus"` + MemoryMB int `json:"memory_mb"` + GPUKind *string `json:"gpu_kind,omitempty"` + KernelArgs []string `json:"kernel_args,omitempty"` +} + +type MachineStopConfig struct { + Timeout time.Duration `json:"timeout"` + Signal string `json:"signal"` +} + +type MachineRestart struct { + MaxRetries int `json:"max_retries"` // only relevant when Policy is "on-fail" + Policy string `json:"policy"` // "no", "always", or "on-fail" +} + +type MachineServiceConcurrency struct { + Type string `json:"type"` + HardLimit int `json:"hard_limit"` + SoftLimit int `json:"soft_limit"` +} + +type MachineCheck struct { + Type string `json:"type"` + Interval time.Duration `json:"interval"` + Timeout time.Duration `json:"timeout"` + GracePeriod time.Duration `json:"grace_period"` + Path string `json:"path"` + TLSServerName *string `json:"tls_server_name,omitempty"` + TLSSkipVerify *bool `json:"tls_skip_verify,omitempty"` + Headers []MachineHTTPHeader `json:"headers,omitempty"` +} + +type MachineHTTPHeader struct { + Name string `json:"name"` + Values []string `json:"values"` +} + +type MachinePort struct { + Port int `json:"port"` + Handlers []string `json:"handlers"` + ForceHTTPS *bool `json:"force_https,omitempty"` + StartPort *int `json:"start_port,omitempty"` + EndPort *int `json:"end_port,omitempty"` +} + +type ImageRef struct { + Registry string `json:"registry"` + Repository string `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` + Labels json.RawMessage `json:"labels"` // TODO(Xe): figure out what this is +} + +type MachineEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Source string `json:"source"` + Timestamp MilliTime `json:"timestamp"` + Request json.RawMessage `json:"request"` // Request can be anything, so we just store it as a raw message +} + +type CheckStatus struct { + Name string `json:"name"` + Status string `json:"status"` + Output string `json:"output"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Machine struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Region string `json:"region"` + InstanceID string `json:"instance_id"` + PrivateIP string `json:"private_ip"` + Config MachineConfig `json:"config"` + ImageRef ImageRef `json:"image_ref"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Events []MachineEvent `json:"events"` + Checks []CheckStatus `json:"checks"` +} + +func (c *Client) GetAppMachines(ctx context.Context, appName string) ([]Machine, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL+"/v1/apps/"+appName+"/machines", nil) + if err != nil { + return nil, fmt.Errorf("flymachines: can't create GetAppMachines request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("flymachines: can't do GetAppMachines request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("flymachines: GetAppMachines request failed: %s", resp.Status) + } + + var machines []Machine + if err := json.NewDecoder(resp.Body).Decode(&machines); err != nil { + return nil, fmt.Errorf("flymachines: can't decode GetAppMachines response: %w", err) + } + + return machines, nil +} + +type CreateMachine struct { + Config MachineConfig `json:"config"` + LeaseTTL int `json:"lease_ttl"` + LSVD bool `json:"lsvd"` // should be true? + Name string `json:"name"` + Region string `json:"region"` + SkipLaunch bool `json:"skip_launch"` + SkipServiceRegistration bool `json:"skip_service_registration"` +} + +func (c *Client) CreateMachine(ctx context.Context, appID string, cm CreateMachine) (*Machine, error) { + buf := new(bytes.Buffer) + if err := json.NewEncoder(buf).Encode(cm); err != nil { + return nil, fmt.Errorf("flymachines: can't encode CreateMachine request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/v1/apps/"+appID+"/machines", buf) + if err != nil { + return nil, fmt.Errorf("flymachines: can't create CreateMachine request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("flymachines: can't do CreateMachine request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("flymachines: CreateMachine request failed: %s", resp.Status) + } + + var machine Machine + if err := json.NewDecoder(resp.Body).Decode(&machine); err != nil { + return nil, fmt.Errorf("flymachines: can't decode CreateMachine response: %w", err) + } + + return &machine, nil +} + +func (c *Client) GetAppMachine(ctx context.Context, appID, machineID string) (*Machine, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL+"/v1/apps/"+appID+"/machines/"+machineID, nil) + if err != nil { + return nil, fmt.Errorf("flymachines: can't create GetMachine request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("flymachines: can't do GetMachine request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("flymachines: GetMachine request failed: %s", resp.Status) + } + + var machine Machine + if err := json.NewDecoder(resp.Body).Decode(&machine); err != nil { + return nil, fmt.Errorf("flymachines: can't decode GetMachine response: %w", err) + } + + return &machine, nil +} |
