aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2023-11-03 16:18:09 -0400
committerXe Iaso <me@xeiaso.net>2023-11-03 16:18:09 -0400
commit7552099904115e6ce9136c7bf8320d6691c9c68c (patch)
treedf3b8604a22da6217db598147a755f582193452a
parentfb6c435b59146d0e964461ed8b2636a7874c4f81 (diff)
downloadx-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.go182
-rw-r--r--web/fly/flymachines/apps_test.go58
-rw-r--r--web/fly/flymachines/client.go47
-rw-r--r--web/fly/flymachines/doc.go9
-rw-r--r--web/fly/flymachines/machines.go226
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
+}