diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcd9c264de..172a0cadb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} - name: Upload checksums as artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: checksums path: dist/checksums.txt @@ -88,7 +88,7 @@ jobs: sync_docs: needs: release runs-on: ubuntu-latest - if: ${{ github.ref == 'refs/heads/master' }} + if: ${{ !contains(github.ref, 'pre') }} steps: - name: Checkout flyctl uses: actions/checkout@v3 @@ -136,7 +136,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} - name: Upload checksums as artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: checksums path: dist/checksums.txt @@ -149,7 +149,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Download checksums - uses: actions/download-artifact@v2.0.8 + uses: actions/download-artifact@v3 with: name: checksums - name: Generate PKGBUILD diff --git a/.golangci.yml b/.golangci.yml index aee79c3359..7c7ecdbb78 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,6 +29,7 @@ linters: enable: # - gofumpt # - goimports + - gofmt - gosimple - govet - ineffassign diff --git a/Makefile b/Makefile index e02b6b7cb3..e42c5cbc29 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test: FORCE # to run one test, use: make preflight-test T=TestAppsV2ConfigSave preflight-test: build if [ -r .direnv/preflight ]; then . .direnv/preflight; fi; \ - go test ./test/preflight --tags=integration -v --run=$(T) + go test ./test/preflight --tags=integration -v -timeout 30m --run=$(T) cmddocs: generate @echo Running Docs Generation diff --git a/README.md b/README.md index a4d1e386ba..2331fe6a7a 100644 --- a/README.md +++ b/README.md @@ -62,19 +62,19 @@ Download the appropriate version from the [Releases](https://github.com/superfly 1. Sign into your fly account ```bash -flyctl auth login +fly auth login ``` 2. List your apps ```bash -flyctl apps list +fly apps list ``` 2. View app status ```bash -flyctl status -a {app-name} +fly status -a {app-name} ``` ## App Settings diff --git a/agent/start.go b/agent/start.go index c47e7b4da7..95e2d1aa2d 100644 --- a/agent/start.go +++ b/agent/start.go @@ -97,10 +97,12 @@ func (alreadyStartingError) Error() string { return "another process is already starting the agent" } -var lockPath = filepath.Join(os.TempDir(), "flyctl.agent.start.lock") +func lockPath() string { + return filepath.Join(flyctl.ConfigDir(), "flyctl.agent.start.lock") +} func lock(ctx context.Context) (unlock filemu.UnlockFunc, err error) { - switch unlock, err = filemu.Lock(ctx, lockPath); { + switch unlock, err = filemu.Lock(ctx, lockPath()); { case err == nil: break // all done case ctx.Err() != nil: diff --git a/api/client.go b/api/client.go index e08500a454..18643a1545 100644 --- a/api/client.go +++ b/api/client.go @@ -10,6 +10,7 @@ import ( "os" "regexp" "strings" + "time" genq "github.com/Khan/genqlient/graphql" "github.com/superfly/graphql" @@ -17,8 +18,9 @@ import ( ) var ( - baseURL string - errorLog bool + baseURL string + errorLog bool + instrumenter InstrumentationService ) // SetBaseURL - Sets the base URL for the API @@ -31,6 +33,14 @@ func SetErrorLog(log bool) { errorLog = log } +func SetInstrumenter(i InstrumentationService) { + instrumenter = i +} + +type InstrumentationService interface { + ReportCallTiming(duration time.Duration) +} + // Client - API client encapsulating the http and GraphQL clients type Client struct { httpClient *http.Client @@ -108,6 +118,13 @@ func (c *Client) Logger() Logger { return c.logger } // RunWithContext - Runs a GraphQL request within a Go context func (c *Client) RunWithContext(ctx context.Context, req *graphql.Request) (Query, error) { + if instrumenter != nil { + start := time.Now() + defer func() { + instrumenter.ReportCallTiming(time.Since(start)) + }() + } + var resp Query err := c.client.Run(ctx, req, &resp) diff --git a/api/machine_types.go b/api/machine_types.go index 973212b5c8..666f909e29 100644 --- a/api/machine_types.go +++ b/api/machine_types.go @@ -17,6 +17,7 @@ const ( MachineFlyPlatformVersion2 = "v2" MachineProcessGroupApp = "app" MachineProcessGroupFlyAppReleaseCommand = "fly_app_release_command" + MachineProcessGroupFlyAppConsole = "fly_app_console" MachineStateDestroyed = "destroyed" MachineStateDestroying = "destroying" MachineStateStarted = "started" @@ -81,6 +82,10 @@ func (m *Machine) IsFlyAppsReleaseCommand() bool { return m.IsFlyAppsPlatform() && m.IsReleaseCommandMachine() } +func (m *Machine) IsFlyAppsConsole() bool { + return m.IsFlyAppsPlatform() && m.HasProcessGroup(MachineProcessGroupFlyAppConsole) +} + func (m *Machine) IsActive() bool { return m.State != MachineStateDestroyed && m.State != MachineStateDestroying } @@ -209,7 +214,7 @@ type StopMachineInput struct { type RestartMachineInput struct { ID string `json:"id,omitempty"` - Signal *Signal `json:"signal,omitempty"` + Signal string `json:"signal,omitempty"` Timeout time.Duration `json:"timeout,omitempty"` ForceStop bool `json:"force_stop,omitempty"` SkipHealthChecks bool `json:"skip_health_checks,omitempty"` @@ -223,10 +228,8 @@ type MachineIP struct { } type RemoveMachineInput struct { - AppID string `json:"appId,omitempty"` - ID string `json:"id,omitempty"` - - Kill bool `json:"kill,omitempty"` + ID string `json:"id,omitempty"` + Kill bool `json:"kill,omitempty"` } type MachineRestartPolicy string @@ -356,13 +359,14 @@ type MachineCheckStatus struct { } type MachinePort struct { - Port *int `json:"port,omitempty" toml:"port,omitempty"` - StartPort *int `json:"start_port,omitempty" toml:"start_port,omitempty"` - EndPort *int `json:"end_port,omitempty" toml:"end_port,omitempty"` - Handlers []string `json:"handlers,omitempty" toml:"handlers,omitempty"` - ForceHTTPS bool `json:"force_https,omitempty" toml:"force_https,omitempty"` - TLSOptions *TLSOptions `json:"tls_options,omitempty" toml:"tls_options,omitempty"` - HTTPOptions *HTTPOptions `json:"http_options,omitempty" toml:"tls_options,omitempty"` + Port *int `json:"port,omitempty" toml:"port,omitempty"` + StartPort *int `json:"start_port,omitempty" toml:"start_port,omitempty"` + EndPort *int `json:"end_port,omitempty" toml:"end_port,omitempty"` + Handlers []string `json:"handlers,omitempty" toml:"handlers,omitempty"` + ForceHTTPS bool `json:"force_https,omitempty" toml:"force_https,omitempty"` + TLSOptions *TLSOptions `json:"tls_options,omitempty" toml:"tls_options,omitempty"` + HTTPOptions *HTTPOptions `json:"http_options,omitempty" toml:"http_options,omitempty"` + ProxyProtoOptions *ProxyProtoOptions `json:"proxy_proto_options,omitempty" toml:"proxy_proto_options,omitempty"` } func (mp *MachinePort) ContainsPort(port int) bool { @@ -413,9 +417,14 @@ func (mp *MachinePort) HasNonHttpPorts() bool { return false } +type ProxyProtoOptions struct { + Version string `json:"version,omitempty" toml:"version,omitempty"` +} + type TLSOptions struct { - Alpn []string `json:"alpn,omitempty" toml:"alpn,omitempty"` - Versions []string `json:"versions,omitempty" toml:"version,omitempty"` + ALPN []string `json:"alpn,omitempty" toml:"alpn,omitempty"` + Versions []string `json:"versions,omitempty" toml:"versions,omitempty"` + DefaultSelfSigned *bool `json:"default_self_signed,omitempty" toml:"default_self_signed,omitempty"` } type HTTPOptions struct { @@ -428,13 +437,16 @@ type HTTPResponseOptions struct { } type MachineService struct { - Protocol string `json:"protocol,omitempty" toml:"protocol,omitempty"` - InternalPort int `json:"internal_port,omitempty" toml:"internal_port,omitempty"` - Autostop *bool `json:"autostop,omitempty"` - Autostart *bool `json:"autostart,omitempty"` - Ports []MachinePort `json:"ports,omitempty" toml:"ports,omitempty"` - Checks []MachineCheck `json:"checks,omitempty" toml:"checks,omitempty"` - Concurrency *MachineServiceConcurrency `json:"concurrency,omitempty" toml:"concurrency"` + Protocol string `json:"protocol,omitempty" toml:"protocol,omitempty"` + InternalPort int `json:"internal_port,omitempty" toml:"internal_port,omitempty"` + Autostop *bool `json:"autostop,omitempty"` + Autostart *bool `json:"autostart,omitempty"` + MinMachinesRunning *int `json:"min_machines_running,omitempty"` + Ports []MachinePort `json:"ports,omitempty" toml:"ports,omitempty"` + Checks []MachineCheck `json:"checks,omitempty" toml:"checks,omitempty"` + Concurrency *MachineServiceConcurrency `json:"concurrency,omitempty" toml:"concurrency"` + ForceInstanceKey *string `json:"force_instance_key" toml:"force_instance_key"` + ForceInstanceDescription *string `json:"force_instance_description,omitempty" toml:"force_instance_description"` } type MachineServiceConcurrency struct { @@ -541,16 +553,15 @@ type MachineStartResponse struct { } type LaunchMachineInput struct { - AppID string `json:"appId,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - OrgSlug string `json:"organizationId,omitempty"` - Region string `json:"region,omitempty"` Config *MachineConfig `json:"config,omitempty"` + Region string `json:"region,omitempty"` + Name string `json:"name,omitempty"` SkipLaunch bool `json:"skip_launch,omitempty"` LeaseTTL int `json:"lease_ttl,omitempty"` + // Client side only - SkipHealthChecks bool + ID string `json:"-"` + SkipHealthChecks bool `json:"-"` } type MachineProcess struct { @@ -571,3 +582,21 @@ type MachineExecResponse struct { StdOut string `json:"stdout,omitempty"` StdErr string `json:"stderr,omitempty"` } + +type MachinePsResponse []ProcessStat + +type ProcessStat struct { + Pid int32 `json:"pid"` + Stime uint64 `json:"stime"` + Rtime uint64 `json:"rtime"` + Command string `json:"command"` + Directory string `json:"directory"` + Cpu uint64 `json:"cpu"` + Rss uint64 `json:"rss"` + ListenSockets []ListenSocket `json:"listen_sockets"` +} + +type ListenSocket struct { + Proto string `json:"proto"` + Address string `json:"address"` +} diff --git a/api/resource_releases.go b/api/resource_releases.go index e84e7102c8..87170907a5 100644 --- a/api/resource_releases.go +++ b/api/resource_releases.go @@ -105,3 +105,37 @@ func (c *Client) GetAppReleaseNomad(ctx context.Context, appName string, id stri return data.App.Release, nil } + +func (c *Client) GetAppCurrentReleaseMachines(ctx context.Context, appName string) (*Release, error) { + query := ` + query ($appName: String!) { + app(name: $appName) { + currentRelease: currentReleaseUnprocessed { + id + version + description + reason + status + imageRef + stable + user { + id + email + name + } + createdAt + } + } + } + ` + + req := c.NewRequest(query) + req.Var("appName", appName) + + data, err := c.RunWithContext(ctx, req) + if err != nil { + return nil, err + } + + return data.App.CurrentRelease, nil +} diff --git a/api/types.go b/api/types.go index 70e45063bb..94c5e2805e 100644 --- a/api/types.go +++ b/api/types.go @@ -2,7 +2,6 @@ package api import ( "fmt" - "syscall" "time" ) @@ -857,10 +856,6 @@ type DeployImageInput struct { Strategy *string `json:"strategy"` } -type Signal struct { - syscall.Signal -} - type Service struct { Description string `json:"description"` Protocol string `json:"protocol,omitempty"` diff --git a/client/client.go b/client/client.go index 9788e47dd3..77fc50b033 100644 --- a/client/client.go +++ b/client/client.go @@ -59,3 +59,35 @@ func FromToken(token string) *Client { func NewClient(token string) *api.Client { return api.NewClient(token, buildinfo.Name(), buildinfo.Version().String(), logger.FromEnv(iostreams.System().ErrOut)) } + +type NewClientOpts struct { + Token string + ClientName string + ClientVersion string + Logger api.Logger +} + +// non-flyctl libraries use this when needing to specify logger, client name, and client version +func NewClientWithOptions(opts *NewClientOpts) *Client { + var log api.Logger + if opts.Logger != nil { + log = opts.Logger + } else { + log = logger.FromEnv(iostreams.System().ErrOut) + } + clientName := buildinfo.Name() + if opts.ClientName != "" { + clientName = opts.ClientName + } + clientVersion := buildinfo.Version().String() + if opts.ClientVersion != "" { + clientVersion = opts.ClientVersion + } + var apiClient *api.Client + if opts.Token != "" { + apiClient = api.NewClient(opts.Token, clientName, clientVersion, log) + } + return &Client{ + api: apiClient, + } +} diff --git a/cmd/autoscaling.go b/cmd/autoscaling.go deleted file mode 100644 index b5c128d278..0000000000 --- a/cmd/autoscaling.go +++ /dev/null @@ -1,196 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "strconv" - "strings" - - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmdctx" - "github.com/superfly/flyctl/internal/appconfig" - - "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/docstrings" - - "github.com/spf13/cobra" -) - -func newAutoscaleCommand(client *client.Client) *Command { - autoscaleStrings := docstrings.Get("autoscale") - - cmd := BuildCommandKS(nil, nil, autoscaleStrings, client, requireSession, requireAppName) - // cmd.Deprecated = "use `flyctl scale` instead" - - disableCmdStrings := docstrings.Get("autoscale.disable") - disableCmd := BuildCommand(cmd, runDisableAutoscaling, disableCmdStrings.Usage, disableCmdStrings.Short, disableCmdStrings.Long, client, requireSession, requireAppName) - disableCmd.Args = cobra.RangeArgs(0, 2) - disableCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - setCmdStrings := docstrings.Get("autoscale.set") - setCmd := BuildCommand(cmd, runSetParams, setCmdStrings.Usage, setCmdStrings.Short, setCmdStrings.Long, client, requireSession, requireAppName) - setCmd.Args = cobra.RangeArgs(0, 2) - setCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - showCmdStrings := docstrings.Get("autoscale.show") - showCmd := BuildCommand(cmd, runAutoscalingShow, showCmdStrings.Usage, showCmdStrings.Short, showCmdStrings.Long, client, requireSession, requireAppName) - showCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - return cmd -} - -func runSetParams(commandContext *cmdctx.CmdContext) error { - return actualScale(commandContext, false) -} - -func runDisableAutoscaling(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - app, err := cmdCtx.Client.API().GetAppCompact(ctx, cmdCtx.AppName) - - if err != nil { - return err - } - - if app.PlatformVersion == appconfig.MachinesPlatform { - printMachinesAutoscalingBanner() - return nil - } - - newcfg := api.UpdateAutoscaleConfigInput{AppID: cmdCtx.AppName, Enabled: api.BoolPointer(false)} - - cfg, err := cmdCtx.Client.API().UpdateAutoscaleConfig(ctx, newcfg) - if err != nil { - return err - } - - printScaleConfig(cmdCtx, cfg) - - return nil -} - -func actualScale(cmdCtx *cmdctx.CmdContext, balanceRegions bool) error { - ctx := cmdCtx.Command.Context() - - app, err := cmdCtx.Client.API().GetAppCompact(ctx, cmdCtx.AppName) - - if err != nil { - return err - } - - if app.PlatformVersion == appconfig.MachinesPlatform { - printMachinesAutoscalingBanner() - return nil - } - - currentcfg, err := cmdCtx.Client.API().AppAutoscalingConfig(ctx, cmdCtx.AppName) - if err != nil { - return err - } - - newcfg := api.UpdateAutoscaleConfigInput{AppID: cmdCtx.AppName} - - newcfg.BalanceRegions = &balanceRegions - newcfg.MinCount = ¤tcfg.MinCount - newcfg.MaxCount = ¤tcfg.MaxCount - - kvargs := make(map[string]string) - - for _, pair := range cmdCtx.Args { - parts := strings.SplitN(pair, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("Scale parameters must be provided as NAME=VALUE pairs (%s is invalid)", pair) - } - key := parts[0] - value := parts[1] - kvargs[strings.ToLower(key)] = value - } - - minval, found := kvargs["min"] - - if found { - minint64val, err := strconv.ParseInt(minval, 10, 64) - if err != nil { - return errors.New("could not parse min count value") - } - minintval := int(minint64val) - newcfg.MinCount = &minintval - delete(kvargs, "min") - } - - maxval, found := kvargs["max"] - - if found { - maxint64val, err := strconv.ParseInt(maxval, 10, 64) - if err != nil { - return errors.New("could not parse max count value") - } - maxintval := int(maxint64val) - newcfg.MaxCount = &maxintval - delete(kvargs, "max") - } - - if len(kvargs) != 0 { - unusedkeys := "" - for k := range kvargs { - if unusedkeys == "" { - unusedkeys = k - } else { - unusedkeys = unusedkeys + ", " + k - } - } - return errors.New("unrecognised parameters in command:" + unusedkeys) - } - - cfg, err := cmdCtx.Client.API().UpdateAutoscaleConfig(ctx, newcfg) - if err != nil { - return err - } - - printScaleConfig(cmdCtx, cfg) - - return nil -} - -func runAutoscalingShow(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - cfg, err := cmdCtx.Client.API().AppAutoscalingConfig(ctx, cmdCtx.AppName) - if err != nil { - return err - } - - printScaleConfig(cmdCtx, cfg) - - return nil -} - -func printScaleConfig(cmdCtx *cmdctx.CmdContext, cfg *api.AutoscalingConfig) { - asJSON := cmdCtx.OutputJSON() - - if asJSON { - cmdCtx.WriteJSON(cfg) - } else { - var mode string - - if !cfg.Enabled { - mode = "Disabled" - } else { - mode = "Enabled" - } - - fmt.Fprintf(cmdCtx.Out, "%15s: %s\n", "Autoscaling", mode) - if cfg.Enabled { - fmt.Fprintf(cmdCtx.Out, "%15s: %d\n", "Min Count", cfg.MinCount) - fmt.Fprintf(cmdCtx.Out, "%15s: %d\n", "Max Count", cfg.MaxCount) - } - } -} - -func printMachinesAutoscalingBanner() { - fmt.Printf(` -Configuring autoscaling via 'flyctl autoscale' is supported only for apps running on Nomad platform. -Refer to this post for details on how to enable autoscaling for Apps V2: -https://community.fly.io/t/increasing-apps-v2-availability/12357 -`) -} diff --git a/cmd/certificates.go b/cmd/certificates.go deleted file mode 100644 index ca5db3f0c9..0000000000 --- a/cmd/certificates.go +++ /dev/null @@ -1,345 +0,0 @@ -package cmd - -import ( - "fmt" - "net" - "strings" - - "github.com/dustin/go-humanize" - "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmdctx" - - "github.com/superfly/flyctl/docstrings" - - "github.com/AlecAivazis/survey/v2" - "github.com/spf13/cobra" - "golang.org/x/net/publicsuffix" -) - -func getAlternateHostname(hostname string) string { - if strings.Split(hostname, ".")[0] == "www" { - return strings.Replace(hostname, "www.", "", 1) - } else { - return "www." + hostname - } -} - -func newCertificatesCommand(client *client.Client) *Command { - certsStrings := docstrings.Get("certs") - - cmd := BuildCommandKS(nil, nil, certsStrings, client, requireAppName, requireSession) - - certsListStrings := docstrings.Get("certs.list") - listCmd := BuildCommandKS(cmd, runCertsList, certsListStrings, client, requireSession, requireAppName) - listCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - certsCreateStrings := docstrings.Get("certs.add") - createCmd := BuildCommandKS(cmd, runCertAdd, certsCreateStrings, client, requireSession, requireAppName) - createCmd.Aliases = []string{"create"} - createCmd.Command.Args = cobra.ExactArgs(1) - - certsDeleteStrings := docstrings.Get("certs.remove") - deleteCmd := BuildCommandKS(cmd, runCertDelete, certsDeleteStrings, client, requireSession, requireAppName) - deleteCmd.Aliases = []string{"delete"} - deleteCmd.Command.Args = cobra.ExactArgs(1) - deleteCmd.AddBoolFlag(BoolFlagOpts{Name: "yes", Shorthand: "y", Description: "accept all confirmations"}) - - certsShowStrings := docstrings.Get("certs.show") - show := BuildCommandKS(cmd, runCertShow, certsShowStrings, client, requireSession, requireAppName) - show.Command.Args = cobra.ExactArgs(1) - show.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - certsCheckStrings := docstrings.Get("certs.check") - check := BuildCommandKS(cmd, runCertCheck, certsCheckStrings, client, requireSession, requireAppName) - check.Command.Args = cobra.ExactArgs(1) - check.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - return cmd -} - -func runCertsList(commandContext *cmdctx.CmdContext) error { - ctx := commandContext.Command.Context() - - certs, err := commandContext.Client.API().GetAppCertificates(ctx, commandContext.AppName) - if err != nil { - return err - } - - return printCertificates(commandContext, certs) -} - -func runCertShow(commandContext *cmdctx.CmdContext) error { - ctx := commandContext.Command.Context() - - hostname := commandContext.Args[0] - - cert, hostcheck, err := commandContext.Client.API().CheckAppCertificate(ctx, commandContext.AppName, hostname) - if err != nil { - return err - } - - if cert.ClientStatus == "Ready" { - commandContext.Statusf("certs", cmdctx.STITLE, "The certificate for %s has been issued.\n\n", hostname) - printCertificate(commandContext, cert) - return nil - } - commandContext.Statusf("certs", cmdctx.STITLE, "The certificate for %s has not been issued yet.\n\n", hostname) - printCertificate(commandContext, cert) - return reportNextStepCert(commandContext, hostname, cert, hostcheck) -} - -func runCertCheck(commandContext *cmdctx.CmdContext) error { - ctx := commandContext.Command.Context() - - hostname := commandContext.Args[0] - - cert, hostcheck, err := commandContext.Client.API().CheckAppCertificate(ctx, commandContext.AppName, hostname) - if err != nil { - return err - } - - if cert.ClientStatus == "Ready" { - // A certificate has been issued - commandContext.Statusf("certs", cmdctx.SINFO, "The certificate for %s has been issued.\n", hostname) - printCertificate(commandContext, cert) - // Details should go here - return nil - } - - commandContext.Statusf("certs", cmdctx.SINFO, "The certificate for %s has not been issued yet.\n", hostname) - - return reportNextStepCert(commandContext, hostname, cert, hostcheck) -} - -func runCertAdd(commandContext *cmdctx.CmdContext) error { - ctx := commandContext.Command.Context() - - hostname := commandContext.Args[0] - - cert, hostcheck, err := commandContext.Client.API().AddCertificate(ctx, commandContext.AppName, hostname) - if err != nil { - return err - } - - return reportNextStepCert(commandContext, hostname, cert, hostcheck) -} - -func runCertDelete(commandContext *cmdctx.CmdContext) error { - ctx := commandContext.Command.Context() - - hostname := commandContext.Args[0] - - if !commandContext.Config.GetBool("yes") { - confirm := false - prompt := &survey.Confirm{ - Message: fmt.Sprintf("Remove certificate %s from app %s?", hostname, commandContext.AppName), - } - err := survey.AskOne(prompt, &confirm) - if err != nil { - return err - } - - if !confirm { - return nil - } - } - - cert, err := commandContext.Client.API().DeleteCertificate(ctx, commandContext.AppName, hostname) - if err != nil { - return err - } - - commandContext.Statusf("certs", cmdctx.SINFO, "Certificate %s deleted from app %s\n", cert.Certificate.Hostname, cert.App.Name) - - return nil -} - -func reportNextStepCert(cmdCtx *cmdctx.CmdContext, hostname string, cert *api.AppCertificate, hostcheck *api.HostnameCheck) error { - ctx := cmdCtx.Command.Context() - alternateHostname := getAlternateHostname(hostname) - - // These are the IPs we have for the app - ips, err := cmdCtx.Client.API().GetIPAddresses(ctx, cmdCtx.AppName) - if err != nil { - return err - } - - var ipV4 api.IPAddress - var ipV6 api.IPAddress - var configuredipV4 bool - var configuredipV6 bool - - // Extract the v4 and v6 addresses we have allocated - for _, x := range ips { - if x.Type == "v4" || x.Type == "shared_v4" { - ipV4 = x - } else if x.Type == "v6" { - ipV6 = x - } - } - - // Do we have A records - if len(hostcheck.ARecords) > 0 { - // Let's check the first A record against our recorded addresses - if !net.ParseIP(hostcheck.ARecords[0]).Equal(net.ParseIP(ipV4.Address)) { - cmdCtx.Statusf("certs", cmdctx.SWARN, "A Record (%s) does not match app's IP (%s)\n", hostcheck.ARecords[0], ipV4.Address) - } else { - configuredipV4 = true - } - } - - if len(hostcheck.AAAARecords) > 0 { - // Let's check the first A record against our recorded addresses - if !net.ParseIP(hostcheck.AAAARecords[0]).Equal(net.ParseIP(ipV6.Address)) { - cmdCtx.Statusf("certs", cmdctx.SWARN, "AAAA Record (%s) does not match app's IP (%s)\n", hostcheck.AAAARecords[0], ipV6.Address) - } else { - configuredipV6 = true - } - } - - if len(hostcheck.ResolvedAddresses) > 0 { - for _, address := range hostcheck.ResolvedAddresses { - if net.ParseIP(address).Equal(net.ParseIP(ipV4.Address)) { - configuredipV4 = true - } else if net.ParseIP(address).Equal(net.ParseIP(ipV6.Address)) { - configuredipV6 = true - } else { - cmdCtx.Statusf("certs", cmdctx.SWARN, "Address resolution (%s) does not match app's IP (%s/%s)\n", address, ipV4.Address, ipV6.Address) - } - } - } - - if cert.IsApex { - // If this is an apex domain we should guide towards creating A and AAAA records - addArecord := !configuredipV4 - addAAAArecord := !cert.AcmeALPNConfigured - - if addArecord || addAAAArecord { - stepcnt := 1 - cmdCtx.Statusf("certs", cmdctx.SINFO, "You are creating a certificate for %s\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "We are using %s for this certificate.\n\n", cert.CertificateAuthority) - if addArecord { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You can direct traffic to %s by:\n\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "%d: Adding an A record to your DNS service which reads\n", stepcnt) - cmdCtx.Statusf("certs", cmdctx.SINFO, "\n A @ %s\n\n", ipV4.Address) - stepcnt = stepcnt + 1 - } - if addAAAArecord { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You can validate your ownership of %s by:\n\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "%d: Adding an AAAA record to your DNS service which reads:\n\n", stepcnt) - cmdCtx.Statusf("certs", cmdctx.SINFO, " AAAA @ %s\n\n", ipV6.Address) - // stepcnt = stepcnt + 1 Uncomment if more steps - } - } else { - if cert.ClientStatus == "Ready" { - cmdCtx.Statusf("certs", cmdctx.SINFO, "Your certificate for %s has been issued, make sure you create another certificate for %s \n", hostname, alternateHostname) - } else { - cmdCtx.Statusf("certs", cmdctx.SINFO, "Your certificate for %s is being issued. Status is %s. Make sure to create another certificate for %s when the current certificate is issued. \n", hostname, cert.ClientStatus, alternateHostname) - } - } - } else if cert.IsWildcard { - // If this is an wildcard domain we should guide towards satisfying a DNS-01 challenge - addArecord := !configuredipV4 - addCNAMErecord := !cert.AcmeDNSConfigured - - stepcnt := 1 - cmdCtx.Statusf("certs", cmdctx.SINFO, "You are creating a wildcard certificate for %s\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "We are using %s for this certificate.\n\n", cert.CertificateAuthority) - if addArecord { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You can direct traffic to %s by:\n\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "%d: Adding an A record to your DNS service which reads\n", stepcnt) - stepcnt = stepcnt + 1 - cmdCtx.Statusf("certs", cmdctx.SINFO, "\n A @ %s\n\n", ipV4.Address) - } - - if addCNAMErecord { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You can validate your ownership of %s by:\n\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "%d: Adding an CNAME record to your DNS service which reads:\n\n", stepcnt) - cmdCtx.Statusf("certs", cmdctx.SINFO, " %s\n", cert.DNSValidationInstructions) - // stepcnt = stepcnt + 1 Uncomment if more steps - } - } else { - // This is not an apex domain - // If A and AAAA record is not configured offer CNAME - - nothingConfigured := !(configuredipV4 && configuredipV6) - onlyV4Configured := configuredipV4 && !configuredipV6 - - if nothingConfigured || onlyV4Configured { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You are creating a certificate for %s\n", hostname) - cmdCtx.Statusf("certs", cmdctx.SINFO, "We are using %s for this certificate.\n\n", readableCertAuthority(cert.CertificateAuthority)) - - if nothingConfigured { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You can configure your DNS for %s by:\n\n", hostname) - - eTLD, _ := publicsuffix.EffectiveTLDPlusOne(hostname) - subdomainname := strings.TrimSuffix(hostname, eTLD) - cmdCtx.Statusf("certs", cmdctx.SINFO, "1: Adding an CNAME record to your DNS service which reads:\n") - cmdCtx.Statusf("certs", cmdctx.SINFO, "\n CNAME %s %s.fly.dev\n", subdomainname, cmdCtx.AppName) - } else if onlyV4Configured { - cmdCtx.Statusf("certs", cmdctx.SINFO, "You can validate your ownership of %s by:\n\n", hostname) - - cmdCtx.Statusf("certs", cmdctx.SINFO, "1: Adding an CNAME record to your DNS service which reads:\n") - cmdCtx.Statusf("certs", cmdctx.SINFO, " %s\n", cert.DNSValidationInstructions) - } - } else { - if cert.ClientStatus == "Ready" { - cmdCtx.Statusf("certs", cmdctx.SINFO, "Your certificate for %s has been issued, make sure you create another certificate for %s \n", hostname, alternateHostname) - } else { - cmdCtx.Statusf("certs", cmdctx.SINFO, "Your certificate for %s is being issued. Status is %s. Make sure to create another certificate for %s when the current certificate is issued. \n", hostname, cert.ClientStatus, alternateHostname) - } - } - } - - return nil -} - -func printCertificate(commandContext *cmdctx.CmdContext, cert *api.AppCertificate) { - if commandContext.OutputJSON() { - commandContext.WriteJSON(cert) - return - } - - myprnt := func(label string, value string) { - commandContext.Statusf("certs", cmdctx.SINFO, "%-25s = %s\n\n", label, value) - } - - certtypes := []string{} - - for _, v := range cert.Issued.Nodes { - certtypes = append(certtypes, v.Type) - } - - myprnt("Hostname", cert.Hostname) - myprnt("DNS Provider", cert.DNSProvider) - myprnt("Certificate Authority", readableCertAuthority(cert.CertificateAuthority)) - myprnt("Issued", strings.Join(certtypes, ",")) - myprnt("Added to App", humanize.Time(cert.CreatedAt)) - myprnt("Source", cert.Source) -} - -func readableCertAuthority(ca string) string { - if ca == "lets_encrypt" { - return "Let's Encrypt" - } - return ca -} - -func printCertificates(commandContext *cmdctx.CmdContext, certs []api.AppCertificateCompact) error { - if commandContext.OutputJSON() { - commandContext.WriteJSON(certs) - return nil - } - - commandContext.Statusf("certs", cmdctx.STITLE, "%-25s %-20s %s\n", "Host Name", "Added", "Status") - - for _, v := range certs { - commandContext.Statusf("certs", cmdctx.SINFO, "%-25s %-20s %s\n", - v.Hostname, - humanize.Time(v.CreatedAt), - v.ClientStatus) - } - - return nil -} diff --git a/cmd/command.go b/cmd/command.go deleted file mode 100644 index 14a7a93771..0000000000 --- a/cmd/command.go +++ /dev/null @@ -1,323 +0,0 @@ -package cmd - -import ( - "fmt" - "path/filepath" - - "github.com/superfly/flyctl/cmdctx" - "github.com/superfly/flyctl/docstrings" - "github.com/superfly/flyctl/flyctl" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/helpers" - "github.com/superfly/flyctl/internal/buildinfo" - "github.com/superfly/flyctl/internal/flyerr" - "github.com/superfly/flyctl/terminal" -) - -// RunFn - Run function for commands which takes a command context -type RunFn func(cmdContext *cmdctx.CmdContext) error - -// Command - Wrapper for a cobra command -type Command struct { - *cobra.Command -} - -// AddCommand adds subcommands to this command -func (c *Command) AddCommand(commands ...*Command) { - for _, cmd := range commands { - c.Command.AddCommand(cmd.Command) - } -} - -func namespace(c *cobra.Command) string { - parentName := flyctl.NSRoot - if c.Parent() != nil { - parentName = c.Parent().Name() - } - return parentName + "." + c.Name() -} - -// StringFlagOpts - options for string flags -type StringFlagOpts struct { - Name string - Shorthand string - Description string - Default string - EnvName string - Hidden bool -} - -// BoolFlagOpts - options for boolean flags -type BoolFlagOpts struct { - Name string - Shorthand string - Description string - Default bool - EnvName string - Hidden bool -} - -// AddStringFlag - Add a string flag to a command -func (c *Command) AddStringFlag(options StringFlagOpts) { - fullName := namespace(c.Command) + "." + options.Name - c.Flags().StringP(options.Name, options.Shorthand, options.Default, options.Description) - - flag := c.Flags().Lookup(options.Name) - flag.Hidden = options.Hidden - err := viper.BindPFlag(fullName, flag) - checkErr(err) - - if options.EnvName != "" { - err := viper.BindEnv(fullName, options.EnvName) - checkErr(err) - } -} - -// AddBoolFlag - Add a boolean flag for a command -func (c *Command) AddBoolFlag(options BoolFlagOpts) { - fullName := namespace(c.Command) + "." + options.Name - c.Flags().BoolP(options.Name, options.Shorthand, options.Default, options.Description) - - flag := c.Flags().Lookup(options.Name) - flag.Hidden = options.Hidden - err := viper.BindPFlag(fullName, flag) - checkErr(err) - - if options.EnvName != "" { - err := viper.BindEnv(fullName, options.EnvName) - checkErr(err) - } -} - -// IntFlagOpts - options for integer flags -type IntFlagOpts struct { - Name string - Shorthand string - Description string - Default int - EnvName string - Hidden bool -} - -// AddIntFlag - Add an integer flag to a command -func (c *Command) AddIntFlag(options IntFlagOpts) { - fullName := namespace(c.Command) + "." + options.Name - c.Flags().IntP(options.Name, options.Shorthand, options.Default, options.Description) - - flag := c.Flags().Lookup(options.Name) - flag.Hidden = options.Hidden - err := viper.BindPFlag(fullName, flag) - checkErr(err) - - if options.EnvName != "" { - err := viper.BindEnv(fullName, options.EnvName) - checkErr(err) - } -} - -// StringSliceFlagOpts - options a string slice flag -type StringSliceFlagOpts struct { - Name string - Shorthand string - Description string - Default []string - EnvName string -} - -// AddStringSliceFlag - add a string slice flag to a command -func (c *Command) AddStringSliceFlag(options StringSliceFlagOpts) { - fullName := namespace(c.Command) + "." + options.Name - - if options.Shorthand != "" { - c.Flags().StringSliceP(options.Name, options.Shorthand, options.Default, options.Description) - } else { - c.Flags().StringSlice(options.Name, options.Default, options.Description) - } - - err := viper.BindPFlag(fullName, c.Flags().Lookup(options.Name)) - checkErr(err) - - if options.EnvName != "" { - err := viper.BindEnv(fullName, options.EnvName) - checkErr(err) - } -} - -// Initializer - Retains Setup and PreRun functions -type Initializer struct { - Setup InitializerFn - PreRun InitializerFn -} - -// Option - A wrapper for an Initializer function that takes a command -type Option func(*Command) Initializer - -// InitializerFn - A wrapper for an Initializer function that takes a command context -type InitializerFn func(*cmdctx.CmdContext) error - -// BuildCommandKS - A wrapper for BuildCommand which takes the docs.KeyStrings bundle instead of the coder having to manually unwrap it -func BuildCommandKS(parent *Command, fn RunFn, keystrings docstrings.KeyStrings, client *client.Client, options ...Option) *Command { - return BuildCommand(parent, fn, keystrings.Usage, keystrings.Short, keystrings.Long, client, options...) -} - -func BuildCommandCobra(parent *Command, fn RunFn, cmd *cobra.Command, client *client.Client, options ...Option) *Command { - flycmd := &Command{ - Command: cmd, - } - - if parent != nil { - parent.AddCommand(flycmd) - } - - initializers := []Initializer{} - - for _, o := range options { - if i := o(flycmd); i.Setup != nil || i.PreRun != nil { - initializers = append(initializers, i) - } - } - - if fn != nil { - flycmd.RunE = func(cmd *cobra.Command, args []string) error { - ctx, err := cmdctx.NewCmdContext(client, namespace(cmd), cmd, args) - if err != nil { - return err - } - - for _, init := range initializers { - if init.Setup != nil { - if err := init.Setup(ctx); err != nil { - return err - } - } - } - - terminal.Debugf("Working Directory: %s\n", ctx.WorkingDir) - terminal.Debugf("App Config File: %s\n", ctx.ConfigFile) - - for _, init := range initializers { - if init.PreRun != nil { - if err := init.PreRun(ctx); err != nil { - return err - } - } - } - - return fn(ctx) - } - } - - return flycmd -} - -// BuildCommand - builds a functioning Command using all the initializers -func BuildCommand(parent *Command, fn RunFn, usageText string, shortHelpText string, longHelpText string, client *client.Client, options ...Option) *Command { - return BuildCommandCobra(parent, fn, &cobra.Command{ - Use: usageText, - Short: shortHelpText, - Long: longHelpText, - }, client, options...) -} - -const defaultConfigFilePath = "./fly.toml" - -func requireSession(cmd *Command) Initializer { - return Initializer{ - PreRun: func(ctx *cmdctx.CmdContext) error { - if !ctx.Client.Authenticated() { - return client.ErrNoAuthToken - } - return nil - }, - } -} - -func addAppConfigFlags(cmd *Command) { - cmd.AddStringFlag(StringFlagOpts{ - Name: "app", - Shorthand: "a", - Description: "App name to operate on", - EnvName: "FLY_APP", - }) - cmd.AddStringFlag(StringFlagOpts{ - Name: "config", - Shorthand: "c", - Description: "Path to an app config file or directory containing one", - Default: defaultConfigFilePath, - EnvName: "FLY_APP_CONFIG", - }) -} - -func setupAppName(ctx *cmdctx.CmdContext) error { - // resolve the config file path - configPath := ctx.Config.GetString("config") - if configPath == "" { - configPath = defaultConfigFilePath - } - if !filepath.IsAbs(configPath) { - absConfigPath, err := filepath.Abs(filepath.Join(ctx.WorkingDir, configPath)) - if err != nil { - return err - } - configPath = absConfigPath - } - resolvedPath, err := flyctl.ResolveConfigFileFromPath(configPath) - if err != nil { - return err - } - ctx.ConfigFile = resolvedPath - - // load the config file if it exists - if helpers.FileExists(ctx.ConfigFile) { - terminal.Debug("Loading app config from", ctx.ConfigFile) - appConfig, err := flyctl.LoadAppConfig(ctx.ConfigFile) - if err != nil { - return err - } - ctx.AppConfig = appConfig - } else { - ctx.AppConfig = flyctl.NewAppConfig() - } - - // set the app name if provided - appName := ctx.Config.GetString("app") - if appName != "" { - ctx.AppName = appName - } else if ctx.AppConfig != nil { - ctx.AppName = ctx.AppConfig.AppName - } - - return nil -} - -func requireAppName(cmd *Command) Initializer { - // TODO: Add Flags to docStrings - - addAppConfigFlags(cmd) - - return Initializer{ - Setup: setupAppName, - PreRun: func(ctx *cmdctx.CmdContext) error { - if ctx.AppName == "" { - return fmt.Errorf("We couldn't find a fly.toml nor an app specified by the -a flag. If you want to launch a new app, use '" + buildinfo.Name() + " launch'") - } - - if ctx.AppConfig == nil { - return nil - } - - if ctx.AppConfig.AppName != "" && ctx.AppConfig.AppName != ctx.AppName { - terminal.Warnf("app flag '%s' does not match app name in config file '%s'\n", ctx.AppName, ctx.AppConfig.AppName) - - if !ctx.Config.GetBool("yes") && !confirm(fmt.Sprintf("Continue using '%s'", ctx.AppName)) { - return flyerr.ErrAbort - } - } - - return nil - }, - } -} diff --git a/cmd/dashboard.go b/cmd/dashboard.go deleted file mode 100644 index e662653d78..0000000000 --- a/cmd/dashboard.go +++ /dev/null @@ -1,36 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmdctx" - - "github.com/superfly/flyctl/docstrings" - - "github.com/skratchdot/open-golang/open" -) - -func newDashboardCommand(client *client.Client) *Command { - dashboardStrings := docstrings.Get("dashboard") - dashboardCmd := BuildCommandKS(nil, runDashboard, dashboardStrings, client, requireSession, requireAppName) - dashboardCmd.Aliases = []string{"dash"} - - dashMetricsStrings := docstrings.Get("dashboard.metrics") - BuildCommandKS(dashboardCmd, runDashboardMetrics, dashMetricsStrings, client, requireSession, requireAppName) - - return dashboardCmd -} - -func runDashboard(cmdCtx *cmdctx.CmdContext) error { - return runDashboardOpen(cmdCtx, "https://fly.io/apps/"+cmdCtx.AppName) -} - -func runDashboardMetrics(cmdCtx *cmdctx.CmdContext) error { - return runDashboardOpen(cmdCtx, "https://fly.io/apps/"+cmdCtx.AppName+"/metrics") -} - -func runDashboardOpen(ctx *cmdctx.CmdContext, url string) error { - fmt.Println("Opening", url) - return open.Run(url) -} diff --git a/cmd/dns.go b/cmd/dns.go deleted file mode 100644 index 19f8b9e26f..0000000000 --- a/cmd/dns.go +++ /dev/null @@ -1,177 +0,0 @@ -package cmd - -import ( - "fmt" - "io/ioutil" - "os" - "strconv" - - "github.com/olekukonko/tablewriter" - "github.com/spf13/cobra" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmdctx" - "github.com/superfly/flyctl/docstrings" -) - -func newDNSCommand(client *client.Client) *Command { - dnsStrings := docstrings.Get("dns-records") - cmd := BuildCommandKS(nil, nil, dnsStrings, client, requireSession) - - listStrings := docstrings.Get("dns-records.list") - listCmd := BuildCommandKS(cmd, runRecordsList, listStrings, client, requireSession) - listCmd.Args = cobra.ExactArgs(1) - - recordsExportStrings := docstrings.Get("dns-records.export") - recordsExportCmd := BuildCommandKS(cmd, runRecordsExport, recordsExportStrings, client, requireSession) - recordsExportCmd.Args = cobra.MinimumNArgs(1) - recordsExportCmd.Args = cobra.MaximumNArgs(3) - recordsExportCmd.AddBoolFlag(BoolFlagOpts{ - Name: "overwrite", - }) - - recordsImportStrings := docstrings.Get("dns-records.import") - recordsImportCmd := BuildCommandKS(cmd, runRecordsImport, recordsImportStrings, client, requireSession) - recordsImportCmd.Args = cobra.MaximumNArgs(3) - recordsImportCmd.Args = cobra.MinimumNArgs(1) - - return cmd -} - -func runRecordsList(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - name := cmdCtx.Args[0] - - records, err := cmdCtx.Client.API().GetDNSRecords(ctx, name) - if err != nil { - return err - } - - fmt.Printf("Records for domain %s\n", name) - - if cmdCtx.OutputJSON() { - cmdCtx.WriteJSON(records) - return nil - } - - table := tablewriter.NewWriter(cmdCtx.Out) - table.SetAutoWrapText(true) - table.SetReflowDuringAutoWrap(true) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetAlignment(tablewriter.ALIGN_LEFT) - table.SetNoWhiteSpace(true) - table.SetTablePadding(" ") - table.SetCenterSeparator("") - table.SetColumnSeparator("") - table.SetRowSeparator("") - table.SetHeader([]string{"FQDN", "TTL", "Type", "Content"}) - - for _, record := range records { - table.Append([]string{record.FQDN, strconv.Itoa(record.TTL), record.Type, record.RData}) - } - - table.Render() - - return nil -} - -func runRecordsExport(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - name := cmdCtx.Args[0] - - domain, err := cmdCtx.Client.API().GetDomain(ctx, name) - if err != nil { - return err - } - - records, err := cmdCtx.Client.API().ExportDNSRecords(ctx, domain.ID) - if err != nil { - return err - } - - if len(cmdCtx.Args) == 1 { - fmt.Println(records) - } else { - filename := cmdCtx.Args[1] - - _, err := os.Stat(filename) - if err == nil { - return fmt.Errorf("File %s already exists", filename) - } - - err = ioutil.WriteFile(filename, []byte(records), 0o644) - if err != nil { - return err - } - - fmt.Printf("Zone exported to %s\n", filename) - } - - return nil -} - -func runRecordsImport(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - name := cmdCtx.Args[0] - var filename string - - if len(cmdCtx.Args) == 1 { - // One arg, use stdin - filename = "-" - } else { - filename = cmdCtx.Args[1] - } - - domain, err := cmdCtx.Client.API().GetDomain(ctx, name) - if err != nil { - return err - } - - var data []byte - - if filename != "-" { - data, err = ioutil.ReadFile(filename) - if err != nil { - return err - } - } else { - data, err = ioutil.ReadAll(os.Stdin) - if err != nil { - return err - } - } - - warnings, changes, err := cmdCtx.Client.API().ImportDNSRecords(ctx, domain.ID, string(data)) - if err != nil { - return err - } - - fmt.Printf("Zonefile import report for %s\n", domain.Name) - - if filename == "-" { - fmt.Printf("Imported from stdin\n") - } else { - fmt.Printf("Imported from %s\n", filename) - } - - fmt.Printf("%d warnings\n", len(warnings)) - for _, warning := range warnings { - fmt.Println("->", warning.Action, warning.Message) - } - - fmt.Printf("%d changes\n", len(changes)) - for _, change := range changes { - switch change.Action { - case "CREATE": - fmt.Println("-> Created", change.NewText) - case "DELETE": - fmt.Println("-> Deleted", change.OldText) - case "UPDATE": - fmt.Println("-> Updated", change.OldText, "=>", change.NewText) - } - } - - return nil -} diff --git a/cmd/domains.go b/cmd/domains.go deleted file mode 100644 index 3ca5e16eab..0000000000 --- a/cmd/domains.go +++ /dev/null @@ -1,166 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/olekukonko/tablewriter" - "github.com/pkg/errors" - "github.com/spf13/cobra" - "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmd/presenters" - "github.com/superfly/flyctl/cmdctx" - "github.com/superfly/flyctl/docstrings" -) - -func newDomainsCommand(client *client.Client) *Command { - domainsStrings := docstrings.Get("domains") - cmd := BuildCommandKS(nil, nil, domainsStrings, client, requireSession) - cmd.Hidden = true - - listStrings := docstrings.Get("domains.list") - listCmd := BuildCommandKS(cmd, runDomainsList, listStrings, client, requireSession) - listCmd.Args = cobra.MaximumNArgs(1) - listCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - showCmd := BuildCommandKS(cmd, runDomainsShow, docstrings.Get("domains.show"), client, requireSession) - showCmd.Args = cobra.ExactArgs(1) - showCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - addCmd := BuildCommandKS(cmd, runDomainsCreate, docstrings.Get("domains.add"), client, requireSession) - addCmd.Args = cobra.MaximumNArgs(2) - - registerCmd := BuildCommandKS(cmd, runDomainsRegister, docstrings.Get("domains.register"), client, requireSession) - registerCmd.Args = cobra.MaximumNArgs(2) - registerCmd.Hidden = true - - return cmd -} - -func runDomainsList(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - var orgSlug string - if len(cmdCtx.Args) == 0 { - org, err := selectOrganization(ctx, cmdCtx.Client.API(), "") - if err != nil { - return err - } - orgSlug = org.Slug - } else { - // TODO: Validity check on org - orgSlug = cmdCtx.Args[0] - } - - domains, err := cmdCtx.Client.API().GetDomains(ctx, orgSlug) - if err != nil { - return err - } - - if cmdCtx.OutputJSON() { - cmdCtx.WriteJSON(domains) - return nil - } - - table := tablewriter.NewWriter(cmdCtx.Out) - - table.SetHeader([]string{"Domain", "Registration Status", "DNS Status", "Created"}) - - for _, domain := range domains { - table.Append([]string{domain.Name, *domain.RegistrationStatus, *domain.DnsStatus, presenters.FormatRelativeTime(domain.CreatedAt)}) - } - - table.Render() - - return nil -} - -func runDomainsShow(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - name := cmdCtx.Args[0] - - domain, err := cmdCtx.Client.API().GetDomain(ctx, name) - if err != nil { - return err - } - - if cmdCtx.OutputJSON() { - cmdCtx.WriteJSON(domain) - return nil - } - - cmdCtx.Statusf("domains", cmdctx.STITLE, "Domain\n") - fmtstring := "%-20s: %-20s\n" - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Name", domain.Name) - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Organization", domain.Organization.Slug) - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Registration Status", *domain.RegistrationStatus) - if *domain.RegistrationStatus == "REGISTERED" { - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Expires At", presenters.FormatTime(domain.ExpiresAt)) - - autorenew := "" - if *domain.AutoRenew { - autorenew = "Enabled" - } else { - autorenew = "Disabled" - } - - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Auto Renew", autorenew) - } - - cmdCtx.StatusLn() - cmdCtx.Statusf("domains", cmdctx.STITLE, "DNS\n") - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Status", *domain.DnsStatus) - if *domain.RegistrationStatus == "UNMANAGED" { - cmdCtx.Statusf("domains", cmdctx.SINFO, fmtstring, "Nameservers", strings.Join(*domain.ZoneNameservers, " ")) - } - - return nil -} - -func runDomainsCreate(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - var org *api.Organization - var name string - var err error - - if len(cmdCtx.Args) == 0 { - org, err = selectOrganization(ctx, cmdCtx.Client.API(), "") - if err != nil { - return err - } - - prompt := &survey.Input{Message: "Domain name to add"} - err := survey.AskOne(prompt, &name) - checkErr(err) - - // TODO: Add some domain validation here - } else if len(cmdCtx.Args) == 2 { - org, err = cmdCtx.Client.API().GetOrganizationBySlug(ctx, cmdCtx.Args[0]) - if err != nil { - return err - } - name = cmdCtx.Args[1] - } else { - return errors.New("specify all arguments (or no arguments to be prompted)") - } - - fmt.Printf("Creating domain %s in organization %s\n", name, org.Slug) - - domain, err := cmdCtx.Client.API().CreateDomain(org.ID, name) - if err != nil { - return err - } - - fmt.Println("Created domain", domain.Name) - - return nil -} - -func runDomainsRegister(_ *cmdctx.CmdContext) error { - - return fmt.Errorf("This command is no longer supported.\n") -} diff --git a/cmd/input.go b/cmd/input.go deleted file mode 100644 index 3373dd34ac..0000000000 --- a/cmd/input.go +++ /dev/null @@ -1,92 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "sort" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/superfly/flyctl/api" -) - -func confirm(message string) bool { - confirm := false - prompt := &survey.Confirm{ - Message: message, - } - err := survey.AskOne(prompt, &confirm) - checkErr(err) - - return confirm -} - -func selectOrganization(ctx context.Context, client *api.Client, slug string) (*api.Organization, error) { - orgs, err := client.GetOrganizations(ctx) - if err != nil { - return nil, err - } - - if slug != "" { - for _, org := range orgs { - if org.Slug == slug { - return &org, nil - } - } - - return nil, fmt.Errorf(`organization "%s" not found`, slug) - } - - if len(orgs) == 1 && orgs[0].Type == "PERSONAL" { - fmt.Printf("Automatically selected %s organization: %s\n", strings.ToLower(orgs[0].Type), orgs[0].Name) - return &orgs[0], nil - } - - sort.Slice(orgs, func(i, j int) bool { return orgs[i].Type < orgs[j].Type }) - - options := []string{} - - for _, org := range orgs { - options = append(options, fmt.Sprintf("%s (%s)", org.Name, org.Slug)) - } - - selectedOrg := 0 - prompt := &survey.Select{ - Message: "Select organization:", - Options: options, - PageSize: 15, - } - if err := survey.AskOne(prompt, &selectedOrg); err != nil { - return nil, err - } - - return &orgs[selectedOrg], nil -} - -func selectWireGuardPeer(ctx context.Context, client *api.Client, slug string) (string, error) { - peers, err := client.GetWireGuardPeers(ctx, slug) - if err != nil { - return "", err - } - - if len(peers) < 1 { - return "", fmt.Errorf(`Organization "%s" does not have any wireguard peers`, slug) - } - - var options []string - for _, peer := range peers { - options = append(options, peer.Name) - } - - selectedPeer := 0 - prompt := &survey.Select{ - Message: "Select peer:", - Options: options, - PageSize: 30, - } - if err := survey.AskOne(prompt, &selectedPeer); err != nil { - return "", err - } - - return peers[selectedPeer].Name, nil -} diff --git a/cmd/presenters/alloc_checks.go b/cmd/presenters/alloc_checks.go deleted file mode 100644 index c32730b8e3..0000000000 --- a/cmd/presenters/alloc_checks.go +++ /dev/null @@ -1,36 +0,0 @@ -package presenters - -import ( - "github.com/superfly/flyctl/api" -) - -// AllocationChecks - Holds check state for an allocation -type AllocationChecks struct { - Checks []api.CheckState -} - -// APIStruct - returns an interface to the check state -func (p *AllocationChecks) APIStruct() interface{} { - return p.Checks -} - -// FieldNames - returns the associated field names for check states -func (p *AllocationChecks) FieldNames() []string { - return []string{"ID", "Service", "State", "Output"} -} - -// Records - formats check states into map -func (p *AllocationChecks) Records() []map[string]string { - out := []map[string]string{} - - for _, check := range p.Checks { - out = append(out, map[string]string{ - "ID": check.Name, - "Service": check.ServiceName, - "State": check.Status, - "Output": check.Output, - }) - } - - return out -} diff --git a/cmd/presenters/alloc_events.go b/cmd/presenters/alloc_events.go deleted file mode 100644 index 8d5d871620..0000000000 --- a/cmd/presenters/alloc_events.go +++ /dev/null @@ -1,37 +0,0 @@ -package presenters - -import ( - "time" - - "github.com/superfly/flyctl/api" -) - -// AllocationEvents - Holds events for an allocation -type AllocationEvents struct { - Events []api.AllocationEvent -} - -// APIStruct - returns an interface to allocation events -func (p *AllocationEvents) APIStruct() interface{} { - return p.Events -} - -// FieldNames - returns the field names for an allocation event -func (p *AllocationEvents) FieldNames() []string { - return []string{"Timestamp", "Type", "Message"} -} - -// Records - formats allocation events into a map -func (p *AllocationEvents) Records() []map[string]string { - out := []map[string]string{} - - for _, event := range p.Events { - out = append(out, map[string]string{ - "Timestamp": event.Timestamp.Format(time.RFC3339), - "Type": event.Type, - "Message": event.Message, - }) - } - - return out -} diff --git a/cmd/presenters/allocations.go b/cmd/presenters/allocations.go deleted file mode 100644 index f31afabc39..0000000000 --- a/cmd/presenters/allocations.go +++ /dev/null @@ -1,215 +0,0 @@ -package presenters - -import ( - "fmt" - "strconv" - "strings" - - "github.com/logrusorgru/aurora" - "github.com/superfly/flyctl/api" -) - -type Allocations struct { - Allocations []*api.AllocationStatus - BackupRegions []api.Region -} - -func (p *Allocations) APIStruct() interface{} { - return p.Allocations -} - -func (p *Allocations) FieldNames() []string { - return []string{"ID", "Process", "Version", "Region", "Desired", "Status", "Health Checks", "Restarts", "Created"} -} - -func (p *Allocations) Records() []map[string]string { - out := []map[string]string{} - multipleVersions := hasMultipleVersions(p.Allocations) - - for _, alloc := range p.Allocations { - version := strconv.Itoa(alloc.Version) - if multipleVersions && alloc.LatestVersion { - version = version + " " + aurora.Green("⇡").String() - } - - region := alloc.Region - if len(p.BackupRegions) > 0 { - for _, r := range p.BackupRegions { - if alloc.Region == r.Code { - region = alloc.Region + "(B)" - break - } - } - } - - out = append(out, map[string]string{ - "ID": alloc.IDShort, - "Process": alloc.TaskName, - "Version": version, - "Status": formatAllocStatus(alloc), - "Desired": alloc.DesiredStatus, - "Region": region, - "Created": FormatRelativeTime(alloc.CreatedAt), - "Health Checks": FormatHealthChecksSummary(alloc), - "Restarts": strconv.Itoa(alloc.Restarts), - }) - } - - return out -} - -func hasMultipleVersions(allocations []*api.AllocationStatus) bool { - var v int - for _, alloc := range allocations { - if v != 0 && v != alloc.Version { - return true - } - v = alloc.Version - } - - return false -} - -func formatAllocStatus(alloc *api.AllocationStatus) string { - status := alloc.Status - - if status == "running" { - for _, c := range alloc.Checks { - if (c.Name == "role" || c.Name == "status") && c.Status != "" { - o := strings.TrimSpace(c.Output) - if len(o) > 12 { - o = o[:12] - } - - if o == "" { - o = "starting" - } - status = fmt.Sprintf( - "%s (%s)", - status, - o, - ) - break - } - } - } - if alloc.Transitioning { - return aurora.Bold(status).String() - } - return status -} - -func passingChecks(checks []api.CheckState) (n int) { - for _, check := range checks { - if check.Status == "passing" { - n++ - } - } - return n -} - -func warnChecks(checks []api.CheckState) (n int) { - for _, check := range checks { - if check.Status == "warn" { - n++ - } - } - return n -} - -func critChecks(checks []api.CheckState) (n int) { - for _, check := range checks { - if check.Status == "critical" { - n++ - } - } - return n -} - -func FormatDeploymentSummary(d *api.DeploymentStatus) string { - if d.InProgress { - return fmt.Sprintf("v%d is being deployed", d.Version) - } - if d.Successful { - return fmt.Sprintf("v%d deployed successfully", d.Version) - } - - return fmt.Sprintf("v%d %s - %s", d.Version, d.Status, d.Description) -} - -func FormatDeploymentAllocSummary(d *api.DeploymentStatus) string { - allocCounts := fmt.Sprintf("%d desired, %d placed, %d healthy, %d unhealthy", d.DesiredCount, d.PlacedCount, d.HealthyCount, d.UnhealthyCount) - - restarts := 0 - for _, alloc := range d.Allocations { - restarts += alloc.Restarts - } - if restarts > 0 { - allocCounts = fmt.Sprintf("%s [restarts: %d]", allocCounts, restarts) - } - - checkCounts := FormatHealthChecksSummary(d.Allocations...) - - if checkCounts == "" { - return allocCounts - } - - return allocCounts + " [health checks: " + checkCounts + "]" -} - -func FormatAllocSummary(alloc *api.AllocationStatus) string { - msg := fmt.Sprintf("%s: %s %s", alloc.IDShort, alloc.Region, alloc.Status) - - if alloc.Status == "pending" { - return msg - } - - if alloc.Failed { - msg += " failed" - } else if alloc.Healthy { - msg += " healthy" - } else { - msg += " unhealthy" - } - - if alloc.Canary { - msg += " [canary]" - } - - if checkStr := FormatHealthChecksSummary(alloc); checkStr != "" { - msg += " [health checks: " + checkStr + "]" - } - - return msg -} - -func FormatHealthChecksSummary(allocs ...*api.AllocationStatus) string { - var total, pass, crit, warn int - - for _, alloc := range allocs { - if n := len(alloc.Checks); n > 0 { - total += n - pass += passingChecks(alloc.Checks) - crit += critChecks(alloc.Checks) - warn += warnChecks(alloc.Checks) - } - } - - if total == 0 { - return "" - } - - checkStr := fmt.Sprintf("%d total", total) - - if pass > 0 { - checkStr += ", " + fmt.Sprintf("%d passing", pass) - } - if warn > 0 { - checkStr += ", " + fmt.Sprintf("%d warning", warn) - } - if crit > 0 { - checkStr += ", " + fmt.Sprintf("%d critical", crit) - } - - return checkStr -} diff --git a/cmd/presenters/apps.go b/cmd/presenters/apps.go deleted file mode 100644 index 76ad0c47dd..0000000000 --- a/cmd/presenters/apps.go +++ /dev/null @@ -1,42 +0,0 @@ -package presenters - -import ( - "github.com/superfly/flyctl/api" -) - -type Apps struct { - App *api.App - Apps []api.App -} - -func (p *Apps) APIStruct() interface{} { - return p.Apps -} - -func (p *Apps) FieldNames() []string { - return []string{"Name", "Owner", "Status", "Latest Deploy"} -} - -func (p *Apps) Records() []map[string]string { - out := []map[string]string{} - - if p.App != nil { - p.Apps = append(p.Apps, *p.App) - } - - for i := range p.Apps { - latestDeploy := "" - if p.Apps[i].Deployed && p.Apps[i].CurrentRelease != nil { - latestDeploy = FormatRelativeTime(p.Apps[i].CurrentRelease.CreatedAt) - } - - out = append(out, map[string]string{ - "Name": p.Apps[i].Name, - "Owner": p.Apps[i].Organization.Slug, - "Status": p.Apps[i].Status, - "Latest Deploy": latestDeploy, - }) - } - - return out -} diff --git a/cmd/presenters/appstatus.go b/cmd/presenters/appstatus.go deleted file mode 100644 index 3e9d47def2..0000000000 --- a/cmd/presenters/appstatus.go +++ /dev/null @@ -1,40 +0,0 @@ -package presenters - -import ( - "strconv" - - "github.com/superfly/flyctl/api" -) - -type AppStatus struct { - AppStatus api.AppStatus -} - -func (p *AppStatus) APIStruct() interface{} { - return p.AppStatus -} - -func (p *AppStatus) FieldNames() []string { - return []string{"Name", "Owner", "Version", "Status", "Hostname"} -} - -func (p *AppStatus) Records() []map[string]string { - out := []map[string]string{} - - info := map[string]string{ - "Name": p.AppStatus.Name, - "Owner": p.AppStatus.Organization.Slug, - "Version": strconv.Itoa(p.AppStatus.Version), - "Status": p.AppStatus.Status, - } - - if len(p.AppStatus.Hostname) > 0 { - info["Hostname"] = p.AppStatus.Hostname - } else { - info["Hostname"] = "" - } - - out = append(out, info) - - return out -} diff --git a/cmd/presenters/compactappInfo.go b/cmd/presenters/compactappInfo.go deleted file mode 100644 index bcee3191c9..0000000000 --- a/cmd/presenters/compactappInfo.go +++ /dev/null @@ -1,40 +0,0 @@ -package presenters - -import ( - "strconv" - - "github.com/superfly/flyctl/api" -) - -type AppInfo struct { - AppInfo api.AppInfo -} - -func (p *AppInfo) APIStruct() interface{} { - return p.AppInfo -} - -func (p *AppInfo) FieldNames() []string { - return []string{"Name", "Owner", "Version", "Status", "Hostname"} -} - -func (p *AppInfo) Records() []map[string]string { - out := []map[string]string{} - - info := map[string]string{ - "Name": p.AppInfo.Name, - "Owner": p.AppInfo.Organization.Slug, - "Version": strconv.Itoa(p.AppInfo.Version), - "Status": p.AppInfo.Status, - } - - if len(p.AppInfo.Hostname) > 0 { - info["Hostname"] = p.AppInfo.Hostname - } else { - info["Hostname"] = "" - } - - out = append(out, info) - - return out -} diff --git a/cmd/presenters/deploymentStatus.go b/cmd/presenters/deploymentStatus.go deleted file mode 100644 index e5092e5a70..0000000000 --- a/cmd/presenters/deploymentStatus.go +++ /dev/null @@ -1,38 +0,0 @@ -package presenters - -import ( - "fmt" - - "github.com/superfly/flyctl/api" -) - -type DeploymentStatus struct { - Status *api.DeploymentStatus -} - -func (p *DeploymentStatus) APIStruct() interface{} { - return p.Status -} - -func (p *DeploymentStatus) FieldNames() []string { - return []string{"ID", "Version", "Status", "Description", "Instances"} -} - -func (p *DeploymentStatus) Records() []map[string]string { - out := []map[string]string{} - - out = append(out, map[string]string{ - "ID": p.Status.ID, - "Version": fmt.Sprintf("v%d", p.Status.Version), - "Status": p.Status.Status, - "Description": p.Status.Description, - "Instances": formatDeploymentAllocations(p.Status), - }) - - return out -} - -func formatDeploymentAllocations(d *api.DeploymentStatus) string { - return fmt.Sprintf("%d desired, %d placed, %d healthy, %d unhealthy", - d.DesiredCount, d.PlacedCount, d.HealthyCount, d.UnhealthyCount) -} diff --git a/cmd/presenters/env.go b/cmd/presenters/env.go deleted file mode 100644 index 1bd832b738..0000000000 --- a/cmd/presenters/env.go +++ /dev/null @@ -1,29 +0,0 @@ -package presenters - -import ( - "fmt" -) - -type Environment struct { - Envs map[string]interface{} -} - -func (p *Environment) APIStruct() interface{} { - return nil -} - -func (p *Environment) FieldNames() []string { - return []string{"Name", "Value"} -} - -func (p *Environment) Records() []map[string]string { - out := []map[string]string{} - - for key, value := range p.Envs { - out = append(out, map[string]string{ - "Name": key, - "Value": fmt.Sprintf("%v", value), - }) - } - return out -} diff --git a/cmd/presenters/formatting.go b/cmd/presenters/formatting.go deleted file mode 100644 index f774165938..0000000000 --- a/cmd/presenters/formatting.go +++ /dev/null @@ -1,59 +0,0 @@ -package presenters - -import ( - "fmt" - "math" - "strings" - "time" -) - -func FormatRelativeTime(t time.Time) string { - if t.Before(time.Now()) { - dur := time.Since(t) - if dur.Seconds() < 1 { - return "just now" - } - if dur.Seconds() < 60 { - return fmt.Sprintf("%ds ago", int64(dur.Seconds())) - } - if dur.Minutes() < 60 { - return fmt.Sprintf("%dm%ds ago", int64(dur.Minutes()), int64(math.Mod(dur.Seconds(), 60))) - } - - if dur.Hours() < 24 { - return fmt.Sprintf("%dh%dm ago", int64(dur.Hours()), int64(math.Mod(dur.Minutes(), 60))) - } - } else { - dur := time.Until(t) - if dur.Seconds() < 60 { - return fmt.Sprintf("%ds", int64(dur.Seconds())) - } - if dur.Minutes() < 60 { - return fmt.Sprintf("%dm%ds", int64(dur.Minutes()), int64(math.Mod(dur.Seconds(), 60))) - } - - if dur.Hours() < 24 { - return fmt.Sprintf("%dh%dm", int64(dur.Hours()), int64(math.Mod(dur.Minutes(), 60))) - } - } - - return FormatTime(t) -} - -func FormatTime(t time.Time) string { - return t.Format(time.RFC3339) -} - -func GetStringInBetweenTwoString(str string, startS string, endS string) (result string, found bool) { - s := strings.Index(str, startS) - if s == -1 { - return result, false - } - newS := str[s+len(startS):] - e := strings.Index(newS, endS) - if e == -1 { - return result, false - } - result = newS[:e] - return result, true -} diff --git a/cmd/presenters/imageDetails.go b/cmd/presenters/imageDetails.go deleted file mode 100644 index 848e13049e..0000000000 --- a/cmd/presenters/imageDetails.go +++ /dev/null @@ -1,42 +0,0 @@ -package presenters - -import ( - "github.com/superfly/flyctl/api" -) - -type ImageDetails struct { - ImageDetails api.ImageVersion - TrackingEnabled bool -} - -func (p *ImageDetails) APIStruct() interface{} { - return p.ImageDetails -} - -func (p *ImageDetails) FieldNames() []string { - return []string{"Registry", "Repository", "Tag", "Version", "Digest"} -} - -func (p *ImageDetails) Records() []map[string]string { - out := []map[string]string{} - - info := map[string]string{ - "Registry": p.ImageDetails.Registry, - "Repository": p.ImageDetails.Repository, - "Tag": p.ImageDetails.Tag, - "Version": p.ImageDetails.Version, - "Digest": p.ImageDetails.Digest, - } - - if info["Version"] == "" { - if p.TrackingEnabled { - info["Version"] = "Not specified" - } else { - info["Version"] = "N/A" - } - } - - out = append(out, info) - - return out -} diff --git a/cmd/presenters/ipAddresses.go b/cmd/presenters/ipAddresses.go deleted file mode 100644 index fb3b73b9b7..0000000000 --- a/cmd/presenters/ipAddresses.go +++ /dev/null @@ -1,32 +0,0 @@ -package presenters - -import ( - "github.com/superfly/flyctl/api" -) - -type IPAddresses struct { - IPAddresses []api.IPAddress -} - -func (p *IPAddresses) APIStruct() interface{} { - return p.IPAddresses -} - -func (p *IPAddresses) FieldNames() []string { - return []string{"Type", "Address", "Region", "Created At"} -} - -func (p *IPAddresses) Records() []map[string]string { - out := []map[string]string{} - - for _, ip := range p.IPAddresses { - out = append(out, map[string]string{ - "Address": ip.Address, - "Type": ip.Type, - "Region": ip.Region, - "Created At": FormatRelativeTime(ip.CreatedAt), - }) - } - - return out -} diff --git a/cmd/presenters/logs.go b/cmd/presenters/logs.go deleted file mode 100644 index b7eb3ae167..0000000000 --- a/cmd/presenters/logs.go +++ /dev/null @@ -1,110 +0,0 @@ -package presenters - -import ( - "encoding/json" - "fmt" - "io" - "strings" - "time" - - "github.com/logrusorgru/aurora" - "github.com/superfly/flyctl/logs" -) - -type LogPresenter struct { - RemoveNewlines bool - HideRegion bool - HideAllocID bool -} - -func (lp *LogPresenter) FPrint(w io.Writer, asJSON bool, entry logs.LogEntry) { - lp.printEntry(w, asJSON, entry) -} - -var ( - newLineReplacer = strings.NewReplacer("\r\n", aurora.Faint("↩︎").String(), "\n", aurora.Faint("↩︎").String()) - newline = []byte("\n") -) - -func (lp *LogPresenter) printEntry(w io.Writer, asJSON bool, entry logs.LogEntry) { - if asJSON { - outBuf, _ := json.MarshalIndent(entry, "", " ") - fmt.Fprintln(w, string(outBuf)) - return - } - - // parse entry.Timestamp and truncate from nanoseconds to milliseconds - timestamp, err := time.Parse(time.RFC3339Nano, entry.Timestamp) - if err != nil { - fmt.Fprintf(w, "Error parsing timestamp: %s\n", err) - return - } - - fmt.Fprintf(w, "%s ", aurora.Faint(timestamp.Format("2006-01-02T15:04:05.000"))) - - if !lp.HideAllocID { - if entry.Meta.Event.Provider != "" { - if entry.Instance != "" { - fmt.Fprintf(w, "%s[%s]", entry.Meta.Event.Provider, entry.Instance) - } else { - fmt.Fprint(w, entry.Meta.Event.Provider) - } - } else if entry.Instance != "" { - fmt.Fprintf(w, "%s", entry.Instance) - } - fmt.Fprint(w, " ") - } - - if !lp.HideRegion { - fmt.Fprintf(w, "%s ", aurora.Green(entry.Region)) - } - - fmt.Fprintf(w, "[%s] ", aurora.Colorize(entry.Level, levelColor(entry.Level))) - - printFieldIfPresent(w, "error.code", entry.Meta.Error.Code) - hadErrorMsg := printFieldIfPresent(w, "error.message", entry.Meta.Error.Message) - printFieldIfPresent(w, "request.method", entry.Meta.HTTP.Request.Method) - printFieldIfPresent(w, "request.url", entry.Meta.URL.Full) - printFieldIfPresent(w, "request.id", entry.Meta.HTTP.Request.ID) - printFieldIfPresent(w, "response.status", entry.Meta.HTTP.Response.StatusCode) - - if !hadErrorMsg { - if lp.RemoveNewlines { - _, _ = newLineReplacer.WriteString(w, entry.Message) - } else { - _, _ = w.Write([]byte(entry.Message)) - } - } - - _, _ = w.Write(newline) -} - -func printFieldIfPresent(w io.Writer, name string, value interface{}) bool { - switch v := value.(type) { - case string: - if v != "" { - fmt.Fprintf(w, `%s"%s" `, aurora.Faint(name+"="), v) - return true - } - case int: - if v > 0 { - fmt.Fprintf(w, "%s%d ", aurora.Faint(name+"="), v) - return true - } - } - - return false -} - -func levelColor(level string) aurora.Color { - switch level { - case "debug": - return aurora.CyanFg - case "info": - return aurora.BlueFg - case "warn": - case "warning": - return aurora.YellowFg - } - return aurora.RedFg -} diff --git a/cmd/presenters/presenter.go b/cmd/presenters/presenter.go deleted file mode 100644 index 62c6bf948b..0000000000 --- a/cmd/presenters/presenter.go +++ /dev/null @@ -1,128 +0,0 @@ -package presenters - -import ( - "encoding/json" - "fmt" - "io" - - "github.com/logrusorgru/aurora" - - "github.com/olekukonko/tablewriter" -) - -// Presentable - Records (and field names) which may be presented by a Presenter -type Presentable interface { - FieldNames() []string - Records() []map[string]string - APIStruct() interface{} -} - -// Presenter - A self managing presenter which can be rendered in multiple ways -type Presenter struct { - Item Presentable - Out io.Writer - Opts Options -} - -// Options - Presenter options -type Options struct { - Vertical bool - HideHeader bool - Title string - AsJSON bool -} - -// Render - Renders a presenter as a field list or table -func (p *Presenter) Render() error { - if p.Opts.AsJSON { - return p.renderJSON() - } - - if p.Opts.Vertical { - return p.renderFieldList() - } - - return p.renderTable() -} - -func (p *Presenter) renderTable() error { - if p.Opts.Title != "" { - fmt.Fprintln(p.Out, aurora.Bold(p.Opts.Title)) - } - - table := tablewriter.NewWriter(p.Out) - - cols := p.Item.FieldNames() - - if !p.Opts.HideHeader { - table.SetHeader(cols) - } - table.SetBorder(false) - table.SetHeaderLine(false) - table.SetAutoWrapText(false) - table.SetAutoFormatHeaders(true) - table.SetAlignment(tablewriter.ALIGN_LEFT) - table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) - table.SetColumnSeparator(" ") - table.SetNoWhiteSpace(true) - table.SetTablePadding(" ") // pad with tabs - - for _, kv := range p.Item.Records() { - fields := []string{} - for _, col := range cols { - fields = append(fields, kv[col]) - } - table.Append(fields) - } - - table.Render() - - fmt.Fprintln(p.Out) - - return nil -} - -func (p *Presenter) renderFieldList() error { - table := tablewriter.NewWriter(p.Out) - - if p.Opts.Title != "" { - fmt.Fprintln(p.Out, aurora.Bold(p.Opts.Title)) - } - cols := p.Item.FieldNames() - - table.SetBorder(false) - table.SetAutoWrapText(false) - table.SetColumnSeparator("=") - table.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_LEFT}) - - for _, kv := range p.Item.Records() { - for _, col := range cols { - table.Append([]string{col, kv[col]}) - } - table.Render() - - fmt.Fprintln(p.Out) - } - - return nil -} - -func (p *Presenter) renderJSON() error { - data := p.Item.APIStruct() - - if data == nil { - return fmt.Errorf("JSON output not available") - } - - if p.Opts.Title == "" { - prettyJSON, err := json.MarshalIndent(data, "", " ") - fmt.Fprintln(p.Out, string(prettyJSON)) - return err - } else { - wrapper := make(map[string]interface{}, 1) - wrapper[p.Opts.Title] = p.Item.APIStruct() - prettyJSON, err := json.MarshalIndent(wrapper, "", " ") - fmt.Fprintln(p.Out, string(prettyJSON)) - return err - } -} diff --git a/cmd/presenters/secrets.go b/cmd/presenters/secrets.go deleted file mode 100644 index 6e43819f20..0000000000 --- a/cmd/presenters/secrets.go +++ /dev/null @@ -1,31 +0,0 @@ -package presenters - -import ( - "github.com/superfly/flyctl/api" -) - -type Secrets struct { - Secrets []api.Secret -} - -func (p *Secrets) APIStruct() interface{} { - return nil -} - -func (p *Secrets) FieldNames() []string { - return []string{"Name", "Digest", "Date"} -} - -func (p *Secrets) Records() []map[string]string { - out := []map[string]string{} - - for _, secret := range p.Secrets { - out = append(out, map[string]string{ - "Name": secret.Name, - "Digest": secret.Digest, - "Date": FormatRelativeTime(secret.CreatedAt), - }) - } - - return out -} diff --git a/cmd/presenters/taskServices.go b/cmd/presenters/taskServices.go deleted file mode 100644 index f627339673..0000000000 --- a/cmd/presenters/taskServices.go +++ /dev/null @@ -1,38 +0,0 @@ -package presenters - -import ( - "fmt" - "strings" - - "github.com/superfly/flyctl/api" -) - -type Services struct { - Services []api.Service -} - -func (p *Services) APIStruct() interface{} { - return p.Services -} - -func (p *Services) FieldNames() []string { - return []string{"Protocol", "Ports"} -} - -func (p *Services) Records() []map[string]string { - out := []map[string]string{} - - for _, service := range p.Services { - ports := []string{} - for _, p := range service.Ports { - ports = append(ports, fmt.Sprintf("%d => %d [%s]", p.Port, service.InternalPort, strings.Join(p.Handlers, ", "))) - } - - out = append(out, map[string]string{ - "Protocol": service.Protocol, - "Ports": strings.Join(ports, "\n"), - }) - } - - return out -} diff --git a/cmd/regions.go b/cmd/regions.go deleted file mode 100644 index 94298a8689..0000000000 --- a/cmd/regions.go +++ /dev/null @@ -1,297 +0,0 @@ -package cmd - -import ( - "strings" - - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmdctx" - "github.com/superfly/flyctl/flaps" - - "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/docstrings" - - "github.com/spf13/cobra" -) - -func newRegionsCommand(client *client.Client) *Command { - regionsStrings := docstrings.Get("regions") - - cmd := BuildCommandKS(nil, nil, regionsStrings, client, requireAppName, requireSession) - - addStrings := docstrings.Get("regions.add") - addCmd := BuildCommandKS(cmd, runRegionsAdd, addStrings, client, requireSession, requireAppName) - addCmd.Args = cobra.MinimumNArgs(1) - addCmd.AddStringFlag(StringFlagOpts{ - Name: "group", - Description: "The process group to add the region to", - Default: "", - }) - addCmd.AddBoolFlag(BoolFlagOpts{Name: "yes", Shorthand: "y", Description: "accept all confirmations"}) - addCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - removeStrings := docstrings.Get("regions.remove") - removeCmd := BuildCommandKS(cmd, runRegionsRemove, removeStrings, client, requireSession, requireAppName) - removeCmd.Args = cobra.MinimumNArgs(1) - removeCmd.AddStringFlag(StringFlagOpts{ - Name: "group", - Description: "The process group to remove the region from", - Default: "", - }) - removeCmd.AddBoolFlag(BoolFlagOpts{Name: "yes", Shorthand: "y", Description: "accept all confirmations"}) - removeCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - setStrings := docstrings.Get("regions.set") - setCmd := BuildCommandKS(cmd, runRegionsSet, setStrings, client, requireSession, requireAppName) - setCmd.Args = cobra.MinimumNArgs(1) - setCmd.AddStringFlag(StringFlagOpts{ - Name: "group", - Description: "The process group to set regions for", - Default: "", - }) - setCmd.AddBoolFlag(BoolFlagOpts{Name: "yes", Shorthand: "y", Description: "accept all confirmations"}) - setCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - setBackupStrings := docstrings.Get("regions.backup") - setBackupCmd := BuildCommand(cmd, runBackupRegionsSet, setBackupStrings.Usage, setBackupStrings.Short, setBackupStrings.Long, client, requireSession, requireAppName) - setBackupCmd.Args = cobra.MinimumNArgs(1) - setBackupCmd.AddBoolFlag(BoolFlagOpts{Name: "yes", Shorthand: "y", Description: "accept all confirmations"}) - setBackupCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - listStrings := docstrings.Get("regions.list") - listCmd := BuildCommand(cmd, runRegionsList, listStrings.Usage, listStrings.Short, listStrings.Long, client, requireSession, requireAppName) - listCmd.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - return cmd -} - -func runRegionsAdd(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - group := cmdCtx.Config.GetString("group") - input := api.ConfigureRegionsInput{ - AppID: cmdCtx.AppName, - Group: group, - AllowRegions: cmdCtx.Args, - } - - regions, backupRegions, err := cmdCtx.Client.API().ConfigureRegions(ctx, input) - if err != nil { - return err - } - - printRegions(cmdCtx, regions, backupRegions) - - return nil -} - -func runRegionsRemove(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - group := cmdCtx.Config.GetString("group") - input := api.ConfigureRegionsInput{ - AppID: cmdCtx.AppName, - Group: group, - DenyRegions: cmdCtx.Args, - } - - regions, backupRegions, err := cmdCtx.Client.API().ConfigureRegions(ctx, input) - if err != nil { - return err - } - - printRegions(cmdCtx, regions, backupRegions) - - return nil -} - -func runRegionsSet(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - addList := make([]string, 0) - delList := make([]string, 0) - - // Get the Region List - regions, _, err := cmdCtx.Client.API().ListAppRegions(ctx, cmdCtx.AppName) - if err != nil { - return err - } - - addList = append(addList, cmdCtx.Args...) - - for _, er := range regions { - found := false - for _, r := range cmdCtx.Args { - if r == er.Code { - found = true - break - } - } - if !found { - delList = append(delList, er.Code) - } - } - - group := cmdCtx.Config.GetString("group") - input := api.ConfigureRegionsInput{ - AppID: cmdCtx.AppName, - Group: group, - AllowRegions: addList, - DenyRegions: delList, - } - - newregions, backupRegions, err := cmdCtx.Client.API().ConfigureRegions(ctx, input) - if err != nil { - return err - } - - printRegions(cmdCtx, newregions, backupRegions) - - return nil -} - -func runRegionsList(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - app, err := cmdCtx.Client.API().GetAppCompact(ctx, cmdCtx.AppName) - - if err != nil { - return err - } - - if app.PlatformVersion == "nomad" { - regions, backupRegions, err := cmdCtx.Client.API().ListAppRegions(ctx, cmdCtx.AppName) - if err != nil { - return err - } - - printRegions(cmdCtx, regions, backupRegions) - - return nil - } - - flapsClient, err := flaps.NewFromAppName(ctx, cmdCtx.AppName) - - if err != nil { - return err - } - - machines, _, err := flapsClient.ListFlyAppsMachines(ctx) - - if err != nil { - return err - } - - machineRegionsMap := make(map[string]map[string]bool) - for _, machine := range machines { - if machineRegionsMap[machine.Config.ProcessGroup()] == nil { - machineRegionsMap[machine.Config.ProcessGroup()] = make(map[string]bool) - } - machineRegionsMap[machine.Config.ProcessGroup()][machine.Region] = true - } - - machineRegions := make(map[string][]string) - for group, regions := range machineRegionsMap { - for region := range regions { - machineRegions[group] = append(machineRegions[group], region) - } - } - - printApssV2Regions(cmdCtx, machineRegions) - return nil -} - -func runBackupRegionsSet(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - input := api.ConfigureRegionsInput{ - AppID: cmdCtx.AppName, - BackupRegions: cmdCtx.Args, - } - - regions, backupRegions, err := cmdCtx.Client.API().ConfigureRegions(ctx, input) - if err != nil { - return err - } - - printRegions(cmdCtx, regions, backupRegions) - - return nil -} - -type printableProcessGroup struct { - Name string - Regions []string -} - -func printApssV2Regions(ctx *cmdctx.CmdContext, machineRegions map[string][]string) { - if ctx.OutputJSON() { - jsonPg := []printableProcessGroup{} - for group, regionlist := range machineRegions { - jsonPg = append(jsonPg, printableProcessGroup{ - Name: group, - Regions: regionlist, - }) - } - - // only show pg if there's more than one - data := struct { - ProcessGroupRegions []printableProcessGroup - }{ - ProcessGroupRegions: jsonPg, - } - ctx.WriteJSON(data) - - return - } - - for group, regionlist := range machineRegions { - ctx.Statusf("regions", cmdctx.STITLE, "Regions [%s]: ", group) - ctx.Status("regions", cmdctx.SINFO, strings.Join(regionlist, ", ")) - } -} - -func printRegions(ctx *cmdctx.CmdContext, regions []api.Region, backupRegions []api.Region) { - if ctx.OutputJSON() { - // only show pg if there's more than one - data := struct { - Regions []api.Region - BackupRegions []api.Region - }{ - Regions: regions, - BackupRegions: backupRegions, - } - ctx.WriteJSON(data) - - return - } - - verbose := ctx.GlobalConfig.GetBool("verbose") - - if verbose { - ctx.Status("regions", cmdctx.STITLE, "Current Region Pool:") - } else { - ctx.Status("regions", cmdctx.STITLE, "Region Pool: ") - } - - for _, r := range regions { - if verbose { - ctx.Statusf("regions", cmdctx.SINFO, " %s %s\n", r.Code, r.Name) - } else { - ctx.Status("regions", cmdctx.SINFO, r.Code) - } - } - - if verbose { - ctx.Status("backupRegions", cmdctx.STITLE, "Current Backup Region Pool:") - } else { - ctx.Status("backupRegions", cmdctx.STITLE, "Backup Region: ") - } - - for _, r := range backupRegions { - if verbose { - ctx.Statusf("backupRegions", cmdctx.SINFO, " %s %s\n", r.Code, r.Name) - } else { - ctx.Status("backupRegions", cmdctx.SINFO, r.Code) - } - } -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 938aafc1f4..0000000000 --- a/cmd/root.go +++ /dev/null @@ -1,96 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/hashicorp/go-multierror" - "github.com/logrusorgru/aurora" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/docstrings" - "github.com/superfly/flyctl/flyctl" - "github.com/superfly/flyctl/internal/flyerr" -) - -// BUG(tqbf): this code is called by root.New() in internal/command/root/root.go; we're apparently -// halfway through a migration out of flyctl/cmd/ and into internal/command/, which I support, but -// this is obviously pretty confusing. I lost 8 minutes to figuring this out so you don't have to. -func NewRootCmd(client *client.Client) *cobra.Command { - rootStrings := docstrings.Get("flyctl") - rootCmd := &Command{ - Command: &cobra.Command{ - Use: rootStrings.Usage, - Short: rootStrings.Short, - Long: rootStrings.Long, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - cmd.SilenceUsage = true - cmd.SilenceErrors = true - }, - }, - } - - rootCmd.PersistentFlags().StringP("access-token", "t", "", "Fly API Access Token") - err := viper.BindPFlag(flyctl.ConfigAPIToken, rootCmd.PersistentFlags().Lookup("access-token")) - checkErr(err) - - rootCmd.PersistentFlags().Bool("verbose", false, "verbose output") - err = viper.BindPFlag(flyctl.ConfigVerboseOutput, rootCmd.PersistentFlags().Lookup("verbose")) - checkErr(err) - - rootCmd.PersistentFlags().String("builtinsfile", "", "Load builtins from named file") - err = viper.BindPFlag(flyctl.ConfigBuiltinsfile, rootCmd.PersistentFlags().Lookup("builtinsfile")) - checkErr(err) - - err = rootCmd.PersistentFlags().MarkHidden("builtinsfile") - checkErr(err) - - rootCmd.SetHelpCommand(&cobra.Command{ - Use: "no-help", - Hidden: true, - }) - - rootCmd.AddCommand( - newCertificatesCommand(client), - newDashboardCommand(client), - newRegionsCommand(client), - newAutoscaleCommand(client), - newDNSCommand(client), - newDomainsCommand(client), - newWireGuardCommand(client), - ) - - return rootCmd.Command -} - -func checkErr(err error) { - if err == nil { - return - } - - if !isCancelledError(err) { - fmt.Println(aurora.Red("Error"), err) - } - - os.Exit(1) -} - -func isCancelledError(err error) bool { - if err == flyerr.ErrAbort { - return true - } - - if err == context.Canceled { - return true - } - - if merr, ok := err.(*multierror.Error); ok { - if len(merr.Errors) == 1 && merr.Errors[0] == context.Canceled { - return true - } - } - - return false -} diff --git a/cmd/wireguard.go b/cmd/wireguard.go deleted file mode 100644 index e1a637b6dd..0000000000 --- a/cmd/wireguard.go +++ /dev/null @@ -1,692 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "os" - "strings" - "text/template" - - "github.com/AlecAivazis/survey/v2" - "github.com/olekukonko/tablewriter" - "github.com/pkg/errors" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/superfly/flyctl/agent" - "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmdctx" - "github.com/superfly/flyctl/docstrings" - "github.com/superfly/flyctl/flyctl" - "github.com/superfly/flyctl/internal/wireguard" - "github.com/superfly/flyctl/terminal" -) - -func newWireGuardCommand(client *client.Client) *Command { - cmd := BuildCommandKS(nil, nil, docstrings.Get("wireguard"), client, requireSession) - cmd.Aliases = []string{"wg"} - - child := func(parent *Command, fn RunFn, ds string) *Command { - return BuildCommandKS(parent, fn, docstrings.Get(ds), client, requireSession) - } - - list := child(cmd, runWireGuardList, "wireguard.list") - list.Args = cobra.MaximumNArgs(1) - list.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - child(cmd, runWireGuardCreate, "wireguard.create").Args = cobra.MaximumNArgs(4) - child(cmd, runWireGuardRemove, "wireguard.remove").Args = cobra.MaximumNArgs(2) - child(cmd, runWireGuardStat, "wireguard.status").Args = cobra.MaximumNArgs(2) - child(cmd, runWireGuardResetPeer, "wireguard.reset").Args = cobra.MaximumNArgs(1) - child(cmd, runWireGuardWebSockets, "wireguard.websockets").Args = cobra.ExactArgs(1) - - tokens := child(cmd, nil, "wireguard.token") - - tokensList := child(tokens, runWireGuardTokenList, "wireguard.token.list") - tokensList.Args = cobra.MaximumNArgs(1) - tokensList.AddBoolFlag(BoolFlagOpts{Name: "json", Shorthand: "j", Description: "JSON output"}) - - child(tokens, runWireGuardTokenCreate, "wireguard.token.create").Args = cobra.MaximumNArgs(2) - child(tokens, runWireGuardTokenDelete, "wireguard.token.delete").Args = cobra.MaximumNArgs(3) - - child(tokens, runWireGuardTokenStartPeer, "wireguard.token.start").Args = cobra.MaximumNArgs(4) - child(tokens, runWireGuardTokenUpdatePeer, "wireguard.token.update").Args = cobra.MaximumNArgs(2) - - return cmd -} - -func argOrPromptImpl(ctx *cmdctx.CmdContext, nth int, prompt string, first bool) (string, error) { - if len(ctx.Args) >= (nth + 1) { - return ctx.Args[nth], nil - } - - val := "" - err := survey.AskOne(&survey.Input{ - Message: prompt, - }, &val) - - return val, err -} - -func argOrPromptLoop(ctx *cmdctx.CmdContext, nth int, prompt, last string) (string, error) { - return argOrPromptImpl(ctx, nth, prompt, last == "") -} - -func argOrPrompt(ctx *cmdctx.CmdContext, nth int, prompt string) (string, error) { - return argOrPromptImpl(ctx, nth, prompt, true) -} - -func orgByArg(cmdCtx *cmdctx.CmdContext) (*api.Organization, error) { - ctx := cmdCtx.Command.Context() - client := cmdCtx.Client.API() - - if len(cmdCtx.Args) == 0 { - org, err := selectOrganization(ctx, client, "") - if err != nil { - return nil, err - } - - return org, nil - } - - return client.GetOrganizationBySlug(ctx, cmdCtx.Args[0]) -} - -func runWireGuardList(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - client := cmdCtx.Client.API() - - org, err := orgByArg(cmdCtx) - if err != nil { - return err - } - - peers, err := client.GetWireGuardPeers(ctx, org.Slug) - if err != nil { - return err - } - - if cmdCtx.OutputJSON() { - cmdCtx.WriteJSON(peers) - return nil - } - - table := tablewriter.NewWriter(cmdCtx.Out) - - table.SetHeader([]string{ - "Name", - "Region", - "Peer IP", - }) - - for _, peer := range peers { - table.Append([]string{peer.Name, peer.Region, peer.Peerip}) - } - - table.Render() - - return nil -} - -func generateWgConf(peer *api.CreatedWireGuardPeer, privkey string, w io.Writer) { - templateStr := ` -[Interface] -PrivateKey = {{.Meta.Privkey}} -Address = {{.Peer.Peerip}}/120 -DNS = {{.Meta.DNS}} - -[Peer] -PublicKey = {{.Peer.Pubkey}} -AllowedIPs = {{.Meta.AllowedIPs}} -Endpoint = {{.Peer.Endpointip}}:51820 -PersistentKeepalive = 15 - -` - data := struct { - Peer *api.CreatedWireGuardPeer - Meta struct { - Privkey string - AllowedIPs string - DNS string - } - }{ - Peer: peer, - } - - addr := net.ParseIP(peer.Peerip).To16() - for i := 6; i < 16; i++ { - addr[i] = 0 - } - - // BUG(tqbf): can't stay this way - data.Meta.AllowedIPs = fmt.Sprintf("%s/48", addr) - - addr[15] = 3 - - data.Meta.DNS = addr.String() - data.Meta.Privkey = privkey - - tmpl := template.Must(template.New("name").Parse(templateStr)) - - tmpl.Execute(w, &data) -} - -func resolveOutputWriter(ctx *cmdctx.CmdContext, idx int, prompt string) (w io.WriteCloser, mustClose bool, err error) { - var ( - f *os.File - filename string - ) - - for { - filename, err = argOrPromptLoop(ctx, idx, prompt, filename) - if err != nil { - return nil, false, err - } - - if filename == "" { - fmt.Println("Provide a filename (or 'stdout')") - continue - } - - if filename == "stdout" { - return os.Stdout, false, nil - } - - f, err = os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err == nil { - return f, true, nil - } - - fmt.Printf("Can't create '%s': %s\n", filename, err) - } -} - -func runWireGuardWebSockets(ctx *cmdctx.CmdContext) error { - switch ctx.Args[0] { - case "enable": - viper.Set(flyctl.ConfigWireGuardWebsockets, true) - - case "disable": - viper.Set(flyctl.ConfigWireGuardWebsockets, false) - - default: - fmt.Printf("bad arg: flyctl wireguard websockets (enable|disable)\n") - } - - if err := flyctl.SaveConfig(); err != nil { - return errors.Wrap(err, "error saving config file") - } - - tryKillingAgent := func() error { - client, err := agent.DefaultClient(ctx.Command.Context()) - if err == agent.ErrAgentNotRunning { - return nil - } else if err != nil { - return err - } - - return client.Kill(ctx.Command.Context()) - } - - // kill the agent if necessary, if that fails print manual instructions - if err := tryKillingAgent(); err != nil { - terminal.Debugf("error stopping the agent: %s", err) - fmt.Printf("Run `flyctl agent restart` to make changes take effect.\n") - } - - return nil -} - -func runWireGuardResetPeer(ctx *cmdctx.CmdContext) error { - org, err := orgByArg(ctx) - if err != nil { - return err - } - - client := ctx.Client.API() - agentclient, err := agent.Establish(context.Background(), client) - if err != nil { - return err - } - - conf, err := agentclient.Reestablish(context.Background(), org.Slug) - if err != nil { - return err - } - - fmt.Printf("New WireGuard peer for organization '%s': '%s'\n", org.Slug, conf.WireGuardState.Name) - return nil -} - -func runWireGuardCreate(ctx *cmdctx.CmdContext) error { - org, err := orgByArg(ctx) - if err != nil { - return err - } - - var region string - var name string - - if len(ctx.Args) > 1 && ctx.Args[1] != "" { - region = ctx.Args[1] - } - - if len(ctx.Args) > 2 && ctx.Args[2] != "" { - name = ctx.Args[2] - } - - state, err := wireguard.Create(ctx.Client.API(), org, region, name) - if err != nil { - return err - } - - data := &state.Peer - - fmt.Printf(` -!!!! WARNING: Output includes private key. Private keys cannot be recovered !!!! -!!!! after creating the peer; if you lose the key, you'll need to remove !!!! -!!!! and re-add the peering connection. !!!! -`) - - w, shouldClose, err := resolveOutputWriter(ctx, 3, "Filename to store WireGuard configuration in, or 'stdout': ") - if err != nil { - return err - } - if shouldClose { - defer w.Close() - } - - generateWgConf(data, state.LocalPrivate, w) - - if shouldClose { - filename := w.(*os.File).Name() - fmt.Printf("Wrote WireGuard configuration to %s; load in your WireGuard client\n", filename) - } - - return nil -} - -func runWireGuardRemove(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - client := cmdCtx.Client.API() - - org, err := orgByArg(cmdCtx) - if err != nil { - return err - } - - var name string - if len(cmdCtx.Args) >= 2 { - name = cmdCtx.Args[1] - } else { - name, err = selectWireGuardPeer(ctx, cmdCtx.Client.API(), org.Slug) - if err != nil { - return err - } - } - - fmt.Printf("Removing WireGuard peer \"%s\" for organization %s\n", name, org.Slug) - - err = client.RemoveWireGuardPeer(ctx, org, name) - if err != nil { - return err - } - - fmt.Println("Removed peer.") - - return wireguard.PruneInvalidPeers(ctx, cmdCtx.Client.API()) -} - -func runWireGuardStat(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - client := cmdCtx.Client.API() - - org, err := orgByArg(cmdCtx) - if err != nil { - return err - } - - var name string - if len(cmdCtx.Args) >= 2 { - name = cmdCtx.Args[1] - } else { - name, err = selectWireGuardPeer(ctx, cmdCtx.Client.API(), org.Slug) - if err != nil { - return err - } - } - - status, err := client.GetWireGuardPeerStatus(ctx, org.Slug, name) - if err != nil { - return err - } - - fmt.Printf("Alive: %+v\n", status.Live) - - if status.WgError != "" { - fmt.Printf("Gateway error: %s\n", status.WgError) - } - - if !status.Live { - return nil - } - - if status.Endpoint != "" { - fmt.Printf("Last Source Address: %s\n", status.Endpoint) - } - - ago := "" - if status.SinceAdded != "" { - ago = " (" + status.SinceAdded + " ago)" - } - - if status.LastHandshake != "" { - fmt.Printf("Last Handshake At: %s%s\n", status.LastHandshake, ago) - } - - ago = "" - if status.SinceHandshake != "" { - ago = " (" + status.SinceHandshake + " ago)" - } - - fmt.Printf("Installed On Gateway At: %s%s\n", status.Added, ago) - - fmt.Printf("Traffic: rx:%d tx:%d\n", status.Rx, status.Tx) - - return nil -} - -func runWireGuardTokenList(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - client := cmdCtx.Client.API() - - org, err := orgByArg(cmdCtx) - if err != nil { - return err - } - - tokens, err := client.GetDelegatedWireGuardTokens(ctx, org.Slug) - if err != nil { - return err - } - - if cmdCtx.OutputJSON() { - cmdCtx.WriteJSON(tokens) - return nil - } - - table := tablewriter.NewWriter(cmdCtx.Out) - - table.SetHeader([]string{ - "Name", - }) - - for _, peer := range tokens { - table.Append([]string{peer.Name}) - } - - table.Render() - - return nil -} - -func runWireGuardTokenCreate(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - client := cmdCtx.Client.API() - - org, err := orgByArg(cmdCtx) - if err != nil { - return err - } - - name, err := argOrPrompt(cmdCtx, 1, "Memorable name for WireGuard token: ") - if err != nil { - return err - } - - data, err := client.CreateDelegatedWireGuardToken(ctx, org, name) - if err != nil { - return err - } - - fmt.Printf(` -!!!! WARNING: Output includes credential information. Credentials cannot !!!! -!!!! be recovered after creation; if you lose the token, you'll need to !!!! -!!!! remove and and re-add it. !!!! - -To use a token to create a WireGuard connection, you can use curl: - - curl -v --request POST - -H "Authorization: Bearer ${WG_TOKEN}" - -H "Content-Type: application/json" - --data '{"name": "node-1", \ - "group": "k8s", \ - "pubkey": "'"${WG_PUBKEY}"'", \ - "region": "dev"}' - http://fly.io/api/v3/wire_guard_peers - -We'll return 'us' (our local 6PN address), 'them' (the gateway IP address), -and 'pubkey' (the public key of the gateway), which you can inject into a -"wg.con". -`) - - w, shouldClose, err := resolveOutputWriter(cmdCtx, 2, "Filename to store WireGuard token in, or 'stdout': ") - if err != nil { - return err - } - if shouldClose { - defer w.Close() - } - - fmt.Fprintf(w, "FLY_WIREGUARD_TOKEN=%s\n", data.Token) - - return nil -} - -func runWireGuardTokenDelete(cmdCtx *cmdctx.CmdContext) error { - ctx := cmdCtx.Command.Context() - - client := cmdCtx.Client.API() - - org, err := orgByArg(cmdCtx) - if err != nil { - return err - } - - kv, err := argOrPrompt(cmdCtx, 1, "'name:' or token:': ") - if err != nil { - return err - } - - tup := strings.SplitN(kv, ":", 2) - if len(tup) != 2 || (tup[0] != "name" && tup[0] != "token") { - return fmt.Errorf("format is name: or token:") - } - - fmt.Printf("Removing WireGuard token \"%s\" for organization %s\n", kv, org.Slug) - - if tup[0] == "name" { - err = client.DeleteDelegatedWireGuardToken(ctx, org, &tup[1], nil) - } else { - err = client.DeleteDelegatedWireGuardToken(ctx, org, nil, &tup[1]) - } - if err != nil { - return err - } - - fmt.Println("Removed token.") - - return nil -} - -func tokenRequest(method, path, token string, data interface{}) (*http.Response, error) { - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(data); err != nil { - return nil, err - } - - req, err := http.NewRequest(method, - fmt.Sprintf("https://fly.io/api/v3/wire_guard_peers%s", path), - buf) - if err != nil { - return nil, err - } - req.Header.Add("Authorization", api.AuthorizationHeader(token)) - req.Header.Add("Content-Type", "application/json") - - return (&http.Client{}).Do(req) -} - -type StartPeerJson struct { - Name string `json:"name"` - Group string `json:"group"` - Pubkey string `json:"pubkey"` - Region string `json:"region"` -} - -type UpdatePeerJson struct { - Pubkey string `json:"pubkey"` -} - -type PeerStatusJson struct { - Us string `json:"us"` - Them string `json:"them"` - Pubkey string `json:"key"` - Error string `json:"error"` -} - -func generateTokenConf(ctx *cmdctx.CmdContext, idx int, stat *PeerStatusJson, privkey string) error { - fmt.Printf(` -!!!! WARNING: Output includes private key. Private keys cannot be recovered !!!! -!!!! after creating the peer; if you lose the key, you'll need to rekey !!!! -!!!! the peering connection. !!!! -`) - - w, shouldClose, err := resolveOutputWriter(ctx, idx, "Filename to store WireGuard configuration in, or 'stdout': ") - if err != nil { - return err - } - if shouldClose { - defer w.Close() - } - - generateWgConf(&api.CreatedWireGuardPeer{ - Peerip: stat.Us, - Pubkey: stat.Pubkey, - Endpointip: stat.Them, - }, privkey, w) - - if shouldClose { - filename := w.(*os.File).Name() - fmt.Printf("Wrote WireGuard configuration to %s; load in your WireGuard client\n", filename) - } - - return nil -} - -func runWireGuardTokenStartPeer(ctx *cmdctx.CmdContext) error { - token := os.Getenv("FLY_WIREGUARD_TOKEN") - if token == "" { - return fmt.Errorf("set FLY_WIREGUARD_TOKEN env") - } - - name, err := argOrPrompt(ctx, 0, "Name (DNS-compatible) for peer: ") - if err != nil { - return err - } - - group, err := argOrPrompt(ctx, 1, "Peer group (i.e. 'k8s'): ") - if err != nil { - return err - } - - region, err := argOrPrompt(ctx, 2, "Gateway region: ") - if err != nil { - return err - } - - pubkey, privatekey := wireguard.C25519pair() - - body := &StartPeerJson{ - Name: name, - Group: group, - Pubkey: pubkey, - Region: region, - } - - resp, err := tokenRequest("POST", "", token, body) - if err != nil { - return err - } - - peerStatus := &PeerStatusJson{} - if err = json.NewDecoder(resp.Body).Decode(peerStatus); err != nil { - if resp.StatusCode != 200 { - return fmt.Errorf("server returned error: %s %w", resp.Status, err) - } - - return err - } - - if peerStatus.Error != "" { - return fmt.Errorf("WireGuard API error: %s", peerStatus.Error) - } - - if err = generateTokenConf(ctx, 3, peerStatus, privatekey); err != nil { - return err - } - - return nil -} - -func runWireGuardTokenUpdatePeer(ctx *cmdctx.CmdContext) error { - token := os.Getenv("FLY_WIREGUARD_TOKEN") - if token == "" { - return fmt.Errorf("set FLY_WIREGUARD_TOKEN env") - } - - name, err := argOrPrompt(ctx, 0, "Name (DNS-compatible) for peer: ") - if err != nil { - return err - } - - pubkey, privatekey := wireguard.C25519pair() - - body := &StartPeerJson{ - Pubkey: pubkey, - } - - resp, err := tokenRequest("PUT", "/"+name, token, body) - if err != nil { - return err - } - - peerStatus := &PeerStatusJson{} - if err = json.NewDecoder(resp.Body).Decode(peerStatus); err != nil { - if resp.StatusCode != 200 { - return fmt.Errorf("server returned error: %s %w", resp.Status, err) - } - - return err - } - - if peerStatus.Error != "" { - return fmt.Errorf("WireGuard API error: %s", peerStatus.Error) - } - - if err = generateTokenConf(ctx, 1, peerStatus, privatekey); err != nil { - return err - } - - return nil -} diff --git a/cmdctx/cmdcontext.go b/cmdctx/cmdcontext.go deleted file mode 100644 index 6e3b4fe665..0000000000 --- a/cmdctx/cmdcontext.go +++ /dev/null @@ -1,235 +0,0 @@ -package cmdctx - -import ( - "encoding/json" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/logrusorgru/aurora" - "github.com/pkg/errors" - "github.com/segmentio/textio" - "github.com/spf13/cobra" - "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmd/presenters" - "github.com/superfly/flyctl/flyctl" - "github.com/superfly/flyctl/iostreams" -) - -// CmdContext - context passed to commands being run -type CmdContext struct { - IO *iostreams.IOStreams - Client *client.Client - Config flyctl.Config - GlobalConfig flyctl.Config - NS string - Args []string - Command *cobra.Command - Out io.Writer - WorkingDir string - ConfigFile string - AppName string - AppConfig *flyctl.AppConfig - MachineConfig *api.MachineConfig -} - -// PresenterOption - options for RenderEx, RenderView, render etc... - -type PresenterOption struct { - Presentable presenters.Presentable - AsJSON bool - Vertical bool - HideHeader bool - Title string -} - -// Thoughts on how we could illuminate output in flyctl. I'm thinking a set of tags - INFO (plain text), -// DETAIL, TITLE (Bold Plain Text), BEGIN (Green bold with arrow), DONE (Blue with arrow), ERROR (red bold)... - -const ( - SINFO = "info" - SWARN = "warning" - SDETAIL = "detail" - STITLE = "title" - SBEGIN = "begin" - SDONE = "done" - SERROR = "error" -) - -func NewCmdContext(flyctlClient *client.Client, ns string, cmd *cobra.Command, args []string) (*CmdContext, error) { - ctx := &CmdContext{ - IO: flyctlClient.IO, - Client: flyctlClient, - NS: ns, - Config: flyctl.ConfigNS(ns), - GlobalConfig: flyctl.FlyConfig, - Args: args, - Command: cmd, - Out: flyctlClient.IO.Out, - } - - cwd, err := os.Getwd() - if err != nil { - return nil, errors.Wrap(err, "Error resolving working directory") - } - ctx.WorkingDir = cwd - - return ctx, nil -} - -// Render - Render a presentable structure via the context -func (commandContext *CmdContext) Render(presentable presenters.Presentable) error { - presenter := &presenters.Presenter{ - Item: presentable, - Out: os.Stdout, - Opts: presenters.Options{ - AsJSON: commandContext.OutputJSON(), - }, - } - - return presenter.Render() -} - -func (commandContext *CmdContext) render(out io.Writer, views ...PresenterOption) error { - for _, v := range views { - presenter := &presenters.Presenter{ - Item: v.Presentable, - Out: out, - Opts: presenters.Options{ - Vertical: v.Vertical, - HideHeader: v.HideHeader, - Title: v.Title, - AsJSON: v.AsJSON, - }, - } - - if err := presenter.Render(); err != nil { - return err - } - } - - return nil -} - -// Frender - render a view to a Writer -func (commandContext *CmdContext) Frender(views ...PresenterOption) error { - // If JSON output wanted, set in all views - if commandContext.OutputJSON() { - for i := range views { - views[i].AsJSON = true - } - } - - return commandContext.render(commandContext.IO.Out, views...) -} - -// FrenderPrefix - render a view to a Writer -func (commandContext *CmdContext) FrenderPrefix(prefix string, views ...PresenterOption) error { - // If JSON output wanted, set in all views - p := textio.NewPrefixWriter(commandContext.IO.Out, " ") - - if commandContext.OutputJSON() { - for i := range views { - views[i].AsJSON = true - } - } - - return commandContext.render(p, views...) -} - -type JSON struct { - TS string - Source string - Status string - Message string -} - -func (commandContext *CmdContext) StatusLn() { - outputJSON := commandContext.OutputJSON() - - if outputJSON { - // Do nothing for JSON - return - } - - fmt.Fprintln(commandContext.IO.Out) -} - -func (commandContext *CmdContext) Status(source string, status string, args ...interface{}) { - outputJSON := commandContext.OutputJSON() - - var message strings.Builder - - for i, v := range args { - message.WriteString(fmt.Sprintf("%s", v)) - if i < len(args)-1 { - message.WriteString(" ") - } - } - - if outputJSON { - outstruct := JSON{ - TS: time.Now().Format(time.RFC3339), - Source: source, - Status: status, - Message: message.String(), - } - outbuf, _ := json.Marshal(outstruct) - fmt.Fprintln(commandContext.IO.Out, string(outbuf)) - return - } else { - fmt.Fprintln(commandContext.IO.Out, statusToEffect(status, message.String())) - } -} - -func statusToEffect(status string, message string) string { - switch status { - case SINFO: - return message - case SWARN: - return aurora.Yellow(message).String() - case SDETAIL: - return aurora.Faint(message).String() - case STITLE: - return aurora.Bold(message).String() - case SBEGIN: - return aurora.Green("==> " + message).String() - case SDONE: - return aurora.Gray(20, "--> "+message).String() - case SERROR: - return aurora.Red("***" + message).String() - } - - return message -} - -func (commandContext *CmdContext) Statusf(source string, status string, format string, args ...interface{}) { - outputJSON := commandContext.OutputJSON() - - message := fmt.Sprintf(format, args...) - - if outputJSON { - outbuf, _ := json.Marshal(JSON{ - TS: time.Now().Format(time.RFC3339), - Source: source, - Status: status, - Message: message, - }) - fmt.Fprintln(commandContext.IO.Out, string(outbuf)) - return - } else { - fmt.Fprint(commandContext.IO.Out, statusToEffect(status, message)) - } -} - -func (commandContext *CmdContext) WriteJSON(myData interface{}) { - outBuf, _ := json.MarshalIndent(myData, "", " ") - fmt.Fprintln(commandContext.IO.Out, string(outBuf)) -} - -func (commandContext *CmdContext) OutputJSON() bool { - return commandContext.Config.GetBool("json") -} diff --git a/docstrings/docstrings.go b/docstrings/docstrings.go deleted file mode 100644 index 4243c266a0..0000000000 --- a/docstrings/docstrings.go +++ /dev/null @@ -1,10 +0,0 @@ -package docstrings - -//go:generate sh ../scripts/helpgen.sh - -// KeyStrings - Struct for help string storage -type KeyStrings struct { - Usage string - Short string - Long string -} diff --git a/docstrings/gen.go b/docstrings/gen.go deleted file mode 100644 index 46c3a25af5..0000000000 --- a/docstrings/gen.go +++ /dev/null @@ -1,861 +0,0 @@ -package docstrings - -// Get - Get a document string -func Get(key string) KeyStrings { - switch key { - case "agent": - return KeyStrings{"agent ", "Commands that manage the Fly agent", - `Commands that manage the Fly agent`, - } - case "agent.daemon-start": - return KeyStrings{"daemon-start", "Run the Fly agent as a service (manually)", - `Run the Fly agent as a service (manually)`, - } - case "agent.ping": - return KeyStrings{"ping", "ping the Fly agent", - `ping the Fly agent`, - } - case "agent.restart": - return KeyStrings{"restart", "Restart the Fly agent", - `Restart the Fly agent`, - } - case "agent.start": - return KeyStrings{"start", "Start the Fly agent", - `Start the Fly agent`, - } - case "agent.stop": - return KeyStrings{"stop", "Stop the Fly agent", - `Stop the Fly agent`, - } - case "apps": - return KeyStrings{"apps", "Manage apps", - `The APPS commands focus on managing your Fly applications. -Start with the CREATE command to register your application. -The LIST command will list all currently registered applications.`, - } - case "apps.create": - return KeyStrings{"create [APPNAME]", "Create a new application", - `The APPS CREATE command will both register a new application -with the Fly platform and create the fly.toml file which controls how -the application will be deployed. The --builder flag allows a cloud native -buildpack to be specified which will be used instead of a Dockerfile to -create the application image when it is deployed.`, - } - case "apps.destroy": - return KeyStrings{"destroy [APPNAME]", "Permanently destroys an app", - `The APPS DESTROY command will remove an application -from the Fly platform.`, - } - case "apps.list": - return KeyStrings{"list", "List applications", - `The APPS LIST command will show the applications currently -registered and available to this user. The list will include applications -from all the organizations the user is a member of. Each application will -be shown with its name, owner and when it was last deployed.`, - } - case "apps.move": - return KeyStrings{"move [APPNAME]", "Move an app to another organization", - `The APPS MOVE command will move an application to another -organization the current user belongs to.`, - } - case "apps.restart": - return KeyStrings{"restart [APPNAME]", "Restart an application", - `The APPS RESTART command will restart all running vms.`, - } - case "apps.resume": - return KeyStrings{"resume [APPNAME]", "Resume an application", - `The APPS RESUME command will restart a previously suspended application. -The application will resume with its original region pool and a min count of one -meaning there will be one running instance once restarted. Use SCALE SET MIN= to raise -the number of configured instances.`, - } - case "apps.suspend": - return KeyStrings{"suspend [APPNAME]", "Suspend an application", - `The APPS SUSPEND command will suspend an application. -All instances will be halted leaving the application running nowhere. -It will continue to consume networking resources (IP address). See APPS RESUME -for details on restarting it.`, - } - case "auth": - return KeyStrings{"auth", "Manage authentication", - `Authenticate with Fly (and logout if you need to). -If you do not have an account, start with the AUTH SIGNUP command. -If you do have and account, begin with the AUTH LOGIN subcommand.`, - } - case "auth.docker": - return KeyStrings{"docker", "Authenticate docker", - `Adds registry.fly.io to the docker daemon's authenticated -registries. This allows you to push images directly to fly from -the docker cli.`, - } - case "auth.login": - return KeyStrings{"login", "Log in a user", - `Logs a user into the Fly platform. Supports browser-based, -email/password and one-time-password authentication. Defaults to using -browser-based authentication.`, - } - case "auth.logout": - return KeyStrings{"logout", "Logs out the currently logged in user", - `Log the currently logged-in user out of the Fly platform. -To continue interacting with Fly, the user will need to log in again.`, - } - case "auth.signup": - return KeyStrings{"signup", "Create a new fly account", - `Creates a new fly account. The command opens the browser -and sends the user to a form to provide appropriate credentials.`, - } - case "auth.token": - return KeyStrings{"token", "Show the current auth token", - `Shows the authentication token that is currently in use. -This can be used as an authentication token with API services, -independent of flyctl.`, - } - case "auth.whoami": - return KeyStrings{"whoami", "Show the currently authenticated user", - `Displays the users email address/service identity currently -authenticated and in use.`, - } - case "autoscale": - return KeyStrings{"autoscale", "Autoscaling app resources", - `Autoscaling application resources`, - } - case "autoscale.disable": - return KeyStrings{"disable", "Disable autoscaling", - `Disable autoscaling to manually controlling app resources`, - } - case "autoscale.set": - return KeyStrings{"set", "Set app autoscaling parameters", - `Enable autoscaling and set the application's autoscaling parameters: - -min=int - minimum number of instances to be allocated globally. -max=int - maximum number of instances to be allocated globally.`, - } - case "autoscale.show": - return KeyStrings{"show", "Show current autoscaling configuration", - `Show current autoscaling configuration`, - } - case "builds": - return KeyStrings{"builds", "Work with Fly builds", - `Fly builds are templates to make developing Fly applications easier.`, - } - case "builds.list": - return KeyStrings{"list", "List builds", - ``, - } - case "builds.logs": - return KeyStrings{"logs", "Show logs associated with builds", - ``, - } - case "builtins": - return KeyStrings{"builtins", "View and manage Flyctl deployment builtins", - `View and manage Flyctl deployment builtins.`, - } - case "builtins.list": - return KeyStrings{"list", "List available Flyctl deployment builtins", - `List available Flyctl deployment builtins and their -descriptions.`, - } - case "builtins.show": - return KeyStrings{"show []", "Show details of a builtin's configuration", - `Show details of a Fly deployment builtins, including -the builtin "Dockerfile" with default settings and other information.`, - } - case "builtins.show-app": - return KeyStrings{"show-app", "Show details of a builtin's configuration", - `Show details of a Fly deployment builtins, including -the builtin "Dockerfile" with an apps settings included -and other information.`, - } - case "certs": - return KeyStrings{"certs", "Manage certificates", - `Manages the certificates associated with a deployed application. -Certificates are created by associating a hostname/domain with the application. -When Fly is then able to validate that hostname/domain, the platform gets -certificates issued for the hostname/domain by Let's Encrypt.`, - } - case "certs.add": - return KeyStrings{"add ", "Add a certificate for an app.", - `Add a certificate for an application. Takes a hostname -as a parameter for the certificate.`, - } - case "certs.check": - return KeyStrings{"check ", "Checks DNS configuration", - `Checks the DNS configuration for the specified hostname. -Displays results in the same format as the SHOW command.`, - } - case "certs.list": - return KeyStrings{"list", "List certificates for an app.", - `List the certificates associated with a deployed application.`, - } - case "certs.remove": - return KeyStrings{"remove ", "Removes a certificate from an app", - `Removes a certificate from an application. Takes hostname -as a parameter to locate the certificate.`, - } - case "certs.show": - return KeyStrings{"show ", "Shows certificate information", - `Shows certificate information for an application. -Takes hostname as a parameter to locate the certificate.`, - } - case "checks": - return KeyStrings{"checks", "Manage health checks", - `Manage health checks`, - } - case "checks.handlers": - return KeyStrings{"handlers", "Manage health check handlers", - `Manage health check handlers`, - } - case "checks.handlers.create": - return KeyStrings{"create", "Create a health check handler", - `Create a health check handler`, - } - case "checks.handlers.delete": - return KeyStrings{"delete ", "Delete a health check handler", - `Delete a health check handler`, - } - case "checks.handlers.list": - return KeyStrings{"list", "List health check handlers", - `List health check handlers`, - } - case "checks.list": - return KeyStrings{"list", "List app health checks", - `List app health checks`, - } - case "config": - return KeyStrings{"config", "Manage an app's configuration", - `The CONFIG commands allow you to work with an application's configuration.`, - } - case "config.env": - return KeyStrings{"env", "Display an app's runtime environment variables", - `Display an app's runtime environment variables. It displays a section for -secrets and another for config file defined environment variables.`, - } - case "config.save": - return KeyStrings{"save", "Save an app's config file", - `Save an application's configuration locally. The configuration data is -retrieved from the Fly service and saved in TOML format.`, - } - case "config.show": - return KeyStrings{"show", "Show an app's configuration", - `Show an application's configuration. The configuration is presented -in JSON format. The configuration data is retrieved from the Fly service.`, - } - case "config.validate": - return KeyStrings{"validate", "Validate an app's config file", - `Validates an application's config file against the Fly platform to -ensure it is correct and meaningful to the platform.`, - } - case "curl": - return KeyStrings{"curl ", "Run a performance test against a url", - `Run a performance test against a url.`, - } - case "dashboard": - return KeyStrings{"dashboard", "Open web browser on Fly Web UI for this app", - `Open web browser on Fly Web UI for this application`, - } - case "dashboard.metrics": - return KeyStrings{"metrics", "Open web browser on Fly Web UI for this app's metrics", - `Open web browser on Fly Web UI for this application's metrics`, - } - case "deploy": - return KeyStrings{"deploy []", "Deploy an app to the Fly platform", - `Deploy an application to the Fly platform. The application can be a local -image, remote image, defined in a Dockerfile or use a CNB buildpack. - -Use the --config/-c flag to select a specific toml configuration file. - -Use the --image/-i flag to specify a local or remote image to deploy. - -Use the --detach flag to return immediately from starting the deployment rather -than monitoring the deployment progress. - -Use flyctl monitor to restart monitoring deployment progress`, - } - case "destroy": - return KeyStrings{"destroy [APPNAME]", "Permanently destroys an app", - `The DESTROY command will remove an application -from the Fly platform.`, - } - case "dig": - return KeyStrings{"dig [type] ", "DNS lookups", - `Make DNS requests against Fly.io's internal DNS server. Valid types include -AAAA and TXT (the two types our servers answer authoritatively), AAAA-NATIVE -and TXT-NATIVE, which resolve with Go's resolver (they're slower, -but may be useful if diagnosing a DNS bug) and A and CNAME -(if you're using the server to test recursive lookups.) -Note that this resolves names against the server for the current organization. You can -set the organization with -o ; otherwise, the command uses the organization -attached to the current app (you can pass an app in with -a ).`, - } - case "dns-records": - return KeyStrings{"dns-records", "Manage DNS records", - `Manage DNS records within a domain`, - } - case "dns-records.export": - return KeyStrings{"export []", "Export DNS records", - `Export DNS records. Will write to a file if a filename is given, otherwise -writers to StdOut.`, - } - case "dns-records.import": - return KeyStrings{"import []", "Import DNS records", - `Import DNS records. Will import from a file is a filename is given, otherwise -imports from StdIn.`, - } - case "dns-records.list": - return KeyStrings{"list ", "List DNS records", - `List DNS records within a domain`, - } - case "docs": - return KeyStrings{"docs", "View Fly documentation", - `View Fly documentation on the Fly.io website. This command will open a -browser to view the content.`, - } - case "domains": - return KeyStrings{"domains", "Manage domains (deprecated)", - `Manage domains -Notice: this feature is deprecated and no longer supported. -You can still view existing domains, but registration is no longer possible.`, - } - case "domains.add": - return KeyStrings{"add [org] [name]", "Add a domain", - `Add a domain to an organization`, - } - case "domains.list": - return KeyStrings{"list []", "List domains", - `List domains for an organization`, - } - case "domains.register": - return KeyStrings{"register [org] [name]", "Register a domain", - `Register a new domain in an organization`, - } - case "domains.show": - return KeyStrings{"show ", "Show domain", - `Show information about a domain`, - } - case "flyctl": - return KeyStrings{"flyctl", "The Fly CLI", - `flyctl is a command line interface to the Fly.io platform. - -It allows users to manage authentication, application launch, -deployment, network configuration, logging and more with just the -one command. - -* Launch an app with the launch command -* Deploy an app with the deploy command -* View a deployed web application with the open command -* Check the status of an application with the status command - -To read more, use the docs command to view Fly's help on the web.`, - } - case "history": - return KeyStrings{"history", "List an app's change history", - `List the history of changes in the application. Includes autoscaling -events and their results.`, - } - case "image": - return KeyStrings{"image", "Manage app image", - `Manage app image`, - } - case "image.show": - return KeyStrings{"show", "Show image details.", - `Show image details.`, - } - case "image.update": - return KeyStrings{"update", "Updates the app's image to the latest available version. (Fly Postgres only)", - `This will update the application's image to the latest available version. -The update will perform a rolling restart against each VM, which may result in a brief service disruption.`, - } - case "ips": - return KeyStrings{"ips", "Manage IP addresses for apps", - `The IPS commands manage IP addresses for applications. An application -can have a number of IP addresses associated with it and this family of commands -allows you to list, allocate and release those addresses. It supports both IPv4 -and IPv6 addresses.`, - } - case "ips.allocate-v4": - return KeyStrings{"allocate-v4", "Allocate an IPv4 address", - `Allocates an IPv4 address to the application.`, - } - case "ips.allocate-v6": - return KeyStrings{"allocate-v6", "Allocate an IPv6 address", - `Allocates an IPv6 address to the application.`, - } - case "ips.list": - return KeyStrings{"list", "List allocated IP addresses", - `Lists the IP addresses allocated to the application.`, - } - case "ips.private": - return KeyStrings{"private", "List instances private IP addresses", - `List instances private IP addresses, accessible from within the -Fly network`, - } - case "ips.release": - return KeyStrings{"release [ADDRESS]", "Release an IP address", - `Releases an IP address from the application.`, - } - case "launch": - return KeyStrings{"launch", "Launch a new app", - `Create and configure a new app from source code or an image reference.`, - } - case "list": - return KeyStrings{"list", "Lists your Fly resources", - `The list command is for listing your resources on has two subcommands, apps and orgs. - -The apps command lists your applications. There are filtering options available. - -The orgs command lists all the organizations you are a member of.`, - } - case "list.apps": - return KeyStrings{"apps [text] [-o org] [-s status]", "Lists all your apps", - `The list apps command lists all your applications. As this may be a -long list, there are options to filter the results. - -Specifying a text string as a parameter will only return applications where the -application name contains the text. - -The --orgs/-o flag allows you to specify the name of an organization that the -application must be owned by. (see list orgs for organization names). - -The --status/-s flag allows you to specify status applications should be at to be -returned in the results. e.g. -s running would only return running applications.`, - } - case "list.orgs": - return KeyStrings{"orgs", "List all your organizations", - `Lists all organizations which your are a member of. It will show the -short name of the organization and the long name.`, - } - case "logs": - return KeyStrings{"logs", "View app logs", - `View application logs as generated by the application running on -the Fly platform. - -Logs can be filtered to a specific instance using the --instance/-i flag or -to all instances running in a specific region using the --region/-r flag.`, - } - case "machine": - return KeyStrings{"machine ", "Commands that manage machines", - `Commands that manage machines`, - } - case "machine.clone": - return KeyStrings{"clone", "Clones a Fly Machine", - `Clones a Fly Machine`, - } - case "machine.kill": - return KeyStrings{"kill ", "Kill (SIGKILL) a Fly machine", - `Kill (SIGKILL) a Fly machine`, - } - case "machine.list": - return KeyStrings{"list", "List Fly machines", - `List Fly machines`, - } - case "machine.remove": - return KeyStrings{"remove ", "Remove a Fly machine", - `Remove a Fly machine`, - } - case "machine.run": - return KeyStrings{"run [command]", "Launch a Fly machine", - `Launch Fly machine with the provided image and command`, - } - case "machine.start": - return KeyStrings{"start ", "Start a Fly machine", - `Start a Fly machine`, - } - case "machine.status": - return KeyStrings{"status ", "Show current status of a running machine", - `Show current status of a running machine`, - } - case "machine.stop": - return KeyStrings{"stop ", "Stop a Fly machine", - `Stop a Fly machine`, - } - case "monitor": - return KeyStrings{"monitor", "Monitor deployments", - `Monitor application deployments and other activities. Use --verbose/-v -to get details of every instance . Control-C to stop output.`, - } - case "move": - return KeyStrings{"move [APPNAME]", "Move an app to another organization", - `The MOVE command will move an application to another -organization the current user belongs to.`, - } - case "open": - return KeyStrings{"open [PATH]", "Open browser to current deployed application", - `Open browser to current deployed application. If an optional path is specified, this is appended to the -URL for deployed application.`, - } - case "orgs": - return KeyStrings{"orgs", "Commands for managing Fly organizations", - `Commands for managing Fly organizations. list, create, show and -destroy organizations. -Organization admins can also invite or remove users from Organizations.`, - } - case "orgs.create": - return KeyStrings{"create ", "Create an organization", - `Create a new organization. Other users can be invited to join the -organization later.`, - } - case "orgs.delete": - return KeyStrings{"delete ", "Delete an organization", - `Delete an existing organization.`, - } - case "orgs.invite": - return KeyStrings{"invite ", "Invite user (by email) to organization", - `Invite a user, by email, to join organization. The invitation will be -sent, and the user will be pending until they respond. See also orgs revoke.`, - } - case "orgs.list": - return KeyStrings{"list", "Lists organizations for current user", - `Lists organizations available to current user.`, - } - case "orgs.remove": - return KeyStrings{"remove ", "Remove a user from an organization", - `Remove a user from an organization. User must have accepted a previous -invitation to join (if not, see orgs revoke).`, - } - case "orgs.revoke": - return KeyStrings{"revoke ", "Revoke a pending invitation to an organization", - `Revokes an invitation to join an organization that has been sent to a -user by email.`, - } - case "orgs.show": - return KeyStrings{"show ", "Show information about an organization", - `Shows information about an organization. -Includes name, slug and type. Summarizes user permissions, DNS zones and -associated member. Details full list of members and roles.`, - } - case "platform": - return KeyStrings{"platform", "Fly platform information", - `The PLATFORM commands are for users looking for information -about the Fly platform.`, - } - case "platform.regions": - return KeyStrings{"regions", "List regions", - `View a list of regions where Fly has edges and/or datacenters`, - } - case "platform.status": - return KeyStrings{"status", "Show current platform status", - `Show current Fly platform status in a browser`, - } - case "platform.vmsizes": - return KeyStrings{"vm-sizes", "List VM Sizes", - `View a list of VM sizes which can be used with the FLYCTL SCALE VM command`, - } - case "postgres": - return KeyStrings{"postgres", "Manage postgres clusters", - `Manage postgres clusters`, - } - case "postgres.attach": - return KeyStrings{"attach", "Attach a postgres cluster to an app", - `Attach a postgres cluster to an app`, - } - case "postgres.connect": - return KeyStrings{"connect", "Connect to the Postgres console", - `Connect to the Postgres console`, - } - case "postgres.create": - return KeyStrings{"create", "Create a postgres cluster", - `Create a postgres cluster`, - } - case "postgres.db": - return KeyStrings{"db", "manage databases in a cluster", - `manage databases in a cluster`, - } - case "postgres.db.create": - return KeyStrings{"create ", "create a database in a cluster", - `create a database in a cluster`, - } - case "postgres.db.list": - return KeyStrings{"list ", "list databases in a cluster", - `list databases in a cluster`, - } - case "postgres.detach": - return KeyStrings{"detach", "Detach a postgres cluster from an app", - `Detach a postgres cluster from an app`, - } - case "postgres.list": - return KeyStrings{"list", "list postgres clusters", - `list postgres clusters`, - } - case "postgres.users": - return KeyStrings{"users", "manage users in a cluster", - `manage users in a cluster`, - } - case "postgres.users.create": - return KeyStrings{"create ", "create a user in a cluster", - `create a user in a cluster`, - } - case "postgres.users.list": - return KeyStrings{"list ", "list users in a cluster", - `list users in a cluster`, - } - case "proxy": - return KeyStrings{"proxy ", "Proxies connections to a fly app", - `Proxies connections to a fly app through the wireguard tunnel`, - } - case "regions": - return KeyStrings{"regions", "Manage regions", - `Configure the region placement rules for an application.`, - } - case "regions.add": - return KeyStrings{"add REGION ...", "Allow the app to run in the provided regions", - `Allow the app to run in one or more regions`, - } - case "regions.backup": - return KeyStrings{"backup REGION ...", "Sets the backup region pool with provided regions", - `Sets the backup region pool with provided regions`, - } - case "regions.list": - return KeyStrings{"list", "Shows the list of regions the app is allowed to run in", - `Shows the list of regions the app is allowed to run in.`, - } - case "regions.remove": - return KeyStrings{"remove REGION ...", "Prevent the app from running in the provided regions", - `Prevent the app from running in the provided regions`, - } - case "regions.set": - return KeyStrings{"set REGION ...", "Sets the region pool with provided regions", - `Sets the region pool with provided regions`, - } - case "releases": - return KeyStrings{"releases", "List app releases", - `List all the releases of the application onto the Fly platform, -including type, when, success/fail and which user triggered the release.`, - } - case "restart": - return KeyStrings{"restart [APPNAME]", "Restart an application", - `The RESTART command will restart all running vms.`, - } - case "resume": - return KeyStrings{"resume [APPNAME]", "Resume an application", - `The RESUME command will restart a previously suspended application. -The application will resume with its original region pool and a min count of one -meaning there will be one running instance once restarted. Use SCALE SET MIN= to raise -the number of configured instances.`, - } - case "scale": - return KeyStrings{"scale", "Scale app resources", - `Scale application resources`, - } - case "scale.count": - return KeyStrings{"count ", "Change an app's VM count to the given value", - `Change an app's VM count to the given value. - -For pricing, see https://fly.io/docs/about/pricing/`, - } - case "scale.memory": - return KeyStrings{"memory ", "Set VM memory", - `Set VM memory to a number of megabytes`, - } - case "scale.show": - return KeyStrings{"show", "Show current resources", - `Show current VM size and counts`, - } - case "scale.vm": - return KeyStrings{"vm [SIZENAME] [flags]", "Change an app's VM to a named size (eg. shared-cpu-1x, dedicated-cpu-1x, dedicated-cpu-2x...)", - `Change an application's VM size to one of the named VM sizes. - -Size names include shared-cpu-1x, dedicated-cpu-1x, dedicated-cpu-2x. - -For a full list of supported sizes use the command flyctl platform vm-sizes - -Memory size can be set with --memory=number-of-MB - -e.g. flyctl scale vm shared-cpu-1x --memory=2048 - -For dedicated vms, this should be a multiple of 1024MB. - -For shared vms, this can be 256MB or a a multiple of 1024MB. - -For pricing, see https://fly.io/docs/about/pricing/`, - } - case "secrets": - return KeyStrings{"secrets", "Manage app secrets", - `Manage application secrets with the set and unset commands. - -Secrets are provided to applications at runtime as ENV variables. Names are -case sensitive and stored as-is, so ensure names are appropriate for -the application and vm environment.`, - } - case "secrets.import": - return KeyStrings{"import [flags]", "Read secrets in name=value from stdin", - `Set one or more encrypted secrets for an application. Values -are read from stdin as name=value`, - } - case "secrets.list": - return KeyStrings{"list", "Lists the secrets available to the app", - `List the secrets available to the application. It shows each secret's -name, a digest of its value and the time the secret was last set. The -actual value of the secret is only available to the application.`, - } - case "secrets.set": - return KeyStrings{"set [flags] NAME=VALUE NAME=VALUE ...", "Set one or more encrypted secrets for an app", - `Set one or more encrypted secrets for an application. - -Secrets are provided to application at runtime as ENV variables. Names are -case sensitive and stored as-is, so ensure names are appropriate for -the application and vm environment. - -Any value that equals "-" will be assigned from STDIN instead of args.`, - } - case "secrets.unset": - return KeyStrings{"unset [flags] NAME NAME ...", "Remove encrypted secrets from an app", - `Remove encrypted secrets from the application. Unsetting a -secret removes its availability to the application.`, - } - case "ssh": - return KeyStrings{"ssh ", "Commands that manage SSH credentials", - `Commands that manage SSH credentials`, - } - case "ssh.console": - return KeyStrings{"console []", "Connect to a running instance of the current app.", - `Connect to a running instance of the current app; with -select, choose instance from list.`, - } - case "ssh.establish": - return KeyStrings{"establish [] []", "Create a root SSH certificate for your organization", - `Create a root SSH certificate for your organization. If -is provided, will re-key an organization; all previously issued creds will be -invalidated.`, - } - case "ssh.issue": - return KeyStrings{"issue [org] [email] [path]", "Issue a new SSH credential.", - `Issue a new SSH credential. With -agent, populate credential -into SSH agent. With -hour, set the number of hours (1-72) for credential -validity.`, - } - case "ssh.log": - return KeyStrings{"log", "Log of all issued certs", - `log of all issued certs`, - } - case "ssh.shell": - return KeyStrings{"shell [org] [address]", "Connect directly to an instance.", - `Connect directly to an instance. With -region, set the -WireGuard region to use for the connection.`, - } - case "status": - return KeyStrings{"status", "Show app status", - `Show the application's current status including application -details, tasks, most recent deployment details and in which regions it is -currently allocated.`, - } - case "status.instance": - return KeyStrings{"instance [instance-id]", "Show instance status", - `Show the instance's current status including logs, checks, -and events.`, - } - case "turboku": - return KeyStrings{"turboku ", "Launches heroku apps", - `Launches heroku apps`, - } - case "version": - return KeyStrings{"version", "Show version information for the flyctl command", - `Shows version information for the flyctl command itself, -including version number and build date.`, - } - case "version.update": - return KeyStrings{"update", "Checks for available updates and automatically updates", - `Checks for update and if one is available, runs the appropriate -command to update the application.`, - } - case "vm": - return KeyStrings{"vm ", "Commands that manage VM instances", - `Commands that manage VM instances`, - } - case "vm.restart": - return KeyStrings{"restart ", "Restart a VM", - `Request for a VM to be asynchronously restarted.`, - } - case "vm.status": - return KeyStrings{"status ", "Show a VM's status", - `Show a VM's current status including logs, checks, and events.`, - } - case "vm.stop": - return KeyStrings{"stop ", "Stop a VM", - `Request for a VM to be asynchronously stopped.`, - } - case "volumes": - return KeyStrings{"volumes ", "Volume management commands", - `Commands for managing Fly Volumes associated with an application.`, - } - case "volumes.create": - return KeyStrings{"create ", "Create new volume for app", - `Create new volume for app. --region flag must be included to specify -region the volume exists in. --size flag is optional, defaults to 3, -sets the size as the number of gigabytes the volume will consume.`, - } - case "volumes.delete": - return KeyStrings{"delete ", "Delete a volume from the app", - `Delete a volume from the application. Requires the volume's ID -number to operate. This can be found through the volumes list command`, - } - case "volumes.list": - return KeyStrings{"list", "List the volumes for app", - `List all the volumes associated with this application.`, - } - case "volumes.show": - return KeyStrings{"show ", "Show details of an app's volume", - `Show details of an app's volume. Requires the volume's ID -number to operate. This can be found through the volumes list command`, - } - case "volumes.snapshots": - return KeyStrings{"snapshots", "Manage volume snapshots", - `Commands for managing volume snapshots`, - } - case "volumes.snapshots.list": - return KeyStrings{"list ", "list snapshots associated with the specified volume", - `list snapshots associated with the specified volume`, - } - case "wireguard": - return KeyStrings{"wireguard ", "Commands that manage WireGuard peer connections", - `Commands that manage WireGuard peer connections`, - } - case "wireguard.create": - return KeyStrings{"create [org] [region] [name]", "Add a WireGuard peer connection", - `Add a WireGuard peer connection to an organization`, - } - case "wireguard.list": - return KeyStrings{"list []", "List all WireGuard peer connections", - `List all WireGuard peer connections`, - } - case "wireguard.remove": - return KeyStrings{"remove [org] [name]", "Remove a WireGuard peer connection", - `Remove a WireGuard peer connection from an organization`, - } - case "wireguard.reset": - return KeyStrings{"reset [org]", "Reset WireGuard peer connection for an organization", - `Reset WireGuard peer connection for an organization`, - } - case "wireguard.status": - return KeyStrings{"status [org] [name]", "Get status a WireGuard peer connection", - `Get status for a WireGuard peer connection`, - } - case "wireguard.token": - return KeyStrings{"token ", "Commands that managed WireGuard delegated access tokens", - `Commands that managed WireGuard delegated access tokens`, - } - case "wireguard.token.create": - return KeyStrings{"create [org] [name]", "Create a new WireGuard token", - `Create a new WireGuard token`, - } - case "wireguard.token.delete": - return KeyStrings{"delete [org] [token]", "Delete a WireGuard token; token is name: or token:", - `Delete a WireGuard token; token is name: or token:`, - } - case "wireguard.token.list": - return KeyStrings{"list []", "List all WireGuard tokens", - `List all WireGuard tokens`, - } - case "wireguard.token.start": - return KeyStrings{"start [name] [group] [region] [file]", "Start a new WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)", - `Start a new WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)`, - } - case "wireguard.token.update": - return KeyStrings{"update [name] [file]", "Rekey a WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)", - `Rekey a WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)`, - } - case "wireguard.websockets": - return KeyStrings{"websockets [enable/disable]", "Enable or disable WireGuard tunneling over WebSockets", - `Enable or disable WireGuard tunneling over WebSockets`, - } - } - panic("unknown command key " + key) -} diff --git a/flaps/flaps.go b/flaps/flaps.go index 97d927ba04..9e47062948 100644 --- a/flaps/flaps.go +++ b/flaps/flaps.go @@ -23,7 +23,9 @@ import ( "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/flyctl" "github.com/superfly/flyctl/internal/buildinfo" + "github.com/superfly/flyctl/internal/instrument" "github.com/superfly/flyctl/internal/logger" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/terminal" ) @@ -40,28 +42,38 @@ type Client struct { } func New(ctx context.Context, app *api.AppCompact) (*Client, error) { - return newFromAppOrAppName(ctx, app, app.Name) + return NewWithOptions(ctx, NewClientOpts{AppCompact: app, AppName: app.Name}) } func NewFromAppName(ctx context.Context, appName string) (*Client, error) { - return newFromAppOrAppName(ctx, nil, appName) + return NewWithOptions(ctx, NewClientOpts{AppName: appName}) } -func newFromAppOrAppName(ctx context.Context, app *api.AppCompact, appName string) (*Client, error) { - if app != nil { - appName = app.Name - } +type NewClientOpts struct { + // required: + AppName string + + // optional, avoids API roundtrip when connecting to flaps by wireguard: + AppCompact *api.AppCompact + // optional: + Logger api.Logger +} + +func NewWithOptions(ctx context.Context, opts NewClientOpts) (*Client, error) { // FIXME: do this once we setup config for `fly config ...` commands, and then use cfg.FlapsBaseURL below // cfg := config.FromContext(ctx) var err error flapsBaseURL := os.Getenv("FLY_FLAPS_BASE_URL") if strings.TrimSpace(strings.ToLower(flapsBaseURL)) == "peer" { - app, err = resolveApp(ctx, app, appName) + orgSlug, err := resolveOrgSlugForApp(ctx, opts.AppCompact, opts.AppName) if err != nil { - return nil, fmt.Errorf("failed to get app '%s': %w", appName, err) + return nil, fmt.Errorf("failed to resolve org for app '%s': %w", opts.AppName, err) } - return newWithUsermodeWireguard(ctx, app) + return newWithUsermodeWireguard(ctx, wireguardConnectionParams{ + appName: opts.AppName, + orgSlug: orgSlug, + }) } else if flapsBaseURL == "" { flapsBaseURL = "https://api.machines.dev" } @@ -69,13 +81,16 @@ func newFromAppOrAppName(ctx context.Context, app *api.AppCompact, appName strin if err != nil { return nil, fmt.Errorf("invalid FLY_FLAPS_BASE_URL '%s' with error: %w", flapsBaseURL, err) } - logger := logger.MaybeFromContext(ctx) + var logger api.Logger = logger.MaybeFromContext(ctx) + if opts.Logger != nil { + logger = opts.Logger + } httpClient, err := api.NewHTTPClient(logger, http.DefaultTransport) if err != nil { return nil, fmt.Errorf("flaps: can't setup HTTP client to %s: %w", flapsUrl.String(), err) } return &Client{ - appName: appName, + appName: opts.AppName, baseUrl: flapsUrl, authToken: flyctl.GetAPIToken(), httpClient: httpClient, @@ -83,6 +98,14 @@ func newFromAppOrAppName(ctx context.Context, app *api.AppCompact, appName strin }, nil } +func resolveOrgSlugForApp(ctx context.Context, app *api.AppCompact, appName string) (string, error) { + app, err := resolveApp(ctx, app, appName) + if err != nil { + return "", err + } + return app.Organization.Slug, nil +} + func resolveApp(ctx context.Context, app *api.AppCompact, appName string) (*api.AppCompact, error) { var err error if app == nil { @@ -92,7 +115,12 @@ func resolveApp(ctx context.Context, app *api.AppCompact, appName string) (*api. return app, err } -func newWithUsermodeWireguard(ctx context.Context, app *api.AppCompact) (*Client, error) { +type wireguardConnectionParams struct { + appName string + orgSlug string +} + +func newWithUsermodeWireguard(ctx context.Context, params wireguardConnectionParams) (*Client, error) { logger := logger.MaybeFromContext(ctx) client := client.FromContext(ctx).API() @@ -101,9 +129,9 @@ func newWithUsermodeWireguard(ctx context.Context, app *api.AppCompact) (*Client return nil, fmt.Errorf("error establishing agent: %w", err) } - dialer, err := agentclient.Dialer(ctx, app.Organization.Slug) + dialer, err := agentclient.Dialer(ctx, params.orgSlug) if err != nil { - return nil, fmt.Errorf("flaps: can't build tunnel for %s: %w", app.Organization.Slug, err) + return nil, fmt.Errorf("flaps: can't build tunnel for %s: %w", params.orgSlug, err) } transport := &http.Transport{ @@ -114,7 +142,7 @@ func newWithUsermodeWireguard(ctx context.Context, app *api.AppCompact) (*Client httpClient, err := api.NewHTTPClient(logger, transport) if err != nil { - return nil, fmt.Errorf("flaps: can't setup HTTP client for %s: %w", app.Organization.Slug, err) + return nil, fmt.Errorf("flaps: can't setup HTTP client for %s: %w", params.orgSlug, err) } flapsBaseUrlString := fmt.Sprintf("http://[%s]:4280", resolvePeerIP(dialer.State().Peer.Peerip)) @@ -124,7 +152,7 @@ func newWithUsermodeWireguard(ctx context.Context, app *api.AppCompact) (*Client } return &Client{ - appName: app.Name, + appName: params.appName, baseUrl: flapsBaseUrl, authToken: flyctl.GetAPIToken(), httpClient: httpClient, @@ -142,42 +170,55 @@ func (f *Client) CreateApp(ctx context.Context, name string, org string) (err er return } -func (f *Client) Launch(ctx context.Context, builder api.LaunchMachineInput) (*api.Machine, error) { - var endpoint string - if builder.ID != "" { - endpoint = fmt.Sprintf("/%s", builder.ID) - } - - out := new(api.Machine) +func (f *Client) Launch(ctx context.Context, builder api.LaunchMachineInput) (out *api.Machine, err error) { + metrics.Started(ctx, "machine_launch") + sendUpdateMetrics := metrics.StartTiming(ctx, "machine_launch/duration") + defer func() { + metrics.Status(ctx, "machine_launch", err == nil) + if err == nil { + sendUpdateMetrics() + } + }() - if err := f.sendRequest(ctx, http.MethodPost, endpoint, builder, out, nil); err != nil { + out = new(api.Machine) + if err := f.sendRequest(ctx, http.MethodPost, "", builder, out, nil); err != nil { return nil, fmt.Errorf("failed to launch VM: %w", err) } return out, nil } -func (f *Client) Update(ctx context.Context, builder api.LaunchMachineInput, nonce string) (*api.Machine, error) { +func (f *Client) Update(ctx context.Context, builder api.LaunchMachineInput, nonce string) (out *api.Machine, err error) { headers := make(map[string][]string) - if nonce != "" { headers[NonceHeader] = []string{nonce} } - endpoint := fmt.Sprintf("/%s", builder.ID) - - out := new(api.Machine) + metrics.Started(ctx, "machine_update") + sendUpdateMetrics := metrics.StartTiming(ctx, "machine_update/duration") + defer func() { + metrics.Status(ctx, "machine_update", err == nil) + if err == nil { + sendUpdateMetrics() + } + }() + endpoint := fmt.Sprintf("/%s", builder.ID) + out = new(api.Machine) if err := f.sendRequest(ctx, http.MethodPost, endpoint, builder, out, headers); err != nil { return nil, fmt.Errorf("failed to update VM %s: %w", builder.ID, err) } return out, nil } -func (f *Client) Start(ctx context.Context, machineID string) (*api.MachineStartResponse, error) { +func (f *Client) Start(ctx context.Context, machineID string) (out *api.MachineStartResponse, err error) { startEndpoint := fmt.Sprintf("/%s/start", machineID) + out = new(api.MachineStartResponse) - out := new(api.MachineStartResponse) + metrics.Started(ctx, "machine_start") + defer func() { + metrics.Status(ctx, "machine_start", err == nil) + }() if err := f.sendRequest(ctx, http.MethodPost, startEndpoint, nil, out, nil); err != nil { return nil, fmt.Errorf("failed to start VM %s: %w", machineID, err) @@ -251,7 +292,7 @@ func (f *Client) Restart(ctx context.Context, in api.RestartMachineInput, nonce restartEndpoint += fmt.Sprintf("&timeout=%d", in.Timeout) } - if in.Signal != nil { + if in.Signal != "" { restartEndpoint += fmt.Sprintf("&signal=%s", in.Signal) } @@ -317,13 +358,14 @@ func (f *Client) ListActive(ctx context.Context) ([]*api.Machine, error) { } machines = lo.Filter(machines, func(m *api.Machine, _ int) bool { - return !m.IsReleaseCommandMachine() && m.IsActive() + return !m.IsReleaseCommandMachine() && !m.IsFlyAppsConsole() && m.IsActive() }) return machines, nil } -// returns apps that are part of the fly apps platform that are not destroyed +// returns apps that are part of the fly apps platform that are not destroyed, +// excluding console machines func (f *Client) ListFlyAppsMachines(ctx context.Context) ([]*api.Machine, *api.Machine, error) { allMachines := make([]*api.Machine, 0) err := f.sendRequest(ctx, http.MethodGet, "", nil, &allMachines, nil) @@ -333,7 +375,7 @@ func (f *Client) ListFlyAppsMachines(ctx context.Context) ([]*api.Machine, *api. var releaseCmdMachine *api.Machine machines := make([]*api.Machine, 0) for _, m := range allMachines { - if m.IsFlyAppsPlatform() && m.IsActive() && !m.IsFlyAppsReleaseCommand() { + if m.IsFlyAppsPlatform() && m.IsActive() && !m.IsFlyAppsReleaseCommand() && !m.IsFlyAppsConsole() { machines = append(machines, m) } else if m.IsFlyAppsReleaseCommand() { releaseCmdMachine = m @@ -438,7 +480,23 @@ func (f *Client) Exec(ctx context.Context, machineID string, in *api.MachineExec return out, nil } +func (f *Client) GetProcesses(ctx context.Context, machineID string) (api.MachinePsResponse, error) { + endpoint := fmt.Sprintf("/%s/ps", machineID) + + var out api.MachinePsResponse + + err := f.sendRequest(ctx, http.MethodGet, endpoint, nil, &out, nil) + if err != nil { + return nil, fmt.Errorf("failed to get processes from VM %s: %w", machineID, err) + } + + return out, nil +} + func (f *Client) sendRequest(ctx context.Context, method, endpoint string, in, out interface{}, headers map[string][]string) error { + timing := instrument.Flaps.Begin() + defer timing.End() + req, err := f.NewRequest(ctx, method, endpoint, in, headers) if err != nil { return err diff --git a/flyctl/app_config.go b/flyctl/app_config.go index e8b7c5ac4a..e644046eaa 100644 --- a/flyctl/app_config.go +++ b/flyctl/app_config.go @@ -182,7 +182,7 @@ func (ac *AppConfig) unmarshalNativeMap(data map[string]interface{}) error { case "dockerfile": b.Dockerfile = fmt.Sprint(v) insection = true - case "build_target": + case "build_target", "build-target": b.DockerBuildTarget = fmt.Sprint(v) insection = true default: @@ -191,7 +191,7 @@ func (ac *AppConfig) unmarshalNativeMap(data map[string]interface{}) error { } } } - if b.Builder != "" || b.Builtin != "" || b.Image != "" || b.Dockerfile != "" || len(b.Args) > 0 { + if b.Builder != "" || b.Builtin != "" || b.Image != "" || b.Dockerfile != "" || b.DockerBuildTarget != "" || len(b.Args) > 0 { ac.Build = &b } } diff --git a/flyctl/app_config_test.go b/flyctl/app_config_test.go index 657980358c..79696a86f5 100644 --- a/flyctl/app_config_test.go +++ b/flyctl/app_config_test.go @@ -22,6 +22,13 @@ func TestLoadTOMLAppConfigWithBuilderName(t *testing.T) { assert.Equal(t, p.Build.Builder, "builder/name") } +func TestLoadTOMLAppConfigWithBuildTarget(t *testing.T) { + path := "./testdata/build-target.toml" + p, err := LoadAppConfig(path) + assert.NoError(t, err) + assert.Equal(t, p.Build.DockerBuildTarget, "target") +} + func TestLoadTOMLAppConfigWithImage(t *testing.T) { path := "./testdata/image.toml" p, err := LoadAppConfig(path) diff --git a/flyctl/flyctl.go b/flyctl/flyctl.go index 8aff2b73dc..cc6d0557a0 100644 --- a/flyctl/flyctl.go +++ b/flyctl/flyctl.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/viper" "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/instrument" "github.com/superfly/flyctl/terminal" "gopkg.in/yaml.v2" ) @@ -83,6 +84,7 @@ func initViper() { api.SetBaseURL(viper.GetString(ConfigAPIBaseURL)) api.SetErrorLog(viper.GetBool(ConfigGQLErrorLogging)) + api.SetInstrumenter(instrument.ApiAdapter) } func loadConfig() error { diff --git a/flyctl/testdata/build-target.toml b/flyctl/testdata/build-target.toml new file mode 100644 index 0000000000..9d24bbf790 --- /dev/null +++ b/flyctl/testdata/build-target.toml @@ -0,0 +1,4 @@ +app = "build-target" + +[build] + build_target = "target" diff --git a/flypg/cmd.go b/flypg/cmd.go index 96952dac6f..43be858403 100644 --- a/flypg/cmd.go +++ b/flypg/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/internal/command/ssh" + "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/iostreams" ) @@ -99,6 +100,25 @@ func (pc *Command) UnregisterMember(ctx context.Context, leaderIP string, standb return nil } +func (pc *Command) ListEvents(ctx context.Context, leaderIP string, flagsName []string) error { + cmd := "gosu postgres repmgr -f /data/repmgr.conf cluster event " + + // Loops through flagsName to add selected options to the command. The format will look like this --> + // gosu postgres repmgr -f /data/repmgr.conf cluster event --compact --event primary_register --limit 5 --node-id 34244738 + for _, flagName := range flagsName { + cmd += fmt.Sprintf("--%s %s ", flagName, flag.GetString(ctx, flagName)) + } + + resp, err := ssh.RunSSHCommand(ctx, pc.app, pc.dialer, leaderIP, cmd, ssh.DefaultSshUsername) + if err != nil { + return err + } + + fmt.Println(string(resp)) + + return nil +} + // encodeCommand will base64 encode a command string so it can be passed // in with exec.Command. func encodeCommand(command string) string { diff --git a/flypg/launcher.go b/flypg/launcher.go index cb12f24f60..2b285b1c80 100644 --- a/flypg/launcher.go +++ b/flypg/launcher.go @@ -136,7 +136,8 @@ func (l *Launcher) LaunchMachinesPostgres(ctx context.Context, config *CreateClu Handlers: []string{ "pg_tls", }, - ForceHttps: false, + + ForceHTTPS: false, }, }, Concurrency: concurrency, @@ -150,7 +151,7 @@ func (l *Launcher) LaunchMachinesPostgres(ctx context.Context, config *CreateClu Handlers: []string{ "pg_tls", }, - ForceHttps: false, + ForceHTTPS: false, }, }, Concurrency: concurrency, @@ -211,10 +212,8 @@ func (l *Launcher) LaunchMachinesPostgres(ctx context.Context, config *CreateClu machineConf.DisableMachineAutostart = api.Pointer(!config.Autostart) launchInput := api.LaunchMachineInput{ - AppID: app.ID, - OrgSlug: config.Organization.ID, - Region: config.Region, - Config: machineConf, + Region: config.Region, + Config: machineConf, } machine, err := flapsClient.Launch(ctx, launchInput) @@ -287,7 +286,7 @@ func (l *Launcher) getPostgresConfig(config *CreateClusterInput) *api.MachineCon } if config.ScaleToZero { - //TODO make this configurable + // TODO make this configurable machineConfig.Env["FLY_SCALE_TO_ZERO"] = "1h" } diff --git a/go.mod b/go.mod index bb8602c83f..73db02e2b3 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,6 @@ require ( github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.5 github.com/samber/lo v1.38.1 - github.com/segmentio/textio v1.2.0 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.2.1 github.com/spf13/pflag v1.0.5 @@ -100,9 +99,9 @@ require ( github.com/containerd/typeurl v1.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/cli v20.10.7+incompatible // indirect - github.com/docker/distribution v2.8.0+incompatible // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.6.3 // indirect - github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-connections v0.4.0 github.com/docker/go-units v0.4.0 // indirect github.com/docker/libnetwork v0.8.0-dev.2.0.20200917202933-d0951081b35f // indirect github.com/emirpasic/gods v1.12.0 // indirect @@ -170,7 +169,7 @@ require ( go.opentelemetry.io/proto/otlp v0.9.0 // indirect golang.org/x/mod v0.6.0 // indirect golang.org/x/sys v0.5.1-0.20230222185716-a3b23cc77e89 - golang.org/x/text v0.7.0 // indirect + golang.org/x/text v0.7.0 golang.org/x/time v0.0.0-20220922220347-f3bd1da661af google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f // indirect google.golang.org/protobuf v1.28.1 // indirect diff --git a/go.sum b/go.sum index aa1ef7a7b8..35531d418d 100644 --- a/go.sum +++ b/go.sum @@ -465,8 +465,8 @@ github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TT github.com/docker/distribution v2.6.0-rc.1.0.20180327202408-83389a148052+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/distribution v2.8.0+incompatible h1:l9EaZDICImO1ngI+uTifW+ZYvvz7fKISBAKpg+MbWbY= -github.com/docker/distribution v2.8.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v0.0.0-20200511152416-a93e9eb0e95c/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v1.4.2-0.20180531152204-71cd53e4a197/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= @@ -1229,8 +1229,6 @@ github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod github.com/securego/gosec v0.0.0-20200103095621-79fbf3af8d83/go.mod h1:vvbZ2Ae7AzSq3/kywjUDxSNq2SJ27RxCz2un0H3ePqE= github.com/securego/gosec v0.0.0-20200401082031-e946c8c39989/go.mod h1:i9l/TNj+yDFh9SZXUTvspXTjbFXgZGP/UvhU1S65A4A= github.com/securego/gosec/v2 v2.3.0/go.mod h1:UzeVyUXbxukhLeHKV3VVqo7HdoQR9MrRfFmZYotn8ME= -github.com/segmentio/textio v1.2.0 h1:Ug4IkV3kh72juJbG8azoSBlgebIbUUxVNrfFcKHfTSQ= -github.com/segmentio/textio v1.2.0/go.mod h1:+Rb7v0YVODP+tK5F7FD9TCkV7gOYx9IgLHWiqtvY8ag= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= diff --git a/helpgen/README.md b/helpgen/README.md deleted file mode 100644 index 23d0d43fd0..0000000000 --- a/helpgen/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# helpgen - -Code generator for help strings. Generator is helpgen.go. Currently outputs to stdout. Takes the name of a .toml file. - -Toml file format (currently): - -```toml -[info] -usage="info" -shortHelp="Show detailed app information" -longHelp="""Shows information about the application on the Fly platform - -Information includes the application's -* name, owner, version, status and hostname -* services -* IP addresses -""" -``` - -Help TOML file is flyctlhelp.toml - -run - -``` -go run helpgen/helpgen.go helpgen/flyctlhelp.toml > docstrings/flyctldocstrings.go -``` - -To generate docstrings/flyctldocstrings.go - -```go -package docstrings - -var docstrings=map[string]KeyStrings{ -"info":KeyStrings{"info","Show detailed app information", - `Shows information about the application on the Fly platform - -Information includes the application's -* name, owner, version, status and hostname -* services -* IP addresses -`, -}, -} - -``` - -This contains a literal initialised map of all the KeyStrings. Consumed by docstrings/docstrings.go - -TODO: Add Flag and Example support diff --git a/helpgen/flyctlhelp.toml b/helpgen/flyctlhelp.toml deleted file mode 100644 index 3b5ebc87f6..0000000000 --- a/helpgen/flyctlhelp.toml +++ /dev/null @@ -1,1045 +0,0 @@ -[flyctl] -longHelp = """flyctl is a command line interface to the Fly.io platform. - -It allows users to manage authentication, application launch, -deployment, network configuration, logging and more with just the -one command. - -* Launch an app with the launch command -* Deploy an app with the deploy command -* View a deployed web application with the open command -* Check the status of an application with the status command - -To read more, use the docs command to view Fly's help on the web. -""" -shortHelp = "The Fly CLI" -usage = "flyctl" - -[image] -longHelp = "Manage app image" -shortHelp = "Manage app image" -usage = "image" -[image.show] -longHelp = "Show image details." -shortHelp = "Show image details." -usage = "show" -[image.update] -longHelp = """This will update the application's image to the latest available version. -The update will perform a rolling restart against each VM, which may result in a brief service disruption. -""" -shortHelp = "Updates the app's image to the latest available version. (Fly Postgres only)" -usage = "update" - -[open] -longHelp = """Open browser to current deployed application. If an optional path is specified, this is appended to the -URL for deployed application. -""" -shortHelp = "Open browser to current deployed application" -usage = "open [PATH]" - -[destroy] -longHelp = """The DESTROY command will remove an application -from the Fly platform. -""" -shortHelp = "Permanently destroys an app" -usage = "destroy [APPNAME]" - -[resume] -longHelp = """The RESUME command will restart a previously suspended application. -The application will resume with its original region pool and a min count of one -meaning there will be one running instance once restarted. Use SCALE SET MIN= to raise -the number of configured instances. -""" -shortHelp = "Resume an application" -usage = "resume [APPNAME]" - -[restart] -longHelp = """The RESTART command will restart all running vms. -""" -shortHelp = "Restart an application" -usage = "restart [APPNAME]" - -[move] -longHelp = """The MOVE command will move an application to another -organization the current user belongs to. -""" -shortHelp = "Move an app to another organization" -usage = "move [APPNAME]" - -[apps] -longHelp = """The APPS commands focus on managing your Fly applications. -Start with the CREATE command to register your application. -The LIST command will list all currently registered applications. -""" -shortHelp = "Manage apps" -usage = "apps" -[apps.list] -longHelp = """The APPS LIST command will show the applications currently -registered and available to this user. The list will include applications -from all the organizations the user is a member of. Each application will -be shown with its name, owner and when it was last deployed. -""" -shortHelp = "List applications" -usage = "list" -[apps.create] -longHelp = """The APPS CREATE command will both register a new application -with the Fly platform and create the fly.toml file which controls how -the application will be deployed. The --builder flag allows a cloud native -buildpack to be specified which will be used instead of a Dockerfile to -create the application image when it is deployed. -""" -shortHelp = "Create a new application" -usage = "create [APPNAME]" -[apps.destroy] -longHelp = """The APPS DESTROY command will remove an application -from the Fly platform. -""" -shortHelp = "Permanently destroys an app" -usage = "destroy [APPNAME]" -[apps.move] -longHelp = """The APPS MOVE command will move an application to another -organization the current user belongs to. -""" -shortHelp = "Move an app to another organization" -usage = "move [APPNAME]" -[apps.suspend] -longHelp = """The APPS SUSPEND command will suspend an application. -All instances will be halted leaving the application running nowhere. -It will continue to consume networking resources (IP address). See APPS RESUME -for details on restarting it. -""" -shortHelp = "Suspend an application" -usage = "suspend [APPNAME]" -[apps.resume] -longHelp = """The APPS RESUME command will restart a previously suspended application. -The application will resume with its original region pool and a min count of one -meaning there will be one running instance once restarted. Use SCALE SET MIN= to raise -the number of configured instances. -""" -shortHelp = "Resume an application" -usage = "resume [APPNAME]" -[apps.restart] -longHelp = """The APPS RESTART command will restart all running vms. -""" -shortHelp = "Restart an application" -usage = "restart [APPNAME]" - -[auth] -longHelp = """Authenticate with Fly (and logout if you need to). -If you do not have an account, start with the AUTH SIGNUP command. -If you do have and account, begin with the AUTH LOGIN subcommand. -""" -shortHelp = "Manage authentication" -usage = "auth" -[auth.whoami] -longHelp = """Displays the users email address/service identity currently -authenticated and in use. -""" -shortHelp = "Show the currently authenticated user" -usage = "whoami" -[auth.token] -longHelp = """Shows the authentication token that is currently in use. -This can be used as an authentication token with API services, -independent of flyctl. -""" -shortHelp = "Show the current auth token" -usage = "token" -[auth.login] -longHelp = """Logs a user into the Fly platform. Supports browser-based, -email/password and one-time-password authentication. Defaults to using -browser-based authentication. -""" -shortHelp = "Log in a user" -usage = "login" -[auth.logout] -longHelp = """Log the currently logged-in user out of the Fly platform. -To continue interacting with Fly, the user will need to log in again. -""" -shortHelp = "Logs out the currently logged in user" -usage = "logout" -[auth.signup] -longHelp = """Creates a new fly account. The command opens the browser -and sends the user to a form to provide appropriate credentials. -""" -shortHelp = "Create a new fly account" -usage = "signup" -[auth.docker] -longHelp = """Adds registry.fly.io to the docker daemon's authenticated -registries. This allows you to push images directly to fly from -the docker cli. -""" -shortHelp = "Authenticate docker" -usage = "docker" - -[builds] -longHelp = """Fly builds are templates to make developing Fly applications easier. -""" -shortHelp = "Work with Fly builds" -usage = "builds" -[builds.list] -longHelp = """ -""" -shortHelp = "List builds" -usage = "list" -[builds.logs] -longHelp = """ -""" -shortHelp = "Show logs associated with builds" -usage = "logs" - -[certs] -longHelp = """Manages the certificates associated with a deployed application. -Certificates are created by associating a hostname/domain with the application. -When Fly is then able to validate that hostname/domain, the platform gets -certificates issued for the hostname/domain by Let's Encrypt. -""" -shortHelp = "Manage certificates" -usage = "certs" -[certs.list] -longHelp = """List the certificates associated with a deployed application. -""" -shortHelp = "List certificates for an app." -usage = "list" -[certs.add] -longHelp = """Add a certificate for an application. Takes a hostname -as a parameter for the certificate. -""" -shortHelp = "Add a certificate for an app." -usage = "add " -[certs.remove] -longHelp = """Removes a certificate from an application. Takes hostname -as a parameter to locate the certificate. -""" -shortHelp = "Removes a certificate from an app" -usage = "remove " -[certs.show] -longHelp = """Shows certificate information for an application. -Takes hostname as a parameter to locate the certificate. -""" -shortHelp = "Shows certificate information" -usage = "show " -[certs.check] -longHelp = """Checks the DNS configuration for the specified hostname. -Displays results in the same format as the SHOW command. -""" -shortHelp = "Checks DNS configuration" -usage = "check " - -[checks] -longHelp = "Manage health checks" -shortHelp = "Manage health checks" -usage = "checks" -[checks.handlers] -longHelp = "Manage health check handlers" -shortHelp = "Manage health check handlers" -usage = "handlers" -[checks.handlers.create] -longHelp = "Create a health check handler" -shortHelp = "Create a health check handler" -usage = "create" -[checks.handlers.delete] -longHelp = "Delete a health check handler" -shortHelp = "Delete a health check handler" -usage = "delete " -[checks.handlers.list] -longHelp = "List health check handlers" -shortHelp = "List health check handlers" -usage = "list" -[checks.list] -longHelp = "List app health checks" -shortHelp = "List app health checks" -usage = "list" - -[curl] -longHelp = """Run a performance test against a url. -""" -shortHelp = "Run a performance test against a url" -usage = "curl " - -[config] -longHelp = """The CONFIG commands allow you to work with an application's configuration. -""" -shortHelp = "Manage an app's configuration" -usage = "config" -[config.show] -longHelp = """Show an application's configuration. The configuration is presented -in JSON format. The configuration data is retrieved from the Fly service. -""" -shortHelp = "Show an app's configuration" -usage = "show" -[config.save] -longHelp = """Save an application's configuration locally. The configuration data is -retrieved from the Fly service and saved in TOML format. -""" -shortHelp = "Save an app's config file" -usage = "save" -[config.validate] -longHelp = """Validates an application's config file against the Fly platform to -ensure it is correct and meaningful to the platform. -""" -shortHelp = "Validate an app's config file" -usage = "validate" -[config.env] -longHelp = """Display an app's runtime environment variables. It displays a section for -secrets and another for config file defined environment variables. -""" -shortHelp = "Display an app's runtime environment variables" -usage = "env" - -[dashboard] -longHelp = """Open web browser on Fly Web UI for this application""" -shortHelp = "Open web browser on Fly Web UI for this app" -usage = "dashboard" - -[dashboard.metrics] -longHelp = """Open web browser on Fly Web UI for this application's metrics""" -shortHelp = "Open web browser on Fly Web UI for this app's metrics" -usage = "metrics" - -[deploy] -longHelp = """Deploy an application to the Fly platform. The application can be a local -image, remote image, defined in a Dockerfile or use a CNB buildpack. - -Use the --config/-c flag to select a specific toml configuration file. - -Use the --image/-i flag to specify a local or remote image to deploy. - -Use the --detach flag to return immediately from starting the deployment rather -than monitoring the deployment progress. - -Use flyctl monitor to restart monitoring deployment progress -""" -shortHelp = "Deploy an app to the Fly platform" -usage = "deploy []" -[dns-records] -longHelp = """Manage DNS records within a domain""" -shortHelp = "Manage DNS records" -usage = "dns-records" - -[dns-records.list] -longHelp = """List DNS records within a domain""" -shortHelp = "List DNS records" -usage = "list " - -[dns-records.export] -longHelp = """Export DNS records. Will write to a file if a filename is given, otherwise -writers to StdOut.""" -shortHelp = "Export DNS records" -usage = "export []" - -[dns-records.import] -longHelp = """Import DNS records. Will import from a file is a filename is given, otherwise -imports from StdIn.""" -shortHelp = "Import DNS records" -usage = "import []" - -[docs] -longHelp = """View Fly documentation on the Fly.io website. This command will open a -browser to view the content. -""" -shortHelp = "View Fly documentation" -usage = "docs" - -[domains] -longHelp = """Manage domains -Notice: this feature is deprecated and no longer supported. -You can still view existing domains, but registration is no longer possible.""" -shortHelp = "Manage domains (deprecated)" -usage = "domains" - -[domains.add] -longHelp = """Add a domain to an organization""" -shortHelp = "Add a domain" -usage = "add [org] [name]" - -[domains.list] -longHelp = """List domains for an organization""" -shortHelp = "List domains" -usage = "list []" - -[domains.register] -longHelp = """Register a new domain in an organization""" -shortHelp = "Register a domain" -usage = "register [org] [name]" - -[domains.show] -longHelp = """Show information about a domain""" -shortHelp = "Show domain" -usage = "show " - -[history] -longHelp = """List the history of changes in the application. Includes autoscaling -events and their results. -""" -shortHelp = "List an app's change history" -usage = "history" - -[ips] -longHelp = """The IPS commands manage IP addresses for applications. An application -can have a number of IP addresses associated with it and this family of commands -allows you to list, allocate and release those addresses. It supports both IPv4 -and IPv6 addresses. -""" -shortHelp = "Manage IP addresses for apps" -usage = "ips" -[ips.list] -longHelp = """Lists the IP addresses allocated to the application. -""" -shortHelp = "List allocated IP addresses" -usage = "list" -[ips.allocate-v4] -longHelp = """Allocates an IPv4 address to the application. -""" -shortHelp = "Allocate an IPv4 address" -usage = "allocate-v4" -[ips.allocate-v6] -longHelp = """Allocates an IPv6 address to the application. -""" -shortHelp = "Allocate an IPv6 address" -usage = "allocate-v6" -[ips.release] -longHelp = """Releases an IP address from the application. -""" -shortHelp = "Release an IP address" -usage = "release [ADDRESS]" -[ips.private] -longHelp = """List instances private IP addresses, accessible from within the -Fly network""" -shortHelp = "List instances private IP addresses" -usage = "private" - -[launch] -longHelp = "Create and configure a new app from source code or an image reference." -shortHelp = "Launch a new app" -usage = "launch" - -[list] -longHelp = """The list command is for listing your resources on has two subcommands, apps and orgs. - -The apps command lists your applications. There are filtering options available. - -The orgs command lists all the organizations you are a member of. -""" -shortHelp = "Lists your Fly resources" -usage = "list" - -[list.apps] -longHelp = """The list apps command lists all your applications. As this may be a -long list, there are options to filter the results. - -Specifying a text string as a parameter will only return applications where the -application name contains the text. - -The --orgs/-o flag allows you to specify the name of an organization that the -application must be owned by. (see list orgs for organization names). - -The --status/-s flag allows you to specify status applications should be at to be -returned in the results. e.g. -s running would only return running applications. -""" -shortHelp = "Lists all your apps" -usage = "apps [text] [-o org] [-s status]" - -[list.orgs] -longHelp = """Lists all organizations which your are a member of. It will show the -short name of the organization and the long name. -""" -shortHelp = "List all your organizations" -usage = "orgs" - -[logs] -longHelp = """View application logs as generated by the application running on -the Fly platform. - -Logs can be filtered to a specific instance using the --instance/-i flag or -to all instances running in a specific region using the --region/-r flag. -""" -shortHelp = "View app logs" -usage = "logs" - -[monitor] -longHelp = """Monitor application deployments and other activities. Use --verbose/-v -to get details of every instance . Control-C to stop output.""" -shortHelp = "Monitor deployments" -usage = "monitor" - -[platform] -longHelp = """The PLATFORM commands are for users looking for information -about the Fly platform. -""" -shortHelp = "Fly platform information" -usage = "platform" - -[platform.regions] -longHelp = """View a list of regions where Fly has edges and/or datacenters -""" -shortHelp = "List regions" -usage = "regions" - -[platform.vmsizes] -longHelp = """View a list of VM sizes which can be used with the FLYCTL SCALE VM command -""" -shortHelp = "List VM Sizes" -usage = "vm-sizes" - -[platform.status] -longHelp = """Show current Fly platform status in a browser -""" -shortHelp = "Show current platform status" -usage = "status" - -[postgres] -longHelp = "Manage postgres clusters" -shortHelp = "Manage postgres clusters" -usage = "postgres" -[postgres.attach] -longHelp = "Attach a postgres cluster to an app" -shortHelp = "Attach a postgres cluster to an app" -usage = "attach" -[postgres.connect] -shortHelp = "Connect to the Postgres console" -longHelp = "Connect to the Postgres console" -usage = "connect" -[postgres.create] -longHelp = "Create a postgres cluster" -shortHelp = "Create a postgres cluster" -usage = "create" -[postgres.db] -longHelp = "manage databases in a cluster" -shortHelp = "manage databases in a cluster" -usage = "db" -[postgres.db.create] -longHelp = "create a database in a cluster" -shortHelp = "create a database in a cluster" -usage = "create " -[postgres.db.list] -longHelp = "list databases in a cluster" -shortHelp = "list databases in a cluster" -usage = "list " -[postgres.detach] -longHelp = "Detach a postgres cluster from an app" -shortHelp = "Detach a postgres cluster from an app" -usage = "detach" -[postgres.list] -longHelp = "list postgres clusters" -shortHelp = "list postgres clusters" -usage = "list" -[postgres.users] -longHelp = "manage users in a cluster" -shortHelp = "manage users in a cluster" -usage = "users" -[postgres.users.create] -longHelp = "create a user in a cluster" -shortHelp = "create a user in a cluster" -usage = "create " -[postgres.users.list] -longHelp = "list users in a cluster" -shortHelp = "list users in a cluster" -usage = "list " - -[regions] -longHelp = """Configure the region placement rules for an application. -""" -shortHelp = "Manage regions" -usage = "regions" - -[regions.add] -longHelp = """Allow the app to run in one or more regions -""" -shortHelp = "Allow the app to run in the provided regions" -usage = "add REGION ..." - -[regions.remove] -longHelp = """Prevent the app from running in the provided regions -""" -shortHelp = "Prevent the app from running in the provided regions" -usage = "remove REGION ..." - -[regions.set] -longHelp = """Sets the region pool with provided regions -""" -shortHelp = "Sets the region pool with provided regions" -usage = "set REGION ..." - -[regions.backup] -longHelp = """Sets the backup region pool with provided regions -""" -shortHelp = "Sets the backup region pool with provided regions" -usage = "backup REGION ..." - -[regions.list] -longHelp = """Shows the list of regions the app is allowed to run in. -""" -shortHelp = "Shows the list of regions the app is allowed to run in" -usage = "list" - -[releases] -longHelp = """List all the releases of the application onto the Fly platform, -including type, when, success/fail and which user triggered the release. -""" -shortHelp = "List app releases" -usage = "releases" - -[autoscale] -longHelp = """Autoscaling application resources -""" -shortHelp = "Autoscaling app resources" -usage = "autoscale" - -[autoscale.disable] -longHelp = """Disable autoscaling to manually controlling app resources -""" -shortHelp = "Disable autoscaling" -usage = "disable" - -[autoscale.show] -longHelp = """Show current autoscaling configuration -""" -shortHelp = "Show current autoscaling configuration" -usage = "show" - -[autoscale.set] -longHelp = """Enable autoscaling and set the application's autoscaling parameters: - -min=int - minimum number of instances to be allocated globally. -max=int - maximum number of instances to be allocated globally. -""" -shortHelp = "Set app autoscaling parameters" -usage = "set" - -[scale] -longHelp = """Scale application resources -""" -shortHelp = "Scale app resources" -usage = "scale" - -[scale.vm] -longHelp = """Change an application's VM size to one of the named VM sizes. - -Size names include shared-cpu-1x, dedicated-cpu-1x, dedicated-cpu-2x. - -For a full list of supported sizes use the command flyctl platform vm-sizes - -Memory size can be set with --memory=number-of-MB - -e.g. flyctl scale vm shared-cpu-1x --memory=2048 - -For dedicated vms, this should be a multiple of 1024MB. - -For shared vms, this can be 256MB or a a multiple of 1024MB. - -For pricing, see https://fly.io/docs/about/pricing/ -""" -shortHelp = "Change an app's VM to a named size (eg. shared-cpu-1x, dedicated-cpu-1x, dedicated-cpu-2x...)" -usage = "vm [SIZENAME] [flags]" - -[scale.count] -longHelp = """Change an app's VM count to the given value. - -For pricing, see https://fly.io/docs/about/pricing/ -""" -shortHelp = "Change an app's VM count to the given value" -usage = "count " - -[scale.memory] -longHelp = """Set VM memory to a number of megabytes -""" -shortHelp = "Set VM memory" -usage = "memory " - -[scale.show] -longHelp = """Show current VM size and counts -""" -shortHelp = "Show current resources" -usage = "show" - -[secrets] -longHelp = """Manage application secrets with the set and unset commands. - -Secrets are provided to applications at runtime as ENV variables. Names are -case sensitive and stored as-is, so ensure names are appropriate for -the application and vm environment. -""" -shortHelp = "Manage app secrets" -usage = "secrets" - -[secrets.list] -longHelp = """List the secrets available to the application. It shows each secret's -name, a digest of its value and the time the secret was last set. The -actual value of the secret is only available to the application. -""" -shortHelp = "Lists the secrets available to the app" -usage = "list" -[secrets.set] -longHelp = """Set one or more encrypted secrets for an application. - -Secrets are provided to application at runtime as ENV variables. Names are -case sensitive and stored as-is, so ensure names are appropriate for -the application and vm environment. - -Any value that equals "-" will be assigned from STDIN instead of args. -""" -shortHelp = "Set one or more encrypted secrets for an app" -usage = "set [flags] NAME=VALUE NAME=VALUE ..." -[secrets.import] -longHelp = """Set one or more encrypted secrets for an application. Values -are read from stdin as name=value -""" -shortHelp = "Read secrets in name=value from stdin" -usage = "import [flags]" - -[secrets.unset] -longHelp = """Remove encrypted secrets from the application. Unsetting a -secret removes its availability to the application. -""" -shortHelp = "Remove encrypted secrets from an app" -usage = "unset [flags] NAME NAME ..." - -[status] -longHelp = """Show the application's current status including application -details, tasks, most recent deployment details and in which regions it is -currently allocated. -""" -shortHelp = "Show app status" -usage = "status" - -[status.instance] -longHelp = """Show the instance's current status including logs, checks, -and events. -""" -shortHelp = "Show instance status" -usage = "instance [instance-id]" - -[version] -longHelp = """Shows version information for the flyctl command itself, -including version number and build date. -""" -shortHelp = "Show version information for the flyctl command" -usage = "version" - -[version.update] -longHelp = """Checks for update and if one is available, runs the appropriate -command to update the application. -""" -shortHelp = "Checks for available updates and automatically updates" -usage = "update" - -[builtins] -longHelp = """View and manage Flyctl deployment builtins. -""" -shortHelp = "View and manage Flyctl deployment builtins" -usage = "builtins" - -[builtins.list] -longHelp = """List available Flyctl deployment builtins and their -descriptions. -""" -shortHelp = "List available Flyctl deployment builtins" -usage = "list" - -[builtins.show] -longHelp = """Show details of a Fly deployment builtins, including -the builtin "Dockerfile" with default settings and other information. -""" -shortHelp = "Show details of a builtin's configuration" -usage = "show []" - -[builtins.show-app] -longHelp = """Show details of a Fly deployment builtins, including -the builtin "Dockerfile" with an apps settings included -and other information. -""" -shortHelp = "Show details of a builtin's configuration" -usage = "show-app" - -[orgs] -longHelp = """Commands for managing Fly organizations. list, create, show and -destroy organizations. -Organization admins can also invite or remove users from Organizations. -""" -shortHelp = "Commands for managing Fly organizations" -usage = "orgs" - -[orgs.list] -longHelp = """Lists organizations available to current user.""" -shortHelp = "Lists organizations for current user" -usage = "list" - -[orgs.show] -longHelp = """Shows information about an organization. -Includes name, slug and type. Summarizes user permissions, DNS zones and -associated member. Details full list of members and roles.""" -shortHelp = "Show information about an organization" -usage = "show " - -[orgs.invite] -longHelp = """Invite a user, by email, to join organization. The invitation will be -sent, and the user will be pending until they respond. See also orgs revoke.""" -shortHelp = "Invite user (by email) to organization" -usage = "invite " - -[orgs.revoke] -longHelp = """Revokes an invitation to join an organization that has been sent to a -user by email.""" -shortHelp = "Revoke a pending invitation to an organization" -usage = "revoke " - -[orgs.remove] -longHelp = """Remove a user from an organization. User must have accepted a previous -invitation to join (if not, see orgs revoke).""" -shortHelp = "Remove a user from an organization" -usage = "remove " - -[orgs.create] -longHelp = """Create a new organization. Other users can be invited to join the -organization later.""" -shortHelp = "Create an organization" -usage = "create " - -[orgs.delete] -longHelp = """Delete an existing organization.""" -shortHelp = "Delete an organization" -usage = "delete " - -[volumes] -longHelp = """Commands for managing Fly Volumes associated with an application.""" -shortHelp = "Volume management commands" -usage = "volumes " - -[volumes.create] -longHelp = """Create new volume for app. --region flag must be included to specify -region the volume exists in. --size flag is optional, defaults to 3, -sets the size as the number of gigabytes the volume will consume.""" -shortHelp = "Create new volume for app" -usage = "create " - -[volumes.list] -longHelp = """List all the volumes associated with this application.""" -shortHelp = "List the volumes for app" -usage = "list" - -[volumes.delete] -longHelp = """Delete a volume from the application. Requires the volume's ID -number to operate. This can be found through the volumes list command""" -shortHelp = "Delete a volume from the app" -usage = "delete " - -[volumes.show] -longHelp = """Show details of an app's volume. Requires the volume's ID -number to operate. This can be found through the volumes list command""" -shortHelp = "Show details of an app's volume" -usage = "show " - -[volumes.snapshots] -longHelp = "Commands for managing volume snapshots" -shortHelp = "Manage volume snapshots" -usage = "snapshots" -[volumes.snapshots.list] -longHelp = "list snapshots associated with the specified volume" -shortHelp = "list snapshots associated with the specified volume" -usage = "list " - -[ssh] -longHelp = """Commands that manage SSH credentials""" -shortHelp = "Commands that manage SSH credentials" -usage = "ssh " - -[ssh.console] -longHelp = """Connect to a running instance of the current app; with -select, choose instance from list.""" -shortHelp = "Connect to a running instance of the current app." -usage = "console []" - -[ssh.log] -longHelp = """log of all issued certs""" -shortHelp = "Log of all issued certs" -usage = "log" - -[ssh.establish] -longHelp = """Create a root SSH certificate for your organization. If -is provided, will re-key an organization; all previously issued creds will be -invalidated.""" -shortHelp = "Create a root SSH certificate for your organization" -usage = "establish [] []" - -[ssh.issue] -longHelp = """Issue a new SSH credential. With -agent, populate credential -into SSH agent. With -hour, set the number of hours (1-72) for credential -validity.""" -shortHelp = "Issue a new SSH credential." -usage = "issue [org] [email] [path]" - -[ssh.shell] -longHelp = """Connect directly to an instance. With -region, set the -WireGuard region to use for the connection.""" -shortHelp = "Connect directly to an instance." -usage = "shell [org] [address]" - -[vm] -longHelp = "Commands that manage VM instances" -shortHelp = "Commands that manage VM instances" -usage = "vm " -[vm.restart] -longHelp = "Request for a VM to be asynchronously restarted." -shortHelp = "Restart a VM" -usage = "restart " -[vm.status] -longHelp = "Show a VM's current status including logs, checks, and events." -shortHelp = "Show a VM's status" -usage = "status " -[vm.stop] -longHelp = "Request for a VM to be asynchronously stopped." -shortHelp = "Stop a VM" -usage = "stop " - -[agent] -longHelp = """Commands that manage the Fly agent""" -shortHelp = "Commands that manage the Fly agent" -usage = "agent " - -[agent.daemon-start] -longHelp = "Run the Fly agent as a service (manually)" -shortHelp = "Run the Fly agent as a service (manually)" -usage = "daemon-start" - -[agent.start] -longHelp = "Start the Fly agent" -shortHelp = "Start the Fly agent" -usage = "start" - -[agent.restart] -longHelp = "Restart the Fly agent" -shortHelp = "Restart the Fly agent" -usage = "restart" - -[agent.stop] -longHelp = "Stop the Fly agent" -shortHelp = "Stop the Fly agent" -usage = "stop" - -[agent.ping] -longHelp = "ping the Fly agent" -shortHelp = "ping the Fly agent" -usage = "ping" - -[wireguard] -longHelp = """Commands that manage WireGuard peer connections""" -shortHelp = "Commands that manage WireGuard peer connections" -usage = "wireguard " - -[wireguard.list] -longHelp = "List all WireGuard peer connections" -shortHelp = "List all WireGuard peer connections" -usage = "list []" - -[wireguard.create] -longHelp = """Add a WireGuard peer connection to an organization""" -shortHelp = "Add a WireGuard peer connection" -usage = "create [org] [region] [name]" - -[wireguard.reset] -longHelp = """Reset WireGuard peer connection for an organization""" -shortHelp = "Reset WireGuard peer connection for an organization" -usage = "reset [org]" - -[wireguard.remove] -longHelp = """Remove a WireGuard peer connection from an organization""" -shortHelp = "Remove a WireGuard peer connection" -usage = "remove [org] [name]" - -[wireguard.status] -longHelp = """Get status for a WireGuard peer connection""" -shortHelp = "Get status a WireGuard peer connection" -usage = "status [org] [name]" - -[wireguard.websockets] -longHelp = """Enable or disable WireGuard tunneling over WebSockets""" -shortHelp = "Enable or disable WireGuard tunneling over WebSockets" -usage = "websockets [enable/disable]" - -[wireguard.token] -longHelp = """Commands that managed WireGuard delegated access tokens""" -shortHelp = "Commands that managed WireGuard delegated access tokens" -usage = "token " - -[wireguard.token.list] -longHelp = "List all WireGuard tokens" -shortHelp = "List all WireGuard tokens" -usage = "list []" - -[wireguard.token.create] -longHelp = "Create a new WireGuard token" -shortHelp = "Create a new WireGuard token" -usage = "create [org] [name]" - -[wireguard.token.delete] -longHelp = "Delete a WireGuard token; token is name: or token:" -shortHelp = "Delete a WireGuard token; token is name: or token:" -usage = "delete [org] [token]" - -[wireguard.token.start] -longHelp = "Start a new WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)" -shortHelp = "Start a new WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)" -usage = "start [name] [group] [region] [file]" - -[wireguard.token.update] -longHelp = "Rekey a WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)" -shortHelp = "Rekey a WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)" -usage = "update [name] [file]" - -[machine] -longHelp = """Commands that manage machines""" -shortHelp = "Commands that manage machines" -usage = "machine " -[machine.clone] -longHelp = "Clones a Fly Machine" -shortHelp = "Clones a Fly Machine" -usage = "clone" -[machine.run] -longHelp = "Launch Fly machine with the provided image and command" -shortHelp = "Launch a Fly machine" -usage = "run [command]" -[machine.list] -longHelp = "List Fly machines" -shortHelp = "List Fly machines" -usage = "list" -[machine.stop] -longHelp = "Stop a Fly machine" -shortHelp = "Stop a Fly machine" -usage = "stop " -[machine.start] -longHelp = "Start a Fly machine" -shortHelp = "Start a Fly machine" -usage = "start " -[machine.kill] -longHelp = "Kill (SIGKILL) a Fly machine" -shortHelp = "Kill (SIGKILL) a Fly machine" -usage = "kill " -[machine.remove] -longHelp = "Remove a Fly machine" -shortHelp = "Remove a Fly machine" -usage = "remove " -[machine.status] -longHelp = """Show current status of a running machine""" -shortHelp = "Show current status of a running machine" -usage = "status " - -[proxy] -longHelp = """Proxies connections to a fly app through the wireguard tunnel""" -shortHelp = "Proxies connections to a fly app" -usage = "proxy " - -[turboku] -longHelp = "Launches heroku apps" -shortHelp = "Launches heroku apps" -usage = "turboku " - -[dig] -longHelp = """Make DNS requests against Fly.io's internal DNS server. Valid types include -AAAA and TXT (the two types our servers answer authoritatively), AAAA-NATIVE -and TXT-NATIVE, which resolve with Go's resolver (they're slower, -but may be useful if diagnosing a DNS bug) and A and CNAME -(if you're using the server to test recursive lookups.) -Note that this resolves names against the server for the current organization. You can -set the organization with -o ; otherwise, the command uses the organization -attached to the current app (you can pass an app in with -a ).""" -shortHelp = "DNS lookups" -usage = "dig [type] " diff --git a/helpgen/helpgen.go b/helpgen/helpgen.go deleted file mode 100644 index 357a2cc3af..0000000000 --- a/helpgen/helpgen.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "sort" - "strings" - - "github.com/pelletier/go-toml" -) - -func main() { - readFile := os.Args[1] - - tree, err := toml.LoadFile(readFile) - if err != nil { - log.Fatal("Can't parse docStrings", err) - } - - mapped := tree.ToMap() - - fmt.Println("package docstrings\n\n// Get - Get a document string\nfunc Get(key string) KeyStrings {switch(key) {") - - dumpMap("", mapped) - - fmt.Println("}\npanic(\"unknown command key \" + key)\n}") -} - -func dumpMap(prefix string, m map[string]interface{}) { - _, prs := m["usage"] - if prs { - usage := m["usage"].(string) - short := m["shortHelp"].(string) - long := m["longHelp"].(string) - fmt.Printf("case \"%s\":\nreturn KeyStrings{\"%s\",\"%s\",\n `%s`,\n}\n", - prefix, strings.TrimSpace(usage), strings.TrimSpace(short), strings.TrimSpace(long)) - } - - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - - sort.Strings(keys) - - for _, k := range keys { - v := m[k] - switch node := v.(type) { - case map[string]interface{}: - if prefix != "" { - dumpMap(prefix+"."+k, v.(map[string]interface{})) - } else { - dumpMap(k, v.(map[string]interface{})) - } - case string: - // Nothing to do - default: - fmt.Fprintln(os.Stderr, "Node ", node, " not handled") - } - } -} diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 6ae0fdcfff..a2432a96fd 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -1,4 +1,4 @@ -// Package app implements functionality related to reading and writing app +// Package appconfig implements functionality related to reading and writing app // configuration files. package appconfig @@ -8,6 +8,7 @@ import ( "reflect" "github.com/superfly/flyctl/api" + "golang.org/x/exp/slices" ) const ( @@ -31,10 +32,11 @@ func NewConfig() *Config { // Config wraps the properties of app configuration. // NOTE: If you any new setting here, please also add a value for it at testdata/rull-reference.toml type Config struct { - AppName string `toml:"app,omitempty" json:"app,omitempty"` - PrimaryRegion string `toml:"primary_region,omitempty" json:"primary_region,omitempty"` - KillSignal *string `toml:"kill_signal,omitempty" json:"kill_signal,omitempty"` - KillTimeout *int `toml:"kill_timeout,omitempty" json:"kill_timeout,omitempty"` + AppName string `toml:"app,omitempty" json:"app,omitempty"` + PrimaryRegion string `toml:"primary_region,omitempty" json:"primary_region,omitempty"` + KillSignal *string `toml:"kill_signal,omitempty" json:"kill_signal,omitempty"` + KillTimeout *api.Duration `toml:"kill_timeout,omitempty" json:"kill_timeout,omitempty"` + ConsoleCommand string `toml:"console_command,omitempty" json:"console_command,omitempty"` // Sections that are typically short and benefit from being on top Experimental *Experimental `toml:"experimental,omitempty" json:"experimental,omitempty"` @@ -100,12 +102,13 @@ type Build struct { } type Experimental struct { - Cmd []string `toml:"cmd,omitempty" json:"cmd,omitempty"` - Entrypoint []string `toml:"entrypoint,omitempty" json:"entrypoint,omitempty"` - Exec []string `toml:"exec,omitempty" json:"exec,omitempty"` - AutoRollback bool `toml:"auto_rollback,omitempty" json:"auto_rollback,omitempty"` - EnableConsul bool `toml:"enable_consul,omitempty" json:"enable_consul,omitempty"` - EnableEtcd bool `toml:"enable_etcd,omitempty" json:"enable_etcd,omitempty"` + Cmd []string `toml:"cmd,omitempty" json:"cmd,omitempty"` + Entrypoint []string `toml:"entrypoint,omitempty" json:"entrypoint,omitempty"` + Exec []string `toml:"exec,omitempty" json:"exec,omitempty"` + AutoRollback bool `toml:"auto_rollback,omitempty" json:"auto_rollback,omitempty"` + EnableConsul bool `toml:"enable_consul,omitempty" json:"enable_consul,omitempty"` + EnableEtcd bool `toml:"enable_etcd,omitempty" json:"enable_etcd,omitempty"` + AllowedPublicPorts []int `toml:"allowed_public_ports,omitempty" json:"allowed_public_ports,omitempty"` } func (c *Config) ConfigFilePath() string { @@ -204,36 +207,49 @@ func (cfg *Config) BuildStrategies() []string { return strategies } -func (cfg *Config) URL() (*url.URL, error) { - hasHttp := false - protocol := "http" - if cfg.HTTPService != nil { - hasHttp = true - if cfg.HTTPService.ForceHTTPS { - protocol = "https" - } - } else { - for _, service := range cfg.Services { - for _, port := range service.Ports { - hasTls := false - for _, handler := range port.Handlers { - if handler == "tls" { - hasTls = true - } else if handler == "http" { - hasHttp = true - } - if *port.Port == 443 && hasHttp && hasTls { - protocol = "https" - break - } - } +func (cfg *Config) URL() *url.URL { + u := &url.URL{ + Scheme: "https", + Host: cfg.AppName + ".fly.dev", + Path: "/", + } + + // HTTPService always listen on https, even if ForceHTTPS is false + if cfg.HTTPService != nil && cfg.HTTPService.InternalPort > 0 { + return u + } + + var httpPorts []int + var httpsPorts []int + for _, service := range cfg.Services { + for _, port := range service.Ports { + if port.Port == nil || !slices.Contains(port.Handlers, "http") { + continue + } + if slices.Contains(port.Handlers, "tls") { + httpsPorts = append(httpsPorts, *port.Port) + } else { + httpPorts = append(httpPorts, *port.Port) } } } - if hasHttp { - return url.Parse(protocol + "://" + cfg.AppName + ".fly.dev") - } else { - return nil, nil + switch { + case slices.Contains(httpsPorts, 443): + return u + case slices.Contains(httpPorts, 80): + u.Scheme = "http" + return u + case len(httpsPorts) > 0: + slices.Sort(httpsPorts) + u.Host = fmt.Sprintf("%s:%d", u.Host, httpsPorts[0]) + return u + case len(httpPorts) > 0: + slices.Sort(httpPorts) + u.Host = fmt.Sprintf("%s:%d", u.Host, httpPorts[0]) + u.Scheme = "http" + return u + default: + return nil } } diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index c42f1cdb98..48911ca55e 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -1,7 +1,6 @@ package appconfig import ( - "net/url" "testing" "github.com/stretchr/testify/assert" @@ -207,42 +206,85 @@ func TestHasNonHttpAndHttpsStandardServices(t *testing.T) { assert.True(t, cfg6.HasNonHttpAndHttpsStandardServices()) } -func TestURLCalculation(t *testing.T) { - port80 := 80 - port443 := 443 +func TestURL(t *testing.T) { + cfg := NewConfig() + cfg.AppName = "test" + cfg.HTTPService = &HTTPService{InternalPort: 8080} + assert.Equal(t, "https://test.fly.dev/", cfg.URL().String()) - http, _ := url.Parse("http://test.fly.dev") - https, _ := url.Parse("https://test.fly.dev") + // Prefer https on 443 over http on 80 + cfg = NewConfig() + cfg.AppName = "test" + cfg.Services = []Service{{ + Protocol: "tcp", + Ports: []api.MachinePort{{ + Port: api.Pointer(80), Handlers: []string{"http"}, + }, { + Port: api.Pointer(443), Handlers: []string{"http", "tls"}, + }}, + }} + assert.Equal(t, "https://test.fly.dev/", cfg.URL().String()) - cfg := NewConfig() + // port 443 is not http, only port 80 is. + cfg = NewConfig() cfg.AppName = "test" - cfg.Services = []Service{{Protocol: "tcp", Ports: []api.MachinePort{ - {Port: &port80, Handlers: []string{"tls"}}, - }}} - url, _ := cfg.URL() - assert.Nil(t, url) + cfg.Services = []Service{{ + Protocol: "tcp", + Ports: []api.MachinePort{{ + Port: api.Pointer(80), Handlers: []string{"http"}, + }, { + Port: api.Pointer(443), Handlers: []string{"tls"}, + }}, + }} + assert.Equal(t, "http://test.fly.dev/", cfg.URL().String()) + // prefer standard http port over non standard https port cfg = NewConfig() cfg.AppName = "test" - cfg.Services = []Service{{Protocol: "tcp", Ports: []api.MachinePort{ - {Port: &port80, Handlers: []string{"http"}}, - }}} - url, _ = cfg.URL() - assert.Equal(t, http, url) + cfg.Services = []Service{{ + Protocol: "tcp", + Ports: []api.MachinePort{{ + Port: api.Pointer(80), Handlers: []string{"http"}, + }, { + Port: api.Pointer(3443), Handlers: []string{"tls", "http"}, + }}, + }} + assert.Equal(t, "http://test.fly.dev/", cfg.URL().String()) + // prefer non standard https port over non standard http port cfg = NewConfig() cfg.AppName = "test" - cfg.Services = []Service{{Protocol: "tcp", Ports: []api.MachinePort{ - {Port: &port443, Handlers: []string{"tls", "http"}}, - }}} - url, _ = cfg.URL() - assert.Equal(t, https, url) + cfg.Services = []Service{{ + Protocol: "tcp", + Ports: []api.MachinePort{{ + Port: api.Pointer(8080), Handlers: []string{"http"}, + }, { + Port: api.Pointer(3443), Handlers: []string{"tls", "http"}, + }}, + }} + assert.Equal(t, "https://test.fly.dev:3443/", cfg.URL().String()) + // Use non standard http port as last meassure cfg = NewConfig() cfg.AppName = "test" - cfg.HTTPService = &HTTPService{ - ForceHTTPS: true, - } - url, _ = cfg.URL() - assert.Equal(t, https, url) + cfg.Services = []Service{{ + Protocol: "tcp", + Ports: []api.MachinePort{{ + Port: api.Pointer(8080), Handlers: []string{"http"}, + }}, + }} + assert.Equal(t, "http://test.fly.dev:8080/", cfg.URL().String()) + + // Otherwise return an empty string so caller knows there is no http service + cfg = NewConfig() + cfg.AppName = "test" + cfg.Services = []Service{{ + Protocol: "tcp", + Ports: []api.MachinePort{{ + Port: api.Pointer(80), Handlers: []string{"fancy"}, + }, { + Port: api.Pointer(443), Handlers: []string{"foo"}, + }}, + }} + assert.Nil(t, cfg.URL()) } diff --git a/internal/appconfig/definition.go b/internal/appconfig/definition.go index f845bffbe7..9deb4d740c 100644 --- a/internal/appconfig/definition.go +++ b/internal/appconfig/definition.go @@ -37,5 +37,6 @@ func (c *Config) SanitizedDefinition() map[string]any { delete(definition, "build") delete(definition, "primary_region") delete(definition, "http_service") + delete(definition, "console_command") return definition } diff --git a/internal/appconfig/definition_test.go b/internal/appconfig/definition_test.go index d82541567e..0bd0ab2c5b 100644 --- a/internal/appconfig/definition_test.go +++ b/internal/appconfig/definition_test.go @@ -72,7 +72,7 @@ func TestFromDefinition(t *testing.T) { assert.Equal(t, &Config{ KillSignal: api.Pointer("SIGINT"), - KillTimeout: api.Pointer(5), + KillTimeout: api.MustParseDuration("5s"), Experimental: &Experimental{ AutoRollback: true, }, @@ -163,10 +163,11 @@ func TestToDefinition(t *testing.T) { definition, err := cfg.ToDefinition() assert.NoError(t, err) assert.Equal(t, &api.Definition{ - "app": "foo", - "primary_region": "sea", - "kill_signal": "SIGTERM", - "kill_timeout": int64(3), + "app": "foo", + "primary_region": "sea", + "kill_signal": "SIGTERM", + "kill_timeout": "3s", + "console_command": "/bin/bash", "build": map[string]any{ "builder": "dockerfile", @@ -194,6 +195,24 @@ func TestToDefinition(t *testing.T) { "hard_limit": int64(10), "soft_limit": int64(4), }, + "tls_options": map[string]any{ + "alpn": []any{"h2", "http/1.1"}, + "versions": []any{"TLSv1.2", "TLSv1.3"}, + "default_self_signed": false, + }, + "http_options": map[string]any{ + "compress": true, + "response": map[string]any{ + "headers": map[string]any{ + "fly-request-id": false, + "fly-wasnt-here": "yes, it was", + "multi-valued": []any{"value1", "value2"}, + }, + }, + }, + "proxy_proto_options": map[string]any{ + "version": "v2", + }, }, "experimental": map[string]any{ @@ -301,12 +320,7 @@ func TestToDefinition(t *testing.T) { } func TestFromDefinitionEnvAsList(t *testing.T) { - jsonBody := []byte(`{"env": [{"ONE": "one", "TWO": 2}, {"TRUE": true}]}`) - definition := &api.Definition{} - err := json.Unmarshal(jsonBody, definition) - require.NoError(t, err) - - cfg, err := FromDefinition(definition) + cfg, err := cfgFromJSON(`{"env": [{"ONE": "one", "TWO": 2}, {"TRUE": true}]}`) require.NoError(t, err) want := map[string]string{ @@ -314,34 +328,53 @@ func TestFromDefinitionEnvAsList(t *testing.T) { "TWO": "2", "TRUE": "true", } - assert.Equal(t, want, cfg.Env) } func TestFromDefinitionChecksAsList(t *testing.T) { - jsonBody := []byte(`{"checks": [{"name": "pg", "port": 80}]}`) - definition := &api.Definition{} - err := json.Unmarshal(jsonBody, definition) - require.NoError(t, err) - - cfg, err := FromDefinition(definition) + cfg, err := cfgFromJSON(`{"checks": [{"name": "pg", "port": 80}]}`) require.NoError(t, err) want := map[string]*ToplevelCheck{ "pg": {Port: api.Pointer(80)}, } - assert.Equal(t, want, cfg.Checks) } func TestFromDefinitionChecksAsEmptyList(t *testing.T) { - jsonBody := []byte(`{"checks": []}`) - definition := &api.Definition{} - err := json.Unmarshal(jsonBody, definition) + cfg, err := cfgFromJSON(`{"checks": []}`) require.NoError(t, err) + assert.Nil(t, cfg.Checks) +} - cfg, err := FromDefinition(definition) +func TestFromDefinitionKillTimeoutInteger(t *testing.T) { + cfg, err := cfgFromJSON(`{"kill_timeout": 20}`) require.NoError(t, err) + assert.Equal(t, api.MustParseDuration("20s"), cfg.KillTimeout) +} - assert.Nil(t, cfg.Checks) +func TestFromDefinitionKillTimeoutFloat(t *testing.T) { + cfg, err := cfgFromJSON(`{"kill_timeout": 1.5}`) + require.NoError(t, err) + assert.Equal(t, api.MustParseDuration("1s"), cfg.KillTimeout) +} + +func TestFromDefinitionKillTimeoutString(t *testing.T) { + cfg, err := cfgFromJSON(`{"kill_timeout": "10s"}`) + require.NoError(t, err) + assert.Equal(t, api.MustParseDuration("10s"), cfg.KillTimeout) +} + +func dFromJSON(jsonBody string) (*api.Definition, error) { + ret := &api.Definition{} + err := json.Unmarshal([]byte(jsonBody), ret) + return ret, err +} + +func cfgFromJSON(jsonBody string) (*Config, error) { + def, err := dFromJSON(jsonBody) + if err != nil { + return nil, err + } + return FromDefinition(def) } diff --git a/internal/appconfig/machines.go b/internal/appconfig/machines.go index 36e66d49bc..b15ea97449 100644 --- a/internal/appconfig/machines.go +++ b/internal/appconfig/machines.go @@ -47,6 +47,40 @@ func (c *Config) ToReleaseMachineConfig() (*api.MachineConfig, error) { mConfig.Env["PRIMARY_REGION"] = c.PrimaryRegion } + // StopConfig + c.tomachineSetStopConfig(mConfig) + + return mConfig, nil +} + +func (c *Config) ToConsoleMachineConfig() (*api.MachineConfig, error) { + mConfig := &api.MachineConfig{ + Init: api.MachineInit{ + // TODO: it would be better to configure init to run no + // command at all. That way we don't rely on /bin/sleep + // being available and working right. However, there's no + // way to do that yet. + Exec: []string{"/bin/sleep", "inf"}, + }, + Restart: api.MachineRestart{ + Policy: api.MachineRestartPolicyNo, + }, + AutoDestroy: true, + DNS: &api.DNSConfig{ + SkipRegistration: true, + }, + Metadata: map[string]string{ + api.MachineConfigMetadataKeyFlyPlatformVersion: api.MachineFlyPlatformVersion2, + api.MachineConfigMetadataKeyFlyProcessGroup: api.MachineProcessGroupFlyAppConsole, + }, + Env: lo.Assign(c.Env), + } + + mConfig.Env["FLY_PROCESS_GROUP"] = api.MachineProcessGroupFlyAppConsole + if c.PrimaryRegion != "" { + mConfig.Env["PRIMARY_REGION"] = c.PrimaryRegion + } + return mConfig, nil } @@ -68,6 +102,13 @@ func (c *Config) updateMachineConfig(src *api.MachineConfig) (*api.MachineConfig if err != nil { return nil, err } + if c.Experimental != nil { + if cmd == nil { + cmd = c.Experimental.Cmd + } + mConfig.Init.Entrypoint = c.Experimental.Entrypoint + mConfig.Init.Exec = c.Experimental.Exec + } mConfig.Init.Cmd = cmd // Metadata @@ -131,5 +172,22 @@ func (c *Config) updateMachineConfig(src *api.MachineConfig) (*api.MachineConfig }) } + // StopConfig + c.tomachineSetStopConfig(mConfig) + return mConfig, nil } + +func (c *Config) tomachineSetStopConfig(mConfig *api.MachineConfig) error { + mConfig.StopConfig = nil + if c.KillSignal == nil && c.KillTimeout == nil { + return nil + } + + mConfig.StopConfig = &api.StopConfig{ + Timeout: c.KillTimeout, + Signal: c.KillSignal, + } + + return nil +} diff --git a/internal/appconfig/machines_test.go b/internal/appconfig/machines_test.go index e3eddfeaf3..82eb415eb2 100644 --- a/internal/appconfig/machines_test.go +++ b/internal/appconfig/machines_test.go @@ -38,6 +38,10 @@ func TestToMachineConfig(t *testing.T) { HTTPPath: api.Pointer("/status"), }, }, + StopConfig: &api.StopConfig{ + Timeout: api.MustParseDuration("10s"), + Signal: api.Pointer("SIGTERM"), + }, } got, err := cfg.ToMachineConfig("", nil) @@ -69,6 +73,28 @@ func TestToMachineConfig(t *testing.T) { assert.Empty(t, got.Init.Cmd) } +func TestToMachineConfig_Experimental(t *testing.T) { + cfg, err := LoadConfig("./testdata/tomachine-experimental.toml") + require.NoError(t, err) + + got, err := cfg.ToMachineConfig("", nil) + require.NoError(t, err) + assert.Equal(t, api.MachineInit{ + Cmd: []string{"/call", "me"}, + Entrypoint: []string{"/IgoFirst"}, + Exec: []string{"ignore", "others"}, + }, got.Init) + + cfg.Processes = map[string]string{"app": "/override experimental"} + got, err = cfg.ToMachineConfig("", nil) + require.NoError(t, err) + assert.Equal(t, api.MachineInit{ + Cmd: []string{"/override", "experimental"}, + Entrypoint: []string{"/IgoFirst"}, + Exec: []string{"ignore", "others"}, + }, got.Init) +} + func TestToMachineConfig_nullifyManagedFields(t *testing.T) { cfg := NewConfig() @@ -120,6 +146,10 @@ func TestToReleaseMachineConfig(t *testing.T) { AutoDestroy: true, Restart: api.MachineRestart{Policy: api.MachineRestartPolicyNo}, DNS: &api.DNSConfig{SkipRegistration: true}, + StopConfig: &api.StopConfig{ + Timeout: api.MustParseDuration("10s"), + Signal: api.Pointer("SIGTERM"), + }, } got, err := cfg.ToReleaseMachineConfig() diff --git a/internal/appconfig/patches.go b/internal/appconfig/patches.go index 1fceb4ef15..ebf26567aa 100644 --- a/internal/appconfig/patches.go +++ b/internal/appconfig/patches.go @@ -17,6 +17,7 @@ var configPatches = []patchFuncType{ patchExperimental, patchTopLevelChecks, patchMounts, + patchTopFields, } func applyPatches(cfgMap map[string]any) (*Config, error) { @@ -48,6 +49,13 @@ func patchRoot(cfgMap map[string]any) (map[string]any, error) { return cfgMap, nil } +func patchTopFields(cfg map[string]any) (map[string]any, error) { + if raw, ok := cfg["kill_timeout"]; ok { + cfg["kill_timeout"] = _castDuration(raw, time.Second) + } + return cfg, nil +} + func patchEnv(cfg map[string]any) (map[string]any, error) { raw, ok := cfg["env"] if !ok { @@ -125,9 +133,11 @@ func patchExperimental(cfg map[string]any) (map[string]any, error) { cast, ok := raw.(map[string]any) if !ok { - return nil, fmt.Errorf("Experimental section of unknown type: %T", cast) + return nil, fmt.Errorf("Experimental section of unknown type: %T", raw) } + metrics := map[string]any{} + for k, v := range cast { switch k { case "cmd", "entrypoint", "exec": @@ -136,6 +146,12 @@ func patchExperimental(cfg map[string]any) (map[string]any, error) { } else { cast[k] = n } + case "kill_timeout": + if _, ok := cfg["kill_timeout"]; !ok { + cfg["kill_timeout"] = _castDuration(v, time.Second) + } + case "metrics_port", "metrics_path": + metrics[strings.TrimPrefix(k, "metrics_")] = v } } @@ -145,6 +161,10 @@ func patchExperimental(cfg map[string]any) (map[string]any, error) { cfg["experimental"] = cast } + if _, ok := cfg["metrics"]; !ok && len(metrics) > 0 { + cfg["metrics"] = metrics + } + return cfg, nil } @@ -353,13 +373,7 @@ func _patchChecks(rawChecks any) ([]map[string]any, error) { func _patchCheck(check map[string]any) (map[string]any, error) { for _, attr := range []string{"interval", "timeout"} { if v, ok := check[attr]; ok { - switch cast := v.(type) { - case string: - // Nothing to do here - case int64: - // Convert milliseconds to microseconds as expected by api.ParseDuration - check[attr] = time.Duration(cast) * time.Millisecond - } + check[attr] = _castDuration(v, time.Millisecond) } } if v, ok := check["headers"]; ok { @@ -373,6 +387,33 @@ func _patchCheck(check map[string]any) (map[string]any, error) { return check, nil } +func _castDuration(v any, shift time.Duration) (ret *string) { + switch cast := v.(type) { + case *string: + return cast + case string: + if cast == "" { + return nil + } + return &cast + case float64: + d := time.Duration(cast) + if shift > 0 { + d = d * shift + } + str := d.String() + return &str + case int64: + d := time.Duration(cast) + if shift > 0 { + d = d * shift + } + str := d.String() + return &str + } + return nil +} + func castToInt(num any) (int, error) { switch cast := num.(type) { case string: diff --git a/internal/appconfig/serde.go b/internal/appconfig/serde.go index e09c523afe..66c67413c3 100644 --- a/internal/appconfig/serde.go +++ b/internal/appconfig/serde.go @@ -96,10 +96,9 @@ func (c *Config) MarshalJSON() ([]byte, error) { // marshalTOML serializes the configuration to TOML format // NOTES: -// * It can't be called `MarshalTOML` because toml libraries don't support marshaler interface on root values -// * Needs to reimplements most of MarshalJSON to enforce order of fields -// * Instead of this, you usually need one WriteTo(), WriteToFile() or WriteToDisk() -// +// - It can't be called `MarshalTOML` because toml libraries don't support marshaler interface on root values +// - Needs to reimplements most of MarshalJSON to enforce order of fields +// - Instead of this, you usually need one WriteTo(), WriteToFile() or WriteToDisk() func (c *Config) marshalTOML() ([]byte, error) { var b bytes.Buffer encoder := toml.NewEncoder(&b) diff --git a/internal/appconfig/serde_test.go b/internal/appconfig/serde_test.go index e7abce0890..fd5f1b0957 100644 --- a/internal/appconfig/serde_test.go +++ b/internal/appconfig/serde_test.go @@ -68,7 +68,7 @@ func TestLoadTOMLAppConfigServicePorts(t *testing.T) { Ports: []api.MachinePort{{ Port: api.Pointer(80), TLSOptions: &api.TLSOptions{ - Alpn: []string{"h2", "http/1.1"}, + ALPN: []string{"h2", "http/1.1"}, Versions: []string{"TLSv1.2", "TLSv1.3"}, }, HTTPOptions: &api.HTTPOptions{ @@ -87,6 +87,34 @@ func TestLoadTOMLAppConfigServicePorts(t *testing.T) { assert.Equal(t, want, p.Services) } +func TestLoadTOMLAppConfigServiceMulti(t *testing.T) { + const path = "./testdata/services-multi.toml" + + p, err := LoadConfig(path) + require.NoError(t, err) + want := []Service{ + { + Protocol: "tcp", + InternalPort: 8081, + Concurrency: &api.MachineServiceConcurrency{ + Type: "requests", + HardLimit: 22, + SoftLimit: 13, + }, + }, + { + Protocol: "tcp", + InternalPort: 9999, + Concurrency: &api.MachineServiceConcurrency{ + Type: "connections", + HardLimit: 10, + SoftLimit: 8, + }, + }, + } + assert.Equal(t, want, p.Services) +} + func TestLoadTOMLAppConfigInvalidV2(t *testing.T) { const path = "./testdata/always-invalid-v2.toml" cfg, err := LoadConfig(path) @@ -151,6 +179,11 @@ func TestLoadTOMLAppConfigExperimental(t *testing.T) { configFilePath: "./testdata/experimental-alt.toml", defaultGroupName: "app", AppName: "foo", + KillTimeout: api.MustParseDuration("3s"), + Metrics: &api.MachineMetrics{ + Path: "/foo", + Port: 9000, + }, Experimental: &Experimental{ Cmd: []string{"cmd"}, Entrypoint: []string{"entrypoint"}, @@ -159,9 +192,12 @@ func TestLoadTOMLAppConfigExperimental(t *testing.T) { RawDefinition: map[string]any{ "app": "foo", "experimental": map[string]any{ - "cmd": "cmd", - "entrypoint": "entrypoint", - "exec": "exec", + "cmd": "cmd", + "entrypoint": "entrypoint", + "exec": "exec", + "kill_timeout": int64(3), + "metrics_path": "/foo", + "metrics_port": int64(9000), }, }, }, cfg) @@ -327,8 +363,9 @@ func TestLoadTOMLAppConfigReferenceFormat(t *testing.T) { defaultGroupName: "app", AppName: "foo", KillSignal: api.Pointer("SIGTERM"), - KillTimeout: api.Pointer(3), + KillTimeout: api.MustParseDuration("3s"), PrimaryRegion: "sea", + ConsoleCommand: "/bin/bash", Experimental: &Experimental{ Cmd: []string{"cmd"}, Entrypoint: []string{"entrypoint"}, @@ -379,6 +416,24 @@ func TestLoadTOMLAppConfigReferenceFormat(t *testing.T) { HardLimit: 10, SoftLimit: 4, }, + TLSOptions: &api.TLSOptions{ + ALPN: []string{"h2", "http/1.1"}, + Versions: []string{"TLSv1.2", "TLSv1.3"}, + DefaultSelfSigned: api.Pointer(false), + }, + HTTPOptions: &api.HTTPOptions{ + Compress: api.Pointer(true), + Response: &api.HTTPResponseOptions{ + Headers: map[string]any{ + "fly-request-id": false, + "fly-wasnt-here": "yes, it was", + "multi-valued": []any{"value1", "value2"}, + }, + }, + }, + ProxyProtoOptions: &api.ProxyProtoOptions{ + Version: "v2", + }, }, Statics: []Static{ diff --git a/internal/appconfig/service.go b/internal/appconfig/service.go index 080a9648fa..077a1fc89d 100644 --- a/internal/appconfig/service.go +++ b/internal/appconfig/service.go @@ -9,15 +9,16 @@ import ( ) type Service struct { - Protocol string `json:"protocol,omitempty" toml:"protocol"` - InternalPort int `json:"internal_port,omitempty" toml:"internal_port"` - AutoStopMachines *bool `json:"auto_stop_machines,omitempty" toml:"auto_stop_machines,omitempty"` - AutoStartMachines *bool `json:"auto_start_machines,omitempty" toml:"auto_start_machines,omitempty"` - Ports []api.MachinePort `json:"ports,omitempty" toml:"ports"` - Concurrency *api.MachineServiceConcurrency `json:"concurrency,omitempty" toml:"concurrency"` - TCPChecks []*ServiceTCPCheck `json:"tcp_checks,omitempty" toml:"tcp_checks,omitempty"` - HTTPChecks []*ServiceHTTPCheck `json:"http_checks,omitempty" toml:"http_checks,omitempty"` - Processes []string `json:"processes,omitempty" toml:"processes,omitempty"` + Protocol string `json:"protocol,omitempty" toml:"protocol"` + InternalPort int `json:"internal_port,omitempty" toml:"internal_port"` + AutoStopMachines *bool `json:"auto_stop_machines,omitempty" toml:"auto_stop_machines,omitempty"` + AutoStartMachines *bool `json:"auto_start_machines,omitempty" toml:"auto_start_machines,omitempty"` + MinMachinesRunning *int `json:"min_machines_running,omitempty" toml:"min_machines_running,omitempty"` + Ports []api.MachinePort `json:"ports,omitempty" toml:"ports"` + Concurrency *api.MachineServiceConcurrency `json:"concurrency,omitempty" toml:"concurrency"` + TCPChecks []*ServiceTCPCheck `json:"tcp_checks,omitempty" toml:"tcp_checks,omitempty"` + HTTPChecks []*ServiceHTTPCheck `json:"http_checks,omitempty" toml:"http_checks,omitempty"` + Processes []string `json:"processes,omitempty" toml:"processes,omitempty"` } type ServiceTCPCheck struct { @@ -44,12 +45,16 @@ type ServiceHTTPCheck struct { } type HTTPService struct { - InternalPort int `json:"internal_port,omitempty" toml:"internal_port" validate:"required,numeric"` - ForceHTTPS bool `toml:"force_https" json:"force_https,omitempty"` - AutoStopMachines *bool `json:"auto_stop_machines,omitempty" toml:"auto_stop_machines,omitempty"` - AutoStartMachines *bool `json:"auto_start_machines,omitempty" toml:"auto_start_machines,omitempty"` - Concurrency *api.MachineServiceConcurrency `toml:"concurrency,omitempty" json:"concurrency,omitempty"` - Processes []string `json:"processes,omitempty" toml:"processes,omitempty"` + InternalPort int `json:"internal_port,omitempty" toml:"internal_port,omitempty" validate:"required,numeric"` + ForceHTTPS bool `toml:"force_https,omitempty" json:"force_https,omitempty"` + AutoStopMachines *bool `json:"auto_stop_machines,omitempty" toml:"auto_stop_machines,omitempty"` + AutoStartMachines *bool `json:"auto_start_machines,omitempty" toml:"auto_start_machines,omitempty"` + MinMachinesRunning *int `json:"min_machines_running,omitempty" toml:"min_machines_running,omitempty"` + Processes []string `json:"processes,omitempty" toml:"processes,omitempty"` + Concurrency *api.MachineServiceConcurrency `toml:"concurrency,omitempty" json:"concurrency,omitempty"` + TLSOptions *api.TLSOptions `json:"tls_options,omitempty" toml:"tls_options,omitempty"` + HTTPOptions *api.HTTPOptions `json:"http_options,omitempty" toml:"http_options,omitempty"` + ProxyProtoOptions *api.ProxyProtoOptions `json:"proxy_proto_options,omitempty" toml:"proxy_proto_options,omitempty"` } func (s *HTTPService) ToService() *Service { @@ -59,15 +64,21 @@ func (s *HTTPService) ToService() *Service { Concurrency: s.Concurrency, Processes: s.Processes, Ports: []api.MachinePort{{ - Port: api.IntPointer(80), - Handlers: []string{"http"}, - ForceHTTPS: s.ForceHTTPS, + Port: api.IntPointer(80), + Handlers: []string{"http"}, + ForceHTTPS: s.ForceHTTPS, + HTTPOptions: s.HTTPOptions, + ProxyProtoOptions: s.ProxyProtoOptions, }, { - Port: api.IntPointer(443), - Handlers: []string{"http", "tls"}, + Port: api.IntPointer(443), + Handlers: []string{"http", "tls"}, + HTTPOptions: s.HTTPOptions, + TLSOptions: s.TLSOptions, + ProxyProtoOptions: s.ProxyProtoOptions, }}, - AutoStopMachines: s.AutoStopMachines, - AutoStartMachines: s.AutoStartMachines, + AutoStopMachines: s.AutoStopMachines, + AutoStartMachines: s.AutoStartMachines, + MinMachinesRunning: s.MinMachinesRunning, } } @@ -81,12 +92,13 @@ func (c *Config) AllServices() (services []Service) { func (svc *Service) toMachineService() *api.MachineService { s := &api.MachineService{ - Protocol: svc.Protocol, - InternalPort: svc.InternalPort, - Ports: svc.Ports, - Concurrency: svc.Concurrency, - Autostop: svc.AutoStopMachines, - Autostart: svc.AutoStartMachines, + Protocol: svc.Protocol, + InternalPort: svc.InternalPort, + Ports: svc.Ports, + Concurrency: svc.Concurrency, + Autostop: svc.AutoStopMachines, + Autostart: svc.AutoStartMachines, + MinMachinesRunning: svc.MinMachinesRunning, } for _, tc := range svc.TCPChecks { diff --git a/internal/appconfig/testdata/experimental-alt.toml b/internal/appconfig/testdata/experimental-alt.toml index 544676680d..a8a888c8fe 100644 --- a/internal/appconfig/testdata/experimental-alt.toml +++ b/internal/appconfig/testdata/experimental-alt.toml @@ -4,3 +4,6 @@ app = "foo" cmd = "cmd" exec = "exec" entrypoint = "entrypoint" +kill_timeout = 3 +metrics_port = 9000 +metrics_path = "/foo" diff --git a/internal/appconfig/testdata/full-reference.toml b/internal/appconfig/testdata/full-reference.toml index 4dd00e75a1..fb430a875f 100644 --- a/internal/appconfig/testdata/full-reference.toml +++ b/internal/appconfig/testdata/full-reference.toml @@ -1,7 +1,8 @@ app = "foo" kill_signal = "SIGTERM" -kill_timeout = 3 +kill_timeout = "3s" primary_region = "sea" +console_command = "/bin/bash" [experimental] cmd = ["cmd"] @@ -49,6 +50,23 @@ primary_region = "sea" hard_limit = 10 soft_limit = 4 + [http_service.tls_options] + alpn = ["h2", "http/1.1"] + versions = ["TLSv1.2", "TLSv1.3"] + default_self_signed = false + + # https://community.fly.io/t/new-feature-basic-http-response-header-modification/3594 + [http_service.http_options] + compress = true + + [http_service.http_options.response.headers] + fly-request-id = false + fly-wasnt-here = "yes, it was" + multi-valued = ["value1", "value2"] + + [http_service.proxy_proto_options] + version = "v2" + [[statics]] guest_path = "/path/to/statics" url_prefix = "/static-assets" diff --git a/internal/appconfig/testdata/services-multi.toml b/internal/appconfig/testdata/services-multi.toml new file mode 100644 index 0000000000..0677aa8fdf --- /dev/null +++ b/internal/appconfig/testdata/services-multi.toml @@ -0,0 +1,19 @@ +app = "foo" + +[[services]] + internal_port = 8081 + protocol = "tcp" + + [services.concurrency] + type = "requests" + hard_limit = 22 + soft_limit = 13 + +[[services]] + internal_port = 9999 + protocol = "tcp" + + [services.concurrency] + type = "connections" + hard_limit = 10 + soft_limit = 8 diff --git a/internal/appconfig/testdata/tomachine-experimental.toml b/internal/appconfig/testdata/tomachine-experimental.toml new file mode 100644 index 0000000000..067501ae3a --- /dev/null +++ b/internal/appconfig/testdata/tomachine-experimental.toml @@ -0,0 +1,6 @@ +app = "foo" + +[experimental] +cmd = ["/call", "me"] +entrypoint = ["/IgoFirst"] +exec = ["ignore", "others"] diff --git a/internal/appconfig/testdata/tomachine.toml b/internal/appconfig/testdata/tomachine.toml index 2d56200fc6..24d5357a40 100644 --- a/internal/appconfig/testdata/tomachine.toml +++ b/internal/appconfig/testdata/tomachine.toml @@ -1,5 +1,7 @@ app = "foo" primary_region = "mia" +kill_signal = "SIGTERM" +kill_timeout = "10s" [deploy] release_command = "migrate-db" diff --git a/internal/appconfig/validation.go b/internal/appconfig/validation.go index 31e94dfd05..93fe7ea4dc 100644 --- a/internal/appconfig/validation.go +++ b/internal/appconfig/validation.go @@ -95,6 +95,8 @@ func (cfg *Config) ValidateForMachinesPlatform(ctx context.Context) (err error, cfg.validateServicesSection, cfg.validateProcessesSection, cfg.validateMachineConversion, + cfg.validateConsoleCommand, + cfg.validateNoExperimental, } for _, vFunc := range validators { @@ -118,6 +120,18 @@ func (cfg *Config) ValidateForMachinesPlatform(ctx context.Context) (err error, return nil, extra_info } +func (cfg *Config) validateNoExperimental() (extraInfo string, err error) { + if cfg.Experimental == nil { + return + } + + if len(cfg.Experimental.AllowedPublicPorts) != 0 { + extraInfo += "experimental.allowed_public_ports is not supported in Apps V2\n" + err = ValidationError + } + return +} + func (cfg *Config) validateBuildStrategies() (extraInfo string, err error) { buildStrats := cfg.BuildStrategies() if len(buildStrats) > 1 { @@ -219,3 +233,11 @@ func (cfg *Config) validateMachineConversion() (extraInfo string, err error) { } return } + +func (cfg *Config) validateConsoleCommand() (extraInfo string, err error) { + if _, vErr := shlex.Split(cfg.ConsoleCommand); vErr != nil { + extraInfo += fmt.Sprintf("Can't shell split console command: '%s'\n", cfg.ConsoleCommand) + err = ValidationError + } + return +} diff --git a/internal/build/imgsrc/archive.go b/internal/build/imgsrc/archive.go index c8b1c18a38..c92c549631 100644 --- a/internal/build/imgsrc/archive.go +++ b/internal/build/imgsrc/archive.go @@ -33,11 +33,7 @@ func CreateArchive(dockerfile, workingDir, ignoreFile string, compressed bool) ( compressed: compressed, } - excludes, err := readDockerignore(workingDir, ignoreFile) - if err != nil { - return nil, errors.Wrap(err, "error reading .dockerignore") - } - archiveOpts.exclusions = excludes + relativeDockerfilePath := "" // copy dockerfile into the archive if it's outside the context dir if !isPathInRoot(dockerfile, workingDir) { @@ -48,9 +44,19 @@ func CreateArchive(dockerfile, workingDir, ignoreFile string, compressed bool) ( archiveOpts.additions = map[string][]byte{ "Dockerfile": dockerfileData, } - } else if _, err := filepath.Rel(workingDir, dockerfile); err != nil { - return nil, err + } else { + p, err := filepath.Rel(workingDir, dockerfile) + if err != nil { + return nil, err + } + relativeDockerfilePath = filepath.ToSlash(p) + } + + excludes, err := readDockerignore(workingDir, ignoreFile, relativeDockerfilePath) + if err != nil { + return nil, errors.Wrap(err, "error reading .dockerignore") } + archiveOpts.exclusions = excludes r, err := archiveDirectory(archiveOpts) if err != nil { @@ -102,7 +108,7 @@ func archiveDirectory(options archiveOptions) (io.ReadCloser, error) { return r, nil } -func readDockerignore(workingDir string, ignoreFile string) ([]string, error) { +func readDockerignore(workingDir, ignoreFile, relativeDockerfilePath string) ([]string, error) { if ignoreFile == "" { ignoreFile = filepath.Join(workingDir, ".dockerignore") } @@ -121,10 +127,10 @@ func readDockerignore(workingDir string, ignoreFile string) ([]string, error) { } }() - return parseDockerignore(file) + return parseDockerignore(file, relativeDockerfilePath) } -func parseDockerignore(r io.Reader) ([]string, error) { +func parseDockerignore(r io.Reader, dockerfile string) ([]string, error) { excludes, err := dockerignore.ReadAll(r) if err != nil { return nil, err @@ -134,12 +140,16 @@ func parseDockerignore(r io.Reader) ([]string, error) { excludes = append(excludes, "!.dockerignore") } - if match, _ := fileutils.Matches("Dockerfile", excludes); match { - excludes = append(excludes, "![Dd]ockerfile") - } - - if match, _ := fileutils.Matches("dockerfile", excludes); match { - excludes = append(excludes, "![Dd]ockerfile") + if dockerfile != "" { + if match, _ := fileutils.Matches(dockerfile, excludes); match { + excludes = append(excludes, "!"+dockerfile) + } + } else { + if match, _ := fileutils.Matches("Dockerfile", excludes); match { + excludes = append(excludes, "![Dd]ockerfile") + } else if match, _ := fileutils.Matches("dockerfile", excludes); match { + excludes = append(excludes, "![Dd]ockerfile") + } } return excludes, nil diff --git a/internal/build/imgsrc/archive_test.go b/internal/build/imgsrc/archive_test.go index be2e8828ac..e493c574bf 100644 --- a/internal/build/imgsrc/archive_test.go +++ b/internal/build/imgsrc/archive_test.go @@ -159,17 +159,39 @@ func TestArchiverNoCompressionWithAdditions(t *testing.T) { } func TestParseDockerignore(t *testing.T) { - cases := map[string][]string{ - "node_modules\n*.jpg": {"node_modules", "*.jpg"}, - "node_modules\n*.jpg\nDockerfile": {"node_modules", "*.jpg", "Dockerfile", "![Dd]ockerfile"}, - "node_modules\n*.jpg\ndockerfile": {"node_modules", "*.jpg", "dockerfile", "![Dd]ockerfile"}, - "node_modules\n*.jpg\n.dockerignore": {"node_modules", "*.jpg", ".dockerignore", "!.dockerignore"}, + type testCase struct { + input string + dockerfile string + expected []string + } + cases := []testCase{ + { + input: "node_modules\n*.jpg", + expected: []string{"node_modules", "*.jpg"}, + }, + { + input: "node_modules\n*.jpg\nDockerfile", + expected: []string{"node_modules", "*.jpg", "Dockerfile", "![Dd]ockerfile"}, + }, + { + input: "node_modules\n*.jpg\ndockerfile", + expected: []string{"node_modules", "*.jpg", "dockerfile", "![Dd]ockerfile"}, + }, + { + input: "node_modules\n*.jpg\n.dockerignore", + expected: []string{"node_modules", "*.jpg", ".dockerignore", "!.dockerignore"}, + }, + { + input: "node_modules\n*.jpg\nDockerfile\nbuild/Dockerfile", + dockerfile: "build/Dockerfile", + expected: []string{"node_modules", "*.jpg", "Dockerfile", "build/Dockerfile", "!build/Dockerfile"}, + }, } - for input, expected := range cases { - excludes, err := parseDockerignore(strings.NewReader(input)) + for _, c := range cases { + excludes, err := parseDockerignore(strings.NewReader(c.input), c.dockerfile) assert.NoError(t, err) - assert.Equal(t, expected, excludes, input) + assert.Equal(t, c.expected, excludes, c.input) } } diff --git a/internal/build/imgsrc/buildpacks_builder.go b/internal/build/imgsrc/buildpacks_builder.go index d7ded18f63..3f00c14145 100644 --- a/internal/build/imgsrc/buildpacks_builder.go +++ b/internal/build/imgsrc/buildpacks_builder.go @@ -10,6 +10,7 @@ import ( projectTypes "github.com/buildpacks/pack/pkg/project/types" "github.com/pkg/errors" "github.com/superfly/flyctl/internal/cmdfmt" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/terminal" ) @@ -71,7 +72,7 @@ func (*buildpacksBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa cmdfmt.PrintDone(streams.ErrOut, msg) build.ContextBuildStart() - excludes, err := readDockerignore(opts.WorkingDir, opts.IgnorefilePath) + excludes, err := readDockerignore(opts.WorkingDir, opts.IgnorefilePath, "") if err != nil { build.ContextBuildFinish() build.BuildFinish() @@ -98,6 +99,9 @@ func (*buildpacksBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa build.BuildFinish() if err != nil { + if dockerFactory.IsRemote() { + metrics.SendNoData(ctx, "remote_builder_failure") + } return nil, "", err } diff --git a/internal/build/imgsrc/builtin_builder.go b/internal/build/imgsrc/builtin_builder.go index 07588eb47f..a3ca7cc38f 100644 --- a/internal/build/imgsrc/builtin_builder.go +++ b/internal/build/imgsrc/builtin_builder.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" "github.com/superfly/flyctl/internal/build/imgsrc/builtins" "github.com/superfly/flyctl/internal/cmdfmt" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/terminal" "golang.org/x/net/context" @@ -64,7 +65,7 @@ func (*builtinBuilder) Run(ctx context.Context, dockerFactory *dockerClientFacto compressed: dockerFactory.IsRemote(), } - excludes, err := readDockerignore(opts.WorkingDir, opts.IgnorefilePath) + excludes, err := readDockerignore(opts.WorkingDir, opts.IgnorefilePath, "") if err != nil { build.BuildFinish() return nil, "", errors.Wrap(err, "error reading .dockerignore") @@ -108,6 +109,9 @@ func (*builtinBuilder) Run(ctx context.Context, dockerFactory *dockerClientFacto imageID, err = runClassicBuild(ctx, streams, docker, r, opts, "", buildArgs) if err != nil { + if dockerFactory.IsRemote() { + metrics.SendNoData(ctx, "remote_builder_failure") + } build.ImageBuildFinish() build.BuildFinish() return nil, "", errors.Wrap(err, "error building") diff --git a/internal/build/imgsrc/docker.go b/internal/build/imgsrc/docker.go index 8cf84d3662..6bbfd1ac4c 100644 --- a/internal/build/imgsrc/docker.go +++ b/internal/build/imgsrc/docker.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "os" "path/filepath" "time" @@ -13,6 +14,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" dockerclient "github.com/docker/docker/client" + "github.com/docker/go-connections/sockets" "github.com/jpillora/backoff" "github.com/oklog/ulid/v2" "github.com/pkg/errors" @@ -21,6 +23,7 @@ import ( "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/flyctl" "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/sentry" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/terminal" @@ -170,12 +173,17 @@ func NewLocalDockerClient() (*dockerclient.Client, error) { return c, nil } -func newRemoteDockerClient(ctx context.Context, apiClient *api.Client, appName string, streams *iostreams.IOStreams, build *build) (*dockerclient.Client, error) { +func newRemoteDockerClient(ctx context.Context, apiClient *api.Client, appName string, streams *iostreams.IOStreams, build *build) (c *dockerclient.Client, err error) { startedAt := time.Now() + defer func() { + if err != nil { + metrics.SendNoData(ctx, "remote_builder_failure") + } + }() + var host string var app *api.App - var err error var machine *api.GqlMachine machine, app, err = remoteBuilderMachine(ctx, apiClient, appName) if err != nil { @@ -294,6 +302,19 @@ func buildRemoteClientOpts(ctx context.Context, apiClient *api.Client, appName, return } + url, err := dockerclient.ParseHostURL(host) + if err != nil { + return nil, fmt.Errorf("failed to parse remote builder host: %w", err) + } + transport := new(http.Transport) + sockets.ConfigureTransport(transport, url.Scheme, url.Host) + // Do not try to run tunneled connections through proxy + transport.Proxy = nil + opts = append(opts, dockerclient.WithHTTPClient(&http.Client{ + Transport: transport, + CheckRedirect: dockerclient.CheckRedirect, + })) + var app *api.AppBasic if app, err = apiClient.GetAppBasic(ctx, appName); err != nil { return nil, fmt.Errorf("error fetching target app: %w", err) diff --git a/internal/build/imgsrc/dockerfile_builder.go b/internal/build/imgsrc/dockerfile_builder.go index 05c2c572af..32ed36f34c 100644 --- a/internal/build/imgsrc/dockerfile_builder.go +++ b/internal/build/imgsrc/dockerfile_builder.go @@ -25,6 +25,7 @@ import ( "github.com/pkg/errors" "github.com/superfly/flyctl/helpers" "github.com/superfly/flyctl/internal/cmdfmt" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/terminal" @@ -90,7 +91,14 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa defer docker.Close() // skipcq: GO-S2307 build.BuilderInitFinish() - defer clearDeploymentTags(ctx, docker, opts.Tag) + defer func() { + // Don't untag images for remote builder, as people sometimes + // run concurrent builds from CI that end up racing with each other + // and one of them failing with 404 while calling docker.ImageInspectWithRaw + if dockerFactory.IsLocal() { + clearDeploymentTags(ctx, docker, opts.Tag) + } + }() build.ContextBuildStart() tb := render.NewTextBlock(ctx, "Creating build context") @@ -100,14 +108,6 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa compressed: dockerFactory.IsRemote(), } - excludes, err := readDockerignore(opts.WorkingDir, opts.IgnorefilePath) - if err != nil { - build.BuildFinish() - build.ContextBuildFinish() - return nil, "", errors.Wrap(err, "error reading .dockerignore") - } - archiveOpts.exclusions = excludes - var relativedockerfilePath string // copy dockerfile into the archive if it's outside the context dir @@ -134,6 +134,14 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa relativedockerfilePath = filepath.ToSlash(p) } + excludes, err := readDockerignore(opts.WorkingDir, opts.IgnorefilePath, relativedockerfilePath) + if err != nil { + build.BuildFinish() + build.ContextBuildFinish() + return nil, "", errors.Wrap(err, "error reading .dockerignore") + } + archiveOpts.exclusions = excludes + // Start tracking this build // Create the docker build context as a compressed tar stream @@ -164,6 +172,9 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa return docker.Info(infoCtx) }() if err != nil { + if dockerFactory.IsRemote() { + metrics.SendNoData(ctx, "remote_builder_failure") + } build.ImageBuildFinish() build.BuildFinish() return nil, "", errors.Wrap(err, "error fetching docker server info") @@ -183,6 +194,9 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa buildkitEnabled, err := buildkitEnabled(docker) terminal.Debugf("buildkitEnabled", buildkitEnabled) if err != nil { + if dockerFactory.IsRemote() { + metrics.SendNoData(ctx, "remote_builder_failure") + } build.ImageBuildFinish() build.BuildFinish() return nil, "", errors.Wrap(err, "error checking for buildkit support") @@ -191,6 +205,9 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa if buildkitEnabled { imageID, err = runBuildKitBuild(ctx, streams, docker, r, opts, relativedockerfilePath, buildArgs) if err != nil { + if dockerFactory.IsRemote() { + metrics.SendNoData(ctx, "remote_builder_failure") + } build.ImageBuildFinish() build.BuildFinish() return nil, "", errors.Wrap(err, "error building") @@ -198,6 +215,9 @@ func (*dockerfileBuilder) Run(ctx context.Context, dockerFactory *dockerClientFa } else { imageID, err = runClassicBuild(ctx, streams, docker, r, opts, relativedockerfilePath, buildArgs) if err != nil { + if dockerFactory.IsRemote() { + metrics.SendNoData(ctx, "remote_builder_failure") + } build.ImageBuildFinish() build.BuildFinish() return nil, "", errors.Wrap(err, "error building") @@ -414,13 +434,20 @@ func runBuildKitBuild(ctx context.Context, streams *iostreams.IOStreams, docker } func pushToFly(ctx context.Context, docker *dockerclient.Client, streams *iostreams.IOStreams, tag string) error { + + metrics.Started(ctx, "image_push") + sendImgPushMetrics := metrics.StartTiming(ctx, "image_push/duration") + pushResp, err := docker.ImagePush(ctx, tag, types.ImagePushOptions{ RegistryAuth: flyRegistryAuth(), }) + metrics.Status(ctx, "image_push", err == nil) + if err != nil { return errors.Wrap(err, "error pushing image to registry") } defer pushResp.Close() // skipcq: GO-S2307 + sendImgPushMetrics() err = jsonmessage.DisplayJSONMessagesStream(pushResp, streams.ErrOut, streams.StderrFd(), streams.IsStderrTTY(), nil) if err != nil { diff --git a/internal/build/imgsrc/resolver_test.go b/internal/build/imgsrc/resolver_test.go index bc5b273e90..e61290d3e2 100644 --- a/internal/build/imgsrc/resolver_test.go +++ b/internal/build/imgsrc/resolver_test.go @@ -10,7 +10,7 @@ import ( ) func TestHeartbeat(t *testing.T) { - dc, err := client.NewClientWithOpts(); + dc, err := client.NewClientWithOpts() assert.NoError(t, err) ctx := context.Background() diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 860924da7e..531eb08a34 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -12,6 +12,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/superfly/flyctl/flyctl" "github.com/superfly/flyctl/internal/filemu" "github.com/superfly/flyctl/internal/update" ) @@ -141,7 +142,9 @@ type wrapper struct { LatestRelease *update.Release `yaml:"latest_release,omitempty"` } -var lockPath = filepath.Join(os.TempDir(), "flyctl.cache.lock") +func lockPath() string { + return filepath.Join(flyctl.ConfigDir(), "flyctl.cache.lock") +} // Save writes the YAML-encoded representation of c to the named file path via // os.WriteFile. @@ -162,7 +165,7 @@ func (c *cache) Save(path string) (err error) { } var unlock filemu.UnlockFunc - if unlock, err = filemu.Lock(context.Background(), lockPath); err != nil { + if unlock, err = filemu.Lock(context.Background(), lockPath()); err != nil { return } defer func() { @@ -180,7 +183,7 @@ func (c *cache) Save(path string) (err error) { // Load loads the YAML-encoded cache file at the given path. func Load(path string) (c Cache, err error) { var unlock filemu.UnlockFunc - if unlock, err = filemu.RLock(context.Background(), lockPath); err != nil { + if unlock, err = filemu.RLock(context.Background(), lockPath()); err != nil { return } defer func() { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7d28f16d26..427478e436 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,6 +10,7 @@ import ( "github.com/AlecAivazis/survey/v2/terminal" "github.com/spf13/cobra" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/iostreams" "github.com/superfly/graphql" @@ -34,8 +35,13 @@ func Run(ctx context.Context, io *iostreams.IOStreams, args ...string) int { cs := io.ColorScheme() - switch _, err := cmd.ExecuteContextC(ctx); { + defer metrics.FlushPending() + + cmd, err := cmd.ExecuteContextC(ctx) + + switch { case err == nil: + metrics.RecordCommandFinish(cmd) return 0 case errors.Is(err, context.Canceled), errors.Is(err, terminal.InterruptErr): return 127 diff --git a/internal/cmdfmt/messages.go b/internal/cmdfmt/messages.go index 5a98797ba8..0bbc8cd952 100644 --- a/internal/cmdfmt/messages.go +++ b/internal/cmdfmt/messages.go @@ -7,7 +7,7 @@ import ( "github.com/logrusorgru/aurora" ) -// extract message printing from cmdctx until we find a better way to do this +// extract message printing from ctx until we find a better way to do this // TODO: deprecate this package in favor of render.TextBlock func PrintBegin(w io.Writer, args ...interface{}) { fmt.Fprintln(w, aurora.Green("==> "+fmt.Sprint(args...))) diff --git a/internal/command/agent/run.go b/internal/command/agent/run.go index 189642f827..13a5d560a6 100644 --- a/internal/command/agent/run.go +++ b/internal/command/agent/run.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/superfly/flyctl/agent/server" + "github.com/superfly/flyctl/flyctl" "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/internal/command" @@ -104,13 +105,14 @@ func (*dupInstanceError) Description() string { return "It looks like another instance of the agent is already running. Please stop it before starting a new one." } -var ( - lockPath = filepath.Join(os.TempDir(), "flyctl.agent.lock") - errDupInstance = new(dupInstanceError) -) +var errDupInstance = new(dupInstanceError) + +func lockPath() string { + return filepath.Join(flyctl.ConfigDir(), "flyctl.agent.lock") +} func lock(ctx context.Context, logger *log.Logger) (unlock filemu.UnlockFunc, err error) { - switch unlock, err = filemu.Lock(ctx, lockPath); { + switch unlock, err = filemu.Lock(ctx, lockPath()); { case err == nil: break // all done case ctx.Err() != nil: diff --git a/internal/command/apps/list.go b/internal/command/apps/list.go index ad706bf3ba..40a946592c 100644 --- a/internal/command/apps/list.go +++ b/internal/command/apps/list.go @@ -34,6 +34,7 @@ be shown with its name, owner and when it was last deployed. flag.Add(cmd, flag.JSONOutput()) flag.Add(cmd, flag.Org()) + cmd.Aliases = []string{"ls"} return cmd } diff --git a/internal/command/apps/move.go b/internal/command/apps/move.go index 5ce88dddae..cc222bfced 100644 --- a/internal/command/apps/move.go +++ b/internal/command/apps/move.go @@ -127,11 +127,8 @@ func runMoveAppOnMachines(ctx context.Context, app *api.AppCompact, targetOrg *a for _, machine := range machines { input := &api.LaunchMachineInput{ - AppID: app.ID, - ID: machine.ID, Name: machine.Name, Region: machine.Region, - OrgSlug: targetOrg.ID, Config: machine.Config, SkipHealthChecks: skipHealthChecks, } diff --git a/internal/command/apps/open.go b/internal/command/apps/open.go index a847085828..f5888427a9 100644 --- a/internal/command/apps/open.go +++ b/internal/command/apps/open.go @@ -43,6 +43,7 @@ to the root URL of the deployed application. } func runOpen(ctx context.Context) error { + iostream := iostreams.FromContext(ctx) appName := appconfig.NameFromContext(ctx) app, err := client.FromContext(ctx).API().GetAppCompact(ctx, appName) @@ -55,19 +56,20 @@ func runOpen(ctx context.Context) error { } appConfig := appconfig.ConfigFromContext(ctx) - appURL, err := appConfig.URL() - if err != nil { - return fmt.Errorf("failed parsing app URL (hostname: %s): %w", app.Hostname, err) + appURL := appConfig.URL() + if appURL == nil { + return errors.New("The app doesn't exspose a public http service") } - relURI := flag.FirstArg(ctx) - if appURL, err = appURL.Parse(relURI); err != nil { - return fmt.Errorf("failed parsing relative URI %s: %w", relURI, err) + if relURI := flag.FirstArg(ctx); relURI != "" { + newURL, err := appURL.Parse(relURI) + if err != nil { + return fmt.Errorf("failed to parse relative URI '%s': %w", relURI, err) + } + appURL = newURL } - iostream := iostreams.FromContext(ctx) fmt.Fprintf(iostream.Out, "opening %s ...\n", appURL) - if err := open.Run(appURL.String()); err != nil { return fmt.Errorf("failed opening %s: %w", appURL, err) } diff --git a/internal/command/apps/releases.go b/internal/command/apps/releases.go index ba5ba940cb..d48807e17f 100644 --- a/internal/command/apps/releases.go +++ b/internal/command/apps/releases.go @@ -10,11 +10,11 @@ import ( "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmd/presenters" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/command" "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/format" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/iostreams" ) @@ -99,7 +99,7 @@ func formatMachinesReleases(releases []api.Release, image bool) ([][]string, []s release.Status, release.Description, release.User.Email, - presenters.FormatRelativeTime(release.CreatedAt), + format.RelativeTime(release.CreatedAt), } if image { row = append(row, release.ImageRef) @@ -131,7 +131,7 @@ func formatNomadReleases(releases []api.Release, image bool) ([][]string, []stri release.Status, formatReleaseDescription(release), release.User.Email, - presenters.FormatRelativeTime(release.CreatedAt), + format.RelativeTime(release.CreatedAt), } if image { row = append(row, release.ImageRef) diff --git a/internal/command/auth/docker.go b/internal/command/auth/docker.go index 9502dac712..34ce91dd2a 100644 --- a/internal/command/auth/docker.go +++ b/internal/command/auth/docker.go @@ -44,7 +44,9 @@ func ensureDockerConfigDir(home string) error { } // It needs to be readable by Docker, if it gets installed in the // future. - if err := os.Mkdir(dockerDir, 0o755); err != nil { + // The permission is 700 as like Docker itself. + // https://github.com/docker/cli/blob/v23.0.5/cli/config/configfile/file.go#L142 + if err := os.Mkdir(dockerDir, 0o700); err != nil { return err } } else if !fi.IsDir() { @@ -57,13 +59,14 @@ func ensureDockerConfigDir(home string) error { // and returns the updated JSON. // // The config.json is structured as follows: -// { -// "auths": { -// "registry.fly.io": { -// "auth": "x:..." -// } -// } -// } +// +// { +// "auths": { +// "registry.fly.io": { +// "auth": "x:..." +// } +// } +// } func addFlyAuthToDockerConfig(cfg *config.Config, configJSON []byte) ([]byte, error) { var dockerConfig map[string]json.RawMessage if len(configJSON) == 0 { @@ -133,7 +136,7 @@ func configureDockerJSON(cfg *config.Config) error { return os.WriteFile(configPath, updatedJSON, 0o644) } -func runDocker(ctx context.Context) (err error) { +func runDocker(ctx context.Context) error { cfg := config.FromContext(ctx) binary, err := exec.LookPath("docker") if err != nil { @@ -153,28 +156,37 @@ func runDocker(ctx context.Context) (err error) { var in io.WriteCloser if in, err = cmd.StdinPipe(); err != nil { - return + return err } - - go func() { - defer in.Close() - - fmt.Fprint(in, cfg.AccessToken) + // This defer is for early-returns before successfully writing to the stream, hence safe. + defer func() { + if in != nil { + in.Close() // skipcq: GO-S2307 + } }() if err = cmd.Start(); err != nil { - return + return err } - if err = cmd.Wait(); err != nil { - err = fmt.Errorf("failed authenticating with %s: %v", host, out.String()) + _, err = fmt.Fprint(in, cfg.AccessToken) + if err != nil { + return err + } + + err = in.Close() + in = nil // Prevent the deferred function from double-closing + if err != nil { + return err + } - return + if err = cmd.Wait(); err != nil { + return fmt.Errorf("failed authenticating with %s: %v", host, out.String()) } io := iostreams.FromContext(ctx) fmt.Fprintf(io.Out, "Authentication successful. You can now tag and push images to %s/{your-app}\n", host) - return + return nil } diff --git a/internal/command/auth/token.go b/internal/command/auth/token.go index 2af5a6dc17..817e5bfa12 100644 --- a/internal/command/auth/token.go +++ b/internal/command/auth/token.go @@ -24,7 +24,9 @@ independent of flyctl. ) cmd := command.New("token", short, long, runAuthToken, - command.RequireSession) + command.ExcludeFromMetrics, + command.RequireSession, + ) flag.Add(cmd, flag.JSONOutput()) return cmd diff --git a/internal/command/autoscale/root.go b/internal/command/autoscale/root.go new file mode 100644 index 0000000000..de56aced07 --- /dev/null +++ b/internal/command/autoscale/root.go @@ -0,0 +1,252 @@ +package autoscale + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" + + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + const ( + short = "V1 APPS ONLY: Autoscaling app resources" + long = `V1 APPS ONLY: Autoscaling application resources` + ) + cmd := command.New("autoscale", short, long, nil) + cmd.AddCommand( + newAutoscaleDisable(), + newAutoscaleSet(), + newAutoscaleShow(), + ) + return cmd +} + +func newAutoscaleDisable() *cobra.Command { + const ( + short = "V1 APPS ONLY: Disable autoscaling" + long = `V1 APPS ONLY: Disable autoscaling to manually control app resources` + ) + cmd := command.New("disable", short, long, runAutoscaleDisable, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newAutoscaleSet() *cobra.Command { + const ( + short = "Set app autoscaling parameters" + long = `V1 APPS ONLY: Enable autoscaling and set the application's autoscaling parameters: + +min=int - minimum number of instances to be allocated globally. +max=int - maximum number of instances to be allocated globally.` + ) + cmd := command.New("set", short, long, runAutoscaleSet, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newAutoscaleShow() *cobra.Command { + const ( + short = "V1 APPS ONLY: Show current autoscaling configuration" + long = `V1 APPS ONLY: Show current autoscaling configuration` + ) + cmd := command.New("show", short, long, runAutoscaleShow, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.NoArgs + return cmd +} + +func runAutoscaleSet(ctx context.Context) error { + return actualScale(ctx, false) +} + +func runAutoscaleDisable(ctx context.Context) error { + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + + app, err := apiClient.GetAppCompact(ctx, appName) + if err != nil { + return err + } + + if app.PlatformVersion == appconfig.MachinesPlatform { + printMachinesAutoscalingBanner() + return nil + } + + newcfg := api.UpdateAutoscaleConfigInput{ + AppID: appName, + Enabled: api.BoolPointer(false), + } + + cfg, err := apiClient.UpdateAutoscaleConfig(ctx, newcfg) + if err != nil { + return err + } + + printScaleConfig(ctx, cfg) + + return nil +} + +func actualScale(ctx context.Context, balanceRegions bool) error { + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + + app, err := apiClient.GetAppCompact(ctx, appName) + if err != nil { + return err + } + + if app.PlatformVersion == appconfig.MachinesPlatform { + printMachinesAutoscalingBanner() + return nil + } + + currentcfg, err := apiClient.AppAutoscalingConfig(ctx, appName) + if err != nil { + return err + } + + newcfg := api.UpdateAutoscaleConfigInput{ + AppID: appName, + } + + newcfg.BalanceRegions = &balanceRegions + newcfg.MinCount = ¤tcfg.MinCount + newcfg.MaxCount = ¤tcfg.MaxCount + + kvargs := make(map[string]string) + + args := flag.Args(ctx) + for _, pair := range args { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("Scale parameters must be provided as NAME=VALUE pairs (%s is invalid)", pair) + } + key := parts[0] + value := parts[1] + kvargs[strings.ToLower(key)] = value + } + + minval, found := kvargs["min"] + + if found { + minint64val, err := strconv.ParseInt(minval, 10, 64) + if err != nil { + return errors.New("could not parse min count value") + } + minintval := int(minint64val) + newcfg.MinCount = &minintval + delete(kvargs, "min") + } + + maxval, found := kvargs["max"] + + if found { + maxint64val, err := strconv.ParseInt(maxval, 10, 64) + if err != nil { + return errors.New("could not parse max count value") + } + maxintval := int(maxint64val) + newcfg.MaxCount = &maxintval + delete(kvargs, "max") + } + + if len(kvargs) != 0 { + unusedkeys := "" + for k := range kvargs { + if unusedkeys == "" { + unusedkeys = k + } else { + unusedkeys = unusedkeys + ", " + k + } + } + return errors.New("unrecognised parameters in command:" + unusedkeys) + } + + cfg, err := apiClient.UpdateAutoscaleConfig(ctx, newcfg) + if err != nil { + return err + } + + printScaleConfig(ctx, cfg) + + return nil +} + +func runAutoscaleShow(ctx context.Context) error { + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + + cfg, err := apiClient.AppAutoscalingConfig(ctx, appName) + if err != nil { + return err + } + + printScaleConfig(ctx, cfg) + + return nil +} + +func printScaleConfig(ctx context.Context, cfg *api.AutoscalingConfig) { + io := iostreams.FromContext(ctx) + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, cfg) + return + } + + var mode string + + if !cfg.Enabled { + mode = "Disabled" + } else { + mode = "Enabled" + } + + fmt.Fprintf(io.Out, "%15s: %s\n", "Autoscaling", mode) + if cfg.Enabled { + fmt.Fprintf(io.Out, "%15s: %d\n", "Min Count", cfg.MinCount) + fmt.Fprintf(io.Out, "%15s: %d\n", "Max Count", cfg.MaxCount) + } +} + +func printMachinesAutoscalingBanner() { + fmt.Printf(` +Configuring autoscaling via 'flyctl autoscale' is supported only for apps running on Nomad platform. +Refer to this post for details on how to enable autoscaling for Apps V2: +https://community.fly.io/t/increasing-apps-v2-availability/12357 +`) +} diff --git a/internal/command/certificates/root.go b/internal/command/certificates/root.go new file mode 100644 index 0000000000..e81a25f384 --- /dev/null +++ b/internal/command/certificates/root.go @@ -0,0 +1,439 @@ +package certificates + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/dustin/go-humanize" + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + "golang.org/x/net/publicsuffix" +) + +func New() *cobra.Command { + const ( + short = "Manage certificates" + long = `Manages the certificates associated with a deployed application. +Certificates are created by associating a hostname/domain with the application. +When Fly is then able to validate that hostname/domain, the platform gets +certificates issued for the hostname/domain by Let's Encrypt.` + ) + cmd := command.New("certs", short, long, nil) + cmd.AddCommand( + newCertificatesList(), + newCertificatesAdd(), + newCertificatesRemove(), + newCertificatesShow(), + newCertificatesCheck(), + ) + return cmd +} + +func newCertificatesList() *cobra.Command { + const ( + short = "List certificates for an app." + long = `List the certificates associated with a deployed application.` + ) + cmd := command.New("list", short, long, runCertificatesList, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.NoArgs + return cmd +} + +func newCertificatesAdd() *cobra.Command { + const ( + short = "Add a certificate for an app." + long = `Add a certificate for an application. Takes a hostname +as a parameter for the certificate.` + ) + cmd := command.New("add ", short, long, runCertificatesAdd, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.ExactArgs(1) + cmd.Aliases = []string{"create"} + return cmd +} + +func newCertificatesRemove() *cobra.Command { + const ( + short = "Removes a certificate from an app" + long = `Removes a certificate from an application. Takes hostname +as a parameter to locate the certificate.` + ) + cmd := command.New("remove ", short, long, runCertificatesRemove, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.Yes(), + ) + cmd.Args = cobra.ExactArgs(1) + cmd.Aliases = []string{"delete"} + return cmd +} + +func newCertificatesShow() *cobra.Command { + const ( + short = "Shows certificate information" + long = `Shows certificate information for an application. +Takes hostname as a parameter to locate the certificate.` + ) + cmd := command.New("show ", short, long, runCertificatesShow, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.ExactArgs(1) + return cmd +} + +func newCertificatesCheck() *cobra.Command { + const ( + short = "Checks DNS configuration" + long = `Checks the DNS configuration for the specified hostname. +Displays results in the same format as the SHOW command.` + ) + cmd := command.New("check ", short, long, runCertificatesCheck, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.ExactArgs(1) + return cmd +} + +func runCertificatesList(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + certs, err := apiClient.GetAppCertificates(ctx, appName) + if err != nil { + return err + } + + return printCertificates(ctx, certs) +} + +func runCertificatesShow(ctx context.Context) error { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + hostname := flag.FirstArg(ctx) + + cert, hostcheck, err := apiClient.CheckAppCertificate(ctx, appName, hostname) + if err != nil { + return err + } + + if cert.ClientStatus == "Ready" { + fmt.Fprintf(io.Out, "The certificate for %s has been issued.\n\n", colorize.Bold(hostname)) + printCertificate(ctx, cert) + return nil + } + + fmt.Fprintf(io.Out, "The certificate for %s has not been issued yet.\n\n", colorize.Yellow(hostname)) + printCertificate(ctx, cert) + return reportNextStepCert(ctx, hostname, cert, hostcheck) +} + +func runCertificatesCheck(ctx context.Context) error { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + hostname := flag.FirstArg(ctx) + + cert, hostcheck, err := apiClient.CheckAppCertificate(ctx, appName, hostname) + if err != nil { + return err + } + + if cert.ClientStatus == "Ready" { + // A certificate has been issued + fmt.Fprintf(io.Out, "The certificate for %s has been issued.\n\n", colorize.Bold(hostname)) + printCertificate(ctx, cert) + return nil + } + + fmt.Fprintf(io.Out, "The certificate for %s has not been issued yet.\n\n", colorize.Yellow(hostname)) + + return reportNextStepCert(ctx, hostname, cert, hostcheck) +} + +func runCertificatesAdd(ctx context.Context) error { + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + hostname := flag.FirstArg(ctx) + + cert, hostcheck, err := apiClient.AddCertificate(ctx, appName, hostname) + if err != nil { + return err + } + + return reportNextStepCert(ctx, hostname, cert, hostcheck) +} + +func runCertificatesRemove(ctx context.Context) error { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + apiClient := client.FromContext(ctx).API() + appName := appconfig.NameFromContext(ctx) + hostname := flag.FirstArg(ctx) + + if !flag.GetYes(ctx) { + confirm := false + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Remove certificate %s from app %s?", hostname, appName), + } + err := survey.AskOne(prompt, &confirm) + if err != nil { + return err + } + + if !confirm { + return nil + } + } + + cert, err := apiClient.DeleteCertificate(ctx, appName, hostname) + if err != nil { + return err + } + + fmt.Fprintf(io.Out, "Certificate %s deleted from app %s\n", + colorize.Bold(cert.Certificate.Hostname), + colorize.Bold(cert.App.Name), + ) + + return nil +} + +func reportNextStepCert(ctx context.Context, hostname string, cert *api.AppCertificate, hostcheck *api.HostnameCheck) error { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + alternateHostname := getAlternateHostname(hostname) + + // These are the IPs we have for the app + ips, err := apiClient.GetIPAddresses(ctx, appName) + if err != nil { + return err + } + + var ipV4 api.IPAddress + var ipV6 api.IPAddress + var configuredipV4 bool + var configuredipV6 bool + + // Extract the v4 and v6 addresses we have allocated + for _, x := range ips { + if x.Type == "v4" || x.Type == "shared_v4" { + ipV4 = x + } else if x.Type == "v6" { + ipV6 = x + } + } + + // Do we have A records + if len(hostcheck.ARecords) > 0 { + // Let's check the first A record against our recorded addresses + if !net.ParseIP(hostcheck.ARecords[0]).Equal(net.ParseIP(ipV4.Address)) { + fmt.Fprintf(io.Out, colorize.Yellow("A Record (%s) does not match app's IP (%s)\n"), hostcheck.ARecords[0], ipV4.Address) + } else { + configuredipV4 = true + } + } + + if len(hostcheck.AAAARecords) > 0 { + // Let's check the first A record against our recorded addresses + if !net.ParseIP(hostcheck.AAAARecords[0]).Equal(net.ParseIP(ipV6.Address)) { + fmt.Fprintf(io.Out, colorize.Yellow("AAAA Record (%s) does not match app's IP (%s)\n"), hostcheck.AAAARecords[0], ipV6.Address) + } else { + configuredipV6 = true + } + } + + if len(hostcheck.ResolvedAddresses) > 0 { + for _, address := range hostcheck.ResolvedAddresses { + if net.ParseIP(address).Equal(net.ParseIP(ipV4.Address)) { + configuredipV4 = true + } else if net.ParseIP(address).Equal(net.ParseIP(ipV6.Address)) { + configuredipV6 = true + } else { + fmt.Fprintf(io.Out, colorize.Yellow("Address resolution (%s) does not match app's IP (%s/%s)\n"), address, ipV4.Address, ipV6.Address) + } + } + } + + if cert.IsApex { + // If this is an apex domain we should guide towards creating A and AAAA records + addArecord := !configuredipV4 + addAAAArecord := !cert.AcmeALPNConfigured + + if addArecord || addAAAArecord { + stepcnt := 1 + fmt.Fprintf(io.Out, "You are creating a certificate for %s\n", colorize.Bold(hostname)) + fmt.Fprintf(io.Out, "We are using %s for this certificate.\n\n", cert.CertificateAuthority) + if addArecord { + fmt.Fprintf(io.Out, "You can direct traffic to %s by:\n\n", hostname) + fmt.Fprintf(io.Out, "%d: Adding an A record to your DNS service which reads\n", stepcnt) + fmt.Fprintf(io.Out, "\n A @ %s\n\n", ipV4.Address) + stepcnt = stepcnt + 1 + } + if addAAAArecord { + fmt.Fprintf(io.Out, "You can validate your ownership of %s by:\n\n", hostname) + fmt.Fprintf(io.Out, "%d: Adding an AAAA record to your DNS service which reads:\n\n", stepcnt) + fmt.Fprintf(io.Out, " AAAA @ %s\n\n", ipV6.Address) + // stepcnt = stepcnt + 1 Uncomment if more steps + } + } else { + if cert.ClientStatus == "Ready" { + fmt.Fprintf(io.Out, "Your certificate for %s has been issued, make sure you create another certificate for %s \n", hostname, alternateHostname) + } else { + fmt.Fprintf(io.Out, "Your certificate for %s is being issued. Status is %s. Make sure to create another certificate for %s when the current certificate is issued. \n", hostname, cert.ClientStatus, alternateHostname) + } + } + } else if cert.IsWildcard { + // If this is an wildcard domain we should guide towards satisfying a DNS-01 challenge + addArecord := !configuredipV4 + addCNAMErecord := !cert.AcmeDNSConfigured + + stepcnt := 1 + fmt.Fprintf(io.Out, "You are creating a wildcard certificate for %s\n", hostname) + fmt.Fprintf(io.Out, "We are using %s for this certificate.\n\n", cert.CertificateAuthority) + if addArecord { + fmt.Fprintf(io.Out, "You can direct traffic to %s by:\n\n", hostname) + fmt.Fprintf(io.Out, "%d: Adding an A record to your DNS service which reads\n", stepcnt) + stepcnt = stepcnt + 1 + fmt.Fprintf(io.Out, "\n A @ %s\n\n", ipV4.Address) + } + + if addCNAMErecord { + fmt.Fprintf(io.Out, "You can validate your ownership of %s by:\n\n", hostname) + fmt.Fprintf(io.Out, "%d: Adding an CNAME record to your DNS service which reads:\n\n", stepcnt) + fmt.Fprintf(io.Out, " %s\n", cert.DNSValidationInstructions) + // stepcnt = stepcnt + 1 Uncomment if more steps + } + } else { + // This is not an apex domain + // If A and AAAA record is not configured offer CNAME + + nothingConfigured := !(configuredipV4 && configuredipV6) + onlyV4Configured := configuredipV4 && !configuredipV6 + + if nothingConfigured || onlyV4Configured { + fmt.Fprintf(io.Out, "You are creating a certificate for %s\n", hostname) + fmt.Fprintf(io.Out, "We are using %s for this certificate.\n\n", readableCertAuthority(cert.CertificateAuthority)) + + if nothingConfigured { + fmt.Fprintf(io.Out, "You can configure your DNS for %s by:\n\n", hostname) + + eTLD, _ := publicsuffix.EffectiveTLDPlusOne(hostname) + subdomainname := strings.TrimSuffix(hostname, eTLD) + fmt.Fprintf(io.Out, "1: Adding an CNAME record to your DNS service which reads:\n") + fmt.Fprintf(io.Out, "\n CNAME %s %s.fly.dev\n", subdomainname, appName) + } else if onlyV4Configured { + fmt.Fprintf(io.Out, "You can validate your ownership of %s by:\n\n", hostname) + + fmt.Fprintf(io.Out, "1: Adding an CNAME record to your DNS service which reads:\n") + fmt.Fprintf(io.Out, " %s\n", cert.DNSValidationInstructions) + } + } else { + if cert.ClientStatus == "Ready" { + fmt.Fprintf(io.Out, "Your certificate for %s has been issued, make sure you create another certificate for %s \n", hostname, alternateHostname) + } else { + fmt.Fprintf(io.Out, "Your certificate for %s is being issued. Status is %s. Make sure to create another certificate for %s when the current certificate is issued. \n", hostname, cert.ClientStatus, alternateHostname) + } + } + } + + return nil +} + +func printCertificate(ctx context.Context, cert *api.AppCertificate) { + io := iostreams.FromContext(ctx) + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, cert) + return + } + + myprnt := func(label string, value string) { + fmt.Fprintf(io.Out, "%-25s = %s\n", label, value) + } + + certtypes := []string{} + + for _, v := range cert.Issued.Nodes { + certtypes = append(certtypes, v.Type) + } + + myprnt("Hostname", cert.Hostname) + myprnt("DNS Provider", cert.DNSProvider) + myprnt("Certificate Authority", readableCertAuthority(cert.CertificateAuthority)) + myprnt("Issued", strings.Join(certtypes, ",")) + myprnt("Added to App", humanize.Time(cert.CreatedAt)) + myprnt("Source", cert.Source) +} + +func readableCertAuthority(ca string) string { + if ca == "lets_encrypt" { + return "Let's Encrypt" + } + return ca +} + +func printCertificates(ctx context.Context, certs []api.AppCertificateCompact) error { + io := iostreams.FromContext(ctx) + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, certs) + return nil + } + + fmt.Fprintf(io.Out, "%-25s %-20s %s\n", "Host Name", "Added", "Status") + for _, v := range certs { + fmt.Fprintf(io.Out, "%-25s %-20s %s\n", v.Hostname, humanize.Time(v.CreatedAt), v.ClientStatus) + } + + return nil +} + +func getAlternateHostname(hostname string) string { + if strings.Split(hostname, ".")[0] == "www" { + return strings.Replace(hostname, "www.", "", 1) + } else { + return "www." + hostname + } +} diff --git a/internal/command/checks/checks.go b/internal/command/checks/checks.go index aa85537751..dfec88f0fb 100644 --- a/internal/command/checks/checks.go +++ b/internal/command/checks/checks.go @@ -14,9 +14,11 @@ func New() *cobra.Command { // fly checks list listCmd := command.New("list", "List health checks", "", runAppCheckList, command.RequireSession, command.RequireAppName) + listCmd.Aliases = []string{"ls"} flag.Add(listCmd, commonFlags, flag.String{Name: "check-name", Description: "Filter checks by name"}, ) + flag.Add(listCmd, flag.JSONOutput()) cmd.AddCommand(listCmd) return cmd } diff --git a/internal/command/checks/list.go b/internal/command/checks/list.go index 523e044a23..10266dad9c 100644 --- a/internal/command/checks/list.go +++ b/internal/command/checks/list.go @@ -50,6 +50,17 @@ func runMachinesAppCheckList(ctx context.Context, app *api.AppCompact) error { return machines[i].ID < machines[j].ID }) + if config.FromContext(ctx).JSONOutput { + checks := map[string][]api.MachineCheckStatus{} + for _, machine := range machines { + checks[machine.ID] = make([]api.MachineCheckStatus, len(machine.Checks)) + for i, check := range machine.Checks { + checks[machine.ID][i] = *check + } + } + return render.JSON(out, checks) + } + fmt.Fprintf(out, "Health Checks for %s\n", app.Name) table := helpers.MakeSimpleTable(out, []string{"Name", "Status", "Machine", "Last Updated", "Output"}) table.SetRowLine(true) diff --git a/internal/command/command.go b/internal/command/command.go index 28b7d29935..cc0fa29fff 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "strconv" "time" @@ -20,15 +21,16 @@ import ( "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/buildinfo" + "github.com/superfly/flyctl/internal/cache" "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/env" - "github.com/superfly/flyctl/internal/logger" - "github.com/superfly/flyctl/internal/update" - - "github.com/superfly/flyctl/internal/cache" "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/instrument" + "github.com/superfly/flyctl/internal/logger" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/state" "github.com/superfly/flyctl/internal/task" + "github.com/superfly/flyctl/internal/update" ) type ( @@ -37,9 +39,6 @@ type ( Runner func(context.Context) error ) -// TODO: remove once all commands are implemented. -var ErrNotImplementedYet = errors.New("command not implemented yet") - func New(usage, short, long string, fn Runner, p ...Preparer) *cobra.Command { return &cobra.Command{ Use: usage, @@ -63,27 +62,23 @@ var commonPreparers = []Preparer{ promptToUpdate, initClient, killOldAgent, + recordMetricsCommandContext, } -// TODO: remove after migration is complete -func WrapRunE(fn func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) (err error) { - ctx := cmd.Context() - ctx = NewContext(ctx, cmd) - ctx = flag.NewContext(ctx, cmd.Flags()) - - // run the common preparers - if ctx, err = prepare(ctx, commonPreparers...); err != nil { - return - } - - err = fn(cmd, args) - - // and the - finalize(ctx) - - return - } +func sendOsMetric(ctx context.Context, state string) { + // Send /runs/[os_name]/[state] + osName := "" + switch runtime.GOOS { + case "darwin": + osName = "macos" + case "linux": + osName = "linux" + case "windows": + osName = "windows" + default: + osName = "other" + } + metrics.SendNoData(ctx, fmt.Sprintf("runs/%s/%s", osName, state)) } func newRunE(fn Runner, preparers ...Preparer) func(*cobra.Command, []string) error { @@ -101,6 +96,13 @@ func newRunE(fn Runner, preparers ...Preparer) func(*cobra.Command, []string) er return } + sendOsMetric(ctx, "started") + defer func() { + if err == nil { + sendOsMetric(ctx, "successful") + } + }() + // run the preparers specific to the command if ctx, err = prepare(ctx, preparers...); err != nil { return @@ -287,6 +289,7 @@ func initClient(ctx context.Context) (context.Context, error) { // TODO: refactor so that api package does NOT depend on global state api.SetBaseURL(cfg.APIBaseURL) api.SetErrorLog(cfg.LogGQLErrors) + api.SetInstrumenter(instrument.ApiAdapter) c := client.FromToken(cfg.AccessToken) logger.Debug("client initialized.") @@ -350,14 +353,14 @@ func startQueryingForNewRelease(ctx context.Context) (context.Context, error) { } // shouldIgnore allows a preparer to disable itself for specific commands -// E.g. `shouldIgnore([][]string{{"version", "update"}, {"machine", "status"}})` -// would return true for "fly version update" and "fly machine status" +// E.g. `shouldIgnore([][]string{{"version", "upgrade"}, {"machine", "status"}})` +// would return true for "fly version upgrade" and "fly machine status" func shouldIgnore(ctx context.Context, cmds [][]string) bool { cmd := FromContext(ctx) for _, ignoredCmd := range cmds { match := true currentCmd := cmd - // The shape of the ignoredCmd slice is something like ["version", "update"], + // The shape of the ignoredCmd slice is something like ["version", "upgrade"], // but we're walking up the tree from the end, so we have to iterate that in reverse for i := len(ignoredCmd) - 1; i >= 0; i-- { if !currentCmd.HasParent() || currentCmd.Use != ignoredCmd[i] { @@ -377,10 +380,9 @@ func shouldIgnore(ctx context.Context, cmds [][]string) bool { } func promptToUpdate(ctx context.Context) (context.Context, error) { - cfg := config.FromContext(ctx) if cfg.JSONOutput || shouldIgnore(ctx, [][]string{ - {"version", "update"}, + {"version", "upgrade"}, }) { return ctx, nil } @@ -415,7 +417,7 @@ func promptToUpdate(ctx context.Context) (context.Context, error) { msg := fmt.Sprintf("Update available %s -> %s.\nRun \"%s\" to upgrade.", current, r.Version, - colorize.Bold(buildinfo.Name()+" version update"), + colorize.Bold(buildinfo.Name()+" version upgrade"), ) fmt.Fprintln(io.ErrOut, colorize.Yellow(msg)) @@ -473,6 +475,16 @@ func killOldAgent(ctx context.Context) (context.Context, error) { return ctx, nil } +func recordMetricsCommandContext(ctx context.Context) (context.Context, error) { + metrics.RecordCommandContext(ctx) + return ctx, nil +} + +func ExcludeFromMetrics(ctx context.Context) (context.Context, error) { + metrics.Enabled = false + return ctx, nil +} + // RequireSession is a Preparer which makes sure a session exists. func RequireSession(ctx context.Context) (context.Context, error) { if !client.FromContext(ctx).Authenticated() { @@ -548,7 +560,7 @@ func appConfigFilePaths(ctx context.Context) (paths []string) { return } -var errRequireAppName = fmt.Errorf("we couldn't find a fly.toml nor an app specified by the -a flag. If you want to launch a new app, use '%s launch'", buildinfo.Name()) +var errRequireAppName = fmt.Errorf("the config for your app is missing an app name, add an app_name field to the fly.toml file or specify with the -a flag`") // RequireAppName is a Preparer which makes sure the user has selected an // application name via command line arguments, the environment or an application @@ -571,8 +583,7 @@ func RequireAppName(ctx context.Context) (context.Context, error) { } if name == "" { - err := fmt.Errorf("the config for your app is missing an app name, add an app_name field to the fly.toml file or specify with the -a flag`") - return nil, err + return nil, errRequireAppName } return appconfig.WithName(ctx, name), nil diff --git a/internal/command/console/console.go b/internal/command/console/console.go new file mode 100644 index 0000000000..a64824eb22 --- /dev/null +++ b/internal/command/console/console.go @@ -0,0 +1,349 @@ +package console + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/samber/lo" + "github.com/spf13/cobra" + + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/flaps" + "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/command/ssh" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/iostreams" + "github.com/superfly/flyctl/terminal" +) + +func New() *cobra.Command { + const ( + usage = "console" + short = "Run a console in a new or existing machine" + long = "Run a console in a new or existing machine. The console command is\n" + + "specified by the `console_command` configuration field. By default, a\n" + + "new machine is created by default using the app's most recently deployed\n" + + "image. An existing machine can be used instead with --machine." + ) + cmd := command.New(usage, short, long, runConsole, command.RequireSession, command.RequireAppName) + + cmd.Args = cobra.NoArgs + flag.Add( + cmd, + flag.App(), + flag.AppConfig(), + flag.Int{ + Name: "cpus", + Description: "How many (shared) CPUs to give the new machine", + }, + flag.String{ + Name: "machine", + Description: "Run the console in the existing machine with the specified ID", + }, + flag.Int{ + Name: "memory", + Description: "How much memory (in MB) to give the new machine", + }, + flag.Bool{ + Name: "select", + Shorthand: "s", + Description: "Select the machine on which to execute the console from a list", + Default: false, + }, + flag.String{ + Name: "user", + Shorthand: "u", + Description: "Unix username to connect as", + Default: ssh.DefaultSshUsername, + }, + ) + + return cmd +} + +func runConsole(ctx context.Context) error { + var ( + io = iostreams.FromContext(ctx) + colorize = io.ColorScheme() + appName = appconfig.NameFromContext(ctx) + apiClient = client.FromContext(ctx).API() + ) + + app, err := apiClient.GetAppCompact(ctx, appName) + if err != nil { + return fmt.Errorf("failed to get app: %w", err) + } + + if app.PlatformVersion != "machines" { + return errors.New("console is only supported for the machines platform") + } + + flapsClient, err := flaps.New(ctx, app) + if err != nil { + return fmt.Errorf("failed to create flaps client: %w", err) + } + ctx = flaps.NewContext(ctx, flapsClient) + + appConfig := appconfig.ConfigFromContext(ctx) + if appConfig == nil { + appConfig, err = appconfig.FromRemoteApp(ctx, appName) + if err != nil { + return fmt.Errorf("failed to fetch app config from backend: %w", err) + } + } + + if err, extraInfo := appConfig.ValidateForMachinesPlatform(ctx); err != nil { + fmt.Fprintln(io.ErrOut, extraInfo) + return err + } + + machine, ephemeral, err := selectMachine(ctx, app, appConfig) + if err != nil { + return err + } + + if ephemeral { + defer func() { + const stopTimeout = 5 * time.Second + + stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout) + defer cancel() + + stopInput := api.StopMachineInput{ + ID: machine.ID, + Timeout: api.Duration{Duration: stopTimeout}, + } + if err := flapsClient.Stop(stopCtx, stopInput, ""); err != nil { + terminal.Warnf("Failed to stop ephemeral console machine: %v\n", err) + terminal.Warn("You may need to destroy it manually (`fly machine destroy`).") + return + } + + fmt.Fprintf(io.Out, "Waiting for ephemeral console machine %s to be destroyed ...", colorize.Bold(machine.ID)) + if err := flapsClient.Wait(stopCtx, machine, api.MachineStateDestroyed, stopTimeout); err != nil { + fmt.Fprintf(io.Out, " %s!\n", colorize.Red("failed")) + terminal.Warnf("Failed to wait for ephemeral console machine to be destroyed: %v\n", err) + terminal.Warn("You may need to destroy it manually (`fly machine destroy`).") + } else { + fmt.Fprintf(io.Out, " %s.\n", colorize.Green("done")) + } + }() + } + + _, dialer, err := ssh.BringUpAgent(ctx, apiClient, app, false) + if err != nil { + return err + } + + params := &ssh.ConnectParams{ + Ctx: ctx, + Org: app.Organization, + Dialer: dialer, + Username: flag.GetString(ctx, "user"), + DisableSpinner: false, + } + sshClient, err := ssh.Connect(params, machine.PrivateIP) + if err != nil { + return err + } + + return ssh.Console(ctx, sshClient, appConfig.ConsoleCommand, true) +} + +func selectMachine(ctx context.Context, app *api.AppCompact, appConfig *appconfig.Config) (*api.Machine, bool, error) { + if flag.GetBool(ctx, "select") { + return promptForMachine(ctx, app, appConfig) + } else if flag.IsSpecified(ctx, "machine") { + return getMachineByID(ctx) + } else { + guest, err := determineEphemeralConsoleMachineGuest(ctx) + if err != nil { + return nil, false, err + } + return makeEphemeralConsoleMachine(ctx, app, appConfig, guest) + } +} + +func promptForMachine(ctx context.Context, app *api.AppCompact, appConfig *appconfig.Config) (*api.Machine, bool, error) { + if flag.IsSpecified(ctx, "machine") { + return nil, false, errors.New("--machine can't be used with -s/--select") + } + + flapsClient := flaps.FromContext(ctx) + machines, err := flapsClient.ListActive(ctx) + if err != nil { + return nil, false, err + } + machines = lo.Filter(machines, func(machine *api.Machine, _ int) bool { + return machine.State == api.MachineStateStarted + }) + + ephemeralGuest, err := determineEphemeralConsoleMachineGuest(ctx) + if err != nil { + return nil, false, err + } + cpuS := lo.Ternary(ephemeralGuest.CPUs == 1, "", "s") + ephemeralGuestStr := fmt.Sprintf("%d %s CPU%s, %d MB of memory", ephemeralGuest.CPUs, ephemeralGuest.CPUKind, cpuS, ephemeralGuest.MemoryMB) + + options := []string{fmt.Sprintf("create an ephemeral machine (%s)", ephemeralGuestStr)} + for _, machine := range machines { + options = append(options, fmt.Sprintf("%s: %s %s %s", machine.Region, machine.ID, machine.PrivateIP, machine.Name)) + } + + index := 0 + if err := prompt.Select(ctx, &index, "Select a machine:", "", options...); err != nil { + return nil, false, fmt.Errorf("failed to prompt for a machine: %w", err) + } + if index == 0 { + return makeEphemeralConsoleMachine(ctx, app, appConfig, ephemeralGuest) + } else { + return machines[index-1], false, nil + } +} + +func getMachineByID(ctx context.Context) (*api.Machine, bool, error) { + if flag.IsSpecified(ctx, "cpus") { + return nil, false, errors.New("--cpus can't be used with --machine") + } + if flag.IsSpecified(ctx, "memory") { + return nil, false, errors.New("--memory can't be used with --machine") + } + + flapsClient := flaps.FromContext(ctx) + machineID := flag.GetString(ctx, "machine") + machine, err := flapsClient.Get(ctx, machineID) + if err != nil { + return nil, false, err + } + if machine.State != api.MachineStateStarted { + return nil, false, fmt.Errorf("machine %s is not started", machineID) + } + if machine.IsFlyAppsReleaseCommand() { + return nil, false, fmt.Errorf("machine %s is a release command machine", machineID) + } + + return machine, false, nil +} + +func makeEphemeralConsoleMachine(ctx context.Context, app *api.AppCompact, appConfig *appconfig.Config, guest *api.MachineGuest) (*api.Machine, bool, error) { + var ( + io = iostreams.FromContext(ctx) + colorize = io.ColorScheme() + apiClient = client.FromContext(ctx).API() + flapsClient = flaps.FromContext(ctx) + ) + + currentRelease, err := apiClient.GetAppCurrentReleaseMachines(ctx, app.Name) + if err != nil { + return nil, false, err + } + if currentRelease == nil { + return nil, false, errors.New("can't create an ephemeral console machine since the app has not yet been released") + } + + machConfig, err := appConfig.ToConsoleMachineConfig() + if err != nil { + return nil, false, fmt.Errorf("failed to generate ephemeral console machine configuration: %w", err) + } + machConfig.Image = currentRelease.ImageRef + machConfig.Guest = guest + + launchInput := api.LaunchMachineInput{ + Config: machConfig, + } + machine, err := flapsClient.Launch(ctx, launchInput) + if err != nil { + return nil, false, fmt.Errorf("failed to launch ephemeral console machine: %w", err) + } + fmt.Fprintf(io.Out, "Created an ephemeral machine %s to run the console.\n", colorize.Bold(machine.ID)) + + const waitTimeout = 15 * time.Second + fmt.Fprintf(io.Out, "Waiting for %s to start ...", colorize.Bold(machine.ID)) + err = flapsClient.Wait(ctx, machine, api.MachineStateStarted, waitTimeout) + if err == nil { + fmt.Fprintf(io.Out, " %s.\n", colorize.Green("done")) + return machine, true, nil + } + + fmt.Fprintf(io.Out, " %s!\n", colorize.Red("failed")) + var flapsErr *flaps.FlapsError + destroyed := false + if errors.As(err, &flapsErr) && flapsErr.ResponseStatusCode == 404 { + destroyed, err = checkMachineDestruction(ctx, machine, err) + } + + if !destroyed { + terminal.Warn("You may need to destroy the machine manually (`fly machine destroy`).") + } + return nil, false, err +} + +func checkMachineDestruction(ctx context.Context, machine *api.Machine, firstErr error) (bool, error) { + flapsClient := flaps.FromContext(ctx) + machine, err := flapsClient.Get(ctx, machine.ID) + if err != nil { + return false, fmt.Errorf("failed to check status of machine: %w", err) + } + + if machine.State != api.MachineStateDestroyed && machine.State != api.MachineStateDestroying { + return false, firstErr + } + + var exitEvent *api.MachineEvent + for _, event := range machine.Events { + if event.Type == "exit" { + exitEvent = event + break + } + } + + if exitEvent == nil || exitEvent.Request == nil { + return true, errors.New("machine was destroyed unexpectedly") + } + + exitCode, err := exitEvent.Request.GetExitCode() + if err != nil { + return true, errors.New("machine exited unexpectedly") + } + + return true, fmt.Errorf("machine exited unexpectedly with code %v", exitCode) +} + +func determineEphemeralConsoleMachineGuest(ctx context.Context) (*api.MachineGuest, error) { + desiredGuest := helpers.Clone(api.MachinePresets["shared-cpu-1x"]) + + if flag.IsSpecified(ctx, "cpus") { + cpus := flag.GetInt(ctx, "cpus") + if !lo.Contains([]int{1, 2, 4, 6, 8}, cpus) { + return nil, errors.New("invalid number of CPUs") + } + desiredGuest.CPUs = cpus + } + + minMemory := desiredGuest.CPUs * api.MIN_MEMORY_MB_PER_SHARED_CPU + maxMemory := desiredGuest.CPUs * api.MAX_MEMORY_MB_PER_SHARED_CPU + + if flag.IsSpecified(ctx, "memory") { + memory := flag.GetInt(ctx, "memory") + cpuS := lo.Ternary(desiredGuest.CPUs == 1, "", "s") + switch { + case memory < minMemory: + return nil, fmt.Errorf("not enough memory (at least %d MB is required for %d shared CPU%s)", minMemory, desiredGuest.CPUs, cpuS) + case memory > maxMemory: + return nil, fmt.Errorf("too much memory (at most %d MB is allowed for %d shared CPU%s)", maxMemory, desiredGuest.CPUs, cpuS) + case memory%256 != 0: + return nil, errors.New("memory must be in increments of 256 MB") + } + desiredGuest.MemoryMB = memory + } else { + desiredGuest.MemoryMB = lo.Max([]int{desiredGuest.MemoryMB, minMemory}) + } + + return desiredGuest, nil +} diff --git a/internal/command/dashboard/root.go b/internal/command/dashboard/root.go new file mode 100644 index 0000000000..1109341f12 --- /dev/null +++ b/internal/command/dashboard/root.go @@ -0,0 +1,63 @@ +package dashboard + +import ( + "context" + "fmt" + + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/iostreams" +) + +func New() *cobra.Command { + const ( + short = "Open web browser on Fly Web UI for this app" + long = `Open web browser on Fly Web UI for this application` + ) + cmd := command.New("dashboard", short, long, runDashboard, + command.RequireSession, + command.RequireAppName, + ) + cmd.AddCommand( + newDashboardMetrics(), + ) + flag.Add(cmd, + flag.App(), + ) + cmd.Aliases = []string{"dash"} + return cmd +} + +func newDashboardMetrics() *cobra.Command { + const ( + short = "Open web browser on Fly Web UI for this app's metrics" + long = `Open web browser on Fly Web UI for this application's metrics` + ) + cmd := command.New("metrics", short, long, runDashboardMetrics, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + ) + return cmd +} + +func runDashboard(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + return runDashboardOpen(ctx, "https://fly.io/apps/"+appName) +} + +func runDashboardMetrics(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + return runDashboardOpen(ctx, "https://fly.io/apps/"+appName+"/metrics") +} + +func runDashboardOpen(ctx context.Context, url string) error { + io := iostreams.FromContext(ctx) + fmt.Fprintln(io.Out, "Opening", url) + return open.Run(url) +} diff --git a/internal/command/deploy/deploy.go b/internal/command/deploy/deploy.go index 406353fd3a..c8735ad88b 100644 --- a/internal/command/deploy/deploy.go +++ b/internal/command/deploy/deploy.go @@ -6,8 +6,9 @@ import ( "strings" "time" + "github.com/logrusorgru/aurora" "github.com/spf13/cobra" - + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/api" @@ -64,8 +65,9 @@ var CommonFlags = flag.Set{ }, flag.Bool{ Name: "force-nomad", - Description: "Use the Apps v1 platform built with Nomad", + Description: "(Deprecated) Use the Apps v1 platform built with Nomad", Default: false, + Hidden: true, }, flag.Bool{ Name: "force-machines", @@ -76,6 +78,20 @@ var CommonFlags = flag.Set{ Name: "vm-size", Description: `The VM size to use when deploying for the first time. See "fly platform vm-sizes" for valid values`, }, + flag.Bool{ + Name: "ha", + Description: "Create spare machines that increases app availability", + Default: true, + }, + flag.Bool{ + Name: "smoke-checks", + Description: "Perform smoke checks during deployment", + Default: true, + }, + flag.Bool{ + Name: "no-public-ips", + Description: "Do not allocate any new public IP addresses", + }, } func New() (cmd *cobra.Command) { @@ -134,6 +150,7 @@ type DeployWithConfigArgs struct { } func DeployWithConfig(ctx context.Context, appConfig *appconfig.Config, args DeployWithConfigArgs) (err error) { + io := iostreams.FromContext(ctx) appName := appconfig.NameFromContext(ctx) apiClient := client.FromContext(ctx).API() appCompact, err := apiClient.GetAppCompact(ctx, appName) @@ -151,6 +168,7 @@ func DeployWithConfig(ctx context.Context, appConfig *appconfig.Config, args Dep return nil } + fmt.Fprintf(io.Out, "\nWatch your app at https://fly.io/apps/%s/monitoring\n\n", appName) switch isV2App, err := useMachines(ctx, appConfig, appCompact, args, apiClient); { case err != nil: return err @@ -163,34 +181,45 @@ func DeployWithConfig(ctx context.Context, appConfig *appconfig.Config, args Dep return err } default: + if flag.GetBool(ctx, "no-public-ips") { + return fmt.Errorf("The --no-public-ips flag can only be used for v2 apps") + } + err = deployToNomad(ctx, appConfig, appCompact, img) if err != nil { return err } } - url, err := appConfig.URL() - if err == nil && url != nil { - fmt.Println("Visit your newly deployed app at", url) + if appURL := appConfig.URL(); appURL != nil { + fmt.Fprintf(io.Out, "\nVisit your newly deployed app at %s\n", appURL) } return err } -func deployToMachines(ctx context.Context, appConfig *appconfig.Config, appCompact *api.AppCompact, img *imgsrc.DeploymentImage) error { +func deployToMachines(ctx context.Context, appConfig *appconfig.Config, appCompact *api.AppCompact, img *imgsrc.DeploymentImage) (err error) { // It's important to push appConfig into context because MachineDeployment will fetch it from there ctx = appconfig.WithConfig(ctx, appConfig) + metrics.Started(ctx, "deploy_machines") + defer func() { + metrics.Status(ctx, "deploy_machines", err == nil) + }() + md, err := NewMachineDeployment(ctx, MachineDeploymentArgs{ - AppCompact: appCompact, - DeploymentImage: img.Tag, - Strategy: flag.GetString(ctx, "strategy"), - EnvFromFlags: flag.GetStringSlice(ctx, "env"), - PrimaryRegionFlag: appConfig.PrimaryRegion, - SkipHealthChecks: flag.GetDetach(ctx), - WaitTimeout: time.Duration(flag.GetInt(ctx, "wait-timeout")) * time.Second, - LeaseTimeout: time.Duration(flag.GetInt(ctx, "lease-timeout")) * time.Second, - VMSize: flag.GetString(ctx, "vm-size"), + AppCompact: appCompact, + DeploymentImage: img.Tag, + Strategy: flag.GetString(ctx, "strategy"), + EnvFromFlags: flag.GetStringSlice(ctx, "env"), + PrimaryRegionFlag: appConfig.PrimaryRegion, + SkipSmokeChecks: flag.GetDetach(ctx) || !flag.GetBool(ctx, "smoke-checks"), + SkipHealthChecks: flag.GetDetach(ctx), + WaitTimeout: time.Duration(flag.GetInt(ctx, "wait-timeout")) * time.Second, + LeaseTimeout: time.Duration(flag.GetInt(ctx, "lease-timeout")) * time.Second, + VMSize: flag.GetString(ctx, "vm-size"), + IncreasedAvailability: flag.GetBool(ctx, "ha"), + AllocPublicIP: !flag.GetBool(ctx, "no-public-ips"), }) if err != nil { sentry.CaptureExceptionWithAppInfo(err, "deploy", appCompact) @@ -204,8 +233,14 @@ func deployToMachines(ctx context.Context, appConfig *appconfig.Config, appCompa return err } -func deployToNomad(ctx context.Context, appConfig *appconfig.Config, appCompact *api.AppCompact, img *imgsrc.DeploymentImage) error { +func deployToNomad(ctx context.Context, appConfig *appconfig.Config, appCompact *api.AppCompact, img *imgsrc.DeploymentImage) (err error) { apiClient := client.FromContext(ctx).API() + + metrics.Started(ctx, "deploy_nomad") + defer func() { + metrics.Status(ctx, "deploy_nomad", err == nil) + }() + // Assign an empty map if nil so later assignments won't fail if appConfig.PrimaryRegion != "" && appConfig.Env["PRIMARY_REGION"] == "" { appConfig.SetEnvVariable("PRIMARY_REGION", appConfig.PrimaryRegion) @@ -216,6 +251,12 @@ func deployToNomad(ctx context.Context, appConfig *appconfig.Config, appCompact return err } + // Give a warning about nomad deprecation every 5 releases + if release.Version%5 == 0 { + io := iostreams.FromContext(ctx) + fmt.Fprintf(io.ErrOut, "%s Apps v1 Platform is deprecated. We recommend migrating your app with `fly migrate-to-v2`", aurora.Yellow("WARN")) + } + if flag.GetDetach(ctx) { return nil } diff --git a/internal/command/deploy/deploy_build.go b/internal/command/deploy/deploy_build.go index b208da813b..c291c6827a 100644 --- a/internal/command/deploy/deploy_build.go +++ b/internal/command/deploy/deploy_build.go @@ -13,12 +13,37 @@ import ( "github.com/superfly/flyctl/internal/cmdutil" "github.com/superfly/flyctl/internal/env" "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/internal/state" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/terminal" ) +func multipleDockerfile(ctx context.Context, appConfig *appconfig.Config) error { + if len(appConfig.BuildStrategies()) == 0 { + // fly.toml doesn't know anything about building this image. + return nil + } + + found := imgsrc.ResolveDockerfile(state.WorkingDirectory(ctx)) + if found == "" { + // No Dockerfile in the directory. + return nil + } + + config, _ := resolveDockerfilePath(ctx, appConfig) + if config == "" { + // No Dockerfile in fly.toml. + return nil + } + + if found != config { + return fmt.Errorf("Ignoring %s, and using %s (from fly.toml).", found, config) + } + return nil +} + // determineImage picks the deployment strategy, builds the image and returns a // DeploymentImage struct func determineImage(ctx context.Context, appConfig *appconfig.Config) (img *imgsrc.DeploymentImage, err error) { @@ -28,12 +53,8 @@ func determineImage(ctx context.Context, appConfig *appconfig.Config) (img *imgs client := client.FromContext(ctx).API() io := iostreams.FromContext(ctx) - if len(appConfig.BuildStrategies()) > 0 { - foundDF := imgsrc.ResolveDockerfile(state.WorkingDirectory(ctx)) - configDF, _ := resolveDockerfilePath(ctx, appConfig) - if foundDF != "" && foundDF != configDF { - terminal.Warnf("Ignoring %s due to config\n", foundDF) - } + if err := multipleDockerfile(ctx, appConfig); err != nil { + terminal.Warnf("%s\n", err.Error()) } resolver := imgsrc.NewResolver(daemonType, client, appConfig.AppName, io) @@ -109,13 +130,21 @@ func determineImage(ctx context.Context, appConfig *appconfig.Config) (img *imgs // finally, build the image heartbeat, err := resolver.StartHeartbeat(ctx) if err != nil { + metrics.SendNoData(ctx, "remote_builder_failure") return nil, err } defer heartbeat.Stop() + metrics.Started(ctx, "remote_build_image") + sendDurationMetrics := metrics.StartTiming(ctx, "remote_build_image/duration") + if img, err = resolver.BuildImage(ctx, io, opts); err == nil && img == nil { err = errors.New("no image specified") } + metrics.Status(ctx, "remote_build_image", err == nil) + if err == nil { + sendDurationMetrics() + } if err == nil { tb.Printf("image: %s\n", img.Tag) diff --git a/internal/command/deploy/deploy_build_test.go b/internal/command/deploy/deploy_build_test.go new file mode 100644 index 0000000000..3275f79fba --- /dev/null +++ b/internal/command/deploy/deploy_build_test.go @@ -0,0 +1,34 @@ +package deploy + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/state" + "os" + "path/filepath" + "testing" +) + +func TestMultipleDockerfile(t *testing.T) { + dir := t.TempDir() + + f, err := os.Create(filepath.Join(dir, "Dockerfile")) + require.NoError(t, err) + defer f.Close() // skipcq: GO-S2307 + + ctx := state.WithWorkingDirectory(context.Background(), dir) + err = multipleDockerfile(ctx, &appconfig.Config{}) + assert.NoError(t, err) + + err = multipleDockerfile( + ctx, + &appconfig.Config{ + Build: &appconfig.Build{ + Dockerfile: "Dockerfile.from-fly-toml", + }, + }, + ) + assert.Error(t, err) +} diff --git a/internal/command/deploy/deploy_first.go b/internal/command/deploy/deploy_first.go index 3cde92943a..d30654ff81 100644 --- a/internal/command/deploy/deploy_first.go +++ b/internal/command/deploy/deploy_first.go @@ -9,11 +9,11 @@ import ( "github.com/superfly/flyctl/internal/prompt" ) -func (md *machineDeployment) provisionFirstDeploy(ctx context.Context) error { +func (md *machineDeployment) provisionFirstDeploy(ctx context.Context, allocPublicIPs bool) error { if !md.isFirstDeploy || md.restartOnly { return nil } - if err := md.provisionIpsOnFirstDeploy(ctx); err != nil { + if err := md.provisionIpsOnFirstDeploy(ctx, allocPublicIPs); err != nil { fmt.Fprintf(md.io.ErrOut, "Failed to provision IP addresses, use `fly ips` commands to remmediate it. ERROR: %s", err) } if err := md.provisionVolumesOnFirstDeploy(ctx); err != nil { @@ -22,9 +22,9 @@ func (md *machineDeployment) provisionFirstDeploy(ctx context.Context) error { return nil } -func (md *machineDeployment) provisionIpsOnFirstDeploy(ctx context.Context) error { +func (md *machineDeployment) provisionIpsOnFirstDeploy(ctx context.Context, allocPublicIPs bool) error { // Provision only if the app hasn't been deployed and have services defined - if !md.isFirstDeploy || len(md.appConfig.AllServices()) == 0 { + if !md.isFirstDeploy || len(md.appConfig.AllServices()) == 0 || !allocPublicIPs { return nil } diff --git a/internal/command/deploy/machines.go b/internal/command/deploy/machines.go index 4cb248c07c..d6b4c4d1ae 100644 --- a/internal/command/deploy/machines.go +++ b/internal/command/deploy/machines.go @@ -31,16 +31,19 @@ type MachineDeployment interface { } type MachineDeploymentArgs struct { - AppCompact *api.AppCompact - DeploymentImage string - Strategy string - EnvFromFlags []string - PrimaryRegionFlag string - SkipHealthChecks bool - RestartOnly bool - WaitTimeout time.Duration - LeaseTimeout time.Duration - VMSize string + AppCompact *api.AppCompact + DeploymentImage string + Strategy string + EnvFromFlags []string + PrimaryRegionFlag string + SkipSmokeChecks bool + SkipHealthChecks bool + RestartOnly bool + WaitTimeout time.Duration + LeaseTimeout time.Duration + VMSize string + IncreasedAvailability bool + AllocPublicIP bool } type machineDeployment struct { @@ -58,6 +61,7 @@ type machineDeployment struct { strategy string releaseId string releaseVersion int + skipSmokeChecks bool skipHealthChecks bool restartOnly bool waitTimeout time.Duration @@ -65,6 +69,8 @@ type machineDeployment struct { leaseDelayBetween time.Duration isFirstDeploy bool machineGuest *api.MachineGuest + increasedAvailability bool + listenAddressChecked map[string]struct{} } func NewMachineDeployment(ctx context.Context, args MachineDeploymentArgs) (MachineDeployment, error) { @@ -110,19 +116,22 @@ func NewMachineDeployment(ctx context.Context, args MachineDeploymentArgs) (Mach io := iostreams.FromContext(ctx) apiClient := client.FromContext(ctx).API() md := &machineDeployment{ - apiClient: apiClient, - gqlClient: apiClient.GenqClient, - flapsClient: flapsClient, - io: io, - colorize: io.ColorScheme(), - app: args.AppCompact, - appConfig: appConfig, - img: args.DeploymentImage, - skipHealthChecks: args.SkipHealthChecks, - restartOnly: args.RestartOnly, - waitTimeout: waitTimeout, - leaseTimeout: leaseTimeout, - leaseDelayBetween: leaseDelayBetween, + apiClient: apiClient, + gqlClient: apiClient.GenqClient, + flapsClient: flapsClient, + io: io, + colorize: io.ColorScheme(), + app: args.AppCompact, + appConfig: appConfig, + img: args.DeploymentImage, + skipSmokeChecks: args.SkipSmokeChecks, + skipHealthChecks: args.SkipHealthChecks, + restartOnly: args.RestartOnly, + waitTimeout: waitTimeout, + leaseTimeout: leaseTimeout, + leaseDelayBetween: leaseDelayBetween, + increasedAvailability: args.IncreasedAvailability, + listenAddressChecked: make(map[string]struct{}), } if err := md.setStrategy(args.Strategy); err != nil { return nil, err @@ -144,7 +153,7 @@ func NewMachineDeployment(ctx context.Context, args MachineDeploymentArgs) (Mach } // Provisioning must come after setVolumes - if err := md.provisionFirstDeploy(ctx); err != nil { + if err := md.provisionFirstDeploy(ctx, args.AllocPublicIP); err != nil { return nil, err } @@ -230,14 +239,15 @@ func (md *machineDeployment) setVolumes(ctx context.Context) error { return nil } -func (md *machineDeployment) popVolumeFor(name string) *api.Volume { - volumes, ok := md.volumes[name] - if !ok { - return nil +func (md *machineDeployment) popVolumeFor(name, region string) *api.Volume { + volumes := md.volumes[name] + for idx, v := range volumes { + if region == "" || region == v.Region { + md.volumes[name] = append(volumes[:idx], volumes[idx+1:]...) + return &v + } } - var vol api.Volume - vol, md.volumes[name] = volumes[0], volumes[1:] - return &vol + return nil } func (md *machineDeployment) validateVolumeConfig() error { @@ -264,6 +274,8 @@ func (md *machineDeployment) validateVolumeConfig() error { mntDst = groupConfig.Mounts[0].Destination } + needsVol := map[string][]string{} + for _, m := range ms { if mntDst == "" && len(m.Config.Mounts) != 0 { // TODO: Detaching a volume from a machine is possible, but it usually means a missconfiguration. @@ -276,13 +288,9 @@ func (md *machineDeployment) validateVolumeConfig() error { } if mntDst != "" && len(m.Config.Mounts) == 0 { - // TODO: Attaching a volume to an existing machine is not possible, but it could replace the machine + // Attaching a volume to an existing machine is not possible, but we replace the machine // by another running on the same zone than the volume. - return fmt.Errorf( - "machine %s [%s] does not have a volume configured and fly.toml expects one with destination %s; "+ - "remove the [mounts] configuration in fly.toml or use the machines API to add a volume to this machine", - m.ID, groupName, mntDst, - ) + needsVol[mntSrc] = append(needsVol[mntSrc], m.Region) } if mms := m.Config.Mounts; len(mms) > 0 && mntSrc != "" && mms[0].Name != "" && mntSrc != mms[0].Name { @@ -295,6 +303,28 @@ func (md *machineDeployment) validateVolumeConfig() error { } } + // Compute the volume differences per region + for volSrc, regions := range needsVol { + currentPerRegion := lo.CountValuesBy(md.volumes[volSrc], func(v api.Volume) string { return v.Region }) + needsPerRegion := lo.CountValues(regions) + + var missing []string + for rn, rc := range needsPerRegion { + diff := rc - currentPerRegion[rn] + if diff > 0 { + missing = append(missing, fmt.Sprintf("%s=%d", rn, diff)) + } + } + if len(missing) > 0 { + // TODO: May change this by a prompt to create new volumes right away (?) + return fmt.Errorf( + "Process group '%s' needs volumes with name '%s' to fullfill mounts defined in fly.toml; "+ + "Run `fly volume create %s -r REGION` for the following regions and counts: %s", + groupName, volSrc, volSrc, strings.Join(missing, " "), + ) + } + } + case false: // Check if there are unattached volumes for new groups with mounts for _, m := range groupConfig.Mounts { @@ -364,12 +394,16 @@ func (md *machineDeployment) setStrategy(passedInStrategy string) error { } else { md.strategy = "rolling" } - if md.strategy != "rolling" && md.strategy != "immediate" { + if !MachineSupportedStrategy(md.strategy) { return fmt.Errorf("error unsupported deployment strategy '%s'; fly deploy for machines supports rolling and immediate strategies", md.strategy) } return nil } +func MachineSupportedStrategy(strategy string) bool { + return strategy == "rolling" || strategy == "immediate" || strategy == "" +} + func (md *machineDeployment) createReleaseInBackend(ctx context.Context) error { _ = `# @genqlient mutation MachinesCreateRelease($input:CreateReleaseInput!) { diff --git a/internal/command/deploy/machines_deploymachinesapp.go b/internal/command/deploy/machines_deploymachinesapp.go index 3f2048a8e5..8d9896c05d 100644 --- a/internal/command/deploy/machines_deploymachinesapp.go +++ b/internal/command/deploy/machines_deploymachinesapp.go @@ -4,12 +4,15 @@ import ( "context" "errors" "fmt" + "net" + "strconv" "strings" "time" "github.com/samber/lo" "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/flaps" + "github.com/superfly/flyctl/helpers" machcmd "github.com/superfly/flyctl/internal/command/machine" "github.com/superfly/flyctl/internal/machine" "github.com/superfly/flyctl/terminal" @@ -130,6 +133,11 @@ func (md *machineDeployment) deployMachinesApp(ctx context.Context) error { } } + // Create spare machines that increases availability unless --ha=false was used + if !md.increasedAvailability { + continue + } + // We strive to provide a HA setup according to: // - Create only 1 machine if the group has mounts // - Create 2 machines for groups with services @@ -253,8 +261,13 @@ func (md *machineDeployment) updateExistingMachines(ctx context.Context, updateE return err } + if err := md.doSmokeChecks(ctx, lm, indexStr); err != nil { + return err + } + if !md.skipHealthChecks { if err := lm.WaitForHealthchecksToPass(ctx, md.waitTimeout, indexStr); err != nil { + md.warnAboutIncorrectListenAddress(ctx, lm) return err } // FIXME: combine this wait with the wait for start as one update line (or two per in noninteractive case) @@ -265,6 +278,8 @@ func (md *machineDeployment) updateExistingMachines(ctx context.Context, updateE md.colorize.Green("success"), ) } + + md.warnAboutIncorrectListenAddress(ctx, lm) } fmt.Fprintf(md.io.ErrOut, " Finished deploying\n") @@ -310,9 +325,14 @@ func (md *machineDeployment) spawnMachineInGroup(ctx context.Context, groupName return "", err } + if err := md.doSmokeChecks(ctx, lm, indexStr); err != nil { + return "", err + } + // And wait (or not) for successful health checks if !md.skipHealthChecks { if err := lm.WaitForHealthchecksToPass(ctx, md.waitTimeout, indexStr); err != nil { + md.warnAboutIncorrectListenAddress(ctx, lm) return "", err } @@ -323,6 +343,8 @@ func (md *machineDeployment) spawnMachineInGroup(ctx context.Context, groupName ) } + md.warnAboutIncorrectListenAddress(ctx, lm) + return newMachineRaw.ID, nil } @@ -379,3 +401,116 @@ func (md *machineDeployment) warnAboutProcessGroupChanges(ctx context.Context, d } fmt.Fprint(md.io.Out, "\n") } + +func (md *machineDeployment) warnAboutIncorrectListenAddress(ctx context.Context, lm machine.LeasableMachine) { + group := lm.Machine().ProcessGroup() + + if _, ok := md.listenAddressChecked[group]; ok { + return + } + md.listenAddressChecked[group] = struct{}{} + + groupConfig, err := md.appConfig.Flatten(group) + if err != nil { + return + } + services := groupConfig.AllServices() + + tcpServices := make(map[int]struct{}) + for _, s := range services { + if s.Protocol == "tcp" { + tcpServices[s.InternalPort] = struct{}{} + } + } + + processes, err := md.flapsClient.GetProcesses(ctx, lm.Machine().ID) + // Let's not fail the whole deployment because of this, as listen address check is just a warning + if err != nil { + return + } + + var foundSockets int + for _, proc := range processes { + for _, ls := range proc.ListenSockets { + foundSockets += 1 + + host, portStr, err := net.SplitHostPort(ls.Address) + if err != nil { + continue + } + port, err := strconv.Atoi(portStr) + if err != nil { + continue + } + + ip := net.ParseIP(host) + + // We don't know VM's internal ipv4 which is also a valid address to bind to. + // Let's assume that whoever binds to a non-loopback address knows what they are doing. + // If we expose this address to flyctl later, we can revisit this logic. + if !ip.IsLoopback() { + delete(tcpServices, port) + } + } + } + + // This can either mean that nothing is listening or that VM is running old init that doesn't expose + // listen sockets. Until we have a way to update init on already created VMs let's ignore this + // and pretend that this is old init. + if foundSockets == 0 { + return + } + + // All services are covered + if len(tcpServices) == 0 { + return + } + + fmt.Fprintf(md.io.ErrOut, "\n%s The app is listening on the incorrect address and will not be reachable by fly-proxy.\n", md.colorize.Yellow("WARNING")) + fmt.Fprintf(md.io.ErrOut, "You can fix this by configuring your app to listen on the following addresses:\n") + for port := range tcpServices { + fmt.Fprintf(md.io.ErrOut, " - %s\n", md.colorize.Green("0.0.0.0:"+strconv.Itoa(port))) + } + fmt.Fprintf(md.io.ErrOut, "Found these processes inside the machine with open listening sockets:\n") + + table := helpers.MakeSimpleTable(md.io.ErrOut, []string{"Process", "Addresses"}) + for _, proc := range processes { + var addresses []string + for _, ls := range proc.ListenSockets { + if ls.Proto == "tcp" { + addresses = append(addresses, ls.Address) + } + } + if len(addresses) > 0 { + table.Append([]string{proc.Command, strings.Join(addresses, ", ")}) + } + } + table.Render() + fmt.Fprintf(md.io.ErrOut, "\n") +} + +func (md *machineDeployment) doSmokeChecks(ctx context.Context, lm machine.LeasableMachine, indexStr string) (err error) { + if md.skipSmokeChecks { + return nil + } + + if err = lm.WaitForSmokeChecksToPass(ctx, indexStr); err == nil { + md.logClearLinesAbove(1) + return nil + } + + fmt.Fprintf(md.io.ErrOut, "Smoke checks for %s failed: %v\n", md.colorize.Bold(lm.Machine().ID), err) + fmt.Fprintf(md.io.ErrOut, "Check its logs: here's the last lines below, or run 'fly logs -i %s':\n", lm.Machine().ID) + logs, _, logErr := md.apiClient.GetAppLogs(ctx, md.app.Name, "", md.appConfig.PrimaryRegion, lm.Machine().ID) + if logErr != nil { + return fmt.Errorf("error getting release_command logs: %w", logErr) + } + for _, l := range logs { + // Ideally we should use InstanceID here, but it's not available in the logs. + if l.Timestamp >= lm.Machine().UpdatedAt { + fmt.Fprintf(md.io.ErrOut, " %s\n", l.Message) + } + } + + return fmt.Errorf("smoke checks for %s failed: %v", lm.Machine().ID, err) +} diff --git a/internal/command/deploy/machines_launchinput.go b/internal/command/deploy/machines_launchinput.go index 5d24fa99f0..388b913c32 100644 --- a/internal/command/deploy/machines_launchinput.go +++ b/internal/command/deploy/machines_launchinput.go @@ -15,11 +15,9 @@ func (md *machineDeployment) launchInputForRestart(origMachineRaw *api.Machine) md.setMachineReleaseData(Config) return &api.LaunchMachineInput{ - ID: origMachineRaw.ID, - AppID: md.app.Name, - OrgSlug: md.app.Organization.ID, - Config: Config, - Region: origMachineRaw.Region, + ID: origMachineRaw.ID, + Config: Config, + Region: origMachineRaw.Region, } } @@ -33,12 +31,13 @@ func (md *machineDeployment) launchInputForLaunch(processGroup string, guest *ap md.setMachineReleaseData(mConfig) // Get the final process group and prevent empty string processGroup = mConfig.ProcessGroup() + region := md.appConfig.PrimaryRegion if len(mConfig.Mounts) > 0 { mount0 := &mConfig.Mounts[0] - vol := md.popVolumeFor(mount0.Name) + vol := md.popVolumeFor(mount0.Name, region) if vol == nil { - return nil, fmt.Errorf("New machine in group '%s' needs an unattached volume named '%s'", processGroup, mount0.Name) + return nil, fmt.Errorf("New machine in group '%s' needs an unattached volume named '%s' in region '%s'", processGroup, mount0.Name, region) } mount0.Volume = vol.ID } @@ -48,9 +47,7 @@ func (md *machineDeployment) launchInputForLaunch(processGroup string, guest *ap } return &api.LaunchMachineInput{ - AppID: md.app.Name, - OrgSlug: md.app.Organization.ID, - Region: md.appConfig.PrimaryRegion, + Region: region, Config: mConfig, SkipLaunch: len(standbyFor) > 0, }, nil @@ -92,9 +89,9 @@ func (md *machineDeployment) launchInputForUpdate(origMachineRaw *api.Machine) ( // way is to destroy the current machine and launch a new one with the new volume attached mount0 := &mMounts[0] terminal.Warnf("Machine %s has volume '%s' attached but fly.toml have a different name: '%s'\n", mID, oMounts[0].Name, mount0.Name) - vol := md.popVolumeFor(mount0.Name) + vol := md.popVolumeFor(mount0.Name, origMachineRaw.Region) if vol == nil { - return nil, fmt.Errorf("machine in group '%s' needs an unattached volume named '%s'", processGroup, mount0.Name) + return nil, fmt.Errorf("machine in group '%s' needs an unattached volume named '%s' in region '%s'", processGroup, mount0.Name, origMachineRaw.Region) } mount0.Volume = vol.ID mID = "" // Forces machine replacement @@ -115,18 +112,22 @@ func (md *machineDeployment) launchInputForUpdate(origMachineRaw *api.Machine) ( // and it is not possible to attach a volume to an existing machine. // The volume could be in a different zone than the machine. mount0 := &mMounts[0] - vol := md.popVolumeFor(mount0.Name) + vol := md.popVolumeFor(mount0.Name, origMachineRaw.Region) if vol == nil { - return nil, fmt.Errorf("machine in group '%s' needs an unattached volume named '%s'", processGroup, mMounts[0].Name) + return nil, fmt.Errorf("machine in group '%s' needs an unattached volume named '%s' in region '%s'", processGroup, mMounts[0].Name, origMachineRaw.Region) } mount0.Volume = vol.ID mID = "" // Forces machine replacement } + // If this is a standby machine that now has a service, then clear + // the standbys list. + if len(mConfig.Services) > 0 && len(mConfig.Standbys) > 0 { + mConfig.Standbys = nil + } + return &api.LaunchMachineInput{ ID: mID, - AppID: md.app.Name, - OrgSlug: md.app.Organization.ID, Region: origMachineRaw.Region, Config: mConfig, SkipLaunch: len(mConfig.Standbys) > 0, diff --git a/internal/command/deploy/machines_launchinput_test.go b/internal/command/deploy/machines_launchinput_test.go index e7d63d60bd..4cca803c7d 100644 --- a/internal/command/deploy/machines_launchinput_test.go +++ b/internal/command/deploy/machines_launchinput_test.go @@ -25,8 +25,7 @@ func Test_launchInputFor_Basic(t *testing.T) { // Launch a new machine want := &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", - Region: "scl", + Region: "scl", Config: &api.MachineConfig{ Env: map[string]string{ "PRIMARY_REGION": "scl", @@ -214,3 +213,27 @@ func Test_launchInputForUpdate_keepUnmanagedFields(t *testing.T) { assert.Equal(t, &api.DNSConfig{SkipRegistration: true}, li.Config.DNS) assert.Equal(t, []api.MachineProcess{{CmdOverride: []string{"foo"}}}, li.Config.Processes) } + +// Check that standby machines with services have their standbys list +// cleared. +func Test_launchInputForUpdate_clearStandbysWithServices(t *testing.T) { + md, err := stabMachineDeployment(&appconfig.Config{ + AppName: "my-cool-app", + PrimaryRegion: "scl", + HTTPService: &appconfig.HTTPService{ + InternalPort: 8080, + }, + }) + require.NoError(t, err) + + li, err := md.launchInputForUpdate(&api.Machine{ + ID: "ab1234567890", + Region: "scl", + Config: &api.MachineConfig{ + Standbys: []string{"xy0987654321"}, + }, + }) + require.NoError(t, err) + + assert.Equal(t, 0, len(li.Config.Standbys)) +} diff --git a/internal/command/deploy/machines_releasecommand.go b/internal/command/deploy/machines_releasecommand.go index e586dc2dd1..997839d81d 100644 --- a/internal/command/deploy/machines_releasecommand.go +++ b/internal/command/deploy/machines_releasecommand.go @@ -117,11 +117,8 @@ func (md *machineDeployment) launchInputForReleaseCommand(origMachineRaw *api.Ma md.setMachineReleaseData(mConfig) return &api.LaunchMachineInput{ - ID: origMachineRaw.ID, - AppID: md.app.Name, - OrgSlug: md.app.Organization.ID, - Config: mConfig, - Region: origMachineRaw.Region, + Config: mConfig, + Region: origMachineRaw.Region, } } diff --git a/internal/command/deploy/machines_test.go b/internal/command/deploy/machines_test.go index 38b3733933..bed12eead3 100644 --- a/internal/command/deploy/machines_test.go +++ b/internal/command/deploy/machines_test.go @@ -37,7 +37,6 @@ func Test_resolveUpdatedMachineConfig_Basic(t *testing.T) { li, err := md.launchInputForLaunch("", nil, nil) require.NoError(t, err) assert.Equal(t, &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", Config: &api.MachineConfig{ Env: map[string]string{ "PRIMARY_REGION": "scl", @@ -100,7 +99,6 @@ func Test_resolveUpdatedMachineConfig_ReleaseCommand(t *testing.T) { li, err := md.launchInputForLaunch("", nil, nil) require.NoError(t, err) assert.Equal(t, &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", Config: &api.MachineConfig{ Env: map[string]string{ "PRIMARY_REGION": "scl", @@ -142,7 +140,6 @@ func Test_resolveUpdatedMachineConfig_ReleaseCommand(t *testing.T) { // New release command machine assert.Equal(t, &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", Config: &api.MachineConfig{ Init: api.MachineInit{ Cmd: []string{"touch", "sky"}, @@ -187,7 +184,6 @@ func Test_resolveUpdatedMachineConfig_ReleaseCommand(t *testing.T) { }, } assert.Equal(t, &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", Config: &api.MachineConfig{ Env: map[string]string{ "PRIMARY_REGION": "scl", @@ -234,7 +230,6 @@ func Test_resolveUpdatedMachineConfig_Mounts(t *testing.T) { li, err := md.launchInputForLaunch("", nil, nil) require.NoError(t, err) assert.Equal(t, &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", Config: &api.MachineConfig{ Image: "super/balloon", Metadata: map[string]string{ @@ -267,7 +262,6 @@ func Test_resolveUpdatedMachineConfig_Mounts(t *testing.T) { li, err = md.launchInputForUpdate(origMachine) require.NoError(t, err) assert.Equal(t, &api.LaunchMachineInput{ - OrgSlug: "my-dangling-org", Config: &api.MachineConfig{ Image: "super/balloon", Metadata: map[string]string{ @@ -309,8 +303,7 @@ func Test_resolveUpdatedMachineConfig_restartOnly(t *testing.T) { } assert.Equal(t, &api.LaunchMachineInput{ - ID: "OrigID", - OrgSlug: "my-dangling-org", + ID: "OrigID", Config: &api.MachineConfig{ Image: "instead-use/the-redmoon", Metadata: map[string]string{ @@ -353,8 +346,7 @@ func Test_resolveUpdatedMachineConfig_restartOnlyProcessGroup(t *testing.T) { } assert.Equal(t, &api.LaunchMachineInput{ - ID: "OrigID", - OrgSlug: "my-dangling-org", + ID: "OrigID", Config: &api.MachineConfig{ Image: "instead-use/the-redmoon", Metadata: map[string]string{ diff --git a/internal/command/dnsrecords/root.go b/internal/command/dnsrecords/root.go new file mode 100644 index 0000000000..90afeb156a --- /dev/null +++ b/internal/command/dnsrecords/root.go @@ -0,0 +1,213 @@ +package dnsrecords + +import ( + "context" + "fmt" + "io" + "os" + "strconv" + + "github.com/olekukonko/tablewriter" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" + + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + const ( + short = "Manage DNS records" + long = "Manage DNS records within a domain" + ) + cmd := command.New("dns-records", short, long, nil) + cmd.AddCommand( + newDNSRecordsList(), + newDNSRecordsExport(), + newDNSRecordsImport(), + ) + return cmd +} + +func newDNSRecordsList() *cobra.Command { + const ( + short = `List DNS records` + long = `List DNS records within a domain` + ) + cmd := command.New("list ", short, long, runDNSRecordsList, + command.RequireSession, + ) + flag.Add(cmd, + flag.JSONOutput(), + ) + cmd.Args = cobra.ExactArgs(1) + return cmd +} + +func newDNSRecordsExport() *cobra.Command { + const ( + short = "Export DNS records" + long = `Export DNS records. Will write to a file if a filename is given, otherwise writers to StdOut.` + ) + cmd := command.New("export [filename]", short, long, runDNSRecordsExport, + command.RequireSession, + ) + cmd.Args = cobra.RangeArgs(1, 2) + return cmd +} + +func newDNSRecordsImport() *cobra.Command { + const ( + short = "Import DNS records" + long = `Import DNS records. Will import from a file is a filename is given, otherwise imports from StdIn.` + ) + cmd := command.New("import [filename]", short, long, runDNSRecordsImport, + command.RequireSession, + ) + cmd.Args = cobra.RangeArgs(1, 2) + return cmd +} + +func runDNSRecordsList(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + name := flag.FirstArg(ctx) + + records, err := apiClient.GetDNSRecords(ctx, name) + if err != nil { + return err + } + + fmt.Printf("Records for domain %s\n", name) + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, records) + return nil + } + + table := tablewriter.NewWriter(io.Out) + table.SetAutoWrapText(true) + table.SetReflowDuringAutoWrap(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetNoWhiteSpace(true) + table.SetTablePadding(" ") + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeader([]string{"FQDN", "TTL", "Type", "Content"}) + + for _, record := range records { + table.Append([]string{record.FQDN, strconv.Itoa(record.TTL), record.Type, record.RData}) + } + + table.Render() + + return nil +} + +func runDNSRecordsExport(ctx context.Context) error { + name := flag.FirstArg(ctx) + apiClient := client.FromContext(ctx).API() + + domain, err := apiClient.GetDomain(ctx, name) + if err != nil { + return err + } + + records, err := apiClient.ExportDNSRecords(ctx, domain.ID) + if err != nil { + return err + } + + args := flag.Args(ctx) + if len(args) == 1 { + fmt.Println(records) + } else { + filename := args[1] + + _, err := os.Stat(filename) + if err == nil { + return fmt.Errorf("File %s already exists", filename) + } + + err = os.WriteFile(filename, []byte(records), 0o644) + if err != nil { + return err + } + + fmt.Printf("Zone exported to %s\n", filename) + } + + return nil +} + +func runDNSRecordsImport(ctx context.Context) error { + name := flag.FirstArg(ctx) + apiClient := client.FromContext(ctx).API() + + var filename string + + args := flag.Args(ctx) + if len(args) == 1 { + // One arg, use stdin + filename = "-" + } else { + filename = args[1] + } + + domain, err := apiClient.GetDomain(ctx, name) + if err != nil { + return err + } + + var data []byte + + if filename != "-" { + data, err = os.ReadFile(filename) + if err != nil { + return err + } + } else { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return err + } + } + + warnings, changes, err := apiClient.ImportDNSRecords(ctx, domain.ID, string(data)) + if err != nil { + return err + } + + fmt.Printf("Zonefile import report for %s\n", domain.Name) + + if filename == "-" { + fmt.Printf("Imported from stdin\n") + } else { + fmt.Printf("Imported from %s\n", filename) + } + + fmt.Printf("%d warnings\n", len(warnings)) + for _, warning := range warnings { + fmt.Println("->", warning.Action, warning.Message) + } + + fmt.Printf("%d changes\n", len(changes)) + for _, change := range changes { + switch change.Action { + case "CREATE": + fmt.Println("-> Created", change.NewText) + case "DELETE": + fmt.Println("-> Deleted", change.OldText) + case "UPDATE": + fmt.Println("-> Updated", change.OldText, "=>", change.NewText) + } + } + + return nil +} diff --git a/internal/command/domains/root.go b/internal/command/domains/root.go new file mode 100644 index 0000000000..4080640d8d --- /dev/null +++ b/internal/command/domains/root.go @@ -0,0 +1,225 @@ +package domains + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/format" + "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" + + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + const ( + short = "Manage domains (deprecated)" + long = `Manage domains +Notice: this feature is deprecated and no longer supported. +You can still view existing domains, but registration is no longer possible.` + ) + cmd := command.New("domains", short, long, nil) + cmd.AddCommand( + newDomainsList(), + newDomainsShow(), + newDomainsAdd(), + newDomainsRegister(), + ) + cmd.Hidden = true + return cmd +} + +func newDomainsList() *cobra.Command { + const ( + short = "List domains" + long = `List domains for an organization` + ) + cmd := command.New("list [org]", short, long, runDomainsList, + command.RequireSession, + ) + flag.Add(cmd, + flag.JSONOutput(), + ) + cmd.Args = cobra.MaximumNArgs(1) + return cmd +} + +func newDomainsShow() *cobra.Command { + const ( + short = "Show domain" + long = `Show information about a domain` + ) + cmd := command.New("show ", short, long, runDomainsShow, + command.RequireSession, + ) + flag.Add(cmd, + flag.JSONOutput(), + ) + cmd.Args = cobra.ExactArgs(1) + return cmd +} + +func newDomainsAdd() *cobra.Command { + const ( + short = "Add a domain" + long = `Add a domain to an organization` + ) + cmd := command.New("disable", short, long, runDomainsCreate, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newDomainsRegister() *cobra.Command { + const ( + short = "Register a domain" + long = `Register a new domain in an organization` + ) + cmd := command.New("register [org] [name]", short, long, runDomainsRegister, + command.RequireSession, + command.RequireAppName, + ) + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + cmd.Args = cobra.MaximumNArgs(2) + cmd.Hidden = true + return cmd +} + +func runDomainsList(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + args := flag.Args(ctx) + var orgSlug string + if len(args) == 0 { + org, err := prompt.Org(ctx) + if err != nil { + return err + } + orgSlug = org.Slug + } else { + // TODO: Validity check on org + orgSlug = args[0] + } + + domains, err := apiClient.GetDomains(ctx, orgSlug) + if err != nil { + return err + } + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, domains) + return nil + } + + table := tablewriter.NewWriter(io.Out) + table.SetHeader([]string{"Domain", "Registration Status", "DNS Status", "Created"}) + for _, domain := range domains { + table.Append([]string{domain.Name, *domain.RegistrationStatus, *domain.DnsStatus, format.RelativeTime(domain.CreatedAt)}) + } + table.Render() + + return nil +} + +func runDomainsShow(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + name := flag.FirstArg(ctx) + + domain, err := apiClient.GetDomain(ctx, name) + if err != nil { + return err + } + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, domain) + return nil + } + + fmt.Fprintf(io.Out, "Domain\n") + fmtstring := "%-20s: %-20s\n" + fmt.Fprintf(io.Out, fmtstring, "Name", domain.Name) + fmt.Fprintf(io.Out, fmtstring, "Organization", domain.Organization.Slug) + fmt.Fprintf(io.Out, fmtstring, "Registration Status", *domain.RegistrationStatus) + if *domain.RegistrationStatus == "REGISTERED" { + fmt.Fprintf(io.Out, fmtstring, "Expires At", format.Time(domain.ExpiresAt)) + + autorenew := "" + if *domain.AutoRenew { + autorenew = "Enabled" + } else { + autorenew = "Disabled" + } + + fmt.Fprintf(io.Out, fmtstring, "Auto Renew", autorenew) + } + + fmt.Fprintf(io.Out, "\nDNS\n") + fmt.Fprintf(io.Out, fmtstring, "Status", *domain.DnsStatus) + if *domain.RegistrationStatus == "UNMANAGED" { + fmt.Fprintf(io.Out, fmtstring, "Nameservers", strings.Join(*domain.ZoneNameservers, " ")) + } + + return nil +} + +func runDomainsCreate(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + var org *api.Organization + var name string + var err error + + args := flag.Args(ctx) + + if len(args) == 0 { + org, err = prompt.Org(ctx) + if err != nil { + return err + } + + if err := prompt.String(ctx, &name, "Domain name to add", "", true); err != nil { + return err + } + + // TODO: Add some domain validation here + } else if len(args) == 2 { + org, err = apiClient.GetOrganizationBySlug(ctx, args[0]) + if err != nil { + return err + } + name = args[1] + } else { + return errors.New("specify all arguments (or no arguments to be prompted)") + } + + fmt.Printf("Creating domain %s in organization %s\n", name, org.Slug) + + domain, err := apiClient.CreateDomain(org.ID, name) + if err != nil { + return err + } + + fmt.Fprintln(io.Out, "Created domain", domain.Name) + + return nil +} + +func runDomainsRegister(_ context.Context) error { + return fmt.Errorf("This command is no longer supported.\n") +} diff --git a/internal/command/image/update_machines.go b/internal/command/image/update_machines.go index fa1a51c596..6d99bd28c9 100644 --- a/internal/command/image/update_machines.go +++ b/internal/command/image/update_machines.go @@ -58,9 +58,6 @@ func updateImageForMachines(ctx context.Context, app *api.AppCompact) error { for machine, machineConf := range eligible { input := &api.LaunchMachineInput{ - ID: machine.ID, - AppID: app.Name, - OrgSlug: app.Organization.Slug, Region: machine.Region, Config: &machineConf, SkipHealthChecks: skipHealthChecks, @@ -167,11 +164,8 @@ func updatePostgresOnMachines(ctx context.Context, app *api.AppCompact) (err err for _, member := range members["replica"] { machine := member.Machine input := &api.LaunchMachineInput{ - ID: machine.ID, - AppID: app.Name, - OrgSlug: app.Organization.Slug, - Region: machine.Region, - Config: &member.TargetConfig, + Region: machine.Region, + Config: &member.TargetConfig, } if err := mach.Update(ctx, machine, input); err != nil { return err @@ -184,18 +178,14 @@ func updatePostgresOnMachines(ctx context.Context, app *api.AppCompact) (err err machine := primary.Machine input := &api.LaunchMachineInput{ - ID: machine.ID, - AppID: app.Name, - OrgSlug: app.Organization.Slug, - Region: machine.Region, - Config: &primary.TargetConfig, + Region: machine.Region, + Config: &primary.TargetConfig, } if err := mach.Update(ctx, machine, input); err != nil { return err } } } else { - if len(members["leader"]) > 0 { leader := members["leader"][0] machine := leader.Machine @@ -222,11 +212,8 @@ func updatePostgresOnMachines(ctx context.Context, app *api.AppCompact) (err err // Update leader input := &api.LaunchMachineInput{ - ID: machine.ID, - AppID: app.Name, - OrgSlug: app.Organization.Slug, - Region: machine.Region, - Config: &leader.TargetConfig, + Region: machine.Region, + Config: &leader.TargetConfig, } if err := mach.Update(ctx, machine, input); err != nil { return err diff --git a/internal/command/ips/list.go b/internal/command/ips/list.go index 9fc80c7c53..3907b575c2 100644 --- a/internal/command/ips/list.go +++ b/internal/command/ips/list.go @@ -24,6 +24,8 @@ func newList() *cobra.Command { command.RequireAppName, ) + cmd.Aliases = []string{"ls"} + flag.Add(cmd, flag.App(), flag.AppConfig(), diff --git a/internal/command/ips/render.go b/internal/command/ips/render.go index ccad611fd7..ac0c24a5b8 100644 --- a/internal/command/ips/render.go +++ b/internal/command/ips/render.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/cmd/presenters" + "github.com/superfly/flyctl/internal/format" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/iostreams" ) @@ -25,7 +25,7 @@ func renderListTable(ctx context.Context, ipAddresses []api.IPAddress) { if ipAddr.Type == "shared_v4" { rows = append(rows, []string{"v4", ipAddr.Address, "public (shared)", ipAddr.Region, ""}) } else { - rows = append(rows, []string{ipAddr.Type, ipAddr.Address, ipType, ipAddr.Region, presenters.FormatRelativeTime(ipAddr.CreatedAt)}) + rows = append(rows, []string{ipAddr.Type, ipAddr.Address, ipType, ipAddr.Region, format.RelativeTime(ipAddr.CreatedAt)}) } } diff --git a/internal/command/launch/freshconfigs.go b/internal/command/launch/freshconfigs.go index c2f651c5de..abdc2d500b 100644 --- a/internal/command/launch/freshconfigs.go +++ b/internal/command/launch/freshconfigs.go @@ -5,32 +5,18 @@ import ( "github.com/superfly/flyctl/internal/appconfig" ) -var ( - v2CheckTimeout = api.MustParseDuration("2s") - v2CheckInterval = api.MustParseDuration("15s") - v2GracePeriod = api.MustParseDuration("5s") -) - func freshV2Config(appName string, srcCfg *appconfig.Config) (*appconfig.Config, error) { newCfg := appconfig.NewConfig() newCfg.AppName = appName newCfg.Build = srcCfg.Build newCfg.PrimaryRegion = srcCfg.PrimaryRegion newCfg.HTTPService = &appconfig.HTTPService{ - InternalPort: 8080, - ForceHTTPS: true, - AutoStartMachines: api.Pointer(true), - AutoStopMachines: api.Pointer(true), + InternalPort: 8080, + ForceHTTPS: true, + AutoStartMachines: api.Pointer(true), + AutoStopMachines: api.Pointer(true), + MinMachinesRunning: api.Pointer(0), } - newCfg.Checks = map[string]*appconfig.ToplevelCheck{ - "alive": { - Type: api.Pointer("tcp"), - Timeout: v2CheckTimeout, - Interval: v2CheckInterval, - GracePeriod: v2GracePeriod, - }, - } - if err := newCfg.SetMachinesPlatform(); err != nil { return nil, err } diff --git a/internal/command/launch/launch.go b/internal/command/launch/launch.go index ed60b76a4f..3c44577d01 100644 --- a/internal/command/launch/launch.go +++ b/internal/command/launch/launch.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/logrusorgru/aurora" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/superfly/flyctl/api" @@ -16,6 +17,7 @@ import ( "github.com/superfly/flyctl/internal/command" "github.com/superfly/flyctl/internal/command/deploy" "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/prompt" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/scanner" @@ -89,6 +91,11 @@ func run(ctx context.Context) (err error) { ForceYes: flag.GetBool(ctx, "now"), } + metrics.Started(ctx, "launch") + defer func() { + metrics.Status(ctx, "launch", err == nil) + }() + // Determine the working directory if absDir, err := filepath.Abs(workingDir); err == nil { workingDir = absDir @@ -166,13 +173,18 @@ func run(ctx context.Context) (err error) { } // Do not change PrimaryRegion after this line appConfig.PrimaryRegion = region.Code - fmt.Fprintf(io.Out, "App will use '%s' region as primary\n", appConfig.PrimaryRegion) + fmt.Fprintf(io.Out, "App will use '%s' region as primary\n\n", appConfig.PrimaryRegion) shouldUseMachines, err := shouldAppUseMachinesPlatform(ctx, org.Slug, existingAppPlatform) if err != nil { return err } + using_appsv1_only_feature := !deploy.MachineSupportedStrategy(flag.GetString(ctx, "strategy")) + if !shouldUseMachines && !using_appsv1_only_feature { + fmt.Fprintf(io.ErrOut, "%s Apps v1 Platform is deprecated. We recommend using the --force-machines flag, or setting\nyour organization's default for new apps to Apps v2 with 'fly orgs apps-v2 default-on '\n", aurora.Yellow("WARN")) + } + var envVars map[string]string = nil envFlags := flag.GetStringSlice(ctx, "env") if len(envFlags) > 0 { diff --git a/internal/command/launch/srcinfo.go b/internal/command/launch/srcinfo.go index 283da97bd2..10b409522b 100644 --- a/internal/command/launch/srcinfo.go +++ b/internal/command/launch/srcinfo.go @@ -276,6 +276,11 @@ func setAppconfigFromSrcinfo(ctx context.Context, srcInfo *scanner.SourceInfo, a appConfig.SetDockerCommand(srcInfo.DockerCommand) } + if srcInfo.ConsoleCommand != "" { + // no V1 compatibility for this feature so bypass setters + appConfig.ConsoleCommand = srcInfo.ConsoleCommand + } + if srcInfo.DockerEntrypoint != "" { appConfig.SetDockerEntrypoint(srcInfo.DockerEntrypoint) } diff --git a/internal/command/logs/ship.go b/internal/command/logs/ship.go index 1d04976d47..0e04cb4ea3 100644 --- a/internal/command/logs/ship.go +++ b/internal/command/logs/ship.go @@ -18,7 +18,6 @@ import ( ) func newShip() (cmd *cobra.Command) { - const ( short = "Ship application logs to Logtail" long = short + "\n" @@ -44,7 +43,6 @@ func runSetup(ctx context.Context) (err error) { // Fetch the target organization from the app appNameResponse, err := gql.GetApp(ctx, client, appName) - if err != nil { return err } @@ -58,7 +56,7 @@ func runSetup(ctx context.Context) (err error) { // Fetch or create the Logtail integration for the app - var addOnName = appName + "-log-shipper" + addOnName := appName + "-log-shipper" getAddOnResponse, err := gql.GetAddOn(ctx, client, addOnName) if err != nil { @@ -71,7 +69,6 @@ func runSetup(ctx context.Context) (err error) { } createAddOnResponse, err := gql.CreateAddOn(ctx, client, input) - if err != nil { return err } @@ -85,13 +82,11 @@ func runSetup(ctx context.Context) (err error) { tokenResponse, err := gql.CreateLimitedAccessToken(ctx, client, appName+"-logs", targetOrg.Id, "read_organization_apps", &gql.LimitedAccessTokenOptions{ "app_ids": []string{targetApp.Name}, }, "") - if err != nil { return } flapsClient, machine, err := EnsureShipperMachine(ctx, targetOrg) - if err != nil { return } @@ -105,7 +100,6 @@ func runSetup(ctx context.Context) (err error) { flapsClient.Wait(ctx, machine, "started", time.Second*5) response, err := flapsClient.Exec(ctx, machine.ID, request) - if err != nil { fmt.Fprintf(io.ErrOut, response.StdErr) return err @@ -114,12 +108,10 @@ func runSetup(ctx context.Context) (err error) { } func EnsureShipperMachine(ctx context.Context, targetOrg gql.AppDataOrganization) (flapsClient *flaps.Client, machine *api.Machine, err error) { - client := client.FromContext(ctx).API().GenqClient io := iostreams.FromContext(ctx) appsResult, err := gql.GetAppsByRole(ctx, client, "log-shipper", targetOrg.Id) - if err != nil { return nil, nil, err } @@ -136,7 +128,6 @@ func EnsureShipperMachine(ctx context.Context, targetOrg gql.AppDataOrganization input.Name = targetOrg.RawSlug + "-log-shipper" createdAppResult, err := gql.CreateApp(ctx, client, input) - if err != nil { return nil, nil, err } @@ -155,14 +146,12 @@ func EnsureShipperMachine(ctx context.Context, targetOrg gql.AppDataOrganization } machines, err := flapsClient.List(ctx, "") - if err != nil { return nil, nil, err } if len(machines) > 0 { machine = machines[0] - } else { machineConf := &api.MachineConfig{ @@ -175,13 +164,11 @@ func EnsureShipperMachine(ctx context.Context, targetOrg gql.AppDataOrganization } launchInput := api.LaunchMachineInput{ - AppID: shipperApp.Name, Name: "log-shipper", Config: machineConf, } regionResponse, err := gql.GetNearestRegion(ctx, client) - if err != nil { return nil, nil, err } diff --git a/internal/command/machine/clone.go b/internal/command/machine/clone.go index 59fa48bdd9..78e09bdd65 100644 --- a/internal/command/machine/clone.go +++ b/internal/command/machine/clone.go @@ -3,6 +3,7 @@ package machine import ( "context" "fmt" + "strings" "time" "github.com/google/shlex" @@ -56,7 +57,7 @@ func newClone() *cobra.Command { }, flag.String{ Name: "attach-volume", - Description: "Existing volume to attach to the new machine", + Description: "Existing volume to attach to the new machine in the form of [:/path/inside/machine]", }, flag.String{ Name: "process-group", @@ -159,15 +160,36 @@ func runMachineClone(ctx context.Context) (err error) { fmt.Fprintf(io.Out, "Auto destroy enabled and will destroy machine on exit. Use --clear-auto-destroy to remove this setting.\n") } - if volID := flag.GetString(ctx, "attach-volume"); volID != "" { - if len(source.Config.Mounts) != 1 { - return fmt.Errorf("Can't attach the volume as the source machine doesn't have any volumes configured") + var volID string + if volumeInfo := flag.GetString(ctx, "attach-volume"); volumeInfo != "" { + splitVolumeInfo := strings.Split(volumeInfo, ":") + volID = splitVolumeInfo[0] + + if len(source.Config.Mounts) > 1 { + return fmt.Errorf("Can't use --attach-volume for machines with more than 1 volume.") + } else if len(source.Config.Mounts) == 0 && len(splitVolumeInfo) != 2 { + return fmt.Errorf("Please specify a mount path on '%s' using :/path/inside/machine", volumeInfo) + } + + // in case user passed a mount path + if len(splitVolumeInfo) == 2 { + // patches the source config so the loop below attaches the volume on the passed mount path + if len(source.Config.Mounts) == 0 { + source.Config.Mounts = []api.MachineMount{ + { + Path: splitVolumeInfo[1], + }, + } + } else if len(source.Config.Mounts) == 1 { + fmt.Fprintf(io.Out, "Info: --attach-volume is overriding previous mount point from `%s` to `%s`.\n", source.Config.Mounts[0].Path, splitVolumeInfo[1]) + source.Config.Mounts[0].Path = splitVolumeInfo[1] + } } } for _, mnt := range source.Config.Mounts { var vol *api.Volume - if volID := flag.GetString(ctx, "attach-volume"); volID != "" { + if volID != "" { fmt.Fprintf(out, "Attaching existing volume %s\n", colorize.Bold(volID)) vol, err = client.GetVolume(ctx, volID) if err != nil { @@ -224,21 +246,21 @@ func runMachineClone(ctx context.Context) (err error) { } // Standby machine - skipLaunch := false - if standbys := flag.GetStringSlice(ctx, "standby-for"); len(standbys) > 0 { - if standbys[0] == "source" { - standbys[0] = source.ID + if flag.IsSpecified(ctx, "standby-for") { + standbys := flag.GetStringSlice(ctx, "standby-for") + for idx := range standbys { + if standbys[idx] == "source" { + standbys[idx] = source.ID + } } - targetConfig.Standbys = standbys - skipLaunch = true + targetConfig.Standbys = lo.Ternary(len(standbys) > 0, standbys, nil) } input := api.LaunchMachineInput{ - AppID: app.Name, Name: flag.GetString(ctx, "name"), Region: region, Config: targetConfig, - SkipLaunch: skipLaunch, + SkipLaunch: len(targetConfig.Standbys) > 0, } fmt.Fprintf(out, "Provisioning a new machine with image %s...\n", source.Config.Image) @@ -250,7 +272,7 @@ func runMachineClone(ctx context.Context) (err error) { fmt.Fprintf(out, " Machine %s has been created...\n", colorize.Bold(launchedMachine.ID)) - if !skipLaunch { + if !input.SkipLaunch { fmt.Fprintf(out, " Waiting for machine %s to start...\n", colorize.Bold(launchedMachine.ID)) // wait for a machine to be started diff --git a/internal/command/machine/destroy.go b/internal/command/machine/destroy.go index 16fa01f13e..8acab7599b 100644 --- a/internal/command/machine/destroy.go +++ b/internal/command/machine/destroy.go @@ -82,12 +82,10 @@ func Destroy(ctx context.Context, app *api.AppCompact, machine *api.Machine, for var ( out = iostreams.FromContext(ctx).Out flapsClient = flaps.FromContext(ctx) - appName = app.Name input = api.RemoveMachineInput{ - AppID: appName, - ID: machine.ID, - Kill: force, + ID: machine.ID, + Kill: force, } ) diff --git a/internal/command/machine/list.go b/internal/command/machine/list.go index 23c7e3b3e1..af12bea508 100644 --- a/internal/command/machine/list.go +++ b/internal/command/machine/list.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/flaps" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/command" @@ -50,26 +49,12 @@ func newList() *cobra.Command { func runMachineList(ctx context.Context) (err error) { var ( appName = appconfig.NameFromContext(ctx) - client = client.FromContext(ctx).API() io = iostreams.FromContext(ctx) silence = flag.GetBool(ctx, "quiet") cfg = config.FromContext(ctx) ) - app, err := client.GetAppCompact(ctx, appName) - if err != nil { - help := newList().Help() - - if help != nil { - fmt.Println(help) - - } - - fmt.Println() - - return err - } - flapsClient, err := flaps.New(ctx, app) + flapsClient, err := flaps.NewFromAppName(ctx, appName) if err != nil { return fmt.Errorf("list of machines could not be retrieved: %w", err) } @@ -112,6 +97,7 @@ func runMachineList(ctx context.Context) (err error) { appPlatform := "" machineProcessGroup := "" + size := "" if machine.Config != nil { if platformVersion, ok := machine.Config.Metadata[api.MachineConfigMetadataKeyFlyPlatformVersion]; ok { @@ -124,6 +110,9 @@ func runMachineList(ctx context.Context) (err error) { } + if machine.Config.Guest != nil { + size = fmt.Sprintf("%s:%dMB", machine.Config.Guest.ToSize(), machine.Config.Guest.MemoryMB) + } } rows = append(rows, []string{ @@ -138,11 +127,12 @@ func runMachineList(ctx context.Context) (err error) { machine.UpdatedAt, appPlatform, machineProcessGroup, + size, }) } - _ = render.Table(io.Out, appName, rows, "ID", "Name", "State", "Region", "Image", "IP Address", "Volume", "Created", "Last Updated", "App Platform", "Process Group") + _ = render.Table(io.Out, appName, rows, "ID", "Name", "State", "Region", "Image", "IP Address", "Volume", "Created", "Last Updated", "App Platform", "Process Group", "Size") } return nil } diff --git a/internal/command/machine/restart.go b/internal/command/machine/restart.go index 8aad302d66..159559fdac 100644 --- a/internal/command/machine/restart.go +++ b/internal/command/machine/restart.go @@ -3,8 +3,7 @@ package machine import ( "context" "fmt" - "strconv" - "syscall" + "strings" "time" "github.com/spf13/cobra" @@ -61,7 +60,6 @@ func newRestart() *cobra.Command { func runMachineRestart(ctx context.Context) error { var ( args = flag.Args(ctx) - signal = flag.GetString(ctx, "signal") timeout = flag.GetInt(ctx, "time") ) @@ -70,17 +68,7 @@ func runMachineRestart(ctx context.Context) error { Timeout: time.Duration(timeout) * time.Second, ForceStop: flag.GetBool(ctx, "force"), SkipHealthChecks: flag.GetBool(ctx, "skip-health-checks"), - } - - if signal != "" { - sig := &api.Signal{} - - s, err := strconv.Atoi(flag.GetString(ctx, "signal")) - if err != nil { - return fmt.Errorf("could not get signal %s", err) - } - sig.Signal = syscall.Signal(s) - input.Signal = sig + Signal: strings.ToUpper(flag.GetString(ctx, "signal")), } machines, ctx, err := selectManyMachines(ctx, args) diff --git a/internal/command/machine/run.go b/internal/command/machine/run.go index 007cf43bc4..9bd559284d 100644 --- a/internal/command/machine/run.go +++ b/internal/command/machine/run.go @@ -246,7 +246,6 @@ func runMachineRun(ctx context.Context) error { } input := api.LaunchMachineInput{ - AppID: app.Name, Name: flag.GetString(ctx, "name"), Region: flag.GetString(ctx, "region"), } @@ -291,11 +290,15 @@ func runMachineRun(ctx context.Context) error { id, instanceID, state, privateIP := machine.ID, machine.InstanceID, machine.State, machine.PrivateIP - fmt.Fprintf(io.Out, "Success! A machine has been successfully launched in app %s, waiting for it to be started\n", appName) + fmt.Fprintf(io.Out, "Success! A machine has been successfully launched in app %s\n", appName) fmt.Fprintf(io.Out, " Machine ID: %s\n", id) fmt.Fprintf(io.Out, " Instance ID: %s\n", instanceID) fmt.Fprintf(io.Out, " State: %s\n", state) + if input.SkipLaunch { + return nil + } + fmt.Fprintf(io.Out, "\n Attempting to start machine...\n\n") s.Start() // wait for machine to be started @@ -788,9 +791,9 @@ func determineMachineConfig(ctx context.Context, input *determineMachineConfigIn } // Standby machine - standbys := flag.GetStringSlice(ctx, "standby-for") - if len(standbys) > 0 { - machineConf.Standbys = standbys + if flag.IsSpecified(ctx, "standby-for") { + standbys := flag.GetStringSlice(ctx, "standby-for") + machineConf.Standbys = lo.Ternary(len(standbys) > 0, standbys, nil) } return machineConf, nil diff --git a/internal/command/machine/stop.go b/internal/command/machine/stop.go index 9e28fd8812..d1d1bbc31b 100644 --- a/internal/command/machine/stop.go +++ b/internal/command/machine/stop.go @@ -74,15 +74,8 @@ func runMachineStop(ctx context.Context) (err error) { func Stop(ctx context.Context, machineID string, signal string, timeout int) (err error) { machineStopInput := api.StopMachineInput{ - ID: machineID, - } - - if sig := strings.ToUpper(signal); sig != "" { - if _, ok := signalSyscallMap[sig]; !ok { - return fmt.Errorf("invalid signal %s", signal) - } - - machineStopInput.Signal = strings.ToUpper(sig) + ID: machineID, + Signal: strings.ToUpper(signal), } if timeout > 0 { @@ -99,18 +92,3 @@ func Stop(ctx context.Context, machineID string, signal string, timeout int) (er return } - -var signalSyscallMap = map[string]struct{}{ - "SIGABRT": {}, - "SIGALRM": {}, - "SIGFPE": {}, - "SIGILL": {}, - "SIGINT": {}, - "SIGKILL": {}, - "SIGPIPE": {}, - "SIGQUIT": {}, - "SIGSEGV": {}, - "SIGTERM": {}, - "SIGTRAP": {}, - "SIGUSR1": {}, -} diff --git a/internal/command/machine/update.go b/internal/command/machine/update.go index 3581ab8ff8..9cf291f56d 100644 --- a/internal/command/machine/update.go +++ b/internal/command/machine/update.go @@ -129,19 +129,17 @@ func runUpdate(ctx context.Context) (err error) { // Perform update input := &api.LaunchMachineInput{ - ID: machine.ID, - AppID: appName, Name: machine.Name, Region: machine.Region, Config: machineConf, - SkipHealthChecks: skipHealthChecks, SkipLaunch: len(machineConf.Standbys) > 0, + SkipHealthChecks: skipHealthChecks, } if err := mach.Update(ctx, machine, input); err != nil { return err } - if !flag.GetDetach(ctx) { + if !(input.SkipLaunch || flag.GetDetach(ctx)) { fmt.Fprintln(io.Out, colorize.Green("==> "+"Monitoring health checks")) if err := watch.MachinesChecks(ctx, []*api.Machine{machine}); err != nil { diff --git a/internal/command/migrate_to_v2/machines.go b/internal/command/migrate_to_v2/machines.go index 4a0900cb78..67762e4308 100644 --- a/internal/command/migrate_to_v2/machines.go +++ b/internal/command/migrate_to_v2/machines.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "time" "github.com/samber/lo" @@ -29,11 +30,16 @@ func (m *v2PlatformMigrator) resolveMachineFromAlloc(alloc *api.AllocationStatus mConfig.Metadata[api.MachineConfigMetadataKeyFlyManagedPostgres] = "true" } + // We have manual overrides for some regions with the names 2 e.g ams2, iad2. + // These cause migrations to fail. Here we handle that specific case. + region := alloc.Region + if strings.HasSuffix(region, "2") { + region = region[0:3] + } + launchInput := &api.LaunchMachineInput{ - AppID: m.appFull.Name, - OrgSlug: m.appFull.Organization.ID, - Region: alloc.Region, - Config: mConfig, + Region: region, + Config: mConfig, } return launchInput, nil diff --git a/internal/command/migrate_to_v2/migrate_to_v2.go b/internal/command/migrate_to_v2/migrate_to_v2.go index 18d51f58e0..1f675101f9 100644 --- a/internal/command/migrate_to_v2/migrate_to_v2.go +++ b/internal/command/migrate_to_v2/migrate_to_v2.go @@ -26,12 +26,14 @@ import ( "github.com/superfly/flyctl/internal/command/deploy" "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/machine" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/prompt" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/internal/sentry" "github.com/superfly/flyctl/internal/state" "github.com/superfly/flyctl/iostreams" "github.com/superfly/flyctl/terminal" + "golang.org/x/exp/maps" "golang.org/x/exp/slices" ) @@ -52,7 +54,6 @@ func newMigrateToV2() *cobra.Command { cmd.Args = cobra.NoArgs flag.Add(cmd, flag.Yes(), - flag.App(), flag.AppConfig(), flag.String{ Name: "primary-region", @@ -62,10 +63,8 @@ func newMigrateToV2() *cobra.Command { return cmd } -func runMigrateToV2(ctx context.Context) error { +func runMigrateToV2(ctx context.Context) (err error) { var ( - err error - appName = appconfig.NameFromContext(ctx) apiClient = client.FromContext(ctx).API() ) @@ -80,6 +79,16 @@ func runMigrateToV2(ctx context.Context) error { return err } + // This is written awkwardly so that NewV2PlatformMigrator failures are tracked, + // but declined migrations are not. + sendMetric := true + defer func() { + if sendMetric { + metrics.Started(ctx, "migrate_to_v2") + metrics.Status(ctx, "migrate_to_v2", err == nil) + } + }() + migrator, err := NewV2PlatformMigrator(ctx, appName) if err != nil { return err @@ -90,6 +99,7 @@ func runMigrateToV2(ctx context.Context) error { return err } if !confirm { + sendMetric = false return nil } } @@ -145,11 +155,10 @@ type v2PlatformMigrator struct { } type recoveryState struct { - machinesCreated []*api.Machine - appLocked bool - scaledToZero bool - platformVersion string - onlyPromptToConfigSave bool + machinesCreated []*api.Machine + appLocked bool + scaledToZero bool + platformVersion string } func NewV2PlatformMigrator(ctx context.Context, appName string) (V2PlatformMigrator, error) { @@ -277,9 +286,8 @@ func (m *v2PlatformMigrator) rollback(ctx context.Context, tb *render.TextBlock) for _, mach := range m.recovery.machinesCreated { input := api.RemoveMachineInput{ - AppID: m.appFull.Name, - ID: mach.ID, - Kill: true, + ID: mach.ID, + Kill: true, } err := m.flapsClient.Destroy(ctx, input, mach.LeaseNonce) if err != nil { @@ -344,12 +352,6 @@ func (m *v2PlatformMigrator) Migrate(ctx context.Context) (err error) { defer func() { if err != nil { - if m.recovery.onlyPromptToConfigSave && !m.isPostgres { - fmt.Fprintf(m.io.ErrOut, "Failed to save application config to disk, but migration was successful.\n") - fmt.Fprintf(m.io.ErrOut, "Please run `fly config save` before further interacting with your app via flyctl.\n") - return - } - header := "" if err == abortedErr { header = "(!) Received abort signal, restoring application to stable state..." @@ -551,17 +553,20 @@ func (m *v2PlatformMigrator) Migrate(ctx context.Context) (err error) { tb.Detail("Saving new configuration") + var configSaveErr error + if !m.isPostgres { - m.recovery.onlyPromptToConfigSave = true - err = m.appConfig.WriteToDisk(ctx, m.configPath) - if err != nil { - return err - } + configSaveErr = m.appConfig.WriteToDisk(ctx, m.configPath) } tb.Done("Done") m.printReplacedVolumes() + if configSaveErr != nil { + fmt.Fprintf(m.io.ErrOut, "Failed to save application config to disk, but migration was successful.\n") + fmt.Fprintf(m.io.ErrOut, "Please run `fly config save` before further interacting with your app via flyctl.\n") + } + return nil } @@ -714,6 +719,16 @@ func (m *v2PlatformMigrator) determinePrimaryRegion(ctx context.Context) error { return nil } + existingRegions := map[string]struct{}{} + for _, alloc := range m.oldAllocs { + existingRegions[alloc.Region] = struct{}{} + } + + if len(existingRegions) == 1 { + m.appConfig.PrimaryRegion = maps.Keys(existingRegions)[0] + return nil + } + // TODO: If this ends up used by postgres migrations, it might be nice to have // the prompt here reflect the special role `primary_region` plays for postgres apps @@ -816,6 +831,12 @@ func (m *v2PlatformMigrator) ConfirmChanges(ctx context.Context) (bool, error) { func determineAppConfigForMachines(ctx context.Context) (*appconfig.Config, error) { appNameFromContext := appconfig.NameFromContext(ctx) + + // We're pulling the remote config because we don't want to inadvertently trigger a new deployment - + // people will expect this to migrate what's _currently_ live. + // That said, we need to reference the local config to get the build config, because it's + // sanitized out before being sent to the API. + localAppConfig := appconfig.ConfigFromContext(ctx) cfg, err := appconfig.FromRemoteApp(ctx, appNameFromContext) if err != nil { return nil, err @@ -823,11 +844,26 @@ func determineAppConfigForMachines(ctx context.Context) (*appconfig.Config, erro if appNameFromContext != "" { cfg.AppName = appNameFromContext } + if localAppConfig != nil { + cfg.Build = localAppConfig.Build + } return cfg, nil } func determineVmSpecs(vmSize api.VMSize) (*api.MachineGuest, error) { - preset := strings.Replace(vmSize.Name, "dedicated-cpu", "performance", 1) + preset := vmSize.Name + preset = strings.Replace(preset, "micro", "shared-cpu", 1) + preset = strings.Replace(preset, "dedicated-cpu", "performance", 1) + switch preset { + case "cpu1mem1": + preset = "performance-1x" + case "cpu2mem2": + preset = "performance-2x" + case "cpu4mem4": + preset = "performance-4x" + case "cpu8mem8": + preset = "performance-8x" + } guest := &api.MachineGuest{} err := guest.SetSize(preset) diff --git a/internal/command/migrate_to_v2/volumes.go b/internal/command/migrate_to_v2/volumes.go index 03bd03bad6..82cea59f2f 100644 --- a/internal/command/migrate_to_v2/volumes.go +++ b/internal/command/migrate_to_v2/volumes.go @@ -75,7 +75,9 @@ func (m *v2PlatformMigrator) migrateAppVolumes(ctx context.Context) error { Name: nomadVolNameToV2VolName(vol.Name), LockID: m.appLock, }) - if err != nil { + if err != nil && strings.HasSuffix(err.Error(), " is not a valid candidate") { + return fmt.Errorf("unfortunately the worker hosting your volume %s (%s) does not have capacity for another volume to support the migration; some other options: 1) try again later and there might be more space on the worker, 2) run a manual migration https://community.fly.io/t/manual-migration-to-apps-v2/11870, or 3) wait until we support volume migrations across workers (we're working on it!)", vol.ID, vol.Name) + } else if err != nil { return err } @@ -83,6 +85,12 @@ func (m *v2PlatformMigrator) migrateAppVolumes(ctx context.Context) error { path := "" if alloc := vol.AttachedAllocation; alloc != nil { allocId = alloc.ID + alloc, ok := lo.Find(m.oldAllocs, func(a *api.AllocationStatus) bool { + return a.ID == allocId + }) + if !ok { + return fmt.Errorf("volume %s[%s] is attached to alloc %s, but that alloc is not running", vol.Name, vol.ID, allocId) + } path = m.nomadVolPath(&vol, alloc.TaskName) if path == "" { return fmt.Errorf("volume %s[%s] is mounted on alloc %s, but has no mountpoint", vol.Name, vol.ID, allocId) diff --git a/internal/command/orgs/list.go b/internal/command/orgs/list.go index da28e0b7cb..51281656a7 100644 --- a/internal/command/orgs/list.go +++ b/internal/command/orgs/list.go @@ -27,6 +27,7 @@ func newList() *cobra.Command { ) flag.Add(cmd, flag.JSONOutput()) + cmd.Aliases = []string{"ls"} return cmd } diff --git a/internal/command/postgres/add_flycast.go b/internal/command/postgres/add_flycast.go index 0e4ddf9a58..bfbd7e5c16 100644 --- a/internal/command/postgres/add_flycast.go +++ b/internal/command/postgres/add_flycast.go @@ -116,7 +116,7 @@ func doAddFlycast(ctx context.Context) error { Handlers: []string{ "pg_tls", }, - ForceHttps: false, + ForceHTTPS: false, }, }, Concurrency: nil, @@ -130,7 +130,7 @@ func doAddFlycast(ctx context.Context) error { Handlers: []string{ "pg_tls", }, - ForceHttps: false, + ForceHTTPS: false, }, }, Concurrency: nil, diff --git a/internal/command/postgres/create.go b/internal/command/postgres/create.go index aef213a4e5..c39b760231 100644 --- a/internal/command/postgres/create.go +++ b/internal/command/postgres/create.go @@ -118,7 +118,6 @@ func run(ctx context.Context) (err error) { Password: flag.GetString(ctx, "password"), SnapshotID: flag.GetString(ctx, "snapshot-id"), Detach: flag.GetDetach(ctx), - Manager: flypg.ReplicationManager, Autostart: flag.GetBool(ctx, "autostart"), } diff --git a/internal/command/postgres/db.go b/internal/command/postgres/db.go index d1405af527..71d7b1d6de 100644 --- a/internal/command/postgres/db.go +++ b/internal/command/postgres/db.go @@ -48,6 +48,8 @@ func newListDbs() *cobra.Command { command.RequireAppName, ) + cmd.Aliases = []string{"ls"} + flag.Add( cmd, flag.App(), diff --git a/internal/command/postgres/events.go b/internal/command/postgres/events.go new file mode 100644 index 0000000000..1a84b399de --- /dev/null +++ b/internal/command/postgres/events.go @@ -0,0 +1,138 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/flypg" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/command/apps" + "github.com/superfly/flyctl/internal/flag" + mach "github.com/superfly/flyctl/internal/machine" +) + +func newEvents() *cobra.Command { + const ( + short = "Track major cluster events" + long = short + "\n" + + usage = "events" + ) + + cmd := command.New(usage, short, long, nil) + + cmd.AddCommand( + newListEvents(), + ) + + flag.Add(cmd, flag.JSONOutput()) + return cmd +} + +func newListEvents() *cobra.Command { + const ( + short = "Outputs a formatted list of cluster events" + long = short + "\n" + + usage = "list" + ) + + cmd := command.New(usage, short, long, runListEvents, + command.RequireSession, + command.RequireAppName, + ) + + flag.Add( + cmd, + flag.App(), + flag.AppConfig(), + flag.String{ + Name: "event", + Shorthand: "e", + Description: "Event type in a postgres cluster", + }, + flag.String{ + Name: "limit", + Shorthand: "l", + Description: "Set the maximum number of entries to output (default: 20)", + }, + flag.String{ + Name: "node-id", + Shorthand: "i", + Description: "Restrict entries to node with this ID", + }, + flag.String{ + Name: "node-name", + Shorthand: "n", + Description: "Restrict entries to node with this name", + }, + flag.Bool{ + Name: "all", + Shorthand: "o", + Description: "Outputs all entries", + }, + flag.Bool{ + Name: "compact", + Shorthand: "d", + Description: "Omit the 'Details' column", + }, + ) + + return cmd + +} + +func runListEvents(ctx context.Context) error { + var ( + client = client.FromContext(ctx).API() + appName = appconfig.NameFromContext(ctx) + ) + + app, err := client.GetAppCompact(ctx, appName) + if err != nil { + return fmt.Errorf("failed retrieving app %s: %w", appName, err) + } + + if !app.IsPostgresApp() { + return fmt.Errorf("app %s is not a postgres app", appName) + } + + ctx, err = apps.BuildContext(ctx, app) + if err != nil { + return err + } + + machines, err := mach.ListActive(ctx) + if err != nil { + return err + } + + leader, err := pickLeader(ctx, machines) + if err != nil { + return err + } + + if !IsFlex(leader) { + return fmt.Errorf("this feature is not compatible with this postgres service ") + } + + ignoreFlags := []string{flag.AccessTokenName, flag.AppName, flag.AppConfigFilePathName, + flag.VerboseName, "help"} + + flagsName := flag.GetFlagsName(ctx, ignoreFlags) + + cmd, err := flypg.NewCommand(ctx, app) + if err != nil { + return err + } + + err = cmd.ListEvents(ctx, leader.PrivateIP, flagsName) + if err != nil { + return err + } + + return nil +} diff --git a/internal/command/postgres/import.go b/internal/command/postgres/import.go index f64c26c9c0..5bad8f132c 100644 --- a/internal/command/postgres/import.go +++ b/internal/command/postgres/import.go @@ -157,10 +157,8 @@ func runImport(ctx context.Context) error { machineConfig.Image = imageRef launchInput := api.LaunchMachineInput{ - AppID: app.ID, - OrgSlug: app.Organization.ID, - Region: region.Code, - Config: machineConfig, + Region: region.Code, + Config: machineConfig, } // Create emphemeral machine @@ -204,7 +202,7 @@ func runImport(ctx context.Context) error { // Destroy machine fmt.Fprintf(io.Out, "%s has been destroyed\n", machine.ID) - if err := flapsClient.Destroy(ctx, api.RemoveMachineInput{ID: machine.ID, AppID: app.ID}, machine.LeaseNonce); err != nil { + if err := flapsClient.Destroy(ctx, api.RemoveMachineInput{ID: machine.ID}, machine.LeaseNonce); err != nil { return fmt.Errorf("failed to destroy machine %s: %s", machine.ID, err) } diff --git a/internal/command/postgres/list.go b/internal/command/postgres/list.go index 5710da3dc1..71002fe67a 100644 --- a/internal/command/postgres/list.go +++ b/internal/command/postgres/list.go @@ -26,6 +26,7 @@ func newList() *cobra.Command { cmd := command.New(usage, short, long, runList) flag.Add(cmd, flag.JSONOutput()) + cmd.Aliases = []string{"ls"} return cmd } diff --git a/internal/command/postgres/postgres.go b/internal/command/postgres/postgres.go index e0c9ce2624..af3eb373fc 100644 --- a/internal/command/postgres/postgres.go +++ b/internal/command/postgres/postgres.go @@ -39,6 +39,7 @@ func New() *cobra.Command { newNomadToMachines(), newAddFlycast(), newImport(), + newEvents(), ) return cmd diff --git a/internal/command/postgres/users.go b/internal/command/postgres/users.go index 765011ee56..a304c0907a 100644 --- a/internal/command/postgres/users.go +++ b/internal/command/postgres/users.go @@ -50,6 +50,8 @@ func newListUsers() *cobra.Command { command.RequireAppName, ) + cmd.Aliases = []string{"ls"} + flag.Add( cmd, flag.App(), diff --git a/internal/command/redis/connect.go b/internal/command/redis/connect.go index 9646335504..23f77c9220 100644 --- a/internal/command/redis/connect.go +++ b/internal/command/redis/connect.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os/exec" - "time" "github.com/spf13/cobra" @@ -92,23 +91,25 @@ func runConnect(ctx context.Context) (err error) { RemoteHost: database.PrivateIp, } - go proxy.Connect(ctx, params) - redisCliPath, err := exec.LookPath("redis-cli") if err != nil { fmt.Fprintf(io.Out, "Could not find redis-cli in your $PATH. Install it or point your redis-cli at: %s", "someurl") - } else { - // TODO: let proxy.Connect inform us about readiness - time.Sleep(3 * time.Second) - cmd := exec.CommandContext(ctx, redisCliPath, "-p", localProxyPort) - cmd.Env = append(cmd.Env, fmt.Sprintf("REDISCLI_AUTH=%s", database.Password)) - cmd.Stdout = io.Out - cmd.Stderr = io.ErrOut - cmd.Stdin = io.In - - cmd.Start() - cmd.Wait() + return + } + + err = proxy.Start(ctx, params) + if err != nil { + return err } + cmd := exec.CommandContext(ctx, redisCliPath, "-p", localProxyPort) + cmd.Env = append(cmd.Env, fmt.Sprintf("REDISCLI_AUTH=%s", database.Password)) + cmd.Stdout = io.Out + cmd.Stderr = io.ErrOut + cmd.Stdin = io.In + + cmd.Start() + cmd.Wait() + return } diff --git a/internal/command/redis/create.go b/internal/command/redis/create.go index c2baab4235..bf85dbe0cf 100644 --- a/internal/command/redis/create.go +++ b/internal/command/redis/create.go @@ -152,7 +152,7 @@ func Create(ctx context.Context, org *api.Organization, name string, region *api var planOptions []string for _, plan := range result.AddOnPlans.Nodes { - planOptions = append(planOptions, fmt.Sprintf("%s: %s Max Data Size", plan.DisplayName, plan.MaxDataSize)) + planOptions = append(planOptions, fmt.Sprintf("%s: %s Max Data Size, ($%d / month)", plan.DisplayName, plan.MaxDataSize, plan.PricePerMonth)) } err = prompt.Select(ctx, &planIndex, "Select an Upstash Redis plan", "", planOptions...) diff --git a/internal/command/redis/list.go b/internal/command/redis/list.go index 6ae66ebf6b..3b85bc2f33 100644 --- a/internal/command/redis/list.go +++ b/internal/command/redis/list.go @@ -24,6 +24,8 @@ func newList() (cmd *cobra.Command) { cmd = command.New(usage, short, long, runList, command.RequireSession) + cmd.Aliases = []string{"ls"} + flag.Add(cmd, flag.Org(), ) diff --git a/internal/command/regions/machines.go b/internal/command/regions/machines.go new file mode 100644 index 0000000000..0c1aa302ff --- /dev/null +++ b/internal/command/regions/machines.go @@ -0,0 +1,78 @@ +package regions + +import ( + "context" + "fmt" + "strings" + + "github.com/superfly/flyctl/flaps" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" +) + +func v2RunRegionsList(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + + flapsClient, err := flaps.NewFromAppName(ctx, appName) + if err != nil { + return err + } + + machines, _, err := flapsClient.ListFlyAppsMachines(ctx) + if err != nil { + return err + } + + machineRegionsMap := make(map[string]map[string]bool) + for _, machine := range machines { + if machineRegionsMap[machine.Config.ProcessGroup()] == nil { + machineRegionsMap[machine.Config.ProcessGroup()] = make(map[string]bool) + } + machineRegionsMap[machine.Config.ProcessGroup()][machine.Region] = true + } + + machineRegions := make(map[string][]string) + for group, regions := range machineRegionsMap { + for region := range regions { + machineRegions[group] = append(machineRegions[group], region) + } + } + + printApssV2Regions(ctx, machineRegions) + return nil +} + +type printableProcessGroup struct { + Name string + Regions []string +} + +func printApssV2Regions(ctx context.Context, machineRegions map[string][]string) { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + + if config.FromContext(ctx).JSONOutput { + jsonPg := []printableProcessGroup{} + for group, regionlist := range machineRegions { + jsonPg = append(jsonPg, printableProcessGroup{ + Name: group, + Regions: regionlist, + }) + } + + // only show pg if there's more than one + data := struct { + ProcessGroupRegions []printableProcessGroup + }{ + ProcessGroupRegions: jsonPg, + } + render.JSON(io.Out, data) + return + } + + for group, regionlist := range machineRegions { + fmt.Fprintf(io.Out, "Regions [%s]: %s\n", colorize.Bold(group), strings.Join(regionlist, ", ")) + } +} diff --git a/internal/command/regions/nomad.go b/internal/command/regions/nomad.go new file mode 100644 index 0000000000..87df088096 --- /dev/null +++ b/internal/command/regions/nomad.go @@ -0,0 +1,162 @@ +package regions + +import ( + "context" + "fmt" + + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" + "golang.org/x/exp/slices" + + "github.com/superfly/flyctl/api" +) + +func runRegionsAdd(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + input := api.ConfigureRegionsInput{ + AppID: appName, + Group: flag.GetString(ctx, "group"), + AllowRegions: flag.Args(ctx), + } + + regions, backupRegions, err := apiClient.ConfigureRegions(ctx, input) + if err != nil { + return err + } + + v1PrintRegions(ctx, regions, backupRegions) + + return nil +} + +func runRegionsRemove(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + input := api.ConfigureRegionsInput{ + AppID: appName, + Group: flag.GetString(ctx, "group"), + DenyRegions: flag.Args(ctx), + } + + regions, backupRegions, err := apiClient.ConfigureRegions(ctx, input) + if err != nil { + return err + } + + v1PrintRegions(ctx, regions, backupRegions) + + return nil +} + +func runRegionsSet(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + // Get the Region List + regions, _, err := apiClient.ListAppRegions(ctx, appName) + if err != nil { + return err + } + + allowedRegions := flag.Args(ctx) + var deniedRegions []string + + for _, er := range regions { + if !slices.Contains(allowedRegions, er.Code) { + deniedRegions = append(deniedRegions, er.Code) + } + } + + input := api.ConfigureRegionsInput{ + AppID: appName, + Group: flag.GetString(ctx, "group"), + AllowRegions: allowedRegions, + DenyRegions: deniedRegions, + } + + newregions, backupRegions, err := apiClient.ConfigureRegions(ctx, input) + if err != nil { + return err + } + + v1PrintRegions(ctx, newregions, backupRegions) + + return nil +} + +func v1RunRegionsList(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + regions, backupRegions, err := apiClient.ListAppRegions(ctx, appName) + if err != nil { + return err + } + + v1PrintRegions(ctx, regions, backupRegions) + + return nil +} + +func runRegionsBackup(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + input := api.ConfigureRegionsInput{ + AppID: appName, + BackupRegions: flag.Args(ctx), + } + + regions, backupRegions, err := apiClient.ConfigureRegions(ctx, input) + if err != nil { + return err + } + + v1PrintRegions(ctx, regions, backupRegions) + + return nil +} + +func v1PrintRegions(ctx context.Context, regions []api.Region, backupRegions []api.Region) { + io := iostreams.FromContext(ctx) + colorize := io.ColorScheme() + + if config.FromContext(ctx).JSONOutput { + data := struct { + Regions []api.Region + BackupRegions []api.Region + }{ + Regions: regions, + BackupRegions: backupRegions, + } + render.JSON(io.Out, data) + return + } + + verbose := flag.GetBool(ctx, "verbose") + + fmt.Fprintln(io.Out, colorize.Bold("Region Pool: ")) + for _, r := range regions { + if verbose { + fmt.Fprintf(io.Out, "%s %s\n", r.Code, r.Name) + } else { + fmt.Fprintf(io.Out, "%s\n", r.Code) + } + } + + fmt.Fprintln(io.Out, colorize.Bold("Backup Region: ")) + for _, r := range backupRegions { + if verbose { + fmt.Fprintf(io.Out, "%s %s\n", r.Code, r.Name) + } else { + fmt.Fprintf(io.Out, "%s\n", r.Code) + } + } +} diff --git a/internal/command/regions/root.go b/internal/command/regions/root.go new file mode 100644 index 0000000000..24f1baa40c --- /dev/null +++ b/internal/command/regions/root.go @@ -0,0 +1,135 @@ +package regions + +import ( + "context" + + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + const ( + short = "V1 APPS ONLY: Manage regions" + long = `V1 APPS ONLY (except 'regions list'): Configure the region placement rules for an application.` + ) + cmd := command.New("regions", short, long, nil) + cmd.AddCommand( + newRegionsAdd(), + newRegionsRemove(), + newRegionsSet(), + newRegionsBackup(), + newRegionsList(), + ) + return cmd +} + +func newRegionsAdd() *cobra.Command { + const ( + short = `V1 APPS ONLY: Allow the app to run in the provided regions` + long = `V1 APPS ONLY: Allow the app to run in one or more regions` + ) + cmd := command.New("add REGION [REGION...]", short, long, runRegionsAdd, + command.RequireSession, + command.RequireAppName, + ) + cmd.Args = cobra.MinimumNArgs(1) + flag.Add(cmd, + flag.App(), + flag.Yes(), + flag.JSONOutput(), + flag.String{Name: "group", Description: "The process group to add the region to"}, + ) + return cmd +} + +func newRegionsRemove() *cobra.Command { + const ( + short = `V1 APPS ONLY: Prevent the app from running in the provided regions` + long = `V1 APPS ONLY: Prevent the app from running in the provided regions` + ) + cmd := command.New("remove REGION [REGION...]", short, long, runRegionsRemove, + command.RequireSession, + command.RequireAppName, + ) + cmd.Args = cobra.MinimumNArgs(1) + flag.Add(cmd, + flag.App(), + flag.Yes(), + flag.JSONOutput(), + flag.String{Name: "group", Description: "The process group to add the region to"}, + ) + return cmd +} + +func newRegionsSet() *cobra.Command { + const ( + short = `V1 APPS ONLY: Sets the region pool with provided regions` + long = `V1 APPS ONLY: Sets the region pool with provided regions` + ) + cmd := command.New("set REGION [REGION...]", short, long, runRegionsSet, + command.RequireSession, + command.RequireAppName, + ) + cmd.Args = cobra.MinimumNArgs(1) + flag.Add(cmd, + flag.App(), + flag.Yes(), + flag.JSONOutput(), + flag.String{Name: "group", Description: "The process group to add the region to"}, + ) + return cmd +} + +func newRegionsBackup() *cobra.Command { + const ( + short = `V1 APPS ONLY: Sets the backup region pool with provided regions` + long = `V1 APPS ONLY: Sets the backup region pool with provided regions` + ) + cmd := command.New("backup REGION [REGION...]", short, long, runRegionsBackup, + command.RequireSession, + command.RequireAppName, + ) + cmd.Args = cobra.MinimumNArgs(1) + flag.Add(cmd, + flag.App(), + flag.Yes(), + flag.JSONOutput(), + ) + return cmd +} + +func newRegionsList() *cobra.Command { + const ( + short = `Shows the list of regions the app is allowed to run in` + long = `Shows the list of regions the app is allowed to run in` + ) + cmd := command.New("list", short, long, runRegionsList, + command.RequireSession, + command.RequireAppName, + ) + cmd.Args = cobra.NoArgs + flag.Add(cmd, + flag.App(), + flag.JSONOutput(), + ) + return cmd +} + +func runRegionsList(ctx context.Context) error { + appName := appconfig.NameFromContext(ctx) + apiClient := client.FromContext(ctx).API() + + app, err := apiClient.GetAppCompact(ctx, appName) + if err != nil { + return err + } + + if app.PlatformVersion == "nomad" { + return v1RunRegionsList(ctx) + } + return v2RunRegionsList(ctx) +} diff --git a/internal/command/root/root.go b/internal/command/root/root.go index a45adcd557..3a3b6b51c7 100644 --- a/internal/command/root/root.go +++ b/internal/command/root/root.go @@ -4,23 +4,27 @@ package root import ( "github.com/spf13/cobra" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/cmd" "github.com/superfly/flyctl/flyctl" "github.com/superfly/flyctl/internal/command" "github.com/superfly/flyctl/internal/command/agent" "github.com/superfly/flyctl/internal/command/apps" "github.com/superfly/flyctl/internal/command/auth" + "github.com/superfly/flyctl/internal/command/autoscale" + "github.com/superfly/flyctl/internal/command/certificates" "github.com/superfly/flyctl/internal/command/checks" "github.com/superfly/flyctl/internal/command/config" + "github.com/superfly/flyctl/internal/command/console" "github.com/superfly/flyctl/internal/command/consul" "github.com/superfly/flyctl/internal/command/create" "github.com/superfly/flyctl/internal/command/curl" + "github.com/superfly/flyctl/internal/command/dashboard" "github.com/superfly/flyctl/internal/command/deploy" "github.com/superfly/flyctl/internal/command/destroy" "github.com/superfly/flyctl/internal/command/dig" + "github.com/superfly/flyctl/internal/command/dnsrecords" "github.com/superfly/flyctl/internal/command/docs" "github.com/superfly/flyctl/internal/command/doctor" + "github.com/superfly/flyctl/internal/command/domains" "github.com/superfly/flyctl/internal/command/extensions" "github.com/superfly/flyctl/internal/command/help" "github.com/superfly/flyctl/internal/command/history" @@ -41,6 +45,7 @@ import ( "github.com/superfly/flyctl/internal/command/postgres" "github.com/superfly/flyctl/internal/command/proxy" "github.com/superfly/flyctl/internal/command/redis" + "github.com/superfly/flyctl/internal/command/regions" "github.com/superfly/flyctl/internal/command/releases" "github.com/superfly/flyctl/internal/command/restart" "github.com/superfly/flyctl/internal/command/resume" @@ -55,71 +60,38 @@ import ( "github.com/superfly/flyctl/internal/command/version" "github.com/superfly/flyctl/internal/command/vm" "github.com/superfly/flyctl/internal/command/volumes" + "github.com/superfly/flyctl/internal/command/wireguard" + "github.com/superfly/flyctl/internal/flag" ) // New initializes and returns a reference to a new root command. func New() *cobra.Command { - /* - const ( - long = `flyctl is a command line interface to the Fly.io platform. + const ( + long = `flyctl is a command line interface to the Fly.io platform. - It allows users to manage authentication, application launch, - deployment, network configuration, logging and more with just the - one command. +It allows users to manage authentication, application launch, +deployment, network configuration, logging and more with just the +one command. - Launch an app with the launch command - Deploy an app with the deploy command - View a deployed web application with the open command - Check the status of an application with the status command +* Launch an app with the launch command +* Deploy an app with the deploy command +* View a deployed web application with the open command +* Check the status of an application with the status command - To read more, use the docs command to view Fly's help on the web. +To read more, use the docs command to view Fly's help on the web. ` - short = "The Fly CLI" - usage = "flyctl" - ) + short = "The Fly CLI" + ) - root := command.New(usage, short, long, nil) - root.SilenceUsage = true - root.SilenceErrors = true - - fs := root.PersistentFlags() - - _ = fs.StringP(flag.AccessTokenName, "t", "", "Fly API Access Token") - _ = fs.BoolP(flag.VerboseName, "v", false, "Verbose output") - - root.AddCommand( - version.New(), - apps.New(), - create.New(), // TODO: deprecate - destroy.New(), // TODO: deprecate - move.New(), // TODO: deprecate - suspend.New(), // TODO: deprecate - resume.New(), // TODO: deprecate - restart.New(), // TODO: deprecate - orgs.New(), - auth.New(), - builds.New(), - open.New(), // TODO: deprecate - curl.New(), - platform.New(), - docs.New(), - releases.New(), - deploy.New(), - history.New(), - status.New(), - logs.New(), - doctor.New(), - dig.New(), - volumes.New(), - agent.New(), - ) - - if os.Getenv("DEV") != "" { - root.AddCommand(services.New()) - } + root := command.New("flyctl", short, long, nil) + root.PersistentPreRun = func(cmd *cobra.Command, args []string) { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + } - return root - */ + fs := root.PersistentFlags() + _ = fs.StringP(flag.AccessTokenName, "t", "", "Fly API Access Token") + _ = fs.BoolP(flag.VerboseName, "", false, "Verbose output") flyctl.InitConfig() @@ -128,7 +100,7 @@ func New() *cobra.Command { // migration is complete. // newCommands is the set of commands which work with the new way - newCommands := []*cobra.Command{ + root.AddCommand( version.New(), apps.New(), create.New(), // TODO: deprecate @@ -176,62 +148,21 @@ func New() *cobra.Command { tokens.New(), extensions.New(), consul.New(), - } + regions.New(), + dnsrecords.New(), + certificates.New(), + dashboard.New(), + wireguard.New(), + autoscale.New(), + domains.New(), + console.New(), + ) // if os.Getenv("DEV") != "" { // newCommands = append(newCommands, services.New()) // } - // newCommandNames is the set of the names of the above commands - newCommandNames := make(map[string]struct{}, len(newCommands)) - for _, cmd := range newCommands { - newCommandNames[cmd.Name()] = struct{}{} - } - - // instead of root being constructed like in the commented out snippet, we - // rebuild it the old way. - root := cmd.NewRootCmd(client.New()) - - // gather the slice of commands which must be replaced with their new - // iterations - var commandsToReplace []*cobra.Command - for _, cmd := range root.Commands() { - if _, exists := newCommandNames[cmd.Name()]; exists { - commandsToReplace = append(commandsToReplace, cmd) - } - } - - // remove them - root.RemoveCommand(commandsToReplace...) - - // make sure the remaining old commands run the preparers - // TODO: remove when migration is done - wrapRunE(root) - - // and finally, add the new commands - root.AddCommand(newCommands...) - root.SetHelpCommand(help.New(root)) - root.RunE = help.NewRootHelp().RunE - return root } - -func wrapRunE(cmd *cobra.Command) { - if cmd.HasAvailableSubCommands() { - for _, c := range cmd.Commands() { - wrapRunE(c) - } - } - - if cmd.RunE == nil && cmd.Run == nil { - return - } - - if cmd.RunE == nil { - panic(cmd.Name()) - } - - cmd.RunE = command.WrapRunE(cmd.RunE) -} diff --git a/internal/command/scale/count.go b/internal/command/scale/count.go index 9fd4c96147..3e0cca9aa6 100644 --- a/internal/command/scale/count.go +++ b/internal/command/scale/count.go @@ -7,11 +7,14 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/flaps" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/command" "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/iostreams" + "golang.org/x/exp/slices" ) func newScaleCount() *cobra.Command { @@ -32,18 +35,43 @@ For pricing, see https://fly.io/docs/about/pricing/` flag.Yes(), flag.Int{Name: "max-per-region", Description: "Max number of VMs per region", Default: -1}, flag.String{Name: "region", Description: "Comma separated list of regions to act on. Defaults to all regions where there is at least one machine running for the app"}, + flag.String{Name: "process-group", Description: "The process group to scale"}, ) return cmd } func runScaleCount(ctx context.Context) error { - appConfig := appconfig.ConfigFromContext(ctx) appName := appconfig.NameFromContext(ctx) + flapsClient, err := flaps.NewFromAppName(ctx, appName) + if err != nil { + return err + } + ctx = flaps.NewContext(ctx, flapsClient) + + appConfig, err := appconfig.FromRemoteApp(ctx, appName) + if err != nil { + return err + } args := flag.Args(ctx) - defaultGroupName := appConfig.DefaultProcessName() - groups, err := parseGroupCounts(args, defaultGroupName) + groupName := "" + + processNames := appConfig.ProcessNames() + if !slices.Contains(processNames, api.MachineProcessGroupApp) { + // No app group found, so we require the process-group flag + groupName = flag.GetString(ctx, "process-group") + + if groupName == "" { + return fmt.Errorf("--process-group flag is required when no group named 'app' is defined") + } + } + + if groupName == "" { + groupName = appConfig.DefaultProcessName() + } + + groups, err := parseGroupCounts(args, groupName) if err != nil { return err } @@ -55,7 +83,7 @@ func runScaleCount(ctx context.Context) error { return err } if isV2 { - return runMachinesScaleCount(ctx, appName, groups, maxPerRegion) + return runMachinesScaleCount(ctx, appName, appConfig, groups, maxPerRegion) } return runNomadScaleCount(ctx, appName, groups, maxPerRegion) } diff --git a/internal/command/scale/count_machines.go b/internal/command/scale/count_machines.go index be62dd9b3e..ea5b015d1d 100644 --- a/internal/command/scale/count_machines.go +++ b/internal/command/scale/count_machines.go @@ -18,19 +18,9 @@ import ( "golang.org/x/exp/slices" ) -func runMachinesScaleCount(ctx context.Context, appName string, expectedGroupCounts map[string]int, maxPerRegion int) error { +func runMachinesScaleCount(ctx context.Context, appName string, appConfig *appconfig.Config, expectedGroupCounts map[string]int, maxPerRegion int) error { io := iostreams.FromContext(ctx) - - flapsClient, err := flaps.NewFromAppName(ctx, appName) - if err != nil { - return err - } - ctx = flaps.NewContext(ctx, flapsClient) - - appConfig, err := appconfig.FromRemoteApp(ctx, appName) - if err != nil { - return err - } + flapsClient := flaps.FromContext(ctx) ctx = appconfig.WithConfig(ctx, appConfig) machines, _, err := flapsClient.ListFlyAppsMachines(ctx) @@ -134,33 +124,21 @@ func runMachinesScaleCount(ctx context.Context, appName string, expectedGroupCou } func launchMachine(ctx context.Context, action *planItem) (*api.Machine, error) { - appName := appconfig.NameFromContext(ctx) flapsClient := flaps.FromContext(ctx) input := api.LaunchMachineInput{ - AppID: appName, Region: action.Region, Config: action.MachineConfig, } - - m, err := flapsClient.Launch(ctx, input) - if err != nil { - return nil, fmt.Errorf("could not launch machine: %w", err) - } - - return m, nil + return flapsClient.Launch(ctx, input) } func destroyMachine(ctx context.Context, machine *api.Machine) error { - appName := appconfig.NameFromContext(ctx) flapsClient := flaps.FromContext(ctx) - input := api.RemoveMachineInput{ - AppID: appName, - ID: machine.ID, - Kill: true, + ID: machine.ID, + Kill: true, } - return flapsClient.Destroy(ctx, input, machine.LeaseNonce) } diff --git a/internal/command/scale/machines.go b/internal/command/scale/machines.go index b68c4dff91..afd3238542 100644 --- a/internal/command/scale/machines.go +++ b/internal/command/scale/machines.go @@ -57,12 +57,9 @@ func v2ScaleVM(ctx context.Context, appName, group, sizeName string, memoryMB in } input := &api.LaunchMachineInput{ - ID: machine.ID, - AppID: appName, - Name: machine.Name, - Region: machine.Region, - Config: machine.Config, - SkipHealthChecks: false, + Name: machine.Name, + Region: machine.Region, + Config: machine.Config, } if err := mach.Update(ctx, machine, input); err != nil { return nil, err diff --git a/internal/command/scale/show.go b/internal/command/scale/show.go index 0e038cffae..2b523c5cda 100644 --- a/internal/command/scale/show.go +++ b/internal/command/scale/show.go @@ -28,6 +28,7 @@ func newScaleShow() *cobra.Command { flag.Add(cmd, flag.App(), flag.AppConfig(), + flag.JSONOutput(), ) return cmd } diff --git a/internal/command/scale/show_machines.go b/internal/command/scale/show_machines.go index b26ba41ace..54058455bc 100644 --- a/internal/command/scale/show_machines.go +++ b/internal/command/scale/show_machines.go @@ -2,6 +2,7 @@ package scale import ( "context" + "encoding/json" "fmt" "strings" @@ -9,6 +10,7 @@ import ( "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/flaps" "github.com/superfly/flyctl/internal/appconfig" + "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/iostreams" "golang.org/x/exp/slices" @@ -37,10 +39,56 @@ func runMachinesScaleShow(ctx context.Context) error { groupNames := lo.Keys(machineGroups) slices.Sort(groupNames) + // TODO: Each machine can technically have a different Guest configuration. + // It's impractical to show the guest for each machine, but arbitrarily + // picking the first one is not ideal either. + representativeGuests := lo.MapValues(machineGroups, func(machines []*api.Machine, _ string) *api.MachineGuest { + if len(machines) == 0 { + return nil + } + return machines[0].Config.Guest + }) + + if flag.GetBool(ctx, "json") { + type groupData struct { + Process string + Count int + CPUKind string + CPUs int + Memory int + Regions map[string]int + } + groups := lo.FilterMap(groupNames, func(name string, _ int) (res groupData, ok bool) { + + machines := machineGroups[name] + guest := representativeGuests[name] + if guest == nil { + return res, false + } + return groupData{ + Process: name, + Count: len(machines), + CPUKind: guest.CPUKind, + CPUs: guest.CPUs, + Memory: guest.MemoryMB, + Regions: lo.CountValues(lo.Map(machines, func(m *api.Machine, _ int) string { + return m.Region + })), + }, true + }) + + prettyJSON, _ := json.MarshalIndent(groups, "", " ") + fmt.Fprintln(io.Out, string(prettyJSON)) + return nil + } + rows := make([][]string, 0, len(machineGroups)) for _, groupName := range groupNames { machines := machineGroups[groupName] - guest := machines[0].Config.Guest + guest := representativeGuests[groupName] + if guest == nil { + continue + } rows = append(rows, []string{ groupName, fmt.Sprintf("%d", len(machines)), diff --git a/internal/command/scale/vm.go b/internal/command/scale/vm.go index e440652ce6..3104592a0e 100644 --- a/internal/command/scale/vm.go +++ b/internal/command/scale/vm.go @@ -23,8 +23,6 @@ For a full list of supported sizes use the command 'flyctl platform vm-sizes' Memory size can be set with --memory=number-of-MB e.g. flyctl scale vm shared-cpu-1x --memory=2048 -For dedicated vms, this should be a multiple of 1024MB. -For shared vms, this can be 256MB or a a multiple of 1024MB. For pricing, see https://fly.io/docs/about/pricing/` ) cmd := command.New("vm [size]", short, long, runScaleVM, diff --git a/internal/command/secrets/list.go b/internal/command/secrets/list.go index 38c95c4d9a..5f0952ecce 100644 --- a/internal/command/secrets/list.go +++ b/internal/command/secrets/list.go @@ -25,6 +25,8 @@ actual value of the secret is only available to the application.` cmd = command.New(usage, short, long, runList, command.RequireSession, command.RequireAppName) + cmd.Aliases = []string{"ls"} + flag.Add(cmd, flag.App(), flag.AppConfig(), diff --git a/internal/command/secrets/secrets.go b/internal/command/secrets/secrets.go index 685c8f5647..e78b342d26 100644 --- a/internal/command/secrets/secrets.go +++ b/internal/command/secrets/secrets.go @@ -50,65 +50,78 @@ func New() *cobra.Command { return secrets } -func deployForSecrets(ctx context.Context, app *api.AppCompact, release *api.Release, stage bool, detach bool) (err error) { - out := iostreams.FromContext(ctx).Out +func deployForSecrets(ctx context.Context, app *api.AppCompact, release *api.Release, stage bool, detach bool) error { + switch app.PlatformVersion { + case appconfig.MachinesPlatform: + return v2deploySecrets(ctx, app, release, stage, detach) + default: + return v1deploySecrets(ctx, app, release, stage, detach) + } +} +func v1deploySecrets(ctx context.Context, app *api.AppCompact, release *api.Release, stage bool, detach bool) error { + out := iostreams.FromContext(ctx).Out if stage { - - if app.PlatformVersion != "machines" { - return errors.New("--stage isn't available for Nomad apps") - } - - fmt.Fprint(out, "Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.\n") - return + return errors.New("--stage isn't available for Nomad apps") } if !app.Deployed { fmt.Fprintln(out, "Secrets are staged for the first deployment") - return + return nil } - if app.PlatformVersion == "machines" { - flapsClient, err := flaps.New(ctx, app) - if err != nil { - return fmt.Errorf("could not create flaps client: %w", err) - } - ctx = flaps.NewContext(ctx, flapsClient) - - // It would be confusing for setting secrets to deploy the current fly.toml file. - // Instead, we always grab the currently deployed app config - cfg, err := appconfig.FromRemoteApp(ctx, app.Name) - if err != nil { - return err - } - ctx = appconfig.WithConfig(ctx, cfg) - - if err != nil { - return fmt.Errorf("error loading appv2 config: %w", err) - } - md, err := deploy.NewMachineDeployment(ctx, deploy.MachineDeploymentArgs{ - AppCompact: app, - RestartOnly: true, - SkipHealthChecks: detach, - }) - if err != nil { - sentry.CaptureExceptionWithAppInfo(err, "secrets", app) - return err - } - err = md.DeployMachinesApp(ctx) - if err != nil { - sentry.CaptureExceptionWithAppInfo(err, "secrets", app) - } - return err + fmt.Fprintf(out, "Release v%d created\n", release.Version) + if flag.GetBool(ctx, "detach") { + return nil } - fmt.Fprintf(out, "Release v%d created\n", release.Version) + return watch.Deployment(ctx, app.Name, release.EvaluationID) +} - if flag.GetBool(ctx, "detach") { - return +func v2deploySecrets(ctx context.Context, app *api.AppCompact, release *api.Release, stage bool, detach bool) error { + out := iostreams.FromContext(ctx).Out + if stage { + fmt.Fprint(out, "Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.\n") + return nil } - err = watch.Deployment(ctx, app.Name, release.EvaluationID) + flapsClient, err := flaps.New(ctx, app) + if err != nil { + return fmt.Errorf("could not create flaps client: %w", err) + } + ctx = flaps.NewContext(ctx, flapsClient) + // Due to https://github.com/superfly/web/issues/1397 we have to be extra careful + machines, _, err := flapsClient.ListFlyAppsMachines(ctx) + if err != nil { + return err + } + if !app.Deployed && len(machines) == 0 { + fmt.Fprintln(out, "Secrets are staged for the first deployment") + return nil + } + + // It would be confusing for setting secrets to deploy the current fly.toml file. + // Instead, we always grab the currently deployed app config + cfg, err := appconfig.FromRemoteApp(ctx, app.Name) + if err != nil { + return fmt.Errorf("error loading appv2 config: %w", err) + } + ctx = appconfig.WithConfig(ctx, cfg) + + md, err := deploy.NewMachineDeployment(ctx, deploy.MachineDeploymentArgs{ + AppCompact: app, + RestartOnly: true, + SkipHealthChecks: detach, + }) + if err != nil { + sentry.CaptureExceptionWithAppInfo(err, "secrets", app) + return err + } + + err = md.DeployMachinesApp(ctx) + if err != nil { + sentry.CaptureExceptionWithAppInfo(err, "secrets", app) + } return err } diff --git a/internal/command/services/list.go b/internal/command/services/list.go index 993ca24b34..3757b98902 100644 --- a/internal/command/services/list.go +++ b/internal/command/services/list.go @@ -17,14 +17,16 @@ func newList() *cobra.Command { short = "List services" ) - services := command.New("list", short, long, runList, command.RequireSession, command.RequireAppName) + cmd := command.New("list", short, long, runList, command.RequireSession, command.RequireAppName) - flag.Add(services, + cmd.Aliases = []string{"ls"} + + flag.Add(cmd, flag.App(), flag.AppConfig(), ) - return services + return cmd } func runList(ctx context.Context) error { diff --git a/internal/command/services/machines.go b/internal/command/services/machines.go index 450383c97f..a90d5a3b08 100644 --- a/internal/command/services/machines.go +++ b/internal/command/services/machines.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/samber/lo" "github.com/superfly/flyctl/api" "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/internal/command/apps" @@ -12,6 +13,8 @@ import ( "github.com/superfly/flyctl/internal/machine" "github.com/superfly/flyctl/internal/render" "github.com/superfly/flyctl/iostreams" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) func ShowMachineServiceInfo(ctx context.Context, app *api.AppInfo) error { @@ -45,31 +48,57 @@ func ShowMachineServiceInfo(ctx context.Context, app *api.AppInfo) error { return nil } - services := [][]string{} + serviceList := [][]string{} + serviceToRegion := map[string][]string{} + serviceToProcessGroup := map[string][]string{} + serviceToMachines := map[string]int{} - for _, service := range machines[0].Config.Services { - for i, port := range service.Ports { - protocol := service.Protocol - if i > 0 { - protocol = "" - } + services := map[string]struct{}{} - handlers := []string{} - for _, handler := range port.Handlers { - handlers = append(handlers, strings.ToUpper(handler)) - } + for _, machine := range machines { + for _, service := range machine.Config.Services { + for _, port := range service.Ports { + protocol := service.Protocol + + handlers := []string{} + for _, handler := range port.Handlers { + handlers = append(handlers, strings.ToUpper(handler)) + } + + ports := fmt.Sprintf("%d => %d", *port.Port, service.InternalPort) + https := cases.Title(language.English, cases.Compact).String(fmt.Sprint(port.ForceHTTPS)) + h := strings.Join(handlers, ",") + + key := getServiceKey(protocol, ports, https, h) + + services[key] = struct{}{} - fields := []string{ - strings.ToUpper(protocol), - fmt.Sprintf("%d => %d [%s]", *port.Port, service.InternalPort, strings.Join(handlers, ",")), - strings.Title(fmt.Sprint(port.ForceHttps)), + serviceToMachines[key]++ + serviceToRegion[key] = append(serviceToRegion[key], machine.Region) + serviceToProcessGroup[key] = append(serviceToProcessGroup[key], machine.ProcessGroup()) } - services = append(services, fields) } + } + + for service := range services { + components := strings.Split(service, "-") + + protocol := strings.ToUpper(components[0]) + ports := strings.ToUpper(components[1]) + https := components[2] + handlers := fmt.Sprintf("[%s]", strings.ToUpper(components[3])) + processGroup := strings.Join(lo.Uniq(serviceToProcessGroup[service]), ",") + regions := strings.Join(lo.Uniq(serviceToRegion[service]), ",") + machineCount := fmt.Sprint(serviceToMachines[service]) + serviceList = append(serviceList, []string{protocol, ports, handlers, https, processGroup, regions, machineCount}) } - _ = render.Table(io.Out, "Services", services, "Protocol", "Ports", "Force HTTPS") + _ = render.Table(io.Out, "Services", serviceList, "Protocol", "Ports", "Handlers", "Force HTTPS", "Process Group", "Regions", "Machines") return nil } + +func getServiceKey(protocol, ports, forcehttps, handlers string) string { + return fmt.Sprintf("%s-%s-%s-%s", protocol, ports, forcehttps, handlers) +} diff --git a/internal/command/ssh/connect.go b/internal/command/ssh/connect.go new file mode 100644 index 0000000000..fd6340b3fa --- /dev/null +++ b/internal/command/ssh/connect.go @@ -0,0 +1,140 @@ +package ssh + +import ( + "context" + "crypto/ed25519" + "fmt" + "net" + "os" + "time" + + "github.com/briandowns/spinner" + "github.com/pkg/errors" + "github.com/superfly/flyctl/agent" + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/helpers" + "github.com/superfly/flyctl/iostreams" + "github.com/superfly/flyctl/ssh" + "github.com/superfly/flyctl/terminal" +) + +const DefaultSshUsername = "root" + +func BringUpAgent(ctx context.Context, client *api.Client, app *api.AppCompact, quiet bool) (*agent.Client, agent.Dialer, error) { + io := iostreams.FromContext(ctx) + + agentclient, err := agent.Establish(ctx, client) + if err != nil { + captureError(err, app) + return nil, nil, errors.Wrap(err, "can't establish agent") + } + + dialer, err := agentclient.Dialer(ctx, app.Organization.Slug) + if err != nil { + captureError(err, app) + return nil, nil, fmt.Errorf("ssh: can't build tunnel for %s: %s\n", app.Organization.Slug, err) + } + + if !quiet { + io.StartProgressIndicatorMsg("Connecting to tunnel") + } + if err := agentclient.WaitForTunnel(ctx, app.Organization.Slug); err != nil { + captureError(err, app) + return nil, nil, errors.Wrapf(err, "tunnel unavailable") + } + if !quiet { + io.StopProgressIndicator() + } + + return agentclient, dialer, nil +} + +type ConnectParams struct { + Ctx context.Context + Org api.OrganizationImpl + Username string + Dialer agent.Dialer + DisableSpinner bool +} + +func Connect(p *ConnectParams, addr string) (*ssh.Client, error) { + terminal.Debugf("Fetching certificate for %s\n", addr) + + cert, pk, err := singleUseSSHCertificate(p.Ctx, p.Org) + if err != nil { + return nil, fmt.Errorf("create ssh certificate: %w (if you haven't created a key for your org yet, try `flyctl ssh issue`)", err) + } + + pemkey := ssh.MarshalED25519PrivateKey(pk, "single-use certificate") + + terminal.Debugf("Keys for %s configured; connecting...\n", addr) + + sshClient := &ssh.Client{ + Addr: net.JoinHostPort(addr, "22"), + User: p.Username, + + Dial: p.Dialer.DialContext, + + Certificate: cert.Certificate, + PrivateKey: string(pemkey), + } + + var endSpin context.CancelFunc + if !p.DisableSpinner { + endSpin = spin(fmt.Sprintf("Connecting to %s...", addr), + fmt.Sprintf("Connecting to %s... complete\n", addr)) + defer endSpin() + } + + if err := sshClient.Connect(p.Ctx); err != nil { + return nil, errors.Wrap(err, "error connecting to SSH server") + } + + terminal.Debugf("Connection completed.\n", addr) + + if !p.DisableSpinner { + endSpin() + } + + return sshClient, nil +} + +func singleUseSSHCertificate(ctx context.Context, org api.OrganizationImpl) (*api.IssuedCertificate, ed25519.PrivateKey, error) { + client := client.FromContext(ctx).API() + hours := 1 + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + return nil, nil, err + } + + icert, err := client.IssueSSHCertificate(ctx, org, []string{DefaultSshUsername, "fly"}, nil, &hours, pub) + if err != nil { + return nil, nil, err + } + + return icert, priv, nil +} + +func spin(in, out string) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + + if !helpers.IsTerminal() { + fmt.Fprintln(os.Stderr, in) + return cancel + } + + go func() { + s := spinner.New(spinner.CharSets[11], 100*time.Millisecond) + s.Writer = os.Stderr + s.Prefix = in + s.FinalMSG = out + s.Start() + defer s.Stop() + + <-ctx.Done() + }() + + return cancel +} diff --git a/internal/command/ssh/console.go b/internal/command/ssh/console.go index b0ee0d1d9b..e375d7e0ac 100644 --- a/internal/command/ssh/console.go +++ b/internal/command/ssh/console.go @@ -3,7 +3,6 @@ package ssh import ( "context" "fmt" - "net" "os" "runtime" "time" @@ -135,35 +134,6 @@ func captureError(err error, app *api.AppCompact) { ) } -func bringUp(ctx context.Context, client *api.Client, app *api.AppCompact) (*agent.Client, agent.Dialer, error) { - io := iostreams.FromContext(ctx) - - agentclient, err := agent.Establish(ctx, client) - if err != nil { - captureError(err, app) - return nil, nil, errors.Wrap(err, "can't establish agent") - } - - dialer, err := agentclient.Dialer(ctx, app.Organization.Slug) - if err != nil { - captureError(err, app) - return nil, nil, fmt.Errorf("ssh: can't build tunnel for %s: %s\n", app.Organization.Slug, err) - } - - if !quiet(ctx) { - io.StartProgressIndicatorMsg("Connecting to tunnel") - } - if err := agentclient.WaitForTunnel(ctx, app.Organization.Slug); err != nil { - captureError(err, app) - return nil, nil, errors.Wrapf(err, "tunnel unavailable") - } - if !quiet(ctx) { - io.StopProgressIndicator() - } - - return agentclient, dialer, nil -} - func runConsole(ctx context.Context) error { client := client.FromContext(ctx).API() appName := appconfig.NameFromContext(ctx) @@ -177,7 +147,7 @@ func runConsole(ctx context.Context) error { return fmt.Errorf("get app: %w", err) } - agentclient, dialer, err := bringUp(ctx, client, app) + agentclient, dialer, err := BringUpAgent(ctx, client, app, quiet(ctx)) if err != nil { return err } @@ -187,22 +157,10 @@ func runConsole(ctx context.Context) error { return err } - // BUG(tqbf): many of these are no longer really params - params := &SSHParams{ - Ctx: ctx, - Org: app.Organization, - Dialer: dialer, - App: appName, - Username: flag.GetString(ctx, "user"), - Cmd: flag.GetString(ctx, "command"), - Stdin: os.Stdin, - Stdout: ioutils.NewWriteCloserWrapper(colorable.NewColorableStdout(), func() error { return nil }), - Stderr: ioutils.NewWriteCloserWrapper(colorable.NewColorableStderr(), func() error { return nil }), - } - // TODO: eventually remove the exception for sh and bash. - allocPTY := params.Cmd == "" || flag.GetBool(ctx, "pty") - if !allocPTY && (params.Cmd == "sh" || params.Cmd == "/bin/sh" || params.Cmd == "bash" || params.Cmd == "/bin/bash") { + cmd := flag.GetString(ctx, "command") + allocPTY := cmd == "" || flag.GetBool(ctx, "pty") + if !allocPTY && (cmd == "sh" || cmd == "/bin/sh" || cmd == "bash" || cmd == "/bin/bash") { terminal.Warn( "Allocating a pseudo-terminal since the command provided is a shell. " + "This behavior will change in the future; please use --pty explicitly if this is what you want.", @@ -210,20 +168,32 @@ func runConsole(ctx context.Context) error { allocPTY = true } - if quiet(ctx) { - params.DisableSpinner = true + params := &ConnectParams{ + Ctx: ctx, + Org: app.Organization, + Dialer: dialer, + Username: flag.GetString(ctx, "user"), + DisableSpinner: quiet(ctx), } - - sshc, err := sshConnect(params, addr) + sshc, err := Connect(params, addr) if err != nil { captureError(err, app) return err } + if err := Console(ctx, sshc, cmd, allocPTY); err != nil { + captureError(err, app) + return err + } + + return nil +} + +func Console(ctx context.Context, sshClient *ssh.Client, cmd string, allocPTY bool) error { sessIO := &ssh.SessionIO{ - Stdin: params.Stdin, - Stdout: params.Stdout, - Stderr: params.Stderr, + Stdin: os.Stdin, + Stdout: ioutils.NewWriteCloserWrapper(colorable.NewColorableStdout(), func() error { return nil }), + Stderr: ioutils.NewWriteCloserWrapper(colorable.NewColorableStderr(), func() error { return nil }), AllocPTY: allocPTY, TermEnv: determineTermEnv(), } @@ -236,56 +206,13 @@ func runConsole(ctx context.Context) error { return nil }() - if err := sshc.Shell(params.Ctx, sessIO, params.Cmd); err != nil { - captureError(err, app) + if err := sshClient.Shell(ctx, sessIO, cmd); err != nil { return errors.Wrap(err, "ssh shell") } return err } -func sshConnect(p *SSHParams, addr string) (*ssh.Client, error) { - terminal.Debugf("Fetching certificate for %s\n", addr) - - cert, pk, err := singleUseSSHCertificate(p.Ctx, p.Org) - if err != nil { - return nil, fmt.Errorf("create ssh certificate: %w (if you haven't created a key for your org yet, try `flyctl ssh issue`)", err) - } - - pemkey := ssh.MarshalED25519PrivateKey(pk, "single-use certificate") - - terminal.Debugf("Keys for %s configured; connecting...\n", addr) - - sshClient := &ssh.Client{ - Addr: net.JoinHostPort(addr, "22"), - User: p.Username, - - Dial: p.Dialer.DialContext, - - Certificate: cert.Certificate, - PrivateKey: string(pemkey), - } - - var endSpin context.CancelFunc - if !p.DisableSpinner { - endSpin = spin(fmt.Sprintf("Connecting to %s...", addr), - fmt.Sprintf("Connecting to %s... complete\n", addr)) - defer endSpin() - } - - if err := sshClient.Connect(p.Ctx); err != nil { - return nil, errors.Wrap(err, "error connecting to SSH server") - } - - terminal.Debugf("Connection completed.\n", addr) - - if !p.DisableSpinner { - endSpin() - } - - return sshClient, nil -} - func addrForMachines(ctx context.Context, app *api.AppCompact, console bool) (addr string, err error) { out := iostreams.FromContext(ctx).Out flapsClient, err := flaps.New(ctx, app) diff --git a/internal/command/ssh/sftp.go b/internal/command/ssh/sftp.go index bddac24823..894bcd07cc 100644 --- a/internal/command/ssh/sftp.go +++ b/internal/command/ssh/sftp.go @@ -94,7 +94,7 @@ func newSFTPConnection(ctx context.Context) (*sftp.Client, error) { return nil, fmt.Errorf("get app: %w", err) } - agentclient, dialer, err := bringUp(ctx, client, app) + agentclient, dialer, err := BringUpAgent(ctx, client, app, quiet(ctx)) if err != nil { return nil, err } @@ -104,19 +104,15 @@ func newSFTPConnection(ctx context.Context) (*sftp.Client, error) { return nil, err } - params := &SSHParams{ + params := &ConnectParams{ Ctx: ctx, Org: app.Organization, Dialer: dialer, - App: appName, Username: DefaultSshUsername, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, DisableSpinner: true, } - conn, err := sshConnect(params, addr) + conn, err := Connect(params, addr) if err != nil { captureError(err, app) return nil, err diff --git a/internal/command/ssh/ssh_terminal.go b/internal/command/ssh/ssh_terminal.go index d988525e94..c6a4736c39 100644 --- a/internal/command/ssh/ssh_terminal.go +++ b/internal/command/ssh/ssh_terminal.go @@ -6,48 +6,18 @@ package ssh import ( "bytes" "context" - "crypto/ed25519" "fmt" "io" "net" - "os" - "time" - "github.com/briandowns/spinner" "github.com/docker/docker/pkg/ioutils" "github.com/pkg/errors" "github.com/superfly/flyctl/agent" "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/client" - "github.com/superfly/flyctl/helpers" "github.com/superfly/flyctl/ssh" "github.com/superfly/flyctl/terminal" ) -func spin(in, out string) context.CancelFunc { - ctx, cancel := context.WithCancel(context.Background()) - - if !helpers.IsTerminal() { - fmt.Fprintln(os.Stderr, in) - return cancel - } - - go func() { - s := spinner.New(spinner.CharSets[11], 100*time.Millisecond) - s.Writer = os.Stderr - s.Prefix = in - s.FinalMSG = out - s.Start() - defer s.Stop() - - <-ctx.Done() - }() - - return cancel -} - -const DefaultSshUsername = "root" - type SSHParams struct { Ctx context.Context Org api.OrganizationImpl @@ -146,20 +116,3 @@ func SSHConnect(p *SSHParams, addr string) error { return nil } - -func singleUseSSHCertificate(ctx context.Context, org api.OrganizationImpl) (*api.IssuedCertificate, ed25519.PrivateKey, error) { - client := client.FromContext(ctx).API() - hours := 1 - - pub, priv, err := ed25519.GenerateKey(nil) - if err != nil { - return nil, nil, err - } - - icert, err := client.IssueSSHCertificate(ctx, org, []string{DefaultSshUsername, "fly"}, nil, &hours, pub) - if err != nil { - return nil, nil, err - } - - return icert, priv, nil -} diff --git a/internal/command/status/machines.go b/internal/command/status/machines.go index 224c7b1b4c..a0e33624a7 100644 --- a/internal/command/status/machines.go +++ b/internal/command/status/machines.go @@ -349,40 +349,27 @@ func renderPGStatus(ctx context.Context, app *api.AppCompact, machines []*api.Ma func isQuorumMet(machines []*api.Machine) (bool, string) { primaryRegion := machines[0].Config.Env["PRIMARY_REGION"] - totalPrimary := 0 - activePrimary := 0 + // We are only considering machines in the primary region. total := 0 - inactive := 0 + active := 0 for _, m := range machines { isPrimaryRegion := m.Region == primaryRegion if isPrimaryRegion { - totalPrimary++ - } + total++ - if m.IsActive() { - if isPrimaryRegion { - activePrimary++ + if m.IsActive() { + active++ } - } else { - inactive++ } - - total++ } quorum := total/2 + 1 - totalActive := (total - inactive) // Verify that we meet basic quorum requirements. - if totalActive <= quorum { - return false, fmt.Sprintf("WARNING: Cluster size does not meet requirements for HA (expected >= 3, got %d)\n", totalActive) - } - - // If quorum is met, verify that we have at least 2 active nodes within the primary region. - if totalActive > 2 && activePrimary < 2 { - return false, fmt.Sprintf("WARNING: Cluster size within the PRIMARY_REGION %q does not meet requirements for HA (expected >= 2, got %d)\n", primaryRegion, totalPrimary) + if active <= quorum { + return false, fmt.Sprintf("WARNING: Cluster size within your primary region %q does not meet HA requirements. (expected >= 3, got %d)\n", primaryRegion, active) } return true, "" diff --git a/internal/command/version/update.go b/internal/command/version/upgrade.go similarity index 83% rename from internal/command/version/update.go rename to internal/command/version/upgrade.go index f161d09a46..100aab787f 100644 --- a/internal/command/version/update.go +++ b/internal/command/version/upgrade.go @@ -21,18 +21,22 @@ import ( "github.com/superfly/flyctl/iostreams" ) -func newUpdate() *cobra.Command { +func newUpgrade() *cobra.Command { const ( - short = "Checks for available updates and automatically updates" + short = "Checks for available updates and automatically upgrades" - long = `Checks for update and if one is available, runs the appropriate -command to update the application.` + long = `Checks for an update and if one is available, runs the appropriate +command to upgrade the application.` ) - return command.New("update", short, long, runUpdate) + cmd := command.New("upgrade", short, long, runUpgrade) + + cmd.Aliases = []string{"update"} + + return cmd } -func runUpdate(ctx context.Context) error { +func runUpgrade(ctx context.Context) error { release, err := update.LatestRelease(ctx, cache.FromContext(ctx).Channel()) switch { case err != nil: @@ -59,15 +63,15 @@ func runUpdate(ctx context.Context) error { return err } - err = printVersionUpdate(ctx, buildinfo.Version(), homebrew) + err = printVersionUpgrade(ctx, buildinfo.Version(), homebrew) if err != nil { - terminal.Debugf("Error printing version update: %v", err) + terminal.Debugf("Error printing version upgrade: %v", err) } return nil } -// printVersionUpdate prints "Updated flyctl [oldVersion] -> [newVersion]" -func printVersionUpdate(ctx context.Context, oldVersion semver.Version, homebrew bool) error { +// printVersionUpgrade prints "Upgraded flyctl [oldVersion] -> [newVersion]" +func printVersionUpgrade(ctx context.Context, oldVersion semver.Version, homebrew bool) error { var ( io = iostreams.FromContext(ctx) @@ -97,12 +101,12 @@ func printVersionUpdate(ctx context.Context, oldVersion semver.Version, homebrew } else { source = fmt.Sprintf("'%s'", os.Args[0]) } - fmt.Fprintf(io.ErrOut, "Flyctl was updated, but the flyctl pointed to by %s is still version %s.\n", source, currentVer.String()) + fmt.Fprintf(io.ErrOut, "Flyctl was upgraded, but the flyctl pointed to by %s is still version %s.\n", source, currentVer.String()) fmt.Fprintf(io.ErrOut, "Please ensure that your PATH is set correctly!") return nil } - fmt.Fprintf(io.Out, "Updated flyctl v%s -> v%s\n", oldVersion.String(), currentVer.String()) + fmt.Fprintf(io.Out, "Upgraded flyctl v%s -> v%s\n", oldVersion.String(), currentVer.String()) return nil } diff --git a/internal/command/version/version.go b/internal/command/version/version.go index f29822f7b6..4ccf326e59 100644 --- a/internal/command/version/version.go +++ b/internal/command/version/version.go @@ -41,7 +41,7 @@ number and build date.` version.AddCommand( newInitState(), - newUpdate(), + newUpgrade(), ) flag.Add(version, flag.JSONOutput()) diff --git a/internal/command/volumes/list.go b/internal/command/volumes/list.go index 55e7e794b4..2015fb6d53 100644 --- a/internal/command/volumes/list.go +++ b/internal/command/volumes/list.go @@ -30,6 +30,8 @@ func newList() *cobra.Command { command.RequireAppName, ) + cmd.Aliases = []string{"ls"} + flag.Add(cmd, flag.App(), flag.AppConfig(), diff --git a/internal/command/volumes/snapshots/list.go b/internal/command/volumes/snapshots/list.go index 96d7efd497..92e41d28b4 100644 --- a/internal/command/volumes/snapshots/list.go +++ b/internal/command/volumes/snapshots/list.go @@ -29,6 +29,8 @@ func newList() *cobra.Command { command.RequireSession, ) + cmd.Aliases = []string{"ls"} + cmd.Args = cobra.ExactArgs(1) flag.Add(cmd, flag.JSONOutput()) diff --git a/internal/command/volumes/snapshots/snapshots.go b/internal/command/volumes/snapshots/snapshots.go index 1db83fd12e..6b078e5fc9 100644 --- a/internal/command/volumes/snapshots/snapshots.go +++ b/internal/command/volumes/snapshots/snapshots.go @@ -18,7 +18,7 @@ func New() *cobra.Command { command.RequireSession, ) - snapshots.Aliases = []string{"snapshot","snaps"} + snapshots.Aliases = []string{"snapshot", "snaps"} snapshots.AddCommand( newList(), diff --git a/internal/command/wireguard/others.go b/internal/command/wireguard/others.go new file mode 100644 index 0000000000..f909139bfb --- /dev/null +++ b/internal/command/wireguard/others.go @@ -0,0 +1,147 @@ +package wireguard + +import ( + "context" + "fmt" + "io" + "net" + "os" + "text/template" + + "github.com/AlecAivazis/survey/v2" + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/iostreams" +) + +func argOrPrompt(ctx context.Context, nth int, prompt string) (string, error) { + args := flag.Args(ctx) + if len(args) >= (nth + 1) { + return args[nth], nil + } + + val := "" + err := survey.AskOne( + &survey.Input{Message: prompt}, + &val, + ) + + return val, err +} + +func orgByArg(ctx context.Context) (*api.Organization, error) { + args := flag.Args(ctx) + + if len(args) == 0 { + org, err := prompt.Org(ctx) + if err != nil { + return nil, err + } + + return org, nil + } + + apiClient := client.FromContext(ctx).API() + return apiClient.GetOrganizationBySlug(ctx, args[0]) +} + +func resolveOutputWriter(ctx context.Context, idx int, prompt string) (w io.WriteCloser, mustClose bool, err error) { + io := iostreams.FromContext(ctx) + var f *os.File + var filename string + + for { + filename, err = argOrPrompt(ctx, idx, prompt) + if err != nil { + return nil, false, err + } + + if filename == "" { + fmt.Fprintln(io.Out, "Provide a filename (or 'stdout')") + continue + } + + if filename == "stdout" { + return os.Stdout, false, nil + } + + f, err = os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + return f, true, nil + } + + fmt.Fprintf(io.Out, "Can't create '%s': %s\n", filename, err) + } +} + +func generateWgConf(peer *api.CreatedWireGuardPeer, privkey string, w io.Writer) { + templateStr := ` +[Interface] +PrivateKey = {{.Meta.Privkey}} +Address = {{.Peer.Peerip}}/120 +DNS = {{.Meta.DNS}} + +[Peer] +PublicKey = {{.Peer.Pubkey}} +AllowedIPs = {{.Meta.AllowedIPs}} +Endpoint = {{.Peer.Endpointip}}:51820 +PersistentKeepalive = 15 + +` + data := struct { + Peer *api.CreatedWireGuardPeer + Meta struct { + Privkey string + AllowedIPs string + DNS string + } + }{ + Peer: peer, + } + + addr := net.ParseIP(peer.Peerip).To16() + for i := 6; i < 16; i++ { + addr[i] = 0 + } + + // BUG(tqbf): can't stay this way + data.Meta.AllowedIPs = fmt.Sprintf("%s/48", addr) + + addr[15] = 3 + + data.Meta.DNS = addr.String() + data.Meta.Privkey = privkey + + tmpl := template.Must(template.New("name").Parse(templateStr)) + tmpl.Execute(w, &data) +} + +func selectWireGuardPeer(ctx context.Context, client *api.Client, slug string) (string, error) { + peers, err := client.GetWireGuardPeers(ctx, slug) + if err != nil { + return "", err + } + + if len(peers) < 1 { + return "", fmt.Errorf(`Organization "%s" does not have any wireguard peers`, slug) + } + + var options []string + for _, peer := range peers { + options = append(options, peer.Name) + } + + selectedPeer := 0 + prompt := &survey.Select{ + Message: "Select peer:", + Options: options, + PageSize: 30, + } + if err := survey.AskOne(prompt, &selectedPeer); err != nil { + return "", err + } + + return peers[selectedPeer].Name, nil +} diff --git a/internal/command/wireguard/root.go b/internal/command/wireguard/root.go new file mode 100644 index 0000000000..92e759ccb7 --- /dev/null +++ b/internal/command/wireguard/root.go @@ -0,0 +1,183 @@ +package wireguard + +import ( + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + const ( + short = "Commands that manage WireGuard peer connections" + long = `Commands that manage WireGuard peer connections` + ) + cmd := command.New("wireguard", short, long, nil) + cmd.Aliases = []string{"wg"} + cmd.AddCommand( + newWireguardList(), + newWireguardCreate(), + newWireguardRemove(), + newWireguardStatus(), + newWireguardReset(), + newWireguardWebsockets(), + newWireguardToken(), + ) + return cmd +} + +func newWireguardList() *cobra.Command { + const ( + short = "List all WireGuard peer connections" + long = `List all WireGuard peer connections` + ) + cmd := command.New("list [org]", short, long, runWireguardList, + command.RequireSession, + ) + flag.Add(cmd, + flag.JSONOutput(), + ) + cmd.Args = cobra.MaximumNArgs(1) + return cmd +} + +func newWireguardCreate() *cobra.Command { + const ( + short = "Add a WireGuard peer connection" + long = `Add a WireGuard peer connection to an organization` + ) + cmd := command.New("create [org] [region] [name] [file]", short, long, runWireguardCreate, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(4) + return cmd +} + +func newWireguardRemove() *cobra.Command { + const ( + short = "Remove a WireGuard peer connection" + long = `Remove a WireGuard peer connection from an organization` + ) + cmd := command.New("remove [org] [name]", short, long, runWireguardRemove, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newWireguardReset() *cobra.Command { + const ( + short = "Reset WireGuard peer connection for an organization" + long = `Reset WireGuard peer connection for an organization` + ) + cmd := command.New("reset [org]", short, long, runWireguardReset, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(1) + return cmd +} + +func newWireguardStatus() *cobra.Command { + const ( + short = "Get status a WireGuard peer connection" + long = `Get status for a WireGuard peer connection` + ) + cmd := command.New("status [org] [name]", short, long, runWireguardStatus, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newWireguardWebsockets() *cobra.Command { + const ( + short = "Enable or disable WireGuard tunneling over WebSockets" + long = `Enable or disable WireGuard tunneling over WebSockets` + ) + cmd := command.New("websockets [enable|disable]", short, long, runWireguardWebsockets, + command.RequireSession, + ) + cmd.Args = cobra.ExactArgs(1) + return cmd +} + +func newWireguardToken() *cobra.Command { + const ( + short = "Commands that managed WireGuard delegated access tokens" + long = `Commands that managed WireGuard delegated access tokens` + ) + cmd := command.New("token", short, long, nil, + command.RequireSession, + ) + cmd.AddCommand( + newWireguardTokenCreate(), + newWireguardTokenDelete(), + newWireguardTokenList(), + newWireguardTokenStart(), + newWireguardTokenUpdate(), + ) + return cmd +} + +func newWireguardTokenList() *cobra.Command { + const ( + short = "List all WireGuard tokens" + long = `List all WireGuard tokens` + ) + cmd := command.New("list [org]", short, long, runWireguardTokenList, + command.RequireSession, + ) + flag.Add(cmd, + flag.JSONOutput(), + ) + cmd.Args = cobra.MaximumNArgs(1) + return cmd +} + +func newWireguardTokenCreate() *cobra.Command { + const ( + short = "Create a new WireGuard token" + long = `Create a new WireGuard token` + ) + cmd := command.New("create [org] [name]", short, long, runWireguardTokenCreate, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newWireguardTokenDelete() *cobra.Command { + const ( + short = "Delete a WireGuard token; token is name: or token:" + long = `Delete a WireGuard token; token is name: or token:` + ) + cmd := command.New("delete [org] [token]", short, long, runWireguardTokenDelete, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} + +func newWireguardTokenStart() *cobra.Command { + const ( + short = "Start a new WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)" + long = `Start a new WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)` + ) + cmd := command.New("start [name] [group] [region] [file]", short, long, runWireguardTokenStart, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(4) + return cmd +} + +func newWireguardTokenUpdate() *cobra.Command { + const ( + short = "Rekey a WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)" + long = `Rekey a WireGuard peer connection associated with a token (set FLY_WIREGUARD_TOKEN)` + ) + cmd := command.New("update [name] [file]", short, long, runWireguardTokenUpdate, + command.RequireSession, + ) + cmd.Args = cobra.MaximumNArgs(2) + return cmd +} diff --git a/internal/command/wireguard/tokens.go b/internal/command/wireguard/tokens.go new file mode 100644 index 0000000000..76f4d1b95d --- /dev/null +++ b/internal/command/wireguard/tokens.go @@ -0,0 +1,297 @@ +package wireguard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/internal/wireguard" + "github.com/superfly/flyctl/iostreams" +) + +func runWireguardTokenList(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + tokens, err := apiClient.GetDelegatedWireGuardTokens(ctx, org.Slug) + if err != nil { + return err + } + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, tokens) + return nil + } + + table := tablewriter.NewWriter(io.Out) + table.SetHeader([]string{"Name"}) + for _, peer := range tokens { + table.Append([]string{peer.Name}) + } + table.Render() + + return nil +} + +func runWireguardTokenCreate(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + name, err := argOrPrompt(ctx, 1, "Memorable name for WireGuard token: ") + if err != nil { + return err + } + + data, err := apiClient.CreateDelegatedWireGuardToken(ctx, org, name) + if err != nil { + return err + } + + fmt.Fprintf(io.Out, ` +!!!! WARNING: Output includes credential information. Credentials cannot !!!! +!!!! be recovered after creation; if you lose the token, you'll need to !!!! +!!!! remove and and re-add it. !!!! + +To use a token to create a WireGuard connection, you can use curl: + + curl -v --request POST + -H "Authorization: Bearer ${WG_TOKEN}" + -H "Content-Type: application/json" + --data '{"name": "node-1", \ + "group": "k8s", \ + "pubkey": "'"${WG_PUBKEY}"'", \ + "region": "dev"}' + http://fly.io/api/v3/wire_guard_peers + +We'll return 'us' (our local 6PN address), 'them' (the gateway IP address), +and 'pubkey' (the public key of the gateway), which you can inject into a +"wg.con". +`) + + w, shouldClose, err := resolveOutputWriter(ctx, 2, "Filename to store WireGuard token in, or 'stdout': ") + if err != nil { + return err + } + if shouldClose { + defer w.Close() // skipcq: GO-S2307 + } + + fmt.Fprintf(w, "FLY_WIREGUARD_TOKEN=%s\n", data.Token) + + return nil +} + +func runWireguardTokenDelete(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + kv, err := argOrPrompt(ctx, 1, "'name:' or token:': ") + if err != nil { + return err + } + + tup := strings.SplitN(kv, ":", 2) + if len(tup) != 2 || (tup[0] != "name" && tup[0] != "token") { + return fmt.Errorf("format is name: or token:") + } + + fmt.Fprintf(io.Out, "Removing WireGuard token \"%s\" for organization %s\n", kv, org.Slug) + + if tup[0] == "name" { + err = apiClient.DeleteDelegatedWireGuardToken(ctx, org, &tup[1], nil) + } else { + err = apiClient.DeleteDelegatedWireGuardToken(ctx, org, nil, &tup[1]) + } + if err != nil { + return err + } + + fmt.Fprintln(io.Out, "Removed token.") + return nil +} + +func tokenRequest(method, path, token string, data interface{}) (*http.Response, error) { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(data); err != nil { + return nil, err + } + + req, err := http.NewRequest(method, + fmt.Sprintf("https://fly.io/api/v3/wire_guard_peers%s", path), + buf) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", api.AuthorizationHeader(token)) + req.Header.Add("Content-Type", "application/json") + + return (&http.Client{}).Do(req) +} + +type StartPeerJson struct { + Name string `json:"name"` + Group string `json:"group"` + Pubkey string `json:"pubkey"` + Region string `json:"region"` +} + +type UpdatePeerJson struct { + Pubkey string `json:"pubkey"` +} + +type PeerStatusJson struct { + Us string `json:"us"` + Them string `json:"them"` + Pubkey string `json:"key"` + Error string `json:"error"` +} + +func generateTokenConf(ctx context.Context, idx int, stat *PeerStatusJson, privkey string) error { + fmt.Printf(` +!!!! WARNING: Output includes private key. Private keys cannot be recovered !!!! +!!!! after creating the peer; if you lose the key, you'll need to rekey !!!! +!!!! the peering connection. !!!! +`) + + w, shouldClose, err := resolveOutputWriter(ctx, idx, "Filename to store WireGuard configuration in, or 'stdout': ") + if err != nil { + return err + } + if shouldClose { + defer w.Close() // skipcq: GO-S2307 + } + + generateWgConf(&api.CreatedWireGuardPeer{ + Peerip: stat.Us, + Pubkey: stat.Pubkey, + Endpointip: stat.Them, + }, privkey, w) + + if shouldClose { + filename := w.(*os.File).Name() + fmt.Printf("Wrote WireGuard configuration to %s; load in your WireGuard client\n", filename) + } + + return nil +} + +func runWireguardTokenStart(ctx context.Context) error { + token := os.Getenv("FLY_WIREGUARD_TOKEN") + if token == "" { + return fmt.Errorf("set FLY_WIREGUARD_TOKEN env") + } + + name, err := argOrPrompt(ctx, 0, "Name (DNS-compatible) for peer: ") + if err != nil { + return err + } + + group, err := argOrPrompt(ctx, 1, "Peer group (i.e. 'k8s'): ") + if err != nil { + return err + } + + region, err := argOrPrompt(ctx, 2, "Gateway region: ") + if err != nil { + return err + } + + pubkey, privatekey := wireguard.C25519pair() + + body := &StartPeerJson{ + Name: name, + Group: group, + Pubkey: pubkey, + Region: region, + } + + resp, err := tokenRequest("POST", "", token, body) + if err != nil { + return err + } + + peerStatus := &PeerStatusJson{} + if err = json.NewDecoder(resp.Body).Decode(peerStatus); err != nil { + if resp.StatusCode != 200 { + return fmt.Errorf("server returned error: %s %w", resp.Status, err) + } + + return err + } + + if peerStatus.Error != "" { + return fmt.Errorf("WireGuard API error: %s", peerStatus.Error) + } + + if err = generateTokenConf(ctx, 3, peerStatus, privatekey); err != nil { + return err + } + + return nil +} + +func runWireguardTokenUpdate(ctx context.Context) error { + token := os.Getenv("FLY_WIREGUARD_TOKEN") + if token == "" { + return fmt.Errorf("set FLY_WIREGUARD_TOKEN env") + } + + name, err := argOrPrompt(ctx, 0, "Name (DNS-compatible) for peer: ") + if err != nil { + return err + } + + pubkey, privatekey := wireguard.C25519pair() + + body := &StartPeerJson{ + Pubkey: pubkey, + } + + resp, err := tokenRequest("PUT", "/"+name, token, body) + if err != nil { + return err + } + + peerStatus := &PeerStatusJson{} + if err = json.NewDecoder(resp.Body).Decode(peerStatus); err != nil { + if resp.StatusCode != 200 { + return fmt.Errorf("server returned error: %s %w", resp.Status, err) + } + + return err + } + + if peerStatus.Error != "" { + return fmt.Errorf("WireGuard API error: %s", peerStatus.Error) + } + + if err = generateTokenConf(ctx, 1, peerStatus, privatekey); err != nil { + return err + } + + return nil +} diff --git a/internal/command/wireguard/wireguard.go b/internal/command/wireguard/wireguard.go new file mode 100644 index 0000000000..876f6bf7c9 --- /dev/null +++ b/internal/command/wireguard/wireguard.go @@ -0,0 +1,260 @@ +package wireguard + +import ( + "context" + "fmt" + "os" + + "github.com/olekukonko/tablewriter" + "github.com/pkg/errors" + "github.com/spf13/viper" + "github.com/superfly/flyctl/agent" + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/flyctl" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/internal/wireguard" + "github.com/superfly/flyctl/iostreams" + "github.com/superfly/flyctl/terminal" +) + +func runWireguardList(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + peers, err := apiClient.GetWireGuardPeers(ctx, org.Slug) + if err != nil { + return err + } + + if config.FromContext(ctx).JSONOutput { + render.JSON(io.Out, peers) + return nil + } + + table := tablewriter.NewWriter(io.Out) + + table.SetHeader([]string{ + "Name", + "Region", + "Peer IP", + }) + + for _, peer := range peers { + table.Append([]string{peer.Name, peer.Region, peer.Peerip}) + } + + table.Render() + + return nil +} + +func runWireguardWebsockets(ctx context.Context) error { + io := iostreams.FromContext(ctx) + + switch flag.FirstArg(ctx) { + case "enable": + viper.Set(flyctl.ConfigWireGuardWebsockets, true) + + case "disable": + viper.Set(flyctl.ConfigWireGuardWebsockets, false) + + default: + fmt.Fprintf(io.Out, "bad arg: flyctl wireguard websockets (enable|disable)\n") + } + + if err := flyctl.SaveConfig(); err != nil { + return errors.Wrap(err, "error saving config file") + } + + tryKillingAgent := func() error { + client, err := agent.DefaultClient(ctx) + if err == agent.ErrAgentNotRunning { + return nil + } else if err != nil { + return err + } + + return client.Kill(ctx) + } + + // kill the agent if necessary, if that fails print manual instructions + if err := tryKillingAgent(); err != nil { + terminal.Debugf("error stopping the agent: %s", err) + fmt.Fprintf(io.Out, "Run `flyctl agent restart` to make changes take effect.\n") + } + + return nil +} + +func runWireguardReset(ctx context.Context) error { + io := iostreams.FromContext(ctx) + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + apiClient := client.FromContext(ctx).API() + agentclient, err := agent.Establish(context.Background(), apiClient) + if err != nil { + return err + } + + conf, err := agentclient.Reestablish(context.Background(), org.Slug) + if err != nil { + return err + } + + fmt.Fprintf(io.Out, "New WireGuard peer for organization '%s': '%s'\n", org.Slug, conf.WireGuardState.Name) + return nil +} + +func runWireguardCreate(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + args := flag.Args(ctx) + var region string + var name string + + if len(args) > 1 && args[1] != "" { + region = args[1] + } + + if len(args) > 2 && args[2] != "" { + name = args[2] + } + + state, err := wireguard.Create(apiClient, org, region, name) + if err != nil { + return err + } + + data := &state.Peer + + fmt.Fprintf(io.Out, ` +!!!! WARNING: Output includes private key. Private keys cannot be recovered !!!! +!!!! after creating the peer; if you lose the key, you'll need to remove !!!! +!!!! and re-add the peering connection. !!!! +`) + + w, shouldClose, err := resolveOutputWriter(ctx, 3, "Filename to store WireGuard configuration in, or 'stdout': ") + if err != nil { + return err + } + if shouldClose { + defer w.Close() // skipcq: GO-S2307 + } + + generateWgConf(data, state.LocalPrivate, w) + + if shouldClose { + filename := w.(*os.File).Name() + fmt.Fprintf(io.Out, "Wrote WireGuard configuration to %s; load in your WireGuard client\n", filename) + } + + return nil +} + +func runWireguardRemove(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + args := flag.Args(ctx) + var name string + if len(args) >= 2 { + name = args[1] + } else { + name, err = selectWireGuardPeer(ctx, apiClient, org.Slug) + if err != nil { + return err + } + } + + fmt.Fprintf(io.Out, "Removing WireGuard peer \"%s\" for organization %s\n", name, org.Slug) + + err = apiClient.RemoveWireGuardPeer(ctx, org, name) + if err != nil { + return err + } + + fmt.Fprintln(io.Out, "Removed peer.") + + return wireguard.PruneInvalidPeers(ctx, apiClient) +} + +func runWireguardStatus(ctx context.Context) error { + io := iostreams.FromContext(ctx) + apiClient := client.FromContext(ctx).API() + + org, err := orgByArg(ctx) + if err != nil { + return err + } + + args := flag.Args(ctx) + var name string + if len(args) >= 2 { + name = args[1] + } else { + name, err = selectWireGuardPeer(ctx, apiClient, org.Slug) + if err != nil { + return err + } + } + + status, err := apiClient.GetWireGuardPeerStatus(ctx, org.Slug, name) + if err != nil { + return err + } + + fmt.Fprintf(io.Out, "Alive: %+v\n", status.Live) + + if status.WgError != "" { + fmt.Fprintf(io.Out, "Gateway error: %s\n", status.WgError) + } + + if !status.Live { + return nil + } + + if status.Endpoint != "" { + fmt.Fprintf(io.Out, "Last Source Address: %s\n", status.Endpoint) + } + + ago := "" + if status.SinceAdded != "" { + ago = " (" + status.SinceAdded + " ago)" + } + + if status.LastHandshake != "" { + fmt.Fprintf(io.Out, "Last Handshake At: %s%s\n", status.LastHandshake, ago) + } + + ago = "" + if status.SinceHandshake != "" { + ago = " (" + status.SinceHandshake + " ago)" + } + + fmt.Fprintf(io.Out, "Installed On Gateway At: %s%s\n", status.Added, ago) + fmt.Fprintf(io.Out, "Traffic: rx:%d tx:%d\n", status.Rx, status.Tx) + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 48d22bce28..5f4c7f0119 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,8 +17,11 @@ const ( envKeyPrefix = "FLY_" apiBaseURLEnvKey = envKeyPrefix + "API_BASE_URL" flapsBaseURLEnvKey = envKeyPrefix + "FLAPS_BASE_URL" + metricsBaseURLEnvKey = envKeyPrefix + "METRICS_BASE_URL" AccessTokenEnvKey = envKeyPrefix + "ACCESS_TOKEN" AccessTokenFileKey = "access_token" + MetricsTokenEnvKey = envKeyPrefix + "METRICS_TOKEN" + MetricsTokenFileKey = "metrics_token" WireGuardStateFileKey = "wire_guard_state" APITokenEnvKey = envKeyPrefix + "API_TOKEN" orgEnvKey = envKeyPrefix + "ORG" @@ -30,9 +33,10 @@ const ( logGQLEnvKey = envKeyPrefix + "LOG_GQL_ERRORS" localOnlyEnvKey = envKeyPrefix + "LOCAL_ONLY" - defaultAPIBaseURL = "https://api.fly.io" - defaultFlapsBaseURL = "https://api.machines.dev" - defaultRegistryHost = "registry.fly.io" + defaultAPIBaseURL = "https://api.fly.io" + defaultFlapsBaseURL = "https://api.machines.dev" + defaultRegistryHost = "registry.fly.io" + defaultMetricsBaseURL = "https://flyctl-metrics.fly.dev" ) // Config wraps the functionality of the configuration file. @@ -47,6 +51,9 @@ type Config struct { // FlapsBaseURL denotes base URL for FLAPS (also known as the Machines API). FlapsBaseURL string + // MetricsBaseURL denotes the base URL of the metrics API. + MetricsBaseURL string + // RegistryHost denotes the docker registry host. RegistryHost string @@ -70,14 +77,18 @@ type Config struct { // AccessToken denotes the user's access token. AccessToken string + + // MetricsToken denotes the user's metrics token. + MetricsToken string } // New returns a new instance of Config populated with default values. func New() *Config { return &Config{ - APIBaseURL: defaultAPIBaseURL, - FlapsBaseURL: defaultFlapsBaseURL, - RegistryHost: defaultRegistryHost, + APIBaseURL: defaultAPIBaseURL, + FlapsBaseURL: defaultFlapsBaseURL, + RegistryHost: defaultRegistryHost, + MetricsBaseURL: defaultMetricsBaseURL, } } @@ -106,6 +117,7 @@ func (cfg *Config) ApplyEnv() { cfg.RegistryHost = env.FirstOrDefault(cfg.RegistryHost, registryHostEnvKey) cfg.APIBaseURL = env.FirstOrDefault(cfg.APIBaseURL, apiBaseURLEnvKey) cfg.FlapsBaseURL = env.FirstOrDefault(cfg.FlapsBaseURL, flapsBaseURLEnvKey) + cfg.MetricsBaseURL = env.FirstOrDefault(cfg.MetricsBaseURL, metricsBaseURLEnvKey) } // ApplyFile sets the properties of cfg which may be set via configuration file @@ -115,11 +127,13 @@ func (cfg *Config) ApplyFile(path string) (err error) { defer cfg.mu.Unlock() var w struct { - AccessToken string `yaml:"access_token"` + AccessToken string `yaml:"access_token"` + MetricsToken string `yaml:"metrics_token"` } if err = unmarshal(path, &w); err == nil { cfg.AccessToken = w.AccessToken + cfg.MetricsToken = w.MetricsToken } return @@ -144,6 +158,10 @@ func (cfg *Config) ApplyFlags(fs *pflag.FlagSet) { }) } +func (cfg *Config) MetricsBaseURLIsProduction() bool { + return cfg.MetricsBaseURL == defaultMetricsBaseURL +} + func applyStringFlags(fs *pflag.FlagSet, flags map[string]*string) { for name, dst := range flags { if !fs.Changed(name) { diff --git a/internal/config/file.go b/internal/config/file.go index a15b2bfe09..5f53d8503b 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -8,6 +8,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/superfly/flyctl/flyctl" "github.com/superfly/flyctl/internal/filemu" ) @@ -19,11 +20,20 @@ func SetAccessToken(path, token string) error { }) } -// Clear clears the access token and wireguard-related keys of the configuration +// SetMetricsToken sets the value of the metrics token at the configuration file +// found at path. +func SetMetricsToken(path, token string) error { + return set(path, map[string]interface{}{ + MetricsTokenFileKey: token, + }) +} + +// Clear clears the access token, metrics token, and wireguard-related keys of the configuration // file found at path. func Clear(path string) (err error) { return set(path, map[string]interface{}{ AccessTokenFileKey: "", + MetricsTokenFileKey: "", WireGuardStateFileKey: map[string]interface{}{}, }) } @@ -45,11 +55,13 @@ func set(path string, vals map[string]interface{}) error { return marshal(path, m) } -var lockPath = filepath.Join(os.TempDir(), "flyctl.config.lock") +func lockPath() string { + return filepath.Join(flyctl.ConfigDir(), "flyctl.config.lock") +} func unmarshal(path string, v interface{}) (err error) { var unlock filemu.UnlockFunc - if unlock, err = filemu.RLock(context.Background(), lockPath); err != nil { + if unlock, err = filemu.RLock(context.Background(), lockPath()); err != nil { return } defer func() { @@ -81,7 +93,7 @@ func unmarshalUnlocked(path string, v interface{}) (err error) { func marshal(path string, v interface{}) (err error) { var unlock filemu.UnlockFunc - if unlock, err = filemu.Lock(context.Background(), lockPath); err != nil { + if unlock, err = filemu.Lock(context.Background(), lockPath()); err != nil { return } defer func() { diff --git a/internal/flag/context.go b/internal/flag/context.go index 05c73e1f4c..4a1b1d71dd 100644 --- a/internal/flag/context.go +++ b/internal/flag/context.go @@ -5,6 +5,7 @@ import ( "time" "github.com/spf13/pflag" + "golang.org/x/exp/slices" ) type contextKey struct{} @@ -121,3 +122,20 @@ func GetAppConfigFilePath(ctx context.Context) string { return path } } + +// GetFlagsName returns the name of flags that have been set except unwanted flags. +func GetFlagsName(ctx context.Context, ignoreFlags []string) []string { + flagsName := []string{} + + FromContext(ctx).Visit(func(f *pflag.Flag) { + if f.Hidden { + return + } + + if !slices.Contains(ignoreFlags, f.Name) { + flagsName = append(flagsName, f.Name) + } + }) + + return flagsName +} diff --git a/internal/instrument/call.go b/internal/instrument/call.go new file mode 100644 index 0000000000..f24452ee5b --- /dev/null +++ b/internal/instrument/call.go @@ -0,0 +1,64 @@ +package instrument + +import ( + "sync" + "time" +) + +var ( + mu sync.Mutex + GraphQL CallInstrumenter + Flaps CallInstrumenter + ApiAdapter = &ApiInstrumenter{metrics: &GraphQL.metrics} +) + +type CallInstrumenter struct { + metrics CallMetrics +} + +type CallMetrics struct { + Calls int + Duration float64 +} + +type CallTimer struct { + start time.Time + metrics *CallMetrics +} + +func (i *CallInstrumenter) Begin() CallTimer { + return CallTimer{ + start: time.Now(), + metrics: &i.metrics, + } +} + +func (i *CallInstrumenter) Get() CallMetrics { + mu.Lock() + defer mu.Unlock() + + return i.metrics +} + +func (t *CallTimer) End() { + mu.Lock() + defer mu.Unlock() + + duration := time.Since(t.start).Seconds() + + t.metrics.Calls += 1 + t.metrics.Duration += duration +} + +// adapter for the api package's instrumentation facade +type ApiInstrumenter struct { + metrics *CallMetrics +} + +func (i *ApiInstrumenter) ReportCallTiming(duration time.Duration) { + mu.Lock() + defer mu.Unlock() + + i.metrics.Calls += 1 + i.metrics.Duration += duration.Seconds() +} diff --git a/internal/machine/leasable_machine.go b/internal/machine/leasable_machine.go index c7d55f4bc9..fc94075574 100644 --- a/internal/machine/leasable_machine.go +++ b/internal/machine/leasable_machine.go @@ -27,6 +27,7 @@ type LeasableMachine interface { Start(context.Context) error Destroy(context.Context, bool) error WaitForState(context.Context, string, time.Duration, string) error + WaitForSmokeChecksToPass(context.Context, string) error WaitForHealthchecksToPass(context.Context, time.Duration, string) error WaitForEventTypeAfterType(context.Context, string, string, time.Duration) (*api.MachineEvent, error) FormattedMachineId() string @@ -59,6 +60,7 @@ func (lm *leasableMachine) Update(ctx context.Context, input api.LaunchMachineIn if !lm.HasLease() { return fmt.Errorf("no current lease for machine %s", lm.machine.ID) } + input.ID = lm.machine.ID updateMachine, err := lm.flapsClient.Update(ctx, input, lm.leaseNonce) if err != nil { return err @@ -75,7 +77,7 @@ func (lm *leasableMachine) Destroy(ctx context.Context, kill bool) error { ID: lm.machine.ID, Kill: kill, } - err := lm.flapsClient.Destroy(ctx, input, lm.machine.LeaseNonce) + err := lm.flapsClient.Destroy(ctx, input, lm.leaseNonce) if err != nil { return err } @@ -89,7 +91,7 @@ func (lm *leasableMachine) FormattedMachineId() string { return res } procGroup := lm.Machine().ProcessGroup() - if procGroup == "" || lm.Machine().IsFlyAppsReleaseCommand() { + if procGroup == "" || lm.Machine().IsFlyAppsReleaseCommand() || lm.Machine().IsFlyAppsConsole() { return res } return fmt.Sprintf("%s [%s]", res, procGroup) @@ -191,6 +193,62 @@ func (lm *leasableMachine) WaitForState(ctx context.Context, desiredState string } } +func (lm *leasableMachine) isConstantlyRestarting(machine *api.Machine) bool { + var ev *api.MachineEvent + + for _, mev := range machine.Events { + if mev.Type == "exit" { + ev = mev + break + } + } + + if ev == nil { + return false + } + + return !ev.Request.ExitEvent.RequestedStop && + ev.Request.ExitEvent.Restarting && + ev.Request.RestartCount > 1 && + ev.Request.ExitEvent.ExitCode != 0 +} + +func (lm *leasableMachine) WaitForSmokeChecksToPass(ctx context.Context, logPrefix string) error { + waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + b := &backoff.Backoff{ + Min: 500 * time.Millisecond, + Max: 2 * time.Second, + Factor: 2, + Jitter: true, + } + + fmt.Fprintf(lm.io.ErrOut, " %s Checking that %s is up and running\n", + logPrefix, + lm.colorize.Bold(lm.FormattedMachineId()), + ) + + for { + machine, err := lm.flapsClient.Get(waitCtx, lm.Machine().ID) + switch { + case errors.Is(waitCtx.Err(), context.Canceled): + return err + case errors.Is(waitCtx.Err(), context.DeadlineExceeded): + return nil + case err != nil: + return fmt.Errorf("error getting machine %s from api: %w", lm.Machine().ID, err) + } + + switch { + case lm.isConstantlyRestarting(machine): + return fmt.Errorf("the app appears to be crashing") + default: + time.Sleep(b.Duration()) + } + } +} + func (lm *leasableMachine) WaitForHealthchecksToPass(ctx context.Context, timeout time.Duration, logPrefix string) error { if len(lm.Machine().Checks) == 0 { return nil diff --git a/internal/machine/query.go b/internal/machine/query.go index 9800bb6c5f..7bef0b2775 100644 --- a/internal/machine/query.go +++ b/internal/machine/query.go @@ -17,7 +17,7 @@ func ListActive(ctx context.Context) ([]*api.Machine, error) { } machines = lo.Filter(machines, func(m *api.Machine, _ int) bool { - return m.Config != nil && m.IsActive() && !m.IsReleaseCommandMachine() + return m.Config != nil && m.IsActive() && !m.IsReleaseCommandMachine() && !m.IsFlyAppsConsole() }) return machines, nil diff --git a/internal/machine/update.go b/internal/machine/update.go index 84d5a85010..3995d258fd 100644 --- a/internal/machine/update.go +++ b/internal/machine/update.go @@ -84,21 +84,22 @@ func Update(ctx context.Context, m *api.Machine, input *api.LaunchMachineInput) input.ID = m.ID updatedMachine, err = flapsClient.Update(ctx, *input, m.LeaseNonce) if err != nil { - return fmt.Errorf("could not stop machine %s: %w", input.ID, err) + return fmt.Errorf("could not update machine %s: %w", m.ID, err) } waitForAction := "start" - if m.Config.Schedule != "" { + if input.SkipLaunch || m.Config.Schedule != "" { waitForAction = "stop" } - if err := WaitForStartOrStop(ctx, updatedMachine, waitForAction, time.Minute*5); err != nil { return err } - if !input.SkipHealthChecks { - if err := watch.MachinesChecks(ctx, []*api.Machine{updatedMachine}); err != nil { - return fmt.Errorf("failed to wait for health checks to pass: %w", err) + if !input.SkipLaunch { + if !input.SkipHealthChecks { + if err := watch.MachinesChecks(ctx, []*api.Machine{updatedMachine}); err != nil { + return fmt.Errorf("failed to wait for health checks to pass: %w", err) + } } } diff --git a/internal/metrics/api.go b/internal/metrics/api.go new file mode 100644 index 0000000000..47688175b0 --- /dev/null +++ b/internal/metrics/api.go @@ -0,0 +1,141 @@ +package metrics + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "sync" + + "github.com/superfly/flyctl/internal/buildinfo" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/terminal" + "golang.org/x/net/websocket" +) + +var Enabled = true +var websocketConn *websocket.Conn +var websocketMu sync.Mutex +var done sync.WaitGroup + +func websocketURL(cfg *config.Config) (*url.URL, error) { + url, err := url.Parse(cfg.MetricsBaseURL) + if err != nil { + return nil, err + } + + switch url.Scheme { + case "http": + url.Scheme = "ws" + case "https": + url.Scheme = "wss" + } + + return url.JoinPath("/socket"), nil +} + +func connectWebsocket(ctx context.Context) (*websocket.Conn, error) { + cfg := config.FromContext(ctx) + + url, err := websocketURL(cfg) + if err != nil { + return nil, err + } + + // websockets require an origin url - this doesn't make sense in flyctl's + // case, so let's just reuse the connection url as the origin. + origin := url + + wsCfg, err := websocket.NewConfig(url.String(), origin.String()) + if err != nil { + return nil, err + } + + authToken, err := getMetricsToken(ctx) + if err != nil { + return nil, err + } + + wsCfg.Header.Set("Authorization", authToken) + wsCfg.Header.Set("User-Agent", fmt.Sprintf("flyctl/%s", buildinfo.Version().String())) + + return websocket.DialConfig(wsCfg) +} + +func getWebsocketConn(ctx context.Context) *websocket.Conn { + websocketMu.Lock() + defer websocketMu.Unlock() + + if websocketConn == nil { + conn, err := connectWebsocket(ctx) + if err != nil { + // failed to connect metrics websocket, nothing we can do + terminal.Debugf("failed to connect metrics websocket: %v\n", err) + return nil + } + websocketConn = conn + } + return websocketConn +} + +type websocketMessage struct { + Metric string `json:"m"` + Payload json.RawMessage `json:"p"` +} + +func rawSendImpl(ctx context.Context, metricSlug string, payload json.RawMessage) error { + conn := getWebsocketConn(ctx) + if conn == nil { + // returning nil here is fine since getWebsocketConn returning + // nil means we have already logged an error + return nil + } + + message := websocketMessage{ + Metric: metricSlug, + Payload: payload, + } + + return websocket.JSON.Send(conn, &message) +} + +func handleErr(err error) { + if err == nil { + return + } + // TODO(ali): Should this ping sentry when it fails? + terminal.Debugf("metrics error: %v\n", err) +} + +func rawSend(parentCtx context.Context, metricSlug string, payload json.RawMessage) { + if !shouldSendMetrics(parentCtx) { + return + } + + done.Add(1) + go func() { + defer done.Done() + handleErr(rawSendImpl(parentCtx, metricSlug, payload)) + }() +} + +func shouldSendMetrics(ctx context.Context) bool { + if !Enabled { + return false + } + + cfg := config.FromContext(ctx) + + // never send metrics to the production collector from dev builds + if buildinfo.IsDev() && cfg.MetricsBaseURLIsProduction() { + return false + } + + return true +} + +func FlushPending() { + // this just waits for metrics to hit write(2) on the websocket connection + // there is no need to wait on a response from the collector + done.Wait() +} diff --git a/internal/metrics/command.go b/internal/metrics/command.go new file mode 100644 index 0000000000..f7aca3fbfe --- /dev/null +++ b/internal/metrics/command.go @@ -0,0 +1,57 @@ +package metrics + +import ( + "context" + "sync" + "time" + + "github.com/spf13/cobra" + "github.com/superfly/flyctl/internal/instrument" +) + +var ( + processStartTime = time.Now() + commandContext context.Context + mu sync.Mutex +) + +type commandStats struct { + Command string `json:"c"` + Duration float64 `json:"d"` + GraphQLCalls int `json:"gc"` + GraphQLDuration float64 `json:"gd"` + FlapsCalls int `json:"fc"` + FlapsDuration float64 `json:"fd"` +} + +func RecordCommandContext(ctx context.Context) { + mu.Lock() + defer mu.Unlock() + + if commandContext != nil { + panic("called metrics.RecordCommandContext twice") + } + + commandContext = ctx +} + +func RecordCommandFinish(cmd *cobra.Command) { + mu.Lock() + defer mu.Unlock() + + duration := time.Since(processStartTime) + + graphql := instrument.GraphQL.Get() + flaps := instrument.Flaps.Get() + + if commandContext != nil { + Send(commandContext, "command/stats", commandStats{ + Command: cmd.CommandPath(), + Duration: duration.Seconds(), + GraphQLCalls: graphql.Calls, + GraphQLDuration: graphql.Duration, + FlapsCalls: flaps.Calls, + FlapsDuration: flaps.Duration, + }) + } +} diff --git a/internal/metrics/helpers.go b/internal/metrics/helpers.go new file mode 100644 index 0000000000..6bb37bb67d --- /dev/null +++ b/internal/metrics/helpers.go @@ -0,0 +1,77 @@ +package metrics + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/superfly/flyctl/terminal" +) + +var ( + unmatchedStatusesMtx = sync.Mutex{} + unmatchedStatuses = map[string]struct{}{} +) + +func withUnmatchedStatuses[T any](cb func(map[string]struct{}) T) T { + unmatchedStatusesMtx.Lock() + defer unmatchedStatusesMtx.Unlock() + return cb(unmatchedStatuses) +} + +func Started(ctx context.Context, metricSlug string) { + ok := withUnmatchedStatuses(func(unmatchedStatuses map[string]struct{}) bool { + if _, ok := unmatchedStatuses[metricSlug]; ok { + return false + } + unmatchedStatuses[metricSlug] = struct{}{} + return true + }) + if !ok { + terminal.Debugf("Metrics: Attempted to send start event for %s, but it was already started", metricSlug) + return + } + + SendNoData(ctx, metricSlug+"/started") + +} +func Status(ctx context.Context, metricSlug string, success bool) { + ok := withUnmatchedStatuses(func(unmatchedStatuses map[string]struct{}) bool { + if _, ok := unmatchedStatuses[metricSlug]; ok { + delete(unmatchedStatuses, metricSlug) + return true + } + return false + }) + if !ok { + terminal.Debugf("Metrics: Attempted to send status for %s, but no start event was sent", metricSlug) + return + } + + Send(ctx, metricSlug+"/status", map[string]bool{"success": success}) +} + +func Send[T any](ctx context.Context, metricSlug string, value T) { + + valJson, err := json.Marshal(value) + if err != nil { + return + } + SendJson(ctx, metricSlug, valJson) +} + +func SendNoData(ctx context.Context, metricSlug string) { + SendJson(ctx, metricSlug, nil) +} + +func SendJson(ctx context.Context, metricSlug string, payload json.RawMessage) { + rawSend(ctx, metricSlug, payload) +} + +func StartTiming(ctx context.Context, metricSlug string) func() { + start := time.Now() + return func() { + Send(ctx, metricSlug, map[string]float64{"duration_seconds": time.Since(start).Seconds()}) + } +} diff --git a/internal/metrics/token.go b/internal/metrics/token.go new file mode 100644 index 0000000000..55bc3edcfc --- /dev/null +++ b/internal/metrics/token.go @@ -0,0 +1,83 @@ +package metrics + +import ( + "context" + "errors" + "fmt" + + "github.com/superfly/flyctl/client" + "github.com/superfly/flyctl/gql" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/state" + "github.com/superfly/flyctl/terminal" +) + +func queryMetricsToken(ctx context.Context) (string, error) { + + // Manually construct an API client with the user's access token. + // We use this over the context API client because we're trying to + // authenticate the human user, not the specific credentials they're using. + cfg := config.FromContext(ctx) + apiClient := client.NewClient(cfg.AccessToken) + + personal, _, err := apiClient.GetCurrentOrganizations(ctx) + if err != nil { + return "", err + } + if personal.ID == "" { + return "", errors.New("no personal organization found") + } + + resp, err := gql.CreateLimitedAccessToken( + ctx, + apiClient.GenqClient, + "flyctl-metrics", + personal.ID, + "identity", + struct{}{}, + "", + ) + if err != nil { + return "", fmt.Errorf("failed creating identity token: %w", err) + } + return resp.CreateLimitedAccessToken.LimitedAccessToken.TokenHeader, nil +} + +func getMetricsToken(parentCtx context.Context) (token string, err error) { + // Prevent metrics panics from bubbling up to the user. + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + cfg := config.FromContext(parentCtx) + if cfg.MetricsToken != "" { + terminal.Debugf("Config has metrics token\n") + return cfg.MetricsToken, nil + } + + if cfg.MetricsToken == "" && cfg.AccessToken != "" { + terminal.Debugf("Querying metrics token from web\n") + token, err := queryMetricsToken(parentCtx) + if err != nil { + return "", err + } + if err = persistMetricsToken(parentCtx, token); err != nil { + return "", err + } + cfg.MetricsToken = token + return token, nil + } + return "", errors.New("no metrics token in config") +} + +func persistMetricsToken(ctx context.Context, token string) error { + path := state.ConfigFile(ctx) + + if err := config.SetMetricsToken(path, token); err != nil { + return fmt.Errorf("failed persisting %s in %s: %w\n", + config.MetricsTokenFileKey, path, err) + } + return nil +} diff --git a/internal/monitor/logs.go b/internal/monitor/logs.go index ec3fb76113..1fbf334cec 100644 --- a/internal/monitor/logs.go +++ b/internal/monitor/logs.go @@ -7,7 +7,7 @@ import ( "github.com/jpillora/backoff" "github.com/superfly/flyctl/api" - "github.com/superfly/flyctl/cmdctx" + "github.com/superfly/flyctl/client" "github.com/superfly/flyctl/terminal" ) @@ -20,8 +20,8 @@ type LogOptions struct { RegionCode string } -func WatchLogs(cc *cmdctx.CmdContext, w io.Writer, opts LogOptions) error { - ctx := cc.Command.Context() +func WatchLogs(ctx context.Context, opts LogOptions) error { + apiClient := client.FromContext(ctx).API() errorCount := 0 @@ -38,10 +38,8 @@ func WatchLogs(cc *cmdctx.CmdContext, w io.Writer, opts LogOptions) error { nextToken := "" - // logPresenter := presenters.LogPresenter{} - for { - entries, token, err := cc.Client.API().GetAppLogs(ctx, opts.AppName, nextToken, opts.RegionCode, opts.VMID) + entries, token, err := apiClient.GetAppLogs(ctx, opts.AppName, nextToken, opts.RegionCode, opts.VMID) if err != nil { terminal.Debugf("error getting app logs: %v\n", err) if api.IsNotAuthenticatedError(err) { @@ -63,8 +61,6 @@ func WatchLogs(cc *cmdctx.CmdContext, w io.Writer, opts LogOptions) error { } else { b.Reset() - // logPresenter.FPrint(w, false, entries) - if token != "" { nextToken = token } diff --git a/internal/update/update.go b/internal/update/update.go index ce8a819c02..0045b57404 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -135,7 +135,7 @@ func IsUnderHomebrew() bool { return strings.HasPrefix(flyBinary, brewBinPrefix) } -func updateCommand(prerelease bool) string { +func upgradeCommand(prerelease bool) string { if IsUnderHomebrew() { return "brew upgrade flyctl" } @@ -180,9 +180,9 @@ func UpgradeInPlace(ctx context.Context, io *iostreams.IOStreams, prelease bool) } fmt.Println(shellToUse, switchToUse) - command := updateCommand(prelease) + command := upgradeCommand(prelease) - fmt.Fprintf(io.ErrOut, "Running automatic update [%s]\n", command) + fmt.Fprintf(io.ErrOut, "Running automatic upgrade [%s]\n", command) cmd := exec.Command(shellToUse, switchToUse, command) cmd.Stdout = io.Out diff --git a/internal/watch/watch.go b/internal/watch/watch.go index 03c24eeff5..45ec7d275c 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -63,8 +63,6 @@ func Deployment(ctx context.Context, appName, evaluationID string) error { } monitor.DeploymentFailed = func(d *api.DeploymentStatus, failedAllocs []*api.AllocationStatus) error { - // cmdCtx.Statusf("deploy", cmdctx.SDETAIL, "v%d %s - %s\n", d.Version, d.Status, d.Description) - if endmessage == "" && d.Status == "failed" { if strings.Contains(d.Description, "no stable release to revert to") { endmessage = fmt.Sprintf("v%d %s - %s\n", d.Version, d.Status, d.Description) diff --git a/proxy/connect.go b/proxy/connect.go index 42ec285f08..8f4a532d5c 100644 --- a/proxy/connect.go +++ b/proxy/connect.go @@ -23,6 +23,8 @@ type ConnectParams struct { DisableSpinner bool } +// Binds to a local port and runs a proxy to a remote address over Wireguard. +// Blocks until context is cancelled. func Connect(ctx context.Context, p *ConnectParams) (err error) { server, err := NewServer(ctx, p) if err != nil { @@ -32,6 +34,22 @@ func Connect(ctx context.Context, p *ConnectParams) (err error) { return server.ProxyServer(ctx) } +// Binds to a local port and then starts a goroutine to run a proxy to a remote +// address over Wireguard. Proxy runs until context is cancelled. +// Blocks only until local listener is bound and ready to accept connections. +func Start(ctx context.Context, p *ConnectParams) error { + server, err := NewServer(ctx, p) + if err != nil { + return err + } + + // currently ignores any error returned by ProxyServer + // TODO return a channel to caller for async error notification + go server.ProxyServer(ctx) + + return nil +} + func NewServer(ctx context.Context, p *ConnectParams) (*Server, error) { var ( io = iostreams.FromContext(ctx) diff --git a/scanner/deno.go b/scanner/deno.go index f36e33cd61..2a79a2455f 100644 --- a/scanner/deno.go +++ b/scanner/deno.go @@ -1,7 +1,7 @@ package scanner func configureDeno(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { - if !checksPass(sourceDir, dirContains("*.ts", "denopkg")) { + if !checksPass(sourceDir, dirContains("*.ts", "denopkg", "deno.land")) { return nil, nil } diff --git a/scanner/django.go b/scanner/django.go index 739c501ae3..987e3b5bf6 100644 --- a/scanner/django.go +++ b/scanner/django.go @@ -2,9 +2,13 @@ package scanner import ( "fmt" + "github.com/blang/semver" + "github.com/logrusorgru/aurora" "github.com/mattn/go-zglob" "github.com/superfly/flyctl/helpers" + "os/exec" "path" + "regexp" "strings" ) @@ -35,109 +39,150 @@ func configureDjango(sourceDir string, config *ScannerConfig) (*SourceInfo, erro UrlPrefix: "/static/", }, }, - SkipDeploy: true, + SkipDeploy: true, + ConsoleCommand: "/code/manage.py shell", } - vars := make(map[string]interface{}) - if checksPass(sourceDir, fileExists("Pipfile")) { - vars["pipenv"] = true - } else if checksPass(sourceDir, fileExists("pyproject.toml")) { - vars["poetry"] = true + // keep `pythonLatestSupported` up to date: https://devguide.python.org/versions/#supported-versions + // Keep the default `pythonVersion` as "3.10" (previously hardcoded on the Dockerfile) + pythonLatestSupported := "3.7.0" + pythonVersion := "3.10" + + pythonFullVersion, err := extractPythonVersion() + + if err == nil && pythonFullVersion != "" { + userVersion, userErr := semver.ParseTolerant(pythonFullVersion) + supportedVersion, supportedErr := semver.ParseTolerant(pythonLatestSupported) + + if userErr == nil && supportedErr == nil { + // if Python version is below 3.7.0, use Python 3.10 (default) + // it is required to have Major, Minor and Patch (e.g. 3.11.2) to be able to use GT + // but only Major and Minor (e.g. 3.11) is used in the Dockerfile + if userVersion.GTE(supportedVersion) { + v, err := semver.Parse(pythonFullVersion) + if err == nil { + pythonVersion = fmt.Sprintf("%d.%d", v.Major, v.Minor) + } + s.Notice += fmt.Sprintf(` +%s Python %s was detected. 'python:%s-slim-buster' image will be set in the Dockerfile. +`, aurora.Faint("[INFO]"), pythonFullVersion, pythonVersion) + } else { + s.Notice += fmt.Sprintf(` +%s It looks like you have Python %s installed, but it has reached its end of support. Using Python %s to build your image instead. +Make sure to update the Dockerfile to use an image that is compatible with the Python version you are using. +%s We highly recommend that you update your application to use Python %s or newer. (https://devguide.python.org/versions/#supported-versions) +`, aurora.Yellow("[WARNING]"), pythonFullVersion, pythonVersion, aurora.Yellow("[WARNING]"), pythonLatestSupported) + } + } + } else { + s.Notice += fmt.Sprintf(` +%s Python version was not detected. Using Python %s to build your image instead. +Make sure to update the Dockerfile to use an image that is compatible with the Python version you are using. +%s We highly recommend that you update your application to use Python %s or newer. (https://devguide.python.org/versions/#supported-versions) +`, aurora.Yellow("[WARNING]"), pythonVersion, aurora.Yellow("[WARNING]"), pythonLatestSupported) + } + + vars["pythonVersion"] = pythonVersion + + if checksPass(sourceDir, fileExists("Pipfile")) { + vars["pipenv"] = true + } else if checksPass(sourceDir, fileExists("pyproject.toml")) { + vars["poetry"] = true } else if checksPass(sourceDir, fileExists("requirements.txt")) { - vars["venv"] = true + vars["venv"] = true } - wsgiFiles, err := zglob.Glob(`./**/wsgi.py`) - - if err == nil && len(wsgiFiles) > 0 { - var wsgiFilesProject []string - for _, wsgiPath := range wsgiFiles { - // when using a virtual environment to manage the dependencies (e.g. venv), the 'site-packages/' folder is created within the virtual environment folder - // This folder contains all the (dependencies) packages installed within the virtual environment - // exclude dependencies matches that contain 'site-packages' in the path (e.g. .venv/lib/python3.11/site-packages/django/core/handlers/wsgi.py) - if !strings.Contains(wsgiPath, "site-packages") { - wsgiFilesProject = append(wsgiFilesProject, wsgiPath) - } - } - - if len(wsgiFilesProject) > 0 { - wsgiFilesLen := len(wsgiFilesProject) - dirPath, _ := path.Split(wsgiFilesProject[wsgiFilesLen-1]) - dirName := path.Base(dirPath) - vars["wsgiName"] = dirName; - vars["wsgiFound"] = true; - if wsgiFilesLen > 1 { - // warning: multiple wsgi.py files found - s.DeployDocs = s.DeployDocs + fmt.Sprintf(` + wsgiFiles, err := zglob.Glob(`./**/wsgi.py`) + + if err == nil && len(wsgiFiles) > 0 { + var wsgiFilesProject []string + for _, wsgiPath := range wsgiFiles { + // when using a virtual environment to manage the dependencies (e.g. venv), the 'site-packages/' folder is created within the virtual environment folder + // This folder contains all the (dependencies) packages installed within the virtual environment + // exclude dependencies matches that contain 'site-packages' in the path (e.g. .venv/lib/python3.11/site-packages/django/core/handlers/wsgi.py) + if !strings.Contains(wsgiPath, "site-packages") { + wsgiFilesProject = append(wsgiFilesProject, wsgiPath) + } + } + + if len(wsgiFilesProject) > 0 { + wsgiFilesLen := len(wsgiFilesProject) + dirPath, _ := path.Split(wsgiFilesProject[wsgiFilesLen-1]) + dirName := path.Base(dirPath) + vars["wsgiName"] = dirName + vars["wsgiFound"] = true + if wsgiFilesLen > 1 { + // warning: multiple wsgi.py files found + s.DeployDocs = s.DeployDocs + fmt.Sprintf(` Multiple wsgi.py files were found in your Django application: [%s] Before proceeding, make sure '%s' is the module containing a WSGI application object named 'application'. If not, update your Dockefile. This module is used on Dockerfile to start the Gunicorn server process. `, strings.Join(wsgiFilesProject, ", "), dirPath) - } - } - } - - // check if settings.py file exists - settingsFiles, err := zglob.Glob(`./**/settings.py`) - - if err == nil && len(settingsFiles) == 0 { - // if no settings.py files are found, check if any *prod*.py (e.g. production.py, prod.py, settings_prod.py) exists in 'settings/' folder - settingsFiles, err = zglob.Glob(`./**/settings/*prod*.py`) - } - - if err == nil && len(settingsFiles) > 0 { - settingsFilesLen := len(settingsFiles) - // check if multiple settings.py files were found; warn the user it's not recommended and what to do instead - if settingsFilesLen > 1 { - // warning: multiple settings.py files found - s.DeployDocs = s.DeployDocs + fmt.Sprintf(` + } + } + } + + // check if settings.py file exists + settingsFiles, err := zglob.Glob(`./**/settings.py`) + + if err == nil && len(settingsFiles) == 0 { + // if no settings.py files are found, check if any *prod*.py (e.g. production.py, prod.py, settings_prod.py) exists in 'settings/' folder + settingsFiles, err = zglob.Glob(`./**/settings/*prod*.py`) + } + + if err == nil && len(settingsFiles) > 0 { + settingsFilesLen := len(settingsFiles) + // check if multiple settings.py files were found; warn the user it's not recommended and what to do instead + if settingsFilesLen > 1 { + // warning: multiple settings.py files found + s.DeployDocs = s.DeployDocs + fmt.Sprintf(` Multiple 'settings.py' files were found in your Django application: [%s] It's not recommended to have multiple 'settings.py' files. Instead, you can have a 'settings/' folder with the settings files according to the different environments (e.g., local.py, staging.py, production.py). In this case, you can specify which settings file to use when running the Django application by setting the 'DJANGO_SETTINGS_MODULE' environment variable to the corresponding settings file. `, strings.Join(settingsFiles, ", ")) - } - // check if STATIC_ROOT setting is set in ANY of the settings.py files - for _, settingsPath := range settingsFiles { - // in production, you must define a STATIC_ROOT directory where collectstatic will copy them. - if checksPass(sourceDir, dirContains(settingsPath, "STATIC_ROOT")) { - vars["collectStatic"] = true - s.DeployDocs = s.DeployDocs + fmt.Sprintf(` + } + // check if STATIC_ROOT setting is set in ANY of the settings.py files + for _, settingsPath := range settingsFiles { + // in production, you must define a STATIC_ROOT directory where collectstatic will copy them. + if checksPass(sourceDir, dirContains(settingsPath, "STATIC_ROOT")) { + vars["collectStatic"] = true + s.DeployDocs = s.DeployDocs + fmt.Sprintf(` 'STATIC_ROOT' setting was detected in '%s'! Static files will be collected during build time by running 'python manage.py collectstatic' on Dockerfile. `, settingsPath) - // check if django.core.management.utils.get_random_secret_key() is used to set a default secret key - // if not found, set a random SECRET_KEY for building purposes - if checksPass(sourceDir, dirContains(settingsPath, "default=get_random_secret_key()")) { - vars["hasRandomSecretKey"] = true - } else { - // generate a random 50 character random string usable as a SECRET_KEY setting value on Dockerfile - // based on https://github.com/django/django/blob/main/django/core/management/utils.py#L79 - randomSecretKey, err := helpers.RandString(50) - if err == nil { - vars["randomSecretKey"] = randomSecretKey - s.DeployDocs = s.DeployDocs + fmt.Sprintf(` + // check if django.core.management.utils.get_random_secret_key() is used to set a default secret key + // if not found, set a random SECRET_KEY for building purposes + if checksPass(sourceDir, dirContains(settingsPath, "default=get_random_secret_key()")) { + vars["hasRandomSecretKey"] = true + } else { + // generate a random 50 character random string usable as a SECRET_KEY setting value on Dockerfile + // based on https://github.com/django/django/blob/main/django/core/management/utils.py#L79 + randomSecretKey, err := helpers.RandString(50) + if err == nil { + vars["randomSecretKey"] = randomSecretKey + s.DeployDocs = s.DeployDocs + fmt.Sprintf(` A default SECRET_KEY was not detected in '%s'! A generated SECRET_KEY "%s" was set on Dockerfile for building purposes. Optionally, you can use django.core.management.utils.get_random_secret_key() to set the SECRET_KEY default value in your %s. `, settingsPath, randomSecretKey, settingsPath) - } - } - break - } - } - } + } + } + break + } + } + } - s.Files = templatesExecute("templates/django", vars) + s.Files = templatesExecute("templates/django", vars) // check if project has a postgres dependency if checksPass(sourceDir, dirContains("requirements.txt", "psycopg")) || - checksPass(sourceDir, dirContains("Pipfile", "psycopg")) || - checksPass(sourceDir, dirContains("pyproject.toml", "psycopg")) { + checksPass(sourceDir, dirContains("Pipfile", "psycopg")) || + checksPass(sourceDir, dirContains("pyproject.toml", "psycopg")) { s.ReleaseCmd = "python manage.py migrate" if !checksPass(sourceDir, dirContains("requirements.txt", "django-environ", "dj-database-url")) { @@ -159,3 +204,30 @@ For detailed documentation, see https://fly.dev/docs/django/ return s, nil } + +func extractPythonVersion() (string, error) { + /* Example Output: + Python 3.11.2 + */ + pythonVersionOutput := "Python 3.10.0" // Fallback to 3.10 + + cmd := exec.Command("python3", "--version") + out, err := cmd.CombinedOutput() + if err == nil { + pythonVersionOutput = string(out) + } else { + cmd := exec.Command("python", "--version") + out, err := cmd.CombinedOutput() + if err == nil { + pythonVersionOutput = string(out) + } + } + + re := regexp.MustCompile(`Python ([0-9]+\.[0-9]+\.[0-9]+)`) + match := re.FindStringSubmatch(pythonVersionOutput) + + if len(match) > 1 { + return match[1], nil // "3.11.2", nil + } + return "", fmt.Errorf("Could not find Python version") +} diff --git a/scanner/node.go b/scanner/node.go index 9a4a55a02d..f51783a54a 100644 --- a/scanner/node.go +++ b/scanner/node.go @@ -3,6 +3,7 @@ package scanner import ( "encoding/hex" "encoding/json" + "fmt" "os" "os/exec" "strings" @@ -52,7 +53,8 @@ func configureNode(sourceDir string, config *ScannerConfig) (*SourceInfo, error) // node-build requires a version, so either use the same version as install locally, // or default to an LTS version - var nodeVersion string = "18.15.0" + var nodeLtsVersion string = "18.16.0" + var nodeVersion string = nodeLtsVersion out, err := exec.Command("node", "-v").Output() @@ -61,6 +63,10 @@ func configureNode(sourceDir string, config *ScannerConfig) (*SourceInfo, error) if nodeVersion[:1] == "v" { nodeVersion = nodeVersion[1:] } + if nodeVersion < "16" { + s.Notice += fmt.Sprintf("\n[WARNING] It looks like you have NodeJS v%s installed, but it has reached it's end of support. Using NodeJS v%s (LTS) to build your image instead.\n", nodeVersion, nodeLtsVersion) + nodeVersion = nodeLtsVersion + } } out, err = exec.Command("yarn", "-v").Output() @@ -104,7 +110,7 @@ func configureNode(sourceDir string, config *ScannerConfig) (*SourceInfo, error) Destination: "/data", }, } - s.Notice = "\nThis launch configuration uses SQLite on a single, dedicated volume. It will not scale beyond a single VM. Look into 'fly postgres' for a more robust production database." + s.Notice += "\nThis launch configuration uses SQLite on a single, dedicated volume. It will not scale beyond a single VM. Look into 'fly postgres' for a more robust production database." } if remix { diff --git a/scanner/python.go b/scanner/python.go index 1d33a14770..322af15e3e 100644 --- a/scanner/python.go +++ b/scanner/python.go @@ -2,7 +2,7 @@ package scanner func configurePython(sourceDir string, config *ScannerConfig) (*SourceInfo, error) { // using 'poetry.lock' as an indicator instead of 'pyproject.toml', as Paketo doesn't support PEP-517 implementations - if !checksPass(sourceDir, fileExists("requirements.txt", "environment.yml", "poetry.lock", "Pipfile")) { + if !checksPass(sourceDir, fileExists("requirements.txt", "environment.yml", "poetry.lock", "Pipfile", "setup.py", "setup.cfg")) { return nil, nil } diff --git a/scanner/rails.go b/scanner/rails.go index cb05967622..0c0ce6db21 100644 --- a/scanner/rails.go +++ b/scanner/rails.go @@ -26,8 +26,9 @@ func configureRails(sourceDir string, config *ScannerConfig) (*SourceInfo, error } s := &SourceInfo{ - Family: "Rails", - Callback: RailsCallback, + Family: "Rails", + Callback: RailsCallback, + ConsoleCommand: "/rails/bin/rails console", } // master.key comes with Rails apps from v5.2 onwards, but may not be present diff --git a/scanner/scanner.go b/scanner/scanner.go index 75f25fc5a7..12feb074d6 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -56,6 +56,7 @@ type SourceInfo struct { Concurrency map[string]int Callback func(srcInfo *SourceInfo, options map[string]bool) error HttpCheckPath string + ConsoleCommand string } type SourceFile struct { diff --git a/scanner/templates/django/Dockerfile b/scanner/templates/django/Dockerfile index adde1d38c9..4e9dfc2c78 100644 --- a/scanner/templates/django/Dockerfile +++ b/scanner/templates/django/Dockerfile @@ -1,4 +1,4 @@ -ARG PYTHON_VERSION=3.10-slim-buster +ARG PYTHON_VERSION={{ .pythonVersion }}-slim-buster FROM python:${PYTHON_VERSION} diff --git a/scanner/templates/ruby/Dockerfile b/scanner/templates/ruby/Dockerfile index 3cc4c54b22..d58ca96b15 100644 --- a/scanner/templates/ruby/Dockerfile +++ b/scanner/templates/ruby/Dockerfile @@ -33,7 +33,7 @@ COPY --from=build /usr/local/bundle /usr/local/bundle COPY --from=build --chown=ruby:ruby /app /app # Copy application code -COPY . . +COPY --chown=ruby:ruby . . # Start the server EXPOSE 8080 diff --git a/scripts/helpgen.sh b/scripts/helpgen.sh deleted file mode 100755 index 01ebdc124b..0000000000 --- a/scripts/helpgen.sh +++ /dev/null @@ -1,3 +0,0 @@ -echo "generating cli help" - -go run ../helpgen/helpgen.go ../helpgen/flyctlhelp.toml | gofmt -s > ../docstrings/gen.go diff --git a/test/preflight/apps_v2_integration_test.go b/test/preflight/apps_v2_integration_test.go index 96a6ee640b..ca2632f523 100644 --- a/test/preflight/apps_v2_integration_test.go +++ b/test/preflight/apps_v2_integration_test.go @@ -32,7 +32,10 @@ func TestAppsV2Example(t *testing.T) { appUrl = fmt.Sprintf("https://%s.fly.dev", appName) ) - result = f.Fly("launch --org %s --name %s --region %s --image nginx --force-machines --internal-port 80 --now --auto-confirm", f.OrgSlug(), appName, f.PrimaryRegion()) + result = f.Fly( + "launch --org %s --name %s --region %s --image nginx --force-machines --internal-port 80 --now --auto-confirm --ha=false", + f.OrgSlug(), appName, f.PrimaryRegion(), + ) require.Contains(f, result.StdOut().String(), "Using image nginx") require.Contains(f, result.StdOut().String(), fmt.Sprintf("Created app '%s' in organization '%s'", appName, f.OrgSlug())) require.Contains(f, result.StdOut().String(), "Wrote config file fly.toml") @@ -64,17 +67,13 @@ func TestAppsV2Example(t *testing.T) { require.Equal(t, len(machList), 1, "There should be exactly one machine") firstMachine := machList[0] - var nilBoolPointer *bool = nil - require.Equal(t, firstMachine.Config.DisableMachineAutostart, nilBoolPointer, "autostart_disabled should be nil") - - // Make sure disabling it works - f.Fly("m update %s --autostart=false -y", firstMachine.ID) - - machList = f.MachinesList(appName) - require.Equal(t, len(machList), 1, "There should be exactly one machine") - firstMachine = machList[0] - - require.Equal(t, firstMachine.Config.DisableMachineAutostart, api.Pointer(true), "autostart_disabled should be set to true") + // DisableMachineAutostart is deprecated and should be nil always + require.Nil(t, firstMachine.Config.DisableMachineAutostart) + require.Equal(t, 1, len(firstMachine.Config.Services)) + require.NotNil(t, firstMachine.Config.Services[0].Autostart) + require.NotNil(t, firstMachine.Config.Services[0].Autostop) + require.True(t, *firstMachine.Config.Services[0].Autostart) + require.True(t, *firstMachine.Config.Services[0].Autostop) secondReg := f.PrimaryRegion() if len(f.OtherRegions()) > 0 { @@ -121,7 +120,10 @@ func TestAppsV2ConfigChanges(t *testing.T) { configFilePath = filepath.Join(f.WorkDir(), appconfig.DefaultConfigFileName) ) - f.Fly("launch --org %s --name %s --region %s --image nginx --force-machines --internal-port 80 --detach --now --auto-confirm", f.OrgSlug(), appName, f.PrimaryRegion()) + f.Fly( + "launch --org %s --name %s --region %s --image nginx --internal-port 8080 --force-machines --now --env FOO=BAR", + f.OrgSlug(), appName, f.PrimaryRegion(), + ) f.Fly("config save -a %s -y", appName) configFileBytes, err := os.ReadFile(configFilePath) @@ -129,7 +131,7 @@ func TestAppsV2ConfigChanges(t *testing.T) { f.Fatalf("error trying to read %s after running fly config save: %v", configFilePath, err) } - newConfigFile := strings.Replace(string(configFileBytes), `grace_period = "5s"`, `grace_period = "3s"`, 1) + newConfigFile := strings.Replace(string(configFileBytes), `FOO = "BAR"`, `BAR = "QUX"`, 1) err = os.WriteFile(configFilePath, []byte(newConfigFile), 0666) if err != nil { f.Fatalf("error trying to write to fly.toml: %s", err) @@ -146,7 +148,7 @@ func TestAppsV2ConfigChanges(t *testing.T) { f.Fatalf("error trying to read %s after running fly config save: %v", configFilePath, err) } - require.Contains(f, string(configFileBytes), `grace_period = "3s"`) + require.Contains(f, string(configFileBytes), `BAR = "QUX"`) } func TestAppsV2ConfigSave_ProcessGroups(t *testing.T) { @@ -199,47 +201,81 @@ func TestAppsV2ConfigSave_OneMachineNoAppConfig(t *testing.T) { } func TestAppsV2ConfigSave_PostgresSingleNode(t *testing.T) { - var ( - err error - f = testlib.NewTestEnvFromEnv(t) - appName = f.CreateRandomAppName() - configFilePath = filepath.Join(f.WorkDir(), appconfig.DefaultConfigFileName) + f := testlib.NewTestEnvFromEnv(t) + appName := f.CreateRandomAppName() + + f.Fly( + "pg create --org %s --name %s --region %s --initial-cluster-size 1 --vm-size shared-cpu-1x --volume-size 1", + f.OrgSlug(), appName, f.PrimaryRegion(), ) - f.Fly("pg create --org %s --name %s --region %s --initial-cluster-size 1 --vm-size shared-cpu-1x --volume-size 1", f.OrgSlug(), appName, f.PrimaryRegion()) f.Fly("status -a %s", appName) f.Fly("config save -a %s", appName) - configFileBytes, err := os.ReadFile(configFilePath) - if err != nil { - f.Fatalf("error trying to read %s after running fly config save: %v", configFilePath, err) + flyToml := f.UnmarshalFlyToml() + want := map[string]any{ + "app": appName, + "primary_region": f.PrimaryRegion(), + "env": map[string]any{ + "PRIMARY_REGION": f.PrimaryRegion(), + }, + "metrics": map[string]any{ + "port": int64(9187), + "path": "/metrics", + }, + "mounts": []map[string]any{{ + "source": "pg_data", + "destination": "/data", + }}, + "services": []map[string]any{ + { + "internal_port": int64(5432), + "protocol": "tcp", + "concurrency": map[string]any{ + "type": "connections", + "soft_limit": int64(1000), + "hard_limit": int64(1000), + }, + "ports": []map[string]any{ + {"handlers": []any{"pg_tls"}, "port": int64(5432)}, + }, + }, + { + "internal_port": int64(5433), + "protocol": "tcp", + "concurrency": map[string]any{ + "type": "connections", + "soft_limit": int64(1000), + "hard_limit": int64(1000), + }, + "ports": []map[string]any{ + {"handlers": []any{"pg_tls"}, "port": int64(5433)}, + }, + }, + }, + "checks": map[string]any{ + "pg": map[string]any{ + "type": "http", + "port": int64(5500), + "path": "/flycheck/pg", + "interval": "15s", + "timeout": "10s", + }, + "role": map[string]any{ + "type": "http", + "port": int64(5500), + "path": "/flycheck/role", + "interval": "15s", + "timeout": "10s", + }, + "vm": map[string]any{ + "type": "http", + "port": int64(5500), + "path": "/flycheck/vm", + "interval": "15s", + "timeout": "10s", + }, + }, } - configFileContent := string(configFileBytes) - require.Contains(f, configFileContent, fmt.Sprintf(`primary_region = "%s"`, f.PrimaryRegion())) - require.Contains(f, configFileContent, `[env]`) - require.Contains(f, configFileContent, fmt.Sprintf(`PRIMARY_REGION = "%s"`, f.PrimaryRegion())) - require.Contains(f, configFileContent, `[metrics] - port = 9187 - path = "/metrics"`) - require.Contains(f, configFileContent, `[mounts] - destination = "/data"`) - require.Contains(f, configFileContent, `[checks] - [checks.pg] - port = 5500 - type = "http" - interval = "15s" - timeout = "10s" - path = "/flycheck/pg" - [checks.role] - port = 5500 - type = "http" - interval = "15s" - timeout = "10s" - path = "/flycheck/role" - [checks.vm] - port = 5500 - type = "http" - interval = "15s" - timeout = "10s" - path = "/flycheck/vm"`) + require.Equal(f, want, flyToml) } func TestAppsV2_PostgresAutostart(t *testing.T) { @@ -317,47 +353,84 @@ func TestAppsV2_PostgresNoMachines(t *testing.T) { } func TestAppsV2ConfigSave_PostgresHA(t *testing.T) { - var ( - err error - f = testlib.NewTestEnvFromEnv(t) - appName = f.CreateRandomAppName() - configFilePath = filepath.Join(f.WorkDir(), appconfig.DefaultConfigFileName) + f := testlib.NewTestEnvFromEnv(t) + appName := f.CreateRandomAppName() + + f.Fly( + "pg create --org %s --name %s --region %s --initial-cluster-size 3 --vm-size shared-cpu-1x --volume-size 1", + f.OrgSlug(), appName, f.PrimaryRegion(), ) - f.Fly("pg create --org %s --name %s --region %s --initial-cluster-size 3 --vm-size shared-cpu-1x --volume-size 1", f.OrgSlug(), appName, f.PrimaryRegion()) f.Fly("status -a %s", appName) f.Fly("config save -a %s", appName) - configFileBytes, err := os.ReadFile(configFilePath) - if err != nil { - f.Fatalf("error trying to read %s after running fly config save: %v", configFilePath, err) + ml := f.MachinesList(appName) + require.Equal(f, 3, len(ml)) + flyToml := f.UnmarshalFlyToml() + require.Equal(f, "shared-cpu-1x", ml[0].Config.Guest.ToSize()) + want := map[string]any{ + "app": appName, + "primary_region": f.PrimaryRegion(), + "env": map[string]any{ + "PRIMARY_REGION": f.PrimaryRegion(), + }, + "metrics": map[string]any{ + "port": int64(9187), + "path": "/metrics", + }, + "mounts": []map[string]any{{ + "source": "pg_data", + "destination": "/data", + }}, + "services": []map[string]any{ + { + "internal_port": int64(5432), + "protocol": "tcp", + "concurrency": map[string]any{ + "type": "connections", + "soft_limit": int64(1000), + "hard_limit": int64(1000), + }, + "ports": []map[string]any{ + {"handlers": []any{"pg_tls"}, "port": int64(5432)}, + }, + }, + { + "internal_port": int64(5433), + "protocol": "tcp", + "concurrency": map[string]any{ + "type": "connections", + "soft_limit": int64(1000), + "hard_limit": int64(1000), + }, + "ports": []map[string]any{ + {"handlers": []any{"pg_tls"}, "port": int64(5433)}, + }, + }, + }, + "checks": map[string]any{ + "pg": map[string]any{ + "type": "http", + "port": int64(5500), + "path": "/flycheck/pg", + "interval": "15s", + "timeout": "10s", + }, + "role": map[string]any{ + "type": "http", + "port": int64(5500), + "path": "/flycheck/role", + "interval": "15s", + "timeout": "10s", + }, + "vm": map[string]any{ + "type": "http", + "port": int64(5500), + "path": "/flycheck/vm", + "interval": "15s", + "timeout": "10s", + }, + }, } - configFileContent := string(configFileBytes) - require.Contains(f, configFileContent, fmt.Sprintf(`primary_region = "%s"`, f.PrimaryRegion())) - require.Contains(f, configFileContent, fmt.Sprintf(`[env] - PRIMARY_REGION = "%s"`, f.PrimaryRegion())) - require.Contains(f, configFileContent, `[metrics] - port = 9187 - path = "/metrics"`) - require.Contains(f, configFileContent, `[mounts] - destination = "/data"`) - require.Contains(f, configFileContent, `[checks] - [checks.pg] - port = 5500 - type = "http" - interval = "15s" - timeout = "10s" - path = "/flycheck/pg" - [checks.role] - port = 5500 - type = "http" - interval = "15s" - timeout = "10s" - path = "/flycheck/role" - [checks.vm] - port = 5500 - type = "http" - interval = "15s" - timeout = "10s" - path = "/flycheck/vm"`) + require.Equal(f, want, flyToml) } func TestAppsV2Config_ParseExperimental(t *testing.T) { @@ -414,7 +487,7 @@ func TestAppsV2Config_ProcessGroups(t *testing.T) { if err != nil { f.Fatalf("error trying to write %s: %v", configFilePath, err) } - cmd := f.Fly("deploy --detach --now --image nginx") + cmd := f.Fly("deploy --detach --now --image nginx --ha=false") cmd.AssertSuccessfulExit() return cmd } @@ -597,47 +670,41 @@ func TestAppsV2MigrateToV2(t *testing.T) { // This test takes forever. I'm sorry. func TestAppsV2MigrateToV2_Volumes(t *testing.T) { - var ( - err error - f = testlib.NewTestEnvFromEnv(t) - appName = f.CreateRandomAppName() - ) - // No spaces or quotes, this is sent unescaped to bash :x - successStr := "myvolumehasloaded" + f := testlib.NewTestEnvFromEnv(t) + appName := f.CreateRandomAppName() - f.Fly("launch --org %s --name %s --region %s --internal-port 80 --force-nomad --image nginx", f.OrgSlug(), appName, f.PrimaryRegion()) - f.Fly("vol create -y --app %s -s 2 --region %s vol_test", appName, f.PrimaryRegion()) - { - toml, err := os.ReadFile("fly.toml") - if err != nil { - f.Fatalf("failed to read fly.toml: %s\n", err) - } - tomlStr := string(toml) + "\n[[mounts]]\n source = \"vol_test\"\n destination = \"/vol\"\n" - if err = os.WriteFile("fly.toml", []byte(tomlStr), 0644); err != nil { - f.Fatalf("failed to write fly.toml: %s\n", err) - } - } + f.Fly("apps create %s -o %s --nomad", appName, f.OrgSlug()) + f.WriteFlyToml(` +app = "%s" +primary_region = "%s" - assertHasFlag := func() { - output := f.Fly("ssh console -q -C 'cat /vol/flag.txt'") - output.AssertSuccessfulExit() - outStr := string(output.StdOut().Bytes()) +[build] + image = "nginx" - require.Contains(t, outStr, successStr) - } +[mounts] + source = "vol_test" + destination = "/vol" + `, appName, f.PrimaryRegion()) + + f.Fly("vol create -y -s 2 --region %s vol_test", f.PrimaryRegion()) + f.Fly("deploy --now --force-nomad") - f.Fly("deploy --detach --now") - f.Fly("ssh console -C \"bash -c 'echo %s > /vol/flag.txt && sync'\"", successStr) + // Give + time.Sleep(2 * time.Second) + f.Fly("ssh console -C 'dd if=/dev/random of=/vol/flag.txt bs=1M count=10'") + f.Fly("ssh console -C 'sync -f /vol/'") + assertHasFlag := func() { + f.Fly("ssh console -q -C 'test -r /vol/flag.txt'") + } assertHasFlag() - time.Sleep(6 * time.Second) + // time.Sleep(9 * time.Second) f.Fly("migrate-to-v2 --primary-region %s --yes", f.PrimaryRegion()) result := f.Fly("status --json") var statusMap map[string]any - err = json.Unmarshal(result.StdOut().Bytes(), &statusMap) - if err != nil { + if err := json.Unmarshal(result.StdOut().Bytes(), &statusMap); err != nil { f.Fatalf("failed to parse json: %v [output]: %s\n", err, result.StdOut().String()) } platformVersion, _ := statusMap["PlatformVersion"].(string) @@ -645,3 +712,17 @@ func TestAppsV2MigrateToV2_Volumes(t *testing.T) { assertHasFlag() } + +func TestNoPublicIPDeployMachines(t *testing.T) { + var ( + result *testlib.FlyctlResult + + f = testlib.NewTestEnvFromEnv(t) + appName = f.CreateRandomAppName() + ) + + f.Fly("launch --org %s --name %s --region %s --now --internal-port 80 --force-machines --image nginx --auto-confirm --no-public-ips", f.OrgSlug(), appName, f.PrimaryRegion()) + result = f.Fly("ips list --json") + // There should be no ips allocated + require.Equal(f, "[]\n", result.StdOut().String()) +} diff --git a/test/preflight/fly_deploy_test.go b/test/preflight/fly_deploy_test.go new file mode 100644 index 0000000000..0ed184c985 --- /dev/null +++ b/test/preflight/fly_deploy_test.go @@ -0,0 +1,38 @@ +//go:build integration +// +build integration + +package preflight + +import ( + "testing" + + //"github.com/samber/lo" + "github.com/stretchr/testify/require" + //"github.com/superfly/flyctl/api" + "github.com/superfly/flyctl/test/preflight/testlib" +) + +func TestFlyDeploy_case01(t *testing.T) { + f := testlib.NewTestEnvFromEnv(t) + appName := f.CreateRandomAppName() + + f.Fly( + "launch --now --org %s --name %s --region %s --image nginx --internal-port 80 --force-machines --ha=false", + f.OrgSlug(), appName, f.PrimaryRegion(), + ) + f.Fly("scale count 1 --region %s --yes", f.SecondaryRegion()) + + f.WriteFlyToml(`%s +[mounts] + source = "data" + destination = "/data" + `, f.ReadFile("fly.toml")) + + x := f.FlyAllowExitFailure("deploy") + require.Contains(f, x.StdErr().String(), `needs volumes with name 'data' to fullfill mounts defined in fly.toml`) + + // Create two volumes because fly launch will start 2 machines because of HA setup + f.Fly("volume create -a %s -r %s -s 1 data -y", appName, f.PrimaryRegion()) + f.Fly("volume create -a %s -r %s -s 1 data -y", appName, f.SecondaryRegion()) + f.Fly("deploy") +} diff --git a/test/preflight/fly_launch_test.go b/test/preflight/fly_launch_test.go index a9d495a8b6..5cb1c7963c 100644 --- a/test/preflight/fly_launch_test.go +++ b/test/preflight/fly_launch_test.go @@ -25,7 +25,6 @@ import ( // - Internal port is set in first call and not replaced unless --internal-port is passed again // - Primary region found in imported fly.toml must be reused if set and no --region is passed // - As we are reusing an existing app, the --org param is not needed after the first call -// func TestFlyLaunch_case01(t *testing.T) { f := testlib.NewTestEnvFromEnv(t) appName := f.CreateRandomAppName() @@ -36,14 +35,11 @@ func TestFlyLaunch_case01(t *testing.T) { "app": appName, "primary_region": f.PrimaryRegion(), "build": map[string]any{"image": "nginx"}, - "http_service": map[string]any{"force_https": true, "internal_port": int64(8080)}, - "checks": map[string]any{ - "alive": map[string]any{ - "type": "tcp", - "interval": "15s", - "timeout": "2s", - "grace_period": "5s", - }, + "http_service": map[string]any{ + "force_https": true, + "internal_port": int64(8080), + "auto_stop_machines": true, + "auto_start_machines": true, }, } require.EqualValues(f, want, toml) @@ -262,7 +258,10 @@ func TestFlyLaunch_case08(t *testing.T) { f := testlib.NewTestEnvFromEnv(t) appName := f.CreateRandomAppName() - f.Fly("launch --detach --now -o %s --name %s --region %s --force-machines --image nginx --vm-size shared-cpu-4x", f.OrgSlug(), appName, f.PrimaryRegion()) + f.Fly( + "launch --ha=false --detach --now -o %s --name %s --region %s --force-machines --image nginx --vm-size shared-cpu-4x", + f.OrgSlug(), appName, f.PrimaryRegion(), + ) ml := f.MachinesList(appName) require.Equal(f, 1, len(ml)) diff --git a/test/preflight/fly_machine_test.go b/test/preflight/fly_machine_test.go index 00334a9d19..d7ffc81c17 100644 --- a/test/preflight/fly_machine_test.go +++ b/test/preflight/fly_machine_test.go @@ -28,7 +28,7 @@ func TestFlyMachineRun_autoStartStop(t *testing.T) { Autostop: api.Pointer(true), Ports: []api.MachinePort{{ Port: api.Pointer(80), - ForceHttps: false, + ForceHTTPS: false, }}, }} require.Nil(f, m.Config.DisableMachineAutostart) @@ -43,7 +43,7 @@ func TestFlyMachineRun_autoStartStop(t *testing.T) { Autostop: api.Pointer(true), Ports: []api.MachinePort{{ Port: api.Pointer(80), - ForceHttps: false, + ForceHTTPS: false, }}, }} require.Nil(f, m.Config.DisableMachineAutostart) @@ -58,7 +58,7 @@ func TestFlyMachineRun_autoStartStop(t *testing.T) { Autostop: api.Pointer(false), Ports: []api.MachinePort{{ Port: api.Pointer(80), - ForceHttps: false, + ForceHTTPS: false, }}, }} require.Nil(f, m.Config.DisableMachineAutostart) @@ -78,6 +78,14 @@ func TestFlyMachineRun_standbyFor(t *testing.T) { } return nil } + findMachineByID := func(machineList []*api.Machine, ID string) *api.Machine { + for _, m := range machineList { + if m.ID == ID { + return m + } + } + return nil + } f.Fly("machine run -a %s nginx", appName) ml := f.MachinesList(appName) @@ -89,16 +97,17 @@ func TestFlyMachineRun_standbyFor(t *testing.T) { f.Fly("machine run -a %s nginx --standby-for=%s", appName, og.ID) ml = f.MachinesList(appName) require.Equal(f, 2, len(ml)) - // Mahcine must be stopped and be standby for first machine ID s1 := findNewMachine(ml, []string{og.ID}) - require.Equal(f, "stopped", s1.State) + require.Contains(f, []string{"created", "stopped"}, s1.State) require.Equal(f, []string{og.ID}, s1.Config.Standbys) // Clear the standbys field - f.Fly("machine update -a %s %s --standby-for=''", appName, s1.ID) + f.Fly("machine update -a %s %s --standby-for='' -y", appName, s1.ID) ml = f.MachinesList(appName) + s1 = findMachineByID(ml, s1.ID) require.Equal(f, 2, len(ml)) + // Updating a stopped machine doesn't start it require.Equal(f, "started", s1.State) require.Empty(f, s1.Config.Standbys) @@ -107,12 +116,13 @@ func TestFlyMachineRun_standbyFor(t *testing.T) { ml = f.MachinesList(appName) require.Equal(f, 3, len(ml)) s2 := findNewMachine(ml, []string{og.ID, s1.ID}) - require.Equal(f, "stopped", s2.State) + require.Contains(f, []string{"created", "stopped"}, s2.State) require.Equal(f, []string{og.ID, s1.ID}, s2.Config.Standbys) // Finally update the standby list to only one machine - f.Fly("machine update -a %s %s --standby-for=%s", appName, s2.ID, s1.ID) + f.Fly("machine update -a %s %s --standby-for=%s -y", appName, s2.ID, s1.ID) ml = f.MachinesList(appName) + s2 = findMachineByID(ml, s2.ID) require.Equal(f, "stopped", s2.State) require.Equal(f, []string{s1.ID}, s2.Config.Standbys) } diff --git a/test/preflight/testlib/test_env.go b/test/preflight/testlib/test_env.go index 02d8526249..2aa17ff94c 100644 --- a/test/preflight/testlib/test_env.go +++ b/test/preflight/testlib/test_env.go @@ -278,35 +278,42 @@ func (f *FlyctlTestEnv) Cleanup(cleanupFunc func()) { } func (f *FlyctlTestEnv) Error(args ...any) { + f.t.Helper() f.DebugPrintHistory() f.t.Error(args...) } func (f *FlyctlTestEnv) Errorf(format string, args ...any) { + f.t.Helper() f.DebugPrintHistory() f.t.Errorf(format, args...) } func (f *FlyctlTestEnv) Fail() { + f.t.Helper() f.DebugPrintHistory() f.t.Fail() } func (f *FlyctlTestEnv) FailNow() { + f.t.Helper() f.DebugPrintHistory() f.t.FailNow() } func (f *FlyctlTestEnv) Failed() bool { + f.t.Helper() return f.t.Failed() } func (f *FlyctlTestEnv) Fatal(args ...any) { + f.t.Helper() f.DebugPrintHistory() f.t.Fatal(args...) } func (f *FlyctlTestEnv) Fatalf(format string, args ...any) { + f.t.Helper() f.DebugPrintHistory() f.t.Fatalf(format, args...) } @@ -316,10 +323,12 @@ func (f *FlyctlTestEnv) Helper() { } func (f *FlyctlTestEnv) Log(args ...any) { + f.t.Helper() f.t.Log(args...) } func (f *FlyctlTestEnv) Logf(format string, args ...any) { + f.t.Helper() f.t.Logf(format, args...) } diff --git a/winbuild.ps1 b/winbuild.ps1 index 13b58638ea..34232f91f2 100644 --- a/winbuild.ps1 +++ b/winbuild.ps1 @@ -1,6 +1,2 @@ # winbuild -cmd /c "go run .\helpgen\helpgen.go .\helpgen\flyctlhelp.toml > .\docstrings\gen.go" - - go fmt .\docstrings\gen.go - - go build -o bin/flyctl.exe . +go build -o bin/flyctl.exe .