├── Dockerfile ├── examples ├── EXAMPLES.MD └── mesh.yaml ├── cmd ├── koyeb │ └── koyeb.go └── gen-doc │ └── gen-doc.go ├── pkg └── koyeb │ ├── exec_windows.go │ ├── idmapper │ ├── helpers.go │ ├── regex.go │ ├── idmap.go │ ├── instance.go │ ├── deployment.go │ ├── regional_deployment.go │ ├── secret.go │ ├── volume.go │ ├── snapshot.go │ ├── idmapper.go │ ├── domain.go │ └── database.go │ ├── renderer │ ├── json.go │ ├── yaml.go │ ├── chain.go │ ├── table.go │ ├── renderer.go │ └── format.go │ ├── ticker.go │ ├── flags.go │ ├── apps_delete.go │ ├── apps_pause.go │ ├── apps_resume.go │ ├── domains_delete.go │ ├── secrets_delete.go │ ├── snapshots_delete.go │ ├── volumes_delete.go │ ├── deployments_cancel.go │ ├── secrets_create.go │ ├── secrets_update.go │ ├── services_pause.go │ ├── services_delete.go │ ├── services_resume.go │ ├── snapshots_create.go │ ├── databases_update.go │ ├── errors │ ├── mapper.go │ ├── errors_test.go │ └── api.go │ ├── volumes_create.go │ ├── databases_delete.go │ ├── snapshots_update.go │ ├── volumes_update.go │ ├── domains_detach.go │ ├── instances_exec.go │ ├── apps_create.go │ ├── domains_refresh.go │ ├── flags_list │ ├── regions.go │ ├── routes.go │ ├── flags_list.go │ ├── env.go │ ├── volumes.go │ ├── ports.go │ └── proxy_ports.go │ ├── services_exec.go │ ├── dates │ └── cobra.go │ ├── domains_create.go │ ├── exec_unix.go │ ├── organizations_switch.go │ ├── domains_attach.go │ ├── instances_logs.go │ ├── deployments_logs.go │ ├── detect_updates.go │ ├── instances_cp.go │ ├── compose_logs.go │ ├── completion.go │ ├── regional_deployments.go │ ├── archives.go │ ├── organizations.go │ ├── apps_update.go │ ├── secrets_get.go │ ├── domains_get.go │ ├── compose_delete.go │ ├── secrets_describe.go │ ├── services_describe.go │ ├── services_get.go │ ├── databases_create.go │ ├── secrets_list.go │ ├── instances_describe.go │ ├── instances_get.go │ ├── regional_deployments_get.go │ ├── apps_list.go │ ├── apps_describe.go │ ├── domains.go │ ├── login.go │ ├── domains_list.go │ ├── snapshots_list.go │ ├── apps_get.go │ ├── volumes_list.go │ ├── client.go │ ├── snapshots.go │ ├── snapshots_get.go │ ├── volumes_get.go │ ├── services_redeploy.go │ ├── deployments_get.go │ ├── services_update.go │ ├── services_list.go │ ├── regional_deployments_list.go │ ├── archive │ └── archive.go │ ├── services_create.go │ ├── context.go │ ├── secrets_reveal.go │ ├── deployments_list.go │ └── metrics.go ├── CONTRIBUTING.md ├── .gitignore ├── .vscode └── launch.json ├── install.sh ├── .github └── workflows │ └── main.yml ├── .goreleaser.yaml ├── Makefile └── go.mod /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as build 2 | RUN apk --no-cache add ca-certificates 3 | 4 | FROM scratch 5 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 6 | COPY koyeb / 7 | ENTRYPOINT ["/koyeb"] 8 | -------------------------------------------------------------------------------- /examples/EXAMPLES.MD: -------------------------------------------------------------------------------- 1 | # Examples 2 | This directory contains example applications that can be deployed using koyeb compose which is docker-compose like deployment tool. 3 | 4 | ``` 5 | koyeb-cli ./examples/mesh.yaml 6 | ``` -------------------------------------------------------------------------------- /cmd/koyeb/koyeb.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb" 7 | ) 8 | 9 | func main() { 10 | if err := koyeb.Run(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/koyeb/exec_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package koyeb 5 | 6 | import ( 7 | "context" 8 | "io" 9 | ) 10 | 11 | func watchTermSize(ctx context.Context, s io.Writer) <-chan *TerminalSize { 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | First: if you're unsure or afraid of anything, just ask or submit the issue or pull request anyways. You won't be yelled at for giving your best effort. The worst that can happen is that you'll be politely asked to change something. We appreciate all contributions! 3 | 4 | This document is work in progress. 5 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/helpers.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import "strings" 4 | 5 | func getKey(id string) Key { 6 | return Key(getFlatID(id)) 7 | } 8 | 9 | func getShortID(id string, length int) string { 10 | fkey := getFlatID(id) 11 | return fkey[:length] 12 | } 13 | 14 | func getFlatID(id string) string { 15 | return strings.ReplaceAll(id, "-", "") 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | build_output 17 | dist 18 | 19 | vendor/ 20 | /koyeb 21 | -------------------------------------------------------------------------------- /pkg/koyeb/renderer/json.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import "fmt" 4 | 5 | type JSONRenderer struct{} 6 | 7 | func (r *JSONRenderer) Render(item ApiResources) { 8 | buf, err := item.MarshalBinary() 9 | // Should never happen, since all the fields of item are marshable 10 | if err != nil { 11 | panic("Unable to marshal resource") 12 | } 13 | fmt.Println(string(buf)) 14 | } 15 | 16 | func (r *JSONRenderer) RenderSeparator() { 17 | } 18 | -------------------------------------------------------------------------------- /pkg/koyeb/ticker.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | "iter" 6 | "time" 7 | ) 8 | 9 | func ticker(ctx context.Context, interval time.Duration) iter.Seq[time.Time] { 10 | t := time.NewTicker(interval) 11 | return func(yield func(time.Time) bool) { 12 | defer t.Stop() 13 | for { 14 | select { 15 | case e := <-t.C: 16 | if !yield(e) { 17 | return 18 | } 19 | case <-ctx.Done(): 20 | return 21 | } 22 | 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /pkg/koyeb/flags.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func GetDurationFlags(cmd *cobra.Command, name string) time.Duration { 10 | val, _ := cmd.Flags().GetDuration(name) 11 | return val 12 | } 13 | 14 | func GetBoolFlags(cmd *cobra.Command, name string) bool { 15 | val, _ := cmd.Flags().GetBool(name) 16 | return val 17 | } 18 | 19 | func GetStringFlags(cmd *cobra.Command, name string) string { 20 | val, _ := cmd.Flags().GetString(name) 21 | return val 22 | } 23 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/regex.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import "regexp" 4 | 5 | const ( 6 | // UUIDv4 is the regular expressions for an UUID v4. 7 | UUIDv4 string = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" 8 | ) 9 | 10 | var ( 11 | // RxUUIDv4 is a compiled regular expression for an UUID v4. 12 | RxUUIDv4 = regexp.MustCompile(UUIDv4) 13 | ) 14 | 15 | // IsUUIDv4 checks if the string is a UUID version 4. 16 | func IsUUIDv4(val string) bool { 17 | return RxUUIDv4.MatchString(val) 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "koyeb", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": "${workspaceFolder}/cmd/koyeb/koyeb.go", 10 | "args": "${input:args}" 11 | } 12 | ], 13 | "inputs": [ 14 | { 15 | "id": "args", 16 | "type": "promptString", 17 | "default": "help", 18 | "description": "CLI arguments" 19 | }, 20 | ] 21 | } -------------------------------------------------------------------------------- /pkg/koyeb/renderer/yaml.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ghodss/yaml" 7 | ) 8 | 9 | type YAMLRenderer struct{} 10 | 11 | func (r *YAMLRenderer) Render(item ApiResources) { 12 | buf, err := item.MarshalBinary() 13 | // Should never happen, since all the fields of item are marshable 14 | if err != nil { 15 | panic("Unable to marshal resource") 16 | } 17 | y, err := yaml.JSONToYAML(buf) 18 | if err != nil { 19 | panic("Unable to convert JSON to YAML") 20 | } 21 | fmt.Printf("%s", string(y)) 22 | } 23 | 24 | func (r *YAMLRenderer) RenderSeparator() { 25 | fmt.Println("---") 26 | } 27 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *AppHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | app, err := h.ResolveAppArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.AppsApi.DeleteApp(ctx.Context, app).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while deleting the application `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | log.Infof("App %s deleted.", args[0]) 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_pause.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *AppHandler) Pause(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | app, err := h.ResolveAppArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.AppsApi.PauseApp(ctx.Context, app).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while pausing the application `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | log.Infof("App %s pausing.", args[0]) 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_resume.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *AppHandler) Resume(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | app, err := h.ResolveAppArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.AppsApi.ResumeApp(ctx.Context, app).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while resuming the application `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | log.Infof("App %s resuming.", args[0]) 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DomainHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | domain, err := h.ResolveDomainArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.DomainsApi.DeleteDomain(ctx.Context, domain).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while deleting the domain `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | log.Infof("Domain %s deleted.", args[0]) 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *SecretHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | secret, err := ResolveSecretArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.SecretsApi.DeleteSecret(ctx.Context, secret).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while deleting the secret `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | log.Infof("Secret %s deleted.", args[0]) 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/koyeb/snapshots_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *SnapshotHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | snapshot, err := ResolveSnapshotArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.SnapshotsApi.DeleteSnapshot(ctx.Context, snapshot).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while deleting the snapshot `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | log.Infof("Snapshot %s deleted.", args[0]) 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/koyeb/volumes_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *VolumeHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | volume, err := ResolveVolumeArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.PersistentVolumesApi.DeletePersistentVolume(ctx.Context, volume).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while deleting the volume `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | log.Infof("Volume %s deleted.", args[0]) 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/koyeb/deployments_cancel.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DeploymentHandler) Cancel(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | deployment, err := h.ResolveDeploymentArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | _, resp, err := ctx.Client.DeploymentsApi.CancelDeployment(ctx.Context, deployment).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while canceling the deployment `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | log.Infof("Deployment %s canceled.", args[0]) 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *SecretHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string, createSecret *koyeb.CreateSecret) error { 12 | res, resp, err := ctx.Client.SecretsApi.CreateSecret(ctx.Context).Secret(*createSecret).Execute() 13 | if err != nil { 14 | return errors.NewCLIErrorFromAPIError( 15 | fmt.Sprintf("Error while creating the secret `%s`", args[0]), 16 | err, 17 | resp, 18 | ) 19 | } 20 | 21 | full := GetBoolFlags(cmd, "full") 22 | getSecretsReply := NewGetSecretReply(ctx.Mapper, &koyeb.GetSecretReply{Secret: res.Secret}, full) 23 | ctx.Renderer.Render(getSecretsReply) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_update.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *SecretHandler) Update(ctx *CLIContext, cmd *cobra.Command, args []string, secret *koyeb.Secret) error { 12 | res, resp, err := ctx.Client.SecretsApi.UpdateSecret2(ctx.Context, secret.GetId()).Secret(*secret).Execute() 13 | if err != nil { 14 | return errors.NewCLIErrorFromAPIError( 15 | fmt.Sprintf("Error while updating the secret `%s`", args[0]), 16 | err, 17 | resp, 18 | ) 19 | } 20 | 21 | full := GetBoolFlags(cmd, "full") 22 | getSecretsReply := NewGetSecretReply(ctx.Mapper, &koyeb.GetSecretReply{Secret: res.Secret}, full) 23 | ctx.Renderer.Render(getSecretsReply) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/koyeb/services_pause.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *ServiceHandler) Pause(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | serviceName, err := h.parseServiceName(cmd, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | service, err := h.ResolveServiceArgs(ctx, serviceName) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | _, resp, err := ctx.Client.ServicesApi.PauseService(ctx.Context, service).Execute() 23 | if err != nil { 24 | return errors.NewCLIErrorFromAPIError( 25 | fmt.Sprintf("Error while pausing the service `%s`", serviceName), 26 | err, 27 | resp, 28 | ) 29 | } 30 | log.Infof("Service %s pausing.", serviceName) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/koyeb/services_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *ServiceHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | serviceName, err := h.parseServiceName(cmd, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | service, err := h.ResolveServiceArgs(ctx, serviceName) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | _, resp, err := ctx.Client.ServicesApi.DeleteService(ctx.Context, service).Execute() 23 | if err != nil { 24 | return errors.NewCLIErrorFromAPIError( 25 | fmt.Sprintf("Error while deleting the service `%s`", serviceName), 26 | err, 27 | resp, 28 | ) 29 | } 30 | log.Infof("Service %s deleted.", serviceName) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/koyeb/services_resume.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *ServiceHandler) Resume(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | serviceName, err := h.parseServiceName(cmd, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | service, err := h.ResolveServiceArgs(ctx, serviceName) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | _, resp, err := ctx.Client.ServicesApi.ResumeService(ctx.Context, service).Execute() 23 | if err != nil { 24 | return errors.NewCLIErrorFromAPIError( 25 | fmt.Sprintf("Error while resuming the service `%s`", serviceName), 26 | err, 27 | resp, 28 | ) 29 | } 30 | log.Infof("Service %s resuming.", serviceName) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/koyeb/snapshots_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *SnapshotHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string, createSnapshot *koyeb.CreateSnapshotRequest) error { 12 | res, resp, err := ctx.Client.SnapshotsApi.CreateSnapshot(ctx.Context).Body(*createSnapshot).Execute() 13 | if err != nil { 14 | return errors.NewCLIErrorFromAPIError( 15 | fmt.Sprintf("Error while creating the snapshot `%s`", args[0]), 16 | err, 17 | resp, 18 | ) 19 | } 20 | 21 | full := GetBoolFlags(cmd, "full") 22 | getSnapshotReply := NewGetSnapshotReply(ctx.Mapper, &koyeb.GetSnapshotReply{Snapshot: res.Snapshot}, full) 23 | ctx.Renderer.Render(getSnapshotReply) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/koyeb/databases_update.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DatabaseHandler) Update(ctx *CLIContext, cmd *cobra.Command, args []string, serviceId string, updateService *koyeb.UpdateService) error { 12 | res, resp, err := ctx.Client.ServicesApi.UpdateService(ctx.Context, serviceId).Service(*updateService).Execute() 13 | if err != nil { 14 | return errors.NewCLIErrorFromAPIError( 15 | fmt.Sprintf("Error while updating the service `%s`", args[0]), 16 | err, 17 | resp, 18 | ) 19 | } 20 | 21 | full := GetBoolFlags(cmd, "full") 22 | getServiceReply := NewGetServiceReply(ctx.Mapper, &koyeb.GetServiceReply{Service: res.Service}, full) 23 | ctx.Renderer.Render(getServiceReply) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/koyeb/errors/mapper.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // NewCLIErrorForMapperResolve returns a new CLIError when a mapper is unable to resolve an identifier to an object ID. 8 | func NewCLIErrorForMapperResolve(objectType string, objectId string, supportedFormats []string) *CLIError { 9 | additional := []string{fmt.Sprintf("The supported formats to resolve a %s are:", objectType)} 10 | for _, e := range supportedFormats { 11 | additional = append(additional, fmt.Sprintf("* %s", e)) 12 | } 13 | 14 | ret := &CLIError{ 15 | What: fmt.Sprintf("Unable to find the %s `%s`", objectType, objectId), 16 | Why: "no object could be found from the provided identifier", 17 | Additional: additional, 18 | Orig: nil, 19 | Solution: CLIErrorSolution(fmt.Sprintf("Provide a valid %s identifier", objectType)), 20 | } 21 | return ret 22 | } 23 | -------------------------------------------------------------------------------- /pkg/koyeb/volumes_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *VolumeHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string, createVolume *koyeb.CreatePersistentVolumeRequest) error { 12 | res, resp, err := ctx.Client.PersistentVolumesApi.CreatePersistentVolume(ctx.Context).Body(*createVolume).Execute() 13 | if err != nil { 14 | return errors.NewCLIErrorFromAPIError( 15 | fmt.Sprintf("Error while creating the volume `%s`", args[0]), 16 | err, 17 | resp, 18 | ) 19 | } 20 | 21 | full := GetBoolFlags(cmd, "full") 22 | getVolumeReply := NewGetVolumeReply(ctx.Mapper, &koyeb.GetPersistentVolumeReply{Volume: res.Volume}, full) 23 | ctx.Renderer.Render(getVolumeReply) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/koyeb/databases_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DatabaseHandler) Delete(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | serviceHandler := NewServiceHandler() 13 | 14 | serviceName, err := serviceHandler.parseServiceName(cmd, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | service, err := h.ResolveDatabaseArgs(ctx, serviceName) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | _, resp, err := ctx.Client.ServicesApi.DeleteService(ctx.Context, service).Execute() 25 | if err != nil { 26 | return errors.NewCLIErrorFromAPIError( 27 | fmt.Sprintf("Error while deleting the database `%s`", args[0]), 28 | err, 29 | resp, 30 | ) 31 | } 32 | log.Infof("Database %s deleted.", service) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /examples/mesh.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | name: demo-app 3 | 4 | services: 5 | - definition: 6 | name: demo 7 | type: WEB 8 | docker: 9 | image: koyeb/demo 10 | regions: 11 | - fra 12 | instance_types: 13 | - scopes: ['region:fra'] 14 | type: 'nano' 15 | scalings: 16 | - scopes: ['region:fra'] 17 | min: 1 18 | max: 1 19 | ports: 20 | - port: 8000 21 | routes: 22 | - port: 8000 23 | path: "/" 24 | - definition: 25 | name: ping-demo 26 | type: WORKER 27 | docker: 28 | image: curlimages/curl 29 | command: sh 30 | args: ['-c', 'while true; do curl demo:8000; sleep 30; done'] 31 | regions: 32 | - fra 33 | instance_types: 34 | - scopes: ['region:fra'] 35 | type: 'nano' 36 | scalings: 37 | - scopes: ['region:fra'] 38 | min: 1 39 | max: 1 40 | -------------------------------------------------------------------------------- /pkg/koyeb/snapshots_update.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *SnapshotHandler) Update(ctx *CLIContext, cmd *cobra.Command, args []string, snapshot *koyeb.UpdateSnapshotRequest) error { 12 | id, err := ResolveSnapshotArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | res, resp, err := ctx.Client.SnapshotsApi.UpdateSnapshot(ctx.Context, id).Body(*snapshot).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while updating the snapshot `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | full := GetBoolFlags(cmd, "full") 27 | getSnapshotReply := NewGetSnapshotReply(ctx.Mapper, &koyeb.GetSnapshotReply{Snapshot: res.Snapshot}, full) 28 | ctx.Renderer.Render(getSnapshotReply) 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/idmap.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | // IDMap is a bidirectional map to store value <> id translations. 4 | // A value could either be a short id, a name and/or a slug. 5 | type IDMap struct { 6 | idCache map[string]string 7 | valCache map[string]string 8 | } 9 | 10 | // NewIDMap creates a new instance. 11 | func NewIDMap() *IDMap { 12 | return &IDMap{ 13 | idCache: map[string]string{}, 14 | valCache: map[string]string{}, 15 | } 16 | } 17 | 18 | // GetID translates a value to an id. 19 | func (idmap *IDMap) GetID(val string) (string, bool) { 20 | id, ok := idmap.valCache[val] 21 | return id, ok 22 | } 23 | 24 | // GetName translates an id to a value. 25 | func (idmap *IDMap) GetValue(id string) (string, bool) { 26 | val, ok := idmap.idCache[id] 27 | return val, ok 28 | } 29 | 30 | // Set writes name and value translation. 31 | func (idmap *IDMap) Set(id string, val string) { 32 | idmap.idCache[id] = val 33 | idmap.valCache[val] = id 34 | } 35 | -------------------------------------------------------------------------------- /pkg/koyeb/volumes_update.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *VolumeHandler) Update(ctx *CLIContext, cmd *cobra.Command, args []string, volume *koyeb.UpdatePersistentVolumeRequest) error { 12 | id, err := ResolveVolumeArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | res, resp, err := ctx.Client.PersistentVolumesApi.UpdatePersistentVolume(ctx.Context, id).Body(*volume).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while updating the volume `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | full := GetBoolFlags(cmd, "full") 27 | getVolumeReply := NewGetVolumeReply(ctx.Mapper, &koyeb.GetPersistentVolumeReply{Volume: res.Volume}, full) 28 | ctx.Renderer.Render(getVolumeReply) 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_detach.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DomainHandler) Detach(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | domainID, err := ctx.Mapper.Domain().ResolveID(args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | updateDomainReq := koyeb.NewUpdateDomainWithDefaults() 18 | updateDomainReq.SetAppId("") 19 | res, resp, err := ctx.Client.DomainsApi.UpdateDomain(ctx.Context, domainID).Domain(*updateDomainReq).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while updating the domain `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | 30 | getDomainReply := NewGetDomainReply(ctx.Mapper, &koyeb.GetDomainReply{Domain: res.Domain}, full) 31 | ctx.Renderer.Render(getDomainReply) 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/koyeb/instances_exec.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | func (h *InstanceHandler) Exec(ctx *CLIContext, cmd *cobra.Command, args []string) error { 13 | instance, err := h.ResolveInstanceArgs(ctx, args[0]) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | returnCode, err := ctx.ExecClient.Exec(ctx.Context, ExecId{ 19 | Id: instance, 20 | Type: koyeb.EXECCOMMANDREQUESTIDTYPE_INSTANCE_ID, 21 | }, args[1:]) 22 | if err != nil { 23 | return &errors.CLIError{ 24 | What: "Error while executing the command", 25 | Why: "the CLI did not succeed to execute the command", 26 | Additional: nil, 27 | Orig: err, 28 | Solution: "Make sure the command is correct and exists in the service. If the problem persists, try to update the CLI to the latest version.", 29 | } 30 | } 31 | if returnCode != 0 { 32 | os.Exit(returnCode) 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *AppHandler) CreateApp(ctx *CLIContext, payload *koyeb.CreateApp) (*koyeb.CreateAppReply, error) { 12 | res, resp, err := ctx.Client.AppsApi.CreateApp(ctx.Context).App(*payload).Execute() 13 | if err != nil { 14 | return nil, errors.NewCLIErrorFromAPIError( 15 | fmt.Sprintf("Error while creating the app `%s`", payload.GetName()), 16 | err, 17 | resp, 18 | ) 19 | } 20 | return res, nil 21 | } 22 | 23 | func (h *AppHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string, createApp *koyeb.CreateApp) error { 24 | createApp.SetName(args[0]) 25 | 26 | res, err := h.CreateApp(ctx, createApp) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | full := GetBoolFlags(cmd, "full") 32 | getAppsReply := NewGetAppReply(ctx.Mapper, &koyeb.GetAppReply{App: res.App}, full) 33 | ctx.Renderer.Render(getAppsReply) 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_refresh.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func (h *DomainHandler) Refresh(ctx *CLIContext, cmd *cobra.Command, args []string) error { 11 | domain, err := h.ResolveDomainArgs(ctx, args[0]) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | _, resp, err := ctx.Client.DomainsApi.RefreshDomainStatus(ctx.Context, domain).Execute() 17 | if err != nil { 18 | return errors.NewCLIErrorFromAPIError( 19 | fmt.Sprintf("Error while refreshing the status of the domain `%s`", domain), 20 | err, 21 | resp, 22 | ) 23 | } 24 | 25 | res, resp, err := ctx.Client.DomainsApi.GetDomain(ctx.Context, domain).Execute() 26 | if err != nil { 27 | return errors.NewCLIErrorFromAPIError( 28 | fmt.Sprintf("Error while retrieving the domain `%s`", domain), 29 | err, 30 | resp, 31 | ) 32 | } 33 | 34 | full := GetBoolFlags(cmd, "full") 35 | getDomainsReply := NewGetDomainReply(ctx.Mapper, res, full) 36 | ctx.Renderer.Render(getDomainsReply) 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/regions.go: -------------------------------------------------------------------------------- 1 | package flags_list 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type FlagRegion struct { 8 | BaseFlag 9 | } 10 | 11 | func NewRegionsListFromFlags(values []string) ([]Flag[string], error) { 12 | ret := make([]Flag[string], 0, len(values)) 13 | 14 | for _, value := range values { 15 | region := &FlagRegion{BaseFlag: BaseFlag{cliValue: value}} 16 | 17 | if strings.HasPrefix(value, "!") { 18 | region.markedForDeletion = true 19 | region.BaseFlag.cliValue = value[1:] 20 | } 21 | ret = append(ret, region) 22 | } 23 | return ret, nil 24 | } 25 | 26 | func (f *FlagRegion) IsEqualTo(item string) bool { 27 | return f.cliValue == item 28 | } 29 | 30 | // UpdateItem does nothing for the region flag. For other flags, eg. --port, this function updates the item from 31 | // the existingItems list. For regions, there is nothing to update: the flag is either --region 32 | // or --region !, there is never a need to update the region. 33 | func (f *FlagRegion) UpdateItem(item *string) { 34 | } 35 | 36 | func (f *FlagRegion) CreateNewItem() *string { 37 | return &f.cliValue 38 | } 39 | -------------------------------------------------------------------------------- /pkg/koyeb/services_exec.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | func (h *ServiceHandler) Exec(ctx *CLIContext, cmd *cobra.Command, args []string) error { 13 | serviceName, err := h.parseServiceName(cmd, args[0]) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | service, err := h.ResolveServiceArgs(ctx, serviceName) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | returnCode, err := ctx.ExecClient.Exec(ctx.Context, ExecId{ 24 | Id: service, 25 | Type: koyeb.EXECCOMMANDREQUESTIDTYPE_SERVICE_ID, 26 | }, args[1:]) 27 | if err != nil { 28 | return &errors.CLIError{ 29 | What: "Error while executing the command", 30 | Why: "the CLI did not succeed to execute the command", 31 | Additional: nil, 32 | Orig: err, 33 | Solution: "Make sure the command is correct and exists in the service. If the problem persists, try to update the CLI to the latest version.", 34 | } 35 | } 36 | if returnCode != 0 { 37 | os.Exit(returnCode) 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/koyeb/dates/cobra.go: -------------------------------------------------------------------------------- 1 | package dates 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/araddon/dateparse" 7 | ) 8 | 9 | // HumanFriendlyDate implements the pflag.Value interface to allow parsing dates in a human-friendly format. 10 | type HumanFriendlyDate struct { 11 | Time time.Time 12 | } 13 | 14 | func (t *HumanFriendlyDate) String() string { 15 | return t.Time.String() 16 | } 17 | 18 | func (t *HumanFriendlyDate) Set(value string) error { 19 | parsed, err := Parse(value) 20 | t.Time = parsed 21 | return err 22 | } 23 | 24 | func (t *HumanFriendlyDate) Type() string { 25 | return "HumanFriendlyDate" 26 | } 27 | 28 | func Parse(value string) (time.Time, error) { 29 | // Try to parse the date using RFC3339 format 30 | if parsed, err := time.Parse(time.RFC3339, value); err == nil { 31 | return parsed, nil 32 | } 33 | 34 | // Fall back to the dateparse library. Use dateparse.ParseStrict instead of 35 | // dateparse.ParseAny to return an error in case of ambiguity, e.g. 36 | // "01/02/03" could be interpreted as "January 2, 2003" or "February 1, 2003". 37 | parsed, err := dateparse.ParseStrict(value) 38 | if err != nil { 39 | return time.Time{}, err 40 | } 41 | return parsed, nil 42 | } 43 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DomainHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | createDomainReq := koyeb.NewCreateDomainWithDefaults() 13 | createDomainReq.SetName(args[0]) 14 | createDomainReq.SetType(koyeb.DOMAINTYPE_CUSTOM) 15 | 16 | attachToApp := GetStringFlags(cmd, "attach-to") 17 | if attachToApp != "" { 18 | appID, err := ctx.Mapper.App().ResolveID(attachToApp) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | createDomainReq.SetAppId(appID) 24 | } 25 | 26 | res, resp, err := ctx.Client.DomainsApi.CreateDomain(ctx.Context).Domain(*createDomainReq).Execute() 27 | if err != nil { 28 | return errors.NewCLIErrorFromAPIError( 29 | fmt.Sprintf("Error while creating the domain `%s`", args[0]), 30 | err, 31 | resp, 32 | ) 33 | } 34 | 35 | full := GetBoolFlags(cmd, "full") 36 | getDomainsReply := NewGetDomainReply(ctx.Mapper, &koyeb.GetDomainReply{Domain: res.Domain}, full) 37 | ctx.Renderer.Render(getDomainsReply) 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/koyeb/exec_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package koyeb 5 | 6 | import ( 7 | "context" 8 | "io" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/moby/term" 14 | 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | func watchTermSize(ctx context.Context, stdout io.Writer) <-chan *TerminalSize { 19 | out := make(chan *TerminalSize) 20 | go func() { 21 | defer close(out) 22 | sigCh := make(chan os.Signal, 1) 23 | signal.Notify(sigCh, syscall.SIGWINCH) 24 | for { 25 | select { 26 | case <-ctx.Done(): 27 | return 28 | case <-sigCh: 29 | termSize, err := getTermSize(stdout) 30 | if err != nil { 31 | continue 32 | } 33 | select { 34 | case <-ctx.Done(): 35 | return 36 | case out <- termSize: 37 | } 38 | } 39 | } 40 | }() 41 | return out 42 | } 43 | 44 | func getTermSize(t io.Writer) (*TerminalSize, error) { 45 | fd, isTerm := term.GetFdInfo(t) 46 | if !isTerm { 47 | return nil, errors.New("not a terminal") 48 | } 49 | ws, err := term.GetWinsize(fd) 50 | if err != nil { 51 | return nil, errors.Wrap(err, "cannot get winsize") 52 | } 53 | return &TerminalSize{Height: int32(ws.Height), Width: int32(ws.Width)}, nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/koyeb/renderer/chain.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | type ChainRenderer struct { 4 | Renderer 5 | isFirst bool // true if the current call to Render is the first one 6 | } 7 | 8 | // Most of the CLI display only one resource, but some commands need to display 9 | // multiple resources, like the `koyeb deployments describe` or `koyeb metrics get` commands. 10 | // 11 | // The ChainRenderer complies with the Renderer interface and can be used to display multiple resources, like this: 12 | // 13 | // chain := renderer.NewChainRenderer() 14 | // chain.Render(resource1) 15 | // chain.Render(resource2) 16 | // chain.Render(resource3) 17 | func NewChainRenderer(Base Renderer) *ChainRenderer { 18 | return &ChainRenderer{ 19 | Renderer: Base, 20 | isFirst: true, 21 | } 22 | } 23 | 24 | func (r *ChainRenderer) Render(item ApiResources) *ChainRenderer { 25 | // This is not the first call to Render, display a separator: a newline for table, nothing for JSON, and `---` for YAML. 26 | if !r.isFirst { 27 | r.RenderSeparator() 28 | } 29 | 30 | if table, ok := r.Renderer.(*TableRenderer); ok { 31 | table.RenderTitle(item) 32 | } 33 | 34 | r.isFirst = false 35 | 36 | // Call the actual renderer 37 | r.Renderer.Render(item) 38 | return r 39 | } 40 | -------------------------------------------------------------------------------- /pkg/koyeb/organizations_switch.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | func (h *OrganizationHandler) Switch(ctx *CLIContext, cmd *cobra.Command, args []string) error { 10 | organization, err := ResolveOrganizationArgs(ctx, args[0]) 11 | if err != nil { 12 | return err 13 | } 14 | viper.Set("organization", organization) 15 | if err := viper.WriteConfig(); err != nil { 16 | return &errors.CLIError{ 17 | What: "Unable to switch the current organization", 18 | Why: "we were unable to write the configuration file", 19 | Additional: []string{ 20 | "The command `koyeb organization switch` needs to update your configuration file, usually located in $HOME/.koyeb.yaml", 21 | "If you do not have write access to this file, you can use the --config flag to specify a different location.", 22 | "Alternatively, you can manually edit the configuration file and set the organization field to the organization ID you want to use.", 23 | "You can also provide the organization UUID with the --organization flag.", 24 | }, 25 | Orig: err, 26 | Solution: "Fix the issue preventing the CLI to write the configuration file, or manually edit the configuration file", 27 | } 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_attach.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DomainHandler) Attach(ctx *CLIContext, cmd *cobra.Command, args []string) error { 12 | domainID, err := ctx.Mapper.Domain().ResolveID(args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | appID, err := ctx.Mapper.App().ResolveID(args[1]) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | updateDomainReq := koyeb.NewUpdateDomainWithDefaults() 23 | updateDomainReq.SetAppId(appID) 24 | 25 | _, resp, err := ctx.Client.DomainsApi.UpdateDomain(ctx.Context, domainID).Domain(*updateDomainReq).Execute() 26 | if err != nil { 27 | return errors.NewCLIErrorFromAPIError( 28 | fmt.Sprintf("Error while updating the domain `%s`", domainID), 29 | err, 30 | resp, 31 | ) 32 | } 33 | 34 | res, resp, err := ctx.Client.AppsApi.GetApp(ctx.Context, appID).Execute() 35 | if err != nil { 36 | return errors.NewCLIErrorFromAPIError( 37 | fmt.Sprintf("Error while retrieving the application `%s`", appID), 38 | err, 39 | resp, 40 | ) 41 | } 42 | 43 | full := GetBoolFlags(cmd, "full") 44 | 45 | getAppReply := NewGetAppReply(ctx.Mapper, res, full) 46 | ctx.Renderer.Render(getAppReply) 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/koyeb/renderer/table.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/logrusorgru/aurora" 8 | "github.com/olekukonko/tablewriter" 9 | ) 10 | 11 | type TableRenderer struct{} 12 | 13 | func (r *TableRenderer) Render(item ApiResources) { 14 | table := tablewriter.NewWriter(os.Stdout) 15 | table.SetAutoWrapText(false) 16 | table.SetAutoFormatHeaders(true) 17 | table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) 18 | table.SetAlignment(tablewriter.ALIGN_LEFT) 19 | table.SetCenterSeparator("") 20 | table.SetColumnSeparator("") 21 | table.SetRowSeparator("") 22 | table.SetHeaderLine(false) 23 | table.SetBorder(false) 24 | table.SetTablePadding("\t") 25 | table.SetNoWhiteSpace(true) 26 | 27 | // Only render the header if there is more than one column 28 | header := item.Headers() 29 | if len(header) > 1 { 30 | table.SetHeader(header) 31 | } 32 | 33 | fields := [][]string{} 34 | for _, field := range item.Fields() { 35 | current := []string{} 36 | for _, h := range item.Headers() { 37 | current = append(current, field[h]) 38 | } 39 | fields = append(fields, current) 40 | } 41 | 42 | table.AppendBulk(fields) 43 | table.Render() 44 | } 45 | 46 | func (r *TableRenderer) RenderTitle(item ApiResources) { 47 | fmt.Println(aurora.Bold(item.Title())) 48 | } 49 | 50 | func (r *TableRenderer) RenderSeparator() { 51 | fmt.Println("") 52 | } 53 | -------------------------------------------------------------------------------- /pkg/koyeb/instances_logs.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *InstanceHandler) Logs(ctx *CLIContext, cmd *cobra.Command, since time.Time, args []string) error { 12 | instance, err := h.ResolveInstanceArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | instanceDetail, resp, err := ctx.Client.InstancesApi.GetInstance(ctx.Context, instance).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while retrieving the logs of the instance `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | startStr := GetStringFlags(cmd, "start-time") 27 | endStr := GetStringFlags(cmd, "end-time") 28 | regex := GetStringFlags(cmd, "regex-search") 29 | text := GetStringFlags(cmd, "text-search") 30 | order := GetStringFlags(cmd, "order") 31 | tail := GetBoolFlags(cmd, "tail") 32 | output := GetStringFlags(cmd, "output") 33 | 34 | return ctx.LogsClient.PrintLogs(ctx, LogsQuery{ 35 | InstanceId: instanceDetail.Instance.GetId(), 36 | Start: startStr, 37 | End: endStr, 38 | Since: since, 39 | Regex: regex, 40 | Text: text, 41 | Order: order, 42 | Tail: tail, 43 | Output: output, 44 | Full: GetBoolFlags(cmd, "full"), 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script is inspired on the Deno installer: (https://deno.land/x/install@v0.1.4/install.sh) 3 | 4 | set -e 5 | 6 | case $(uname -sm) in 7 | "Darwin x86_64") target="darwin_amd64" ;; 8 | "Darwin arm64") target="darwin_arm64" ;; 9 | "Linux aarch64") target="linux_arm64" ;; 10 | *) target="linux_amd64" ;; 11 | esac 12 | 13 | 14 | version=$(curl https://api.github.com/repos/koyeb/koyeb-cli/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | cut -c 2-) 15 | koyeb_url="https://github.com/koyeb/koyeb-cli/releases/latest/download/koyeb-cli_${version}_${target}.tar.gz" 16 | 17 | koyeb_install="${KOYEB_INSTALL:-$HOME/.koyeb}" 18 | bin_dir="$koyeb_install/bin" 19 | exe="$bin_dir/koyeb" 20 | 21 | if [ ! -d "$bin_dir" ]; then 22 | mkdir -p "$bin_dir" 23 | fi 24 | 25 | curl --fail --location --progress-bar --output "$exe.tar.gz" "$koyeb_url" 26 | tar xvf "$exe.tar.gz" -C "$bin_dir" 27 | chmod +x "$exe" 28 | rm "$exe.tar.gz" 29 | 30 | echo "Koyeb CLI was installed successfully to $exe" 31 | if command -v koyeb >/dev/null; then 32 | echo "Run 'koyeb --help' to get started" 33 | else 34 | case $SHELL in 35 | /bin/zsh) shell_profile=".zshrc" ;; 36 | *) shell_profile=".bash_profile" ;; 37 | esac 38 | echo "Manually add the directory to your \$HOME/$shell_profile (or similar)" 39 | echo " export PATH=\"$bin_dir:\$PATH\"" 40 | echo "Run '$exe --help' to get started" 41 | fi 42 | 43 | -------------------------------------------------------------------------------- /cmd/gen-doc/gen-doc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path" 7 | "strings" 8 | 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb" 10 | "github.com/spf13/cobra/doc" 11 | ) 12 | 13 | func genMarkdownDocumentation(outputDir string) { 14 | rootCmd := koyeb.GetRootCommand() 15 | linkHandler := func(name string) string { 16 | base := strings.TrimSuffix(name, path.Ext(name)) 17 | return "#" + strings.Replace(strings.ToLower(base), "_", "-", -1) 18 | } 19 | 20 | filePrepender := func(filename string) string { 21 | if filename == outputDir+"/koyeb.md" { 22 | return `--- 23 | title: "Koyeb CLI Reference" 24 | shortTitle: Reference 25 | description: "Discover all the commands available via the Koyeb CLI and how to use them to interact with the Koyeb serverless platform directly from the terminal." 26 | --- 27 | 28 | # Koyeb CLI Reference 29 | 30 | The Koyeb CLI allows you to interact with Koyeb directly from the terminal. This documentation references all commands and options available in the CLI. 31 | 32 | If you have not installed the Koyeb CLI yet, please read the [installation guide](/build-and-deploy/cli/installation). 33 | ` 34 | } 35 | return "" 36 | } 37 | 38 | err := doc.GenMarkdownTreeCustom(rootCmd, outputDir, filePrepender, linkHandler) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | } 43 | 44 | func main() { 45 | outputDir := "./docs" 46 | if len(os.Args) > 1 { 47 | outputDir = os.Args[1] 48 | } 49 | 50 | genMarkdownDocumentation(outputDir) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/koyeb/deployments_logs.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *DeploymentHandler) Logs(ctx *CLIContext, cmd *cobra.Command, since time.Time, args []string) error { 12 | deployment, err := h.ResolveDeploymentArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | deploymentDetail, resp, err := ctx.Client.DeploymentsApi.GetDeployment(ctx.Context, deployment).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while retrieving the deployment `%s`", args[0]), 21 | err, 22 | resp, 23 | ) 24 | } 25 | 26 | logsType := GetStringFlags(cmd, "type") 27 | startStr := GetStringFlags(cmd, "start-time") 28 | endStr := GetStringFlags(cmd, "end-time") 29 | regex := GetStringFlags(cmd, "regex-search") 30 | text := GetStringFlags(cmd, "text-search") 31 | order := GetStringFlags(cmd, "order") 32 | tail := GetBoolFlags(cmd, "tail") 33 | output := GetStringFlags(cmd, "output") 34 | 35 | return ctx.LogsClient.PrintLogs(ctx, LogsQuery{ 36 | Type: logsType, 37 | DeploymentId: deploymentDetail.Deployment.GetId(), 38 | ServiceId: "", 39 | InstanceId: "", 40 | Since: since, 41 | Start: startStr, 42 | End: endStr, 43 | Text: text, 44 | Order: order, 45 | Tail: tail, 46 | Regex: regex, 47 | Full: GetBoolFlags(cmd, "full"), 48 | Output: output, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/koyeb/detect_updates.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "time" 8 | 9 | "github.com/blang/semver" 10 | "github.com/rhysd/go-github-selfupdate/selfupdate" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const DevVersion = "develop" 15 | 16 | func DetectUpdates() { 17 | if Version == DevVersion { 18 | return 19 | } 20 | version, err := semver.Parse(Version) 21 | if err != nil { 22 | log.Errorf("unable to parse version: %v", err) 23 | return 24 | } 25 | 26 | detectUpdateFile := path.Join(os.TempDir(), "koyeb-cli-detect-update") 27 | dFile, _ := os.Stat(detectUpdateFile) 28 | 29 | if dFile != nil { 30 | oneHourAgo := time.Now().Add(-time.Hour) 31 | if dFile.ModTime().After(oneHourAgo) { 32 | return 33 | } 34 | } 35 | latest, found, err := selfupdate.DetectLatest(GithubRepo) 36 | if err != nil { 37 | fmt.Fprintln(os.Stderr, err) 38 | return 39 | } 40 | if !found { 41 | return 42 | } 43 | 44 | if latest.Version.Compare(version) > 0 { 45 | fmt.Fprintf(os.Stderr, "* A new version of the koyeb-cli (%s) is available *\nSee update instructions here: %s\n", latest.Version, latest.URL) 46 | } 47 | if dFile == nil { 48 | if _, err := os.Create(detectUpdateFile); err != nil { 49 | log.Debugf("Unable to create detect update file: %v", err) 50 | return 51 | } 52 | } else { 53 | now := time.Now().Local() 54 | if err := os.Chtimes(detectUpdateFile, now, now); err != nil { 55 | log.Debugf("Unable to update detect update file: %v", err) 56 | return 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/koyeb/instances_cp.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *InstanceHandler) ExtractFileSpec(ctx *CLIContext, target string) (*FileSpec, error) { 12 | switch i := strings.Index(target, ":"); i { 13 | case 0: 14 | return nil, &errors.CLIError{ 15 | What: "Error while copying", 16 | Why: "Filespec must match the canonical format: [instance:]file/path", 17 | Additional: nil, 18 | Orig: nil, 19 | Solution: "If the problem persists, try to update the CLI to the latest version.", 20 | } 21 | case -1: 22 | return &FileSpec{ 23 | FilePath: target, 24 | }, nil 25 | default: 26 | id, filePath := target[:i], target[i+1:] 27 | 28 | instanceID, err := h.ResolveInstanceArgs(ctx, id) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return &FileSpec{ 34 | InstanceID: instanceID, 35 | FilePath: filePath, 36 | }, nil 37 | } 38 | } 39 | 40 | func (h *InstanceHandler) Cp(ctx *CLIContext, cmd *cobra.Command, args []string) error { 41 | src, dst := args[0], args[1] 42 | 43 | srcSpec, err := h.ExtractFileSpec(ctx, src) 44 | if err != nil { 45 | return err 46 | } 47 | dstSpec, err := h.ExtractFileSpec(ctx, dst) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | manager, err := NewCopyManager(srcSpec, dstSpec) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | log.Infof("Copying from %s to %s ...", src, dst) 58 | return manager.Copy(ctx) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/koyeb/compose_logs.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewComposeLogsCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "logs", 14 | Short: "l", 15 | Args: cobra.ExactArgs(1), 16 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 17 | composeFile, err := parseComposeFile(args[0]) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if composeFile == nil { 23 | return nil 24 | } 25 | 26 | appList, _, err := ctx.Client.AppsApi.ListApps(ctx.Context).Name(*composeFile.App.Name).Execute() 27 | if err != nil { 28 | return err 29 | } 30 | if !appList.HasApps() { 31 | return nil 32 | } 33 | 34 | serviceList, _, err := ctx.Client.ServicesApi.ListServices(ctx.Context).AppId(*appList.Apps[0].Id).Execute() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | var cancel context.CancelFunc 40 | ctx.Context, cancel = context.WithCancel(ctx.Context) 41 | defer cancel() 42 | 43 | wg := sync.WaitGroup{} 44 | wg.Add(1) 45 | 46 | for _, svc := range serviceList.Services { 47 | lq := LogsQuery{ 48 | ServiceId: svc.GetId(), 49 | Order: "asc", 50 | Tail: true, 51 | } 52 | go func() { 53 | if err := ctx.LogsClient.PrintLogs(ctx, lq); err != nil { 54 | log.Errorf("Error while getting logs: %s", err) 55 | return 56 | } 57 | }() 58 | } 59 | 60 | wg.Wait() 61 | 62 | return nil 63 | }), 64 | } 65 | 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | 3 | name: Release 4 | 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | if: startswith(github.ref, 'refs/heads') # Only run on branches 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-go@v4 12 | with: 13 | go-version: "1.22" 14 | - name: golangci-lint 15 | uses: golangci/golangci-lint-action@v3 16 | - name: "Run tests" 17 | run: | 18 | make test 19 | build: 20 | name: Upload Release Asset 21 | runs-on: ubuntu-latest 22 | if: startswith(github.ref, 'refs/tags/v') # Push events to matching v*, i.e. v1.0, v20.15.10 23 | env: 24 | GO111MODULE: "on" 25 | GOPATH: ${{ github.workspace }} 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v2 29 | with: 30 | path: ./src/github.com/${{ github.repository }} 31 | - name: Login to Docker Package Registry 32 | shell: bash 33 | run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin 34 | - uses: actions/setup-go@v2 35 | with: 36 | go-version: "1.22" 37 | - name: Get the version 38 | id: get_version 39 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 40 | - name: goreleaser 41 | uses: goreleaser/goreleaser-action@v2 42 | with: 43 | version: latest 44 | workdir: ./src/github.com/${{ github.repository }} 45 | args: release --clean 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 48 | -------------------------------------------------------------------------------- /pkg/koyeb/renderer/renderer.go: -------------------------------------------------------------------------------- 1 | // Package renderer provides a set of renderers to display API resources. 2 | // 3 | // The default TableRenderer displays the resources as a table. The JSONRenderer 4 | // and YAMLRenderer display the resources as JSON and YAML respectively. 5 | // ChainRenderer can be used to display multiple resources. 6 | // 7 | // The resource to display must implement the ApiResources interface. 8 | package renderer 9 | 10 | import ( 11 | "errors" 12 | ) 13 | 14 | type ApiResources interface { 15 | Headers() []string 16 | Fields() []map[string]string 17 | MarshalBinary() ([]byte, error) 18 | Title() string 19 | } 20 | 21 | type Renderer interface { 22 | Render(ApiResources) 23 | RenderSeparator() 24 | } 25 | 26 | // OutputFormat implements the flag.Value interface to parse the --output flag. 27 | type OutputFormat string 28 | 29 | const ( 30 | JSONFormat OutputFormat = "json" 31 | YAMLFormat OutputFormat = "yaml" 32 | TableFormat OutputFormat = "table" 33 | ) 34 | 35 | func (f *OutputFormat) String() string { 36 | return string(*f) 37 | } 38 | 39 | func (f *OutputFormat) Set(value string) error { 40 | switch value { 41 | case "json", "yaml", "table": 42 | *f = OutputFormat(value) 43 | return nil 44 | } 45 | return errors.New(`invalid output format. Valid values are "json", "yaml" and "table"`) 46 | } 47 | 48 | func (f *OutputFormat) Type() string { 49 | return "output" 50 | } 51 | 52 | func NewRenderer(format OutputFormat) Renderer { 53 | switch format { 54 | case JSONFormat: 55 | return &JSONRenderer{} 56 | case YAMLFormat: 57 | return &YAMLRenderer{} 58 | default: 59 | return &TableRenderer{} 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/koyeb/completion.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var completionCmd = &cobra.Command{ 10 | Use: "completion [bash|zsh|fish|powershell]", 11 | Short: "Generate completion script", 12 | Long: `To load completions: 13 | 14 | Bash: 15 | 16 | $ source <(koyeb completion bash) 17 | 18 | # To load completions for each session, execute once: 19 | Linux: 20 | $ koyeb completion bash > /etc/bash_completion.d/koyeb 21 | MacOS: 22 | $ koyeb completion bash > /usr/local/etc/bash_completion.d/koyeb 23 | 24 | Zsh: 25 | 26 | # If shell completion is not already enabled in your environment you will need 27 | # to enable it. You can execute the following once: 28 | 29 | $ echo "autoload -U compinit; compinit" >> ~/.zshrc 30 | 31 | # To load completions for each session, execute once: 32 | $ koyeb completion zsh > "${fpath[1]}/_koyeb" 33 | 34 | # You will need to start a new shell for this setup to take effect. 35 | 36 | Fish: 37 | 38 | $ koyeb completion fish | source 39 | 40 | # To load completions for each session, execute once: 41 | $ koyeb completion fish > ~/.config/fish/completions/koyeb.fish 42 | `, 43 | DisableFlagsInUseLine: true, 44 | ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, 45 | Args: cobra.ExactValidArgs(1), 46 | RunE: func(cmd *cobra.Command, args []string) error { 47 | switch args[0] { 48 | case "bash": 49 | return cmd.Root().GenBashCompletionV2(os.Stdout, true) 50 | case "zsh": 51 | return cmd.Root().GenZshCompletion(os.Stdout) 52 | case "fish": 53 | return cmd.Root().GenFishCompletion(os.Stdout, true) 54 | case "powershell": 55 | return cmd.Root().GenPowerShellCompletion(os.Stdout) 56 | } 57 | return nil 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /pkg/koyeb/regional_deployments.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func NewRegionalDeploymentCmd() *cobra.Command { 8 | h := NewRegionalDeploymentHandler() 9 | 10 | regionalDeploymentCmd := &cobra.Command{ 11 | Use: "regional-deployments ACTION", 12 | Aliases: []string{"rd", "rdep", "rdepl", "rdeploy", "rdeployment", "regional-deployment"}, 13 | Short: "Regional deployments", 14 | } 15 | 16 | listRegionalDeploymentCmd := &cobra.Command{ 17 | Use: "list", 18 | Short: "List regional deployments", 19 | RunE: WithCLIContext(h.List), 20 | } 21 | listRegionalDeploymentCmd.Flags().String("deployment", "", "Limit the list to regional deployments of a specific deployment") 22 | regionalDeploymentCmd.AddCommand(listRegionalDeploymentCmd) 23 | 24 | getRegionalDeploymentCmd := &cobra.Command{ 25 | Use: "get NAME", 26 | Short: "Get regional deployment", 27 | Args: cobra.ExactArgs(1), 28 | RunE: WithCLIContext(h.Get), 29 | } 30 | regionalDeploymentCmd.AddCommand(getRegionalDeploymentCmd) 31 | 32 | return regionalDeploymentCmd 33 | } 34 | 35 | func NewRegionalDeploymentHandler() *RegionalDeploymentHandler { 36 | return &RegionalDeploymentHandler{} 37 | } 38 | 39 | type RegionalDeploymentHandler struct { 40 | } 41 | 42 | func (h *RegionalDeploymentHandler) ResolveDeploymentArgs(ctx *CLIContext, val string) (string, error) { 43 | deploymentMapper := ctx.Mapper.Deployment() 44 | id, err := deploymentMapper.ResolveID(val) 45 | if err != nil { 46 | return "", err 47 | } 48 | return id, nil 49 | } 50 | 51 | func (h *RegionalDeploymentHandler) ResolveRegionalDeploymentArgs(ctx *CLIContext, val string) (string, error) { 52 | regionalDeploymentMapper := ctx.Mapper.RegionalDeployment() 53 | id, err := regionalDeploymentMapper.ResolveID(val) 54 | if err != nil { 55 | return "", err 56 | } 57 | return id, nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/koyeb/archives.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | func NewArchiveCmd() *cobra.Command { 10 | h := NewArchiveHandler() 11 | 12 | archiveCmd := &cobra.Command{ 13 | Use: "archives ACTION", 14 | Aliases: []string{"archive"}, 15 | Short: "Archives", 16 | } 17 | 18 | createArchiveCmd := &cobra.Command{ 19 | Use: "create NAME", 20 | Short: "Create archive", 21 | Args: cobra.ExactArgs(1), 22 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 23 | err := h.ParseFlags(ctx, cmd.Flags()) 24 | if err != nil { 25 | return err 26 | } 27 | return h.Create(ctx, cmd, args[0]) 28 | }), 29 | } 30 | h.addFlags(createArchiveCmd.Flags()) 31 | archiveCmd.AddCommand(createArchiveCmd) 32 | return archiveCmd 33 | } 34 | 35 | func NewArchiveHandler() *ArchiveHandler { 36 | return &ArchiveHandler{} 37 | } 38 | 39 | type ArchiveHandler struct { 40 | ignoreDirectories []string 41 | } 42 | 43 | // Add the flags for Archive sources 44 | func (a *ArchiveHandler) addFlags(flags *pflag.FlagSet) { 45 | flags.StringSlice( 46 | "ignore-dir", 47 | []string{".git", "node_modules", "vendor"}, 48 | "Set directories to ignore when building the archive.\n"+ 49 | "To ignore multiple directories, use the flag multiple times.\n"+ 50 | "To include all directories, set the flag to an empty string.", 51 | ) 52 | } 53 | 54 | func (a *ArchiveHandler) ParseFlags(ctx *CLIContext, flags *pflag.FlagSet) error { 55 | ignoreDirectories, err := flags.GetStringSlice("ignore-dir") 56 | if err != nil { 57 | return err 58 | } 59 | a.ParseIgnoreDirectories(ignoreDirectories) 60 | return nil 61 | } 62 | 63 | func (a *ArchiveHandler) ParseIgnoreDirectories(ignoreDirectories []string) { 64 | // special case: if the flag is set to an empty string, we do not ignore any directories 65 | if len(ignoreDirectories) == 1 && ignoreDirectories[0] == "" { 66 | ignoreDirectories = []string{} 67 | } 68 | a.ignoreDirectories = ignoreDirectories 69 | } 70 | -------------------------------------------------------------------------------- /pkg/koyeb/organizations.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewOrganizationCmd() *cobra.Command { 13 | h := NewOrganizationHandler() 14 | rootCmd := &cobra.Command{ 15 | Use: "organizations ACTION", 16 | Aliases: []string{"organizations", "organization", "orgas", "orga", "orgs", "org", "organisations", "organisation"}, 17 | Short: "Organization", 18 | } 19 | listCmd := &cobra.Command{ 20 | Use: "list", 21 | Short: "List organizations", 22 | RunE: WithCLIContext(h.List), 23 | } 24 | rootCmd.AddCommand(listCmd) 25 | 26 | switchCmd := &cobra.Command{ 27 | Use: "switch", 28 | Short: "Switch the CLI context to another organization", 29 | Args: cobra.ExactArgs(1), 30 | RunE: WithCLIContext(h.Switch), 31 | } 32 | rootCmd.AddCommand(switchCmd) 33 | return rootCmd 34 | } 35 | 36 | func NewOrganizationHandler() *OrganizationHandler { 37 | return &OrganizationHandler{} 38 | } 39 | 40 | type OrganizationHandler struct { 41 | } 42 | 43 | func ResolveOrganizationArgs(ctx *CLIContext, val string) (string, error) { 44 | organizationMapper := ctx.Mapper.Organization() 45 | id, err := organizationMapper.ResolveID(val) 46 | if err != nil { 47 | return "", err 48 | } 49 | return id, nil 50 | } 51 | 52 | // GetOrganizationToken calls /v1/organizations/{organizationId}/switch which returns a token to access the resources of organizationId 53 | func GetOrganizationToken(api koyeb.OrganizationApi, ctx context.Context, organizationId string) (string, error) { 54 | //SwitchOrganization requires to pass an empty body 55 | body := make(map[string]interface{}) 56 | res, resp, err := api.SwitchOrganization(ctx, organizationId).Body(body).Execute() 57 | if err != nil { 58 | return "", errors.NewCLIErrorFromAPIError( 59 | fmt.Sprintf("Error while switching the context to organization `%s`", organizationId), 60 | err, 61 | resp, 62 | ) 63 | } 64 | return *res.Token.Id, nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_update.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func (h *AppHandler) Update(ctx *CLIContext, cmd *cobra.Command, args []string, updateApp *koyeb.UpdateApp) error { 12 | app, err := h.ResolveAppArgs(ctx, args[0]) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | appRes, appResp, err := ctx.Client.AppsApi.UpdateApp2(ctx.Context, app).App(*updateApp).Execute() 18 | if err != nil { 19 | return errors.NewCLIErrorFromAPIError( 20 | fmt.Sprintf("Error while updating the application `%s`", args[0]), 21 | err, 22 | appResp, 23 | ) 24 | } 25 | 26 | var newDomain *koyeb.Domain 27 | if domain := GetStringFlags(cmd, "domain"); domain != "" { 28 | updateDomainReq := koyeb.NewUpdateDomainWithDefaults() 29 | updateDomainReq.SetSubdomain(domain) 30 | 31 | domainID, err := ctx.Mapper.App().GetAutoDomain(app) 32 | if err != nil { 33 | return &errors.CLIError{ 34 | What: fmt.Sprintf("Error while renaming the automatic domain for `%s` to %q", args[0], domain), 35 | Why: "Could not find the automatic domain for the application", 36 | Additional: []string{"This could be a temporary error"}, 37 | Orig: err, 38 | Solution: errors.SolutionTryAgainOrUpdateOrIssue, 39 | } 40 | } 41 | 42 | domainRes, domainResp, err := ctx.Client.DomainsApi.UpdateDomain(ctx.Context, domainID).Domain(*updateDomainReq).Execute() 43 | if err != nil { 44 | return errors.NewCLIErrorFromAPIError( 45 | fmt.Sprintf("Error while renaming the automatic domain for `%s` to %q", args[0], domain), 46 | err, 47 | domainResp, 48 | ) 49 | } 50 | 51 | if domainRes.Domain != nil { 52 | newDomain = domainRes.Domain 53 | } 54 | } 55 | 56 | full := GetBoolFlags(cmd, "full") 57 | if newDomain != nil { 58 | appRes.App.Domains = append(appRes.App.Domains, *newDomain) 59 | } 60 | getAppsReply := NewGetAppReply(ctx.Mapper, &koyeb.GetAppReply{App: appRes.App}, full) 61 | ctx.Renderer.Render(getAppsReply) 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/koyeb/errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | const ( 11 | testSol1 CLIErrorSolution = "Solution 1" 12 | testSol2 CLIErrorSolution = "Solution 1" 13 | ) 14 | 15 | func TestTemplate(t *testing.T) { 16 | values := map[string]struct { 17 | err *CLIError 18 | expected string 19 | }{ 20 | "minimal": { 21 | err: &CLIError{ 22 | What: "Error title", 23 | Why: "error message", 24 | Solution: testSol1, 25 | }, 26 | expected: `❌ Error title: error message 27 | 28 | 🏥 How to solve the issue? 29 | Solution 1 30 | `, 31 | }, 32 | "with_additional_info": { 33 | err: &CLIError{ 34 | What: "Error title", 35 | Why: "error message", 36 | Additional: []string{"additional info 1", "additional info 2"}, 37 | Solution: testSol1, 38 | }, 39 | expected: `❌ Error title: error message 40 | 41 | 🔎 Additional details 42 | additional info 1 43 | additional info 2 44 | 45 | 🏥 How to solve the issue? 46 | Solution 1 47 | `, 48 | }, 49 | "with_original_error": { 50 | err: &CLIError{ 51 | What: "Error title", 52 | Why: "error message", 53 | Orig: fmt.Errorf("original error"), 54 | Solution: testSol1, 55 | }, 56 | expected: `❌ Error title: error message 57 | 58 | 🏥 How to solve the issue? 59 | Solution 1 60 | 61 | 🕦 The original error was: 62 | original error 63 | `, 64 | }, 65 | "full": { 66 | err: &CLIError{ 67 | What: "Error title", 68 | Why: "error message", 69 | Additional: []string{"additional info 1", "additional info 2"}, 70 | Orig: fmt.Errorf("original error"), 71 | Solution: testSol1, 72 | }, 73 | expected: `❌ Error title: error message 74 | 75 | 🔎 Additional details 76 | additional info 1 77 | additional info 2 78 | 79 | 🏥 How to solve the issue? 80 | Solution 1 81 | 82 | 🕦 The original error was: 83 | original error 84 | `, 85 | }, 86 | } 87 | 88 | for name, tc := range values { 89 | t.Run(name, func(t *testing.T) { 90 | assert.Equal(t, tc.expected, tc.err.Error()) 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *SecretHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | secret, err := ResolveSecretArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.SecretsApi.GetSecret(ctx.Context, secret).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the secret `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | getSecretsReply := NewGetSecretReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(getSecretsReply) 31 | return nil 32 | } 33 | 34 | type GetSecretReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetSecretReply 37 | full bool 38 | } 39 | 40 | func NewGetSecretReply(mapper *idmapper.Mapper, res *koyeb.GetSecretReply, full bool) *GetSecretReply { 41 | return &GetSecretReply{ 42 | mapper: mapper, 43 | value: res, 44 | full: full, 45 | } 46 | } 47 | 48 | func (GetSecretReply) Title() string { 49 | return "Secret" 50 | } 51 | 52 | func (r *GetSecretReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetSecret().MarshalJSON() 54 | } 55 | 56 | func (r *GetSecretReply) Headers() []string { 57 | return []string{"id", "name", "type", "value", "created_at"} 58 | } 59 | 60 | func (r *GetSecretReply) Fields() []map[string]string { 61 | item := r.value.GetSecret() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "name": item.GetName(), 65 | "type": formatSecretType(item.GetType()), 66 | "value": "*****", 67 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 68 | } 69 | 70 | resp := []map[string]string{fields} 71 | return resp 72 | } 73 | 74 | func formatSecretType(st koyeb.SecretType) string { 75 | return string(st) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *DomainHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | domain, err := h.ResolveDomainArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.DomainsApi.GetDomain(ctx.Context, domain).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the domain `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | getDomainsReply := NewGetDomainReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(getDomainsReply) 31 | return nil 32 | } 33 | 34 | type GetDomainReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetDomainReply 37 | full bool 38 | } 39 | 40 | func NewGetDomainReply(mapper *idmapper.Mapper, value *koyeb.GetDomainReply, full bool) *GetDomainReply { 41 | return &GetDomainReply{ 42 | mapper: mapper, 43 | value: value, 44 | full: full, 45 | } 46 | } 47 | 48 | func (GetDomainReply) Title() string { 49 | return "Domain" 50 | } 51 | 52 | func (r *GetDomainReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetDomain().MarshalJSON() 54 | } 55 | 56 | func (r *GetDomainReply) Headers() []string { 57 | return []string{"id", "name", "app", "status", "type", "created_at"} 58 | } 59 | 60 | func (r *GetDomainReply) Fields() []map[string]string { 61 | item := r.value.GetDomain() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "name": item.GetName(), 65 | "app": renderer.FormatAppName(r.mapper, item.GetAppId(), r.full), 66 | "status": string(item.GetStatus()), 67 | "type": string(item.GetType()), 68 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 69 | } 70 | 71 | resp := []map[string]string{fields} 72 | return resp 73 | } 74 | -------------------------------------------------------------------------------- /pkg/koyeb/compose_delete.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/briandowns/spinner" 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func NewComposeDeleteCmd() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "delete", 16 | Short: "d", 17 | Args: cobra.ExactArgs(1), 18 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 19 | composeFile, err := parseComposeFile(args[0]) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if composeFile == nil { 25 | return nil 26 | } 27 | 28 | appList, _, err := ctx.Client.AppsApi.ListApps(ctx.Context).Name(*composeFile.App.Name).Execute() 29 | if err != nil { 30 | return err 31 | } 32 | if !appList.HasApps() || len(appList.Apps) == 0 { 33 | return nil 34 | } 35 | 36 | if _, _, err := ctx.Client.AppsApi.DeleteApp(ctx.Context, appList.Apps[0].GetId()).Execute(); err != nil { 37 | return err 38 | } 39 | 40 | return monitorAppDelete(ctx, appList.Apps[0].GetId()) 41 | }), 42 | } 43 | 44 | return cmd 45 | } 46 | 47 | func monitorAppDelete(ctx *CLIContext, appId string) error { 48 | s := spinner.New(spinner.CharSets[21], 100*time.Millisecond, spinner.WithColor("red")) 49 | s.Start() 50 | defer s.Stop() 51 | 52 | previousStatus := koyeb.AppStatus("") 53 | for { 54 | resApp, resp, err := ctx.Client.AppsApi.GetApp(ctx.Context, appId).Execute() 55 | if err != nil { 56 | return errors.NewCLIErrorFromAPIError( 57 | "Error while fetching deployment status", 58 | err, 59 | resp, 60 | ) 61 | } 62 | 63 | currentStatus := resApp.App.GetStatus() 64 | if previousStatus != currentStatus { 65 | previousStatus = currentStatus 66 | s.Suffix = fmt.Sprintf(" Deleting app %s: %s", *resApp.App.Name, currentStatus) 67 | } 68 | 69 | if isAppMonitoringEndState(currentStatus) { 70 | break 71 | } 72 | 73 | time.Sleep(5 * time.Second) 74 | } 75 | 76 | fmt.Printf("\n") 77 | if previousStatus == koyeb.APPSTATUS_DELETED { 78 | fmt.Println("Succcessfully deleted ✅♻️") 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_describe.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *SecretHandler) Describe(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | secret, err := ResolveSecretArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.SecretsApi.GetSecret(ctx.Context, secret).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the secret `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | getSecretsReply := NewDescribeSecretReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(getSecretsReply) 31 | return nil 32 | } 33 | 34 | type DescribeSecretReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetSecretReply 37 | full bool 38 | } 39 | 40 | func NewDescribeSecretReply(mapper *idmapper.Mapper, value *koyeb.GetSecretReply, full bool) *DescribeSecretReply { 41 | return &DescribeSecretReply{ 42 | mapper: mapper, 43 | value: value, 44 | full: full, 45 | } 46 | } 47 | 48 | func (DescribeSecretReply) Title() string { 49 | return "Secret" 50 | } 51 | 52 | func (r *DescribeSecretReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetSecret().MarshalJSON() 54 | } 55 | 56 | func (r *DescribeSecretReply) Headers() []string { 57 | return []string{"id", "name", "type", "value", "created_at", "updated_at"} 58 | } 59 | 60 | func (r *DescribeSecretReply) Fields() []map[string]string { 61 | item := r.value.GetSecret() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "name": item.GetName(), 65 | "type": formatSecretType(item.GetType()), 66 | "value": "*****", 67 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 68 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 69 | } 70 | 71 | resp := []map[string]string{fields} 72 | return resp 73 | } 74 | -------------------------------------------------------------------------------- /pkg/koyeb/services_describe.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func (h *ServiceHandler) Describe(ctx *CLIContext, cmd *cobra.Command, args []string) error { 13 | serviceName, err := h.parseServiceName(cmd, args[0]) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | service, err := h.ResolveServiceArgs(ctx, serviceName) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | res, resp, err := ctx.Client.ServicesApi.GetService(ctx.Context, service).Execute() 24 | if err != nil { 25 | return errors.NewCLIErrorFromAPIError( 26 | fmt.Sprintf("Error while retrieving the service `%s`", serviceName), 27 | err, 28 | resp, 29 | ) 30 | } 31 | 32 | instancesRes, resp, err := ctx.Client.InstancesApi.ListInstances(ctx.Context). 33 | Statuses([]string{ 34 | string(koyeb.INSTANCESTATUS_ALLOCATING), 35 | string(koyeb.INSTANCESTATUS_STARTING), 36 | string(koyeb.INSTANCESTATUS_HEALTHY), 37 | string(koyeb.INSTANCESTATUS_UNHEALTHY), 38 | string(koyeb.INSTANCESTATUS_STOPPING), 39 | }). 40 | ServiceId(res.Service.GetId()). 41 | Execute() 42 | if err != nil { 43 | return errors.NewCLIErrorFromAPIError( 44 | fmt.Sprintf("Error while listing the instances of the service `%s`", serviceName), 45 | err, 46 | resp, 47 | ) 48 | } 49 | 50 | deploymentsRes, resp, err := ctx.Client.DeploymentsApi.ListDeployments(ctx.Context).ServiceId(res.Service.GetId()).Execute() 51 | if err != nil { 52 | return errors.NewCLIErrorFromAPIError( 53 | fmt.Sprintf("Error while listing the deployments of the service `%s`", serviceName), 54 | err, 55 | resp, 56 | ) 57 | } 58 | 59 | full := GetBoolFlags(cmd, "full") 60 | 61 | getServiceReply := NewGetServiceReply(ctx.Mapper, res, full) 62 | listInstancesReply := NewListInstancesReply(ctx.Mapper, instancesRes, full) 63 | listDeploymentsReply := NewListDeploymentsReply(ctx.Mapper, deploymentsRes, full) 64 | renderer.NewChainRenderer(ctx.Renderer). 65 | Render(getServiceReply). 66 | Render(listInstancesReply). 67 | Render(listDeploymentsReply) 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/koyeb/renderer/format.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 8 | ) 9 | 10 | func FormatTime(t time.Time) string { 11 | return t.Format(time.RFC822) 12 | } 13 | 14 | func FormatAppName(mapper *idmapper.Mapper, id string, full bool) string { 15 | if !full { 16 | sid, err := mapper.App().GetName(id) 17 | if err == nil { 18 | return sid 19 | } 20 | } 21 | return id 22 | } 23 | 24 | func FormatServiceSlug(mapper *idmapper.Mapper, id string, full bool) string { 25 | if !full { 26 | sid, err := mapper.Service().GetSlug(id) 27 | if err == nil { 28 | return sid 29 | } 30 | } 31 | return id 32 | } 33 | 34 | // FormatID formats the ID to be displayed in the CLI. If full is false, only the first 8 characters are displayed. 35 | func FormatID(fullId string, full bool) string { 36 | if full { 37 | return fullId 38 | } 39 | if fullId == "" { 40 | return "" 41 | } 42 | return fullId[:8] 43 | } 44 | 45 | type Size interface { 46 | GetSize() (int64, int64, string) 47 | } 48 | 49 | type ByteSize int64 50 | 51 | func (b ByteSize) GetSize() (int64, int64, string) { 52 | return int64(b), 1, "B" 53 | } 54 | 55 | type KiBSize int64 56 | 57 | func (k KiBSize) GetSize() (int64, int64, string) { 58 | return int64(k) * 1024, 1024, "KiB" 59 | } 60 | 61 | type MiBSize int64 62 | 63 | func (m MiBSize) GetSize() (int64, int64, string) { 64 | return int64(m) * 1024 * 1024, 1024 * 1024, "MiB" 65 | } 66 | 67 | type GiBSize int64 68 | 69 | func (g GiBSize) GetSize() (int64, int64, string) { 70 | return int64(g) * 1024 * 1024 * 1024, 1024 * 1024 * 1024, "GiB" 71 | } 72 | 73 | type KBSize int64 74 | 75 | func (k KBSize) GetSize() (int64, int64, string) { 76 | return int64(k) * 1000, 1000, "KB" 77 | } 78 | 79 | type MBSize int64 80 | 81 | func (m MBSize) GetSize() (int64, int64, string) { 82 | return int64(m) * 1000 * 1000, 1000 * 1000, "MB" 83 | } 84 | 85 | type GBSize int64 86 | 87 | func (g GBSize) GetSize() (int64, int64, string) { 88 | return int64(g) * 1000 * 1000 * 1000, 1000 * 1000 * 1000, "GB" 89 | } 90 | 91 | func FormatSize(sized Size) string { 92 | size, mul, unit := sized.GetSize() 93 | 94 | return fmt.Sprintf("%.2f%s", float64(size)/float64(mul), unit) 95 | } 96 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - id: default 4 | main: ./cmd/koyeb 5 | binary: koyeb 6 | env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - darwin 10 | - linux 11 | ignore: 12 | - goos: darwin 13 | goarch: "386" 14 | - goos: linux 15 | goarch: "386" 16 | ldflags: 17 | - -X github.com/koyeb/koyeb-cli/pkg/koyeb.BuildDate={{ .Date }} 18 | - -X github.com/koyeb/koyeb-cli/pkg/koyeb.Version={{ .Version }} 19 | - -X github.com/koyeb/koyeb-cli/pkg/koyeb.Commit={{ .ShortCommit }} 20 | - id: windows 21 | main: ./cmd/koyeb 22 | binary: koyeb 23 | env: 24 | - CGO_ENABLED=0 25 | goos: 26 | - windows 27 | ignore: 28 | - goos: windows 29 | goarch: "386" 30 | ldflags: 31 | - -X github.com/koyeb/koyeb-cli/pkg/koyeb.BuildDate={{ .Date }} 32 | - -X github.com/koyeb/koyeb-cli/pkg/koyeb.Version={{ .Version }} 33 | - -X github.com/koyeb/koyeb-cli/pkg/koyeb.Commit={{ .ShortCommit }} 34 | 35 | archives: 36 | - id: windows 37 | builds: 38 | - windows 39 | wrap_in_directory: false 40 | format: zip 41 | - id: default 42 | builds: 43 | - default 44 | files: [only-the-binary*] 45 | wrap_in_directory: false 46 | format: tar.gz 47 | 48 | release: 49 | prerelease: auto 50 | header: | 51 | ## The best version ever of Koyeb CLI is here! 52 | 53 | Welcome to this new release! 54 | Check the changelog in [CHANGES.md](https://github.com/koyeb/koyeb-cli/blob/master/CHANGES.md) for more details. 55 | For upgrade instructions, see [README.md](https://github.com/koyeb/koyeb-cli/blob/master/README.md). 56 | 57 | dockers: 58 | - goos: linux 59 | goarch: amd64 60 | image_templates: 61 | - "koyeb/koyeb-cli:latest" 62 | - "koyeb/koyeb-cli:v{{ .Version }}" 63 | skip_push: auto 64 | 65 | checksum: 66 | name_template: "checksums.txt" 67 | 68 | snapshot: 69 | version_template: "{{.Branch}}-{{.ShortCommit}}" 70 | 71 | brews: 72 | - repository: 73 | owner: koyeb 74 | name: homebrew-tap 75 | name: koyeb 76 | directory: Formula 77 | homepage: https://koyeb.com 78 | skip_upload: auto 79 | test: | 80 | system "#{bin}/koyeb version" 81 | install: | 82 | bin.install "koyeb" 83 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST_OPTS=-v -test.timeout 300s 2 | 3 | define gen-doc-in-dir 4 | rm -f ./$1/* 5 | go run cmd/gen-doc/gen-doc.go $1 6 | sed -i.bak 's/.*koyeb completion.*//' ./$1/*.md 7 | sed -i.bak 's/### SEE ALSO.*//' ./$1/*.md 8 | cat ./$1/koyeb.md >> ./$1/reference.md 9 | cat ./$1/koyeb_login.md >> ./$1/reference.md 10 | cat ./$1/koyeb_apps.md >> ./$1/reference.md 11 | cat ./$1/koyeb_apps_*.md >> ./$1/reference.md 12 | cat ./$1/koyeb_archives.md >> ./$1/reference.md 13 | cat ./$1/koyeb_archives_*.md >> ./$1/reference.md 14 | cat ./$1/koyeb_deploy.md >> ./$1/reference.md 15 | cat ./$1/koyeb_domains.md >> ./$1/reference.md 16 | cat ./$1/koyeb_domains_*.md >> ./$1/reference.md 17 | cat ./$1/koyeb_organizations.md >> ./$1/reference.md 18 | cat ./$1/koyeb_organizations_*.md >> ./$1/reference.md 19 | cat ./$1/koyeb_secrets.md >> ./$1/reference.md 20 | cat ./$1/koyeb_secrets_*.md >> ./$1/reference.md 21 | cat ./$1/koyeb_services.md >> ./$1/reference.md 22 | cat ./$1/koyeb_services_*.md >> ./$1/reference.md 23 | cat ./$1/koyeb_deployments.md >> ./$1/reference.md 24 | cat ./$1/koyeb_deployments_*.md >> ./$1/reference.md 25 | cat ./$1/koyeb_instances.md >> ./$1/reference.md 26 | cat ./$1/koyeb_instances_*.md >> ./$1/reference.md 27 | cat ./$1/koyeb_databases.md >> ./$1/reference.md 28 | cat ./$1/koyeb_databases_*.md >> ./$1/reference.md 29 | cat ./$1/koyeb_version.md >> ./$1/reference.md 30 | cat ./$1/koyeb_volumes.md >> ./$1/reference.md 31 | find ./$1 -type f -not -name 'reference.md' -delete 32 | endef 33 | 34 | help: ## help 35 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_0-9-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort 36 | 37 | tidy: ## apply go mod tidy 38 | test ${CI} || go mod tidy 39 | 40 | install: ## install 41 | go install cmd/koyeb/koyeb.go 42 | 43 | fmt: ## apply go format 44 | gofmt -s -w ./ 45 | 46 | gen-doc: ## generate markdown documentation 47 | $(call gen-doc-in-dir,docs) 48 | 49 | test: tidy lint 50 | @mkdir -p ./.temp 51 | @$(call gen-doc-in-dir,.temp) 52 | @diff -r -q ./docs ./.temp > /dev/null && { \ 53 | test -z "`gofmt -d ./cmd ./pkg | tee /dev/stderr`"; \ 54 | go test $(TEST_OPTS) ./...; \ 55 | } || { \ 56 | echo >&2 "make gen-doc has a diff"; \ 57 | } 58 | @rm -rf ./.temp; 59 | 60 | lint: 61 | golangci-lint run -v ./... 62 | -------------------------------------------------------------------------------- /pkg/koyeb/services_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *ServiceHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | serviceName, err := h.parseServiceName(cmd, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | service, err := h.ResolveServiceArgs(ctx, serviceName) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | res, resp, err := ctx.Client.ServicesApi.GetService(ctx.Context, service).Execute() 25 | if err != nil { 26 | return errors.NewCLIErrorFromAPIError( 27 | fmt.Sprintf("Error while retrieving the service `%s`", serviceName), 28 | err, 29 | resp, 30 | ) 31 | } 32 | 33 | full := GetBoolFlags(cmd, "full") 34 | getServiceReply := NewGetServiceReply(ctx.Mapper, res, full) 35 | ctx.Renderer.Render(getServiceReply) 36 | return nil 37 | } 38 | 39 | type GetServiceReply struct { 40 | mapper *idmapper.Mapper 41 | value *koyeb.GetServiceReply 42 | full bool 43 | } 44 | 45 | func NewGetServiceReply(mapper *idmapper.Mapper, value *koyeb.GetServiceReply, full bool) *GetServiceReply { 46 | return &GetServiceReply{ 47 | mapper: mapper, 48 | value: value, 49 | full: full, 50 | } 51 | } 52 | 53 | func (GetServiceReply) Title() string { 54 | return "Service" 55 | } 56 | 57 | func (r *GetServiceReply) MarshalBinary() ([]byte, error) { 58 | return r.value.GetService().MarshalJSON() 59 | } 60 | 61 | func (r *GetServiceReply) Headers() []string { 62 | return []string{"id", "app", "name", "status", "created_at"} 63 | } 64 | 65 | func (r *GetServiceReply) Fields() []map[string]string { 66 | item := r.value.GetService() 67 | fields := map[string]string{ 68 | "id": renderer.FormatID(item.GetId(), r.full), 69 | "app": renderer.FormatAppName(r.mapper, item.GetAppId(), r.full), 70 | "name": item.GetName(), 71 | "status": formatServiceStatus(item.GetStatus()), 72 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 73 | } 74 | 75 | resp := []map[string]string{fields} 76 | return resp 77 | } 78 | 79 | func formatServiceStatus(status koyeb.ServiceStatus) string { 80 | return string(status) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/koyeb/databases_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | stderrors "errors" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // Try to create a koyeb application. Do nothing if the application already exists. 15 | func TryCreateKoyebApplication(name string, ctx *CLIContext) error { 16 | createApp := koyeb.NewCreateAppWithDefaults() 17 | createApp.SetName(name) 18 | 19 | _, resp, err := ctx.Client.AppsApi.CreateApp(ctx.Context).App(*createApp).Execute() 20 | if err != nil { 21 | var openAPIError *koyeb.GenericOpenAPIError 22 | 23 | // The only way to know if the CreateApp API call failed because the app already exists is to check the error message. 24 | // This is not ideal, but the API does not return a specific error code for this case. 25 | if stderrors.As(err, &openAPIError) { 26 | if errorWithFields, ok := openAPIError.Model().(koyeb.ErrorWithFields); ok { 27 | fields := errorWithFields.GetFields() 28 | if len(fields) == 1 && fields[0].GetField() == "name" && fields[0].GetDescription() == "already exists" { 29 | return nil 30 | } 31 | } 32 | } 33 | return errors.NewCLIErrorFromAPIError( 34 | fmt.Sprintf("Error while creating the app `%s`", name), 35 | err, 36 | resp, 37 | ) 38 | } 39 | return nil 40 | } 41 | 42 | func (h *DatabaseHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string, createService *koyeb.CreateService) error { 43 | serviceHandler := NewServiceHandler() 44 | appName, err := serviceHandler.parseAppName(cmd, args[0]) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if err := TryCreateKoyebApplication(appName, ctx); err != nil { 50 | return err 51 | } 52 | 53 | appID, err := h.ResolveAppArgs(ctx, appName) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | createService.SetAppId(appID) 59 | res, resp, err := ctx.Client.ServicesApi.CreateService(ctx.Context).Service(*createService).Execute() 60 | if err != nil { 61 | return errors.NewCLIErrorFromAPIError( 62 | "Error while creating the database service", 63 | err, 64 | resp, 65 | ) 66 | } 67 | 68 | log.Infof( 69 | "Database creation in progress. To access the connection strings, run `koyeb database get %s` in a few seconds.", 70 | res.Service.GetId()[:8], 71 | ) 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *SecretHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | list := []koyeb.Secret{} 15 | 16 | page := int64(0) 17 | offset := int64(0) 18 | limit := int64(100) 19 | for { 20 | res, resp, err := ctx.Client.SecretsApi.ListSecrets(ctx.Context). 21 | Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 22 | if err != nil { 23 | return errors.NewCLIErrorFromAPIError("Error while listing secrets", err, resp) 24 | } 25 | list = append(list, res.GetSecrets()...) 26 | 27 | page++ 28 | offset = page * limit 29 | if offset >= res.GetCount() { 30 | break 31 | } 32 | } 33 | 34 | full := GetBoolFlags(cmd, "full") 35 | listSecretsReply := NewListSecretsReply(ctx.Mapper, &koyeb.ListSecretsReply{Secrets: list}, full) 36 | ctx.Renderer.Render(listSecretsReply) 37 | return nil 38 | } 39 | 40 | type ListSecretsReply struct { 41 | mapper *idmapper.Mapper 42 | value *koyeb.ListSecretsReply 43 | full bool 44 | } 45 | 46 | func NewListSecretsReply(mapper *idmapper.Mapper, value *koyeb.ListSecretsReply, full bool) *ListSecretsReply { 47 | return &ListSecretsReply{ 48 | mapper: mapper, 49 | value: value, 50 | full: full, 51 | } 52 | } 53 | 54 | func (ListSecretsReply) Title() string { 55 | return "Secrets" 56 | } 57 | 58 | func (r *ListSecretsReply) MarshalBinary() ([]byte, error) { 59 | return r.value.MarshalJSON() 60 | } 61 | 62 | func (r *ListSecretsReply) Headers() []string { 63 | return []string{"id", "name", "type", "value", "created_at"} 64 | } 65 | 66 | func (r *ListSecretsReply) Fields() []map[string]string { 67 | items := r.value.GetSecrets() 68 | resp := make([]map[string]string, 0, len(items)) 69 | 70 | for _, item := range items { 71 | fields := map[string]string{ 72 | "id": renderer.FormatID(item.GetId(), r.full), 73 | "name": item.GetName(), 74 | "type": formatSecretType(item.GetType()), 75 | "value": "*****", 76 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 77 | } 78 | resp = append(resp, fields) 79 | } 80 | 81 | return resp 82 | } 83 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/instance.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type InstanceMapper struct { 12 | ctx context.Context 13 | client *koyeb.APIClient 14 | fetched bool 15 | sidMap *IDMap 16 | } 17 | 18 | func NewInstanceMapper(ctx context.Context, client *koyeb.APIClient) *InstanceMapper { 19 | return &InstanceMapper{ 20 | ctx: ctx, 21 | client: client, 22 | fetched: false, 23 | sidMap: NewIDMap(), 24 | } 25 | } 26 | 27 | func (mapper *InstanceMapper) ResolveID(val string) (string, error) { 28 | if IsUUIDv4(val) { 29 | return val, nil 30 | } 31 | 32 | if !mapper.fetched { 33 | err := mapper.fetch() 34 | if err != nil { 35 | return "", err 36 | } 37 | } 38 | 39 | id, ok := mapper.sidMap.GetID(val) 40 | if ok { 41 | return id, nil 42 | } 43 | return "", errors.NewCLIErrorForMapperResolve( 44 | "instance", 45 | val, 46 | []string{"instance full UUID", "instance short ID (8 characters)"}, 47 | ) 48 | } 49 | 50 | func (mapper *InstanceMapper) fetch() error { 51 | radix := NewRadixTree() 52 | 53 | page := int64(0) 54 | offset := int64(0) 55 | limit := int64(100) 56 | for { 57 | 58 | res, resp, err := mapper.client.InstancesApi.ListInstances(mapper.ctx). 59 | Limit(strconv.FormatInt(limit, 10)). 60 | Offset(strconv.FormatInt(offset, 10)). 61 | Execute() 62 | if err != nil { 63 | return errors.NewCLIErrorFromAPIError( 64 | "Error listing instances to resolve the provided identifier to an object ID", 65 | err, 66 | resp, 67 | ) 68 | } 69 | 70 | instances := res.GetInstances() 71 | for i := range instances { 72 | instance := &instances[i] 73 | radix.Insert(getKey(instance.GetId()), instance) 74 | } 75 | 76 | page++ 77 | offset = page * limit 78 | if offset >= res.GetCount() { 79 | break 80 | } 81 | } 82 | 83 | minLength := radix.MinimalLength(8) 84 | err := radix.ForEach(func(key Key, value Value) error { 85 | instance := value.(*koyeb.InstanceListItem) 86 | id := instance.GetId() 87 | sid := getShortID(id, minLength) 88 | 89 | mapper.sidMap.Set(id, sid) 90 | 91 | return nil 92 | }) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | mapper.fetched = true 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/koyeb/instances_describe.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *InstanceHandler) Describe(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | instance, err := h.ResolveInstanceArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.InstancesApi.GetInstance(ctx.Context, instance).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the instance `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | describeInstancesReply := NewDescribeInstanceReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(describeInstancesReply) 31 | return nil 32 | } 33 | 34 | type DescribeInstanceReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetInstanceReply 37 | full bool 38 | } 39 | 40 | func NewDescribeInstanceReply(mapper *idmapper.Mapper, value *koyeb.GetInstanceReply, full bool) *DescribeInstanceReply { 41 | return &DescribeInstanceReply{ 42 | mapper: mapper, 43 | value: value, 44 | full: full, 45 | } 46 | } 47 | 48 | func (DescribeInstanceReply) Title() string { 49 | return "Instance" 50 | } 51 | 52 | func (r *DescribeInstanceReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetInstance().MarshalJSON() 54 | } 55 | 56 | func (r *DescribeInstanceReply) Headers() []string { 57 | return []string{"id", "service", "status", "region", "datacenter", "messages", "created_at", "updated_at"} 58 | } 59 | 60 | func (r *DescribeInstanceReply) Fields() []map[string]string { 61 | item := r.value.GetInstance() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 65 | "status": formatInstanceStatus(item.GetStatus()), 66 | "region": item.GetRegion(), 67 | "datacenter": item.GetDatacenter(), 68 | "messages": formatMessages(item.GetMessages()), 69 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 70 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 71 | } 72 | 73 | resp := []map[string]string{fields} 74 | return resp 75 | } 76 | -------------------------------------------------------------------------------- /pkg/koyeb/instances_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 10 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *InstanceHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 15 | instance, err := h.ResolveInstanceArgs(ctx, args[0]) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | res, resp, err := ctx.Client.InstancesApi.GetInstance(ctx.Context, instance).Execute() 21 | if err != nil { 22 | return errors.NewCLIErrorFromAPIError( 23 | fmt.Sprintf("Error while retrieving the instance `%s`", args[0]), 24 | err, 25 | resp, 26 | ) 27 | } 28 | 29 | full := GetBoolFlags(cmd, "full") 30 | getInstancesReply := NewGetInstanceReply(ctx.Mapper, res, full) 31 | ctx.Renderer.Render(getInstancesReply) 32 | return nil 33 | } 34 | 35 | type GetInstanceReply struct { 36 | mapper *idmapper.Mapper 37 | value *koyeb.GetInstanceReply 38 | full bool 39 | } 40 | 41 | func NewGetInstanceReply(mapper *idmapper.Mapper, value *koyeb.GetInstanceReply, full bool) *GetInstanceReply { 42 | return &GetInstanceReply{ 43 | mapper: mapper, 44 | value: value, 45 | full: full, 46 | } 47 | } 48 | 49 | func (GetInstanceReply) Title() string { 50 | return "Instance" 51 | } 52 | 53 | func (r *GetInstanceReply) MarshalBinary() ([]byte, error) { 54 | return r.value.GetInstance().MarshalJSON() 55 | } 56 | 57 | func (r *GetInstanceReply) Headers() []string { 58 | return []string{"id", "service", "status", "region", "datacenter", "created_at"} 59 | } 60 | 61 | func (r *GetInstanceReply) Fields() []map[string]string { 62 | item := r.value.GetInstance() 63 | fields := map[string]string{ 64 | "id": renderer.FormatID(item.GetId(), r.full), 65 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 66 | "status": formatInstanceStatus(item.GetStatus()), 67 | "region": item.GetRegion(), 68 | "datacenter": item.GetDatacenter(), 69 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 70 | } 71 | 72 | resp := []map[string]string{fields} 73 | return resp 74 | } 75 | 76 | func formatInstanceStatus(status koyeb.InstanceStatus) string { 77 | return string(status) 78 | } 79 | 80 | func formatMessages(msg []string) string { 81 | return strings.Join(msg, "\n") 82 | } 83 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/deployment.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type DeploymentMapper struct { 12 | ctx context.Context 13 | client *koyeb.APIClient 14 | fetched bool 15 | sidMap *IDMap 16 | } 17 | 18 | func NewDeploymentMapper(ctx context.Context, client *koyeb.APIClient) *DeploymentMapper { 19 | return &DeploymentMapper{ 20 | ctx: ctx, 21 | client: client, 22 | fetched: false, 23 | sidMap: NewIDMap(), 24 | } 25 | } 26 | 27 | func (mapper *DeploymentMapper) ResolveID(val string) (string, error) { 28 | if IsUUIDv4(val) { 29 | return val, nil 30 | } 31 | 32 | if !mapper.fetched { 33 | err := mapper.fetch() 34 | if err != nil { 35 | return "", err 36 | } 37 | } 38 | 39 | id, ok := mapper.sidMap.GetID(val) 40 | if ok { 41 | return id, nil 42 | } 43 | return "", errors.NewCLIErrorForMapperResolve( 44 | "deployments", 45 | val, 46 | []string{"deployment full UUID", "deployment short ID (8 characters)"}, 47 | ) 48 | } 49 | 50 | func (mapper *DeploymentMapper) fetch() error { 51 | radix := NewRadixTree() 52 | 53 | page := int64(0) 54 | offset := int64(0) 55 | limit := int64(100) 56 | for { 57 | 58 | res, resp, err := mapper.client.DeploymentsApi.ListDeployments(mapper.ctx). 59 | Limit(strconv.FormatInt(limit, 10)). 60 | Offset(strconv.FormatInt(offset, 10)). 61 | Execute() 62 | if err != nil { 63 | return errors.NewCLIErrorFromAPIError( 64 | "Error listing deployments to resolve the provided identifier to an object ID", 65 | err, 66 | resp, 67 | ) 68 | } 69 | 70 | deployments := res.GetDeployments() 71 | for i := range deployments { 72 | deployment := &deployments[i] 73 | radix.Insert(getKey(deployment.GetId()), deployment) 74 | } 75 | 76 | page++ 77 | offset = page * limit 78 | if offset >= res.GetCount() { 79 | break 80 | } 81 | } 82 | 83 | minLength := radix.MinimalLength(8) 84 | err := radix.ForEach(func(key Key, value Value) error { 85 | deployment := value.(*koyeb.DeploymentListItem) 86 | id := deployment.GetId() 87 | sid := getShortID(id, minLength) 88 | 89 | mapper.sidMap.Set(id, sid) 90 | 91 | return nil 92 | }) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | mapper.fetched = true 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/koyeb/regional_deployments_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *RegionalDeploymentHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | regionalDeployment, err := h.ResolveRegionalDeploymentArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.RegionalDeploymentsApi.GetRegionalDeployment(ctx.Context, regionalDeployment).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the regional deployment `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | getRegionalDeploymentsReply := NewGetRegionalDeploymentReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(getRegionalDeploymentsReply) 31 | return nil 32 | } 33 | 34 | type GetRegionalDeploymentReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetRegionalDeploymentReply 37 | full bool 38 | } 39 | 40 | func NewGetRegionalDeploymentReply(mapper *idmapper.Mapper, value *koyeb.GetRegionalDeploymentReply, full bool) *GetRegionalDeploymentReply { 41 | return &GetRegionalDeploymentReply{ 42 | mapper: mapper, 43 | value: value, 44 | full: full, 45 | } 46 | } 47 | 48 | func (GetRegionalDeploymentReply) Title() string { 49 | return "Deployment" 50 | } 51 | 52 | func (r *GetRegionalDeploymentReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetRegionalDeployment().MarshalJSON() 54 | } 55 | 56 | func (r *GetRegionalDeploymentReply) Headers() []string { 57 | return []string{"id", "app", "service", "messages", "region", "created_at"} 58 | } 59 | 60 | func (r *GetRegionalDeploymentReply) Fields() []map[string]string { 61 | item := r.value.GetRegionalDeployment() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "app": renderer.FormatAppName(r.mapper, item.GetAppId(), r.full), 65 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 66 | "messages": formatDeploymentMessages(item.GetMessages(), 0), 67 | "region": *item.Region, 68 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 69 | } 70 | 71 | resp := []map[string]string{fields} 72 | return resp 73 | } 74 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *AppHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | list := []koyeb.AppListItem{} 15 | 16 | page := int64(0) 17 | offset := int64(0) 18 | limit := int64(100) 19 | for { 20 | res, resp, err := ctx.Client.AppsApi.ListApps(ctx.Context). 21 | Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 22 | if err != nil { 23 | return errors.NewCLIErrorFromAPIError( 24 | "Error while listing the applications", 25 | err, 26 | resp, 27 | ) 28 | } 29 | list = append(list, res.GetApps()...) 30 | 31 | page++ 32 | offset = page * limit 33 | if offset >= res.GetCount() { 34 | break 35 | } 36 | } 37 | 38 | full := GetBoolFlags(cmd, "full") 39 | listAppsReply := NewListAppsReply(ctx.Mapper, &koyeb.ListAppsReply{Apps: list}, full) 40 | ctx.Renderer.Render(listAppsReply) 41 | return nil 42 | } 43 | 44 | type ListAppsReply struct { 45 | mapper *idmapper.Mapper 46 | value *koyeb.ListAppsReply 47 | full bool 48 | } 49 | 50 | func NewListAppsReply(mapper *idmapper.Mapper, value *koyeb.ListAppsReply, full bool) *ListAppsReply { 51 | return &ListAppsReply{ 52 | mapper: mapper, 53 | value: value, 54 | full: full, 55 | } 56 | } 57 | 58 | func (ListAppsReply) Title() string { 59 | return "Apps" 60 | } 61 | 62 | func (r *ListAppsReply) MarshalBinary() ([]byte, error) { 63 | return r.value.MarshalJSON() 64 | } 65 | 66 | func (r *ListAppsReply) Headers() []string { 67 | return []string{"id", "name", "status", "domains", "created_at"} 68 | } 69 | 70 | func (r *ListAppsReply) Fields() []map[string]string { 71 | items := r.value.GetApps() 72 | resp := make([]map[string]string, 0, len(items)) 73 | 74 | maxDomainsLength := 80 75 | if r.full { 76 | maxDomainsLength = 0 77 | } 78 | 79 | for _, item := range items { 80 | fields := map[string]string{ 81 | "id": renderer.FormatID(item.GetId(), r.full), 82 | "name": item.GetName(), 83 | "status": formatAppStatus(item.GetStatus()), 84 | "domains": formatDomains(item.GetDomains(), maxDomainsLength), 85 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 86 | } 87 | resp = append(resp, fields) 88 | } 89 | 90 | return resp 91 | } 92 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/regional_deployment.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type RegionalDeploymentMapper struct { 12 | ctx context.Context 13 | client *koyeb.APIClient 14 | fetched bool 15 | sidMap *IDMap 16 | } 17 | 18 | func NewRegionalDeploymentMapper(ctx context.Context, client *koyeb.APIClient) *RegionalDeploymentMapper { 19 | return &RegionalDeploymentMapper{ 20 | ctx: ctx, 21 | client: client, 22 | fetched: false, 23 | sidMap: NewIDMap(), 24 | } 25 | } 26 | 27 | func (mapper *RegionalDeploymentMapper) ResolveID(val string) (string, error) { 28 | if IsUUIDv4(val) { 29 | return val, nil 30 | } 31 | 32 | if !mapper.fetched { 33 | err := mapper.fetch() 34 | if err != nil { 35 | return "", err 36 | } 37 | } 38 | 39 | id, ok := mapper.sidMap.GetID(val) 40 | if ok { 41 | return id, nil 42 | } 43 | return "", errors.NewCLIErrorForMapperResolve( 44 | "secret", 45 | val, 46 | []string{"regional deployment full UUID", "regional deployment short ID (8 characters)"}, 47 | ) 48 | } 49 | 50 | func (mapper *RegionalDeploymentMapper) fetch() error { 51 | radix := NewRadixTree() 52 | 53 | page := int64(0) 54 | offset := int64(0) 55 | limit := int64(100) 56 | for { 57 | 58 | res, resp, err := mapper.client.RegionalDeploymentsApi.ListRegionalDeployments(mapper.ctx). 59 | Limit(strconv.FormatInt(limit, 10)). 60 | Offset(strconv.FormatInt(offset, 10)). 61 | Execute() 62 | if err != nil { 63 | return errors.NewCLIErrorFromAPIError( 64 | "Error listing the regional deployments to resolve the provided identifier to an object ID", 65 | err, 66 | resp, 67 | ) 68 | } 69 | 70 | deployments := res.GetRegionalDeployments() 71 | for i := range deployments { 72 | deployment := &deployments[i] 73 | radix.Insert(getKey(deployment.GetId()), deployment) 74 | } 75 | 76 | page++ 77 | offset = page * limit 78 | if offset >= res.GetCount() { 79 | break 80 | } 81 | } 82 | 83 | minLength := radix.MinimalLength(8) 84 | err := radix.ForEach(func(key Key, value Value) error { 85 | deployment := value.(*koyeb.RegionalDeploymentListItem) 86 | id := deployment.GetId() 87 | sid := getShortID(id, minLength) 88 | 89 | mapper.sidMap.Set(id, sid) 90 | 91 | return nil 92 | }) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | mapper.fetched = true 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/routes.go: -------------------------------------------------------------------------------- 1 | package flags_list 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | type FlagRoute struct { 13 | BaseFlag 14 | path string 15 | port int64 16 | } 17 | 18 | func NewRouteListFromFlags(values []string) ([]Flag[koyeb.DeploymentRoute], error) { 19 | ret := make([]Flag[koyeb.DeploymentRoute], 0, len(values)) 20 | 21 | for _, value := range values { 22 | route := &FlagRoute{BaseFlag: BaseFlag{cliValue: value}} 23 | 24 | if strings.HasPrefix(value, "!") { 25 | route.markedForDeletion = true 26 | value = value[1:] 27 | } 28 | 29 | split := strings.Split(value, ":") 30 | route.path = split[0] 31 | 32 | if route.markedForDeletion { 33 | if len(split) > 1 || route.path == "" { 34 | return nil, &errors.CLIError{ 35 | What: "Error while configuring the service", 36 | Why: fmt.Sprintf("unable to parse the route \"%s\"", route.cliValue), 37 | Additional: []string{ 38 | "To remove a route from the service, prefix it with '!', e.g. '!/' or '!/foo'", 39 | "The port should not be specified when removing a route from the service", 40 | }, 41 | Orig: nil, 42 | Solution: "Fix the route and try again", 43 | } 44 | } 45 | } else { 46 | route.port = 80 47 | if len(split) > 1 { 48 | portNum, err := strconv.Atoi(split[1]) 49 | if err != nil { 50 | return nil, &errors.CLIError{ 51 | What: "Error while configuring the service", 52 | Why: fmt.Sprintf("unable to parse the port from the route \"%s\"", route.cliValue), 53 | Additional: []string{ 54 | "Routes must be specified as PATH[:PORT]", 55 | "PATH is the route to expose (e.g. / or /foo)", 56 | "PORT must be a valid port number configured with the --ports flag. It can be omitted, in which case it defaults to \"80\"", 57 | }, 58 | Orig: nil, 59 | Solution: "Fix the route and try again", 60 | } 61 | } 62 | route.port = int64(portNum) 63 | } 64 | } 65 | ret = append(ret, route) 66 | } 67 | return ret, nil 68 | } 69 | 70 | func (f *FlagRoute) IsEqualTo(route koyeb.DeploymentRoute) bool { 71 | return f.path == *route.Path 72 | } 73 | 74 | func (f *FlagRoute) UpdateItem(route *koyeb.DeploymentRoute) { 75 | route.Path = koyeb.PtrString(f.path) 76 | route.Port = koyeb.PtrInt64(f.port) 77 | } 78 | 79 | func (f *FlagRoute) CreateNewItem() *koyeb.DeploymentRoute { 80 | item := koyeb.NewDeploymentRouteWithDefaults() 81 | f.UpdateItem(item) 82 | return item 83 | } 84 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/secret.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type SecretMapper struct { 12 | ctx context.Context 13 | client *koyeb.APIClient 14 | fetched bool 15 | sidMap *IDMap 16 | nameMap *IDMap 17 | } 18 | 19 | func NewSecretMapper(ctx context.Context, client *koyeb.APIClient) *SecretMapper { 20 | return &SecretMapper{ 21 | ctx: ctx, 22 | client: client, 23 | fetched: false, 24 | sidMap: NewIDMap(), 25 | nameMap: NewIDMap(), 26 | } 27 | } 28 | 29 | func (mapper *SecretMapper) ResolveID(val string) (string, error) { 30 | if IsUUIDv4(val) { 31 | return val, nil 32 | } 33 | 34 | if !mapper.fetched { 35 | err := mapper.fetch() 36 | if err != nil { 37 | return "", err 38 | } 39 | } 40 | 41 | id, ok := mapper.sidMap.GetID(val) 42 | if ok { 43 | return id, nil 44 | } 45 | 46 | id, ok = mapper.nameMap.GetID(val) 47 | if ok { 48 | return id, nil 49 | } 50 | 51 | return "", errors.NewCLIErrorForMapperResolve( 52 | "secret", 53 | val, 54 | []string{"secret full UUID", "secret short ID (8 characters)", "secret name"}, 55 | ) 56 | } 57 | 58 | func (mapper *SecretMapper) fetch() error { 59 | radix := NewRadixTree() 60 | 61 | page := int64(0) 62 | offset := int64(0) 63 | limit := int64(100) 64 | for { 65 | res, resp, err := mapper.client.SecretsApi.ListSecrets(mapper.ctx). 66 | Limit(strconv.FormatInt(limit, 10)). 67 | Offset(strconv.FormatInt(offset, 10)). 68 | Execute() 69 | if err != nil { 70 | return errors.NewCLIErrorFromAPIError( 71 | "Error listing secrets to resolve the provided identifier to an object ID", 72 | err, 73 | resp, 74 | ) 75 | } 76 | 77 | secrets := res.GetSecrets() 78 | for i := range secrets { 79 | secret := &secrets[i] 80 | radix.Insert(getKey(secret.GetId()), secret) 81 | } 82 | 83 | page++ 84 | offset = page * limit 85 | if offset >= res.GetCount() { 86 | break 87 | } 88 | } 89 | 90 | minLength := radix.MinimalLength(8) 91 | err := radix.ForEach(func(key Key, value Value) error { 92 | secret := value.(*koyeb.Secret) 93 | id := secret.GetId() 94 | name := secret.GetName() 95 | sid := getShortID(id, minLength) 96 | 97 | mapper.sidMap.Set(id, sid) 98 | mapper.nameMap.Set(id, name) 99 | 100 | return nil 101 | }) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | mapper.fetched = true 107 | 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_describe.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *AppHandler) Describe(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | app, err := h.ResolveAppArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.AppsApi.GetApp(ctx.Context, app).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the application `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | resListServices, resp, err := ctx.Client.ServicesApi.ListServices(ctx.Context).AppId(res.App.GetId()).Limit("100").Execute() 28 | if err != nil { 29 | return errors.NewCLIErrorFromAPIError( 30 | fmt.Sprintf("Error while listing the services of the application `%s`", args[0]), 31 | err, 32 | resp, 33 | ) 34 | } 35 | 36 | full := GetBoolFlags(cmd, "full") 37 | describeAppsReply := NewDescribeAppReply(ctx.Mapper, res, full) 38 | listServicesReply := NewListServicesReply(ctx.Mapper, resListServices, full) 39 | renderer.NewChainRenderer(ctx.Renderer).Render(describeAppsReply).Render(listServicesReply) 40 | return nil 41 | } 42 | 43 | type DescribeAppReply struct { 44 | mapper *idmapper.Mapper 45 | value *koyeb.GetAppReply 46 | full bool 47 | } 48 | 49 | func NewDescribeAppReply(mapper *idmapper.Mapper, value *koyeb.GetAppReply, full bool) *DescribeAppReply { 50 | return &DescribeAppReply{ 51 | mapper: mapper, 52 | value: value, 53 | full: full, 54 | } 55 | } 56 | 57 | func (DescribeAppReply) Title() string { 58 | return "App" 59 | } 60 | 61 | func (r *DescribeAppReply) MarshalBinary() ([]byte, error) { 62 | return r.value.GetApp().MarshalJSON() 63 | } 64 | 65 | func (r *DescribeAppReply) Headers() []string { 66 | return []string{"id", "name", "status", "domains", "created_at", "updated_at"} 67 | } 68 | 69 | func (r *DescribeAppReply) Fields() []map[string]string { 70 | item := r.value.GetApp() 71 | fields := map[string]string{ 72 | "id": renderer.FormatID(item.GetId(), r.full), 73 | "name": item.GetName(), 74 | "status": formatAppStatus(item.GetStatus()), 75 | "domains": formatDomains(item.GetDomains(), 0), 76 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 77 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 78 | } 79 | 80 | resp := []map[string]string{fields} 81 | return resp 82 | } 83 | -------------------------------------------------------------------------------- /pkg/koyeb/domains.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func NewDomainCmd() *cobra.Command { 8 | h := NewDomainHandler() 9 | 10 | domainCmd := &cobra.Command{ 11 | Use: "domains ACTION", 12 | Aliases: []string{"dom", "domain"}, 13 | Short: "Domains", 14 | } 15 | 16 | getDomainCmd := &cobra.Command{ 17 | Use: "get NAME", 18 | Short: "Get domain", 19 | Args: cobra.ExactArgs(1), 20 | RunE: WithCLIContext(h.Get), 21 | } 22 | domainCmd.AddCommand(getDomainCmd) 23 | 24 | createDomainCmd := &cobra.Command{ 25 | Use: "create NAME", 26 | Short: "Create domain", 27 | Args: cobra.ExactArgs(1), 28 | RunE: WithCLIContext(h.Create), 29 | } 30 | createDomainCmd.Flags().String("attach-to", "", "Upon creation, assign to given app") 31 | domainCmd.AddCommand(createDomainCmd) 32 | 33 | describeDomainCmd := &cobra.Command{ 34 | Use: "describe", 35 | Short: "Describe domain", 36 | Args: cobra.ExactArgs(1), 37 | RunE: WithCLIContext(h.Describe), 38 | } 39 | domainCmd.AddCommand(describeDomainCmd) 40 | 41 | listDomainCmd := &cobra.Command{ 42 | Use: "list", 43 | Short: "List domains", 44 | RunE: WithCLIContext(h.List), 45 | } 46 | domainCmd.AddCommand(listDomainCmd) 47 | 48 | deleteDomainCmd := &cobra.Command{ 49 | Use: "delete", 50 | Short: "Delete domain", 51 | RunE: WithCLIContext(h.Delete), 52 | } 53 | domainCmd.AddCommand(deleteDomainCmd) 54 | 55 | refreshDomainCmd := &cobra.Command{ 56 | Use: "refresh NAME", 57 | Short: "Refresh a custom domain verification status", 58 | Args: cobra.ExactArgs(1), 59 | RunE: WithCLIContext(h.Refresh), 60 | } 61 | domainCmd.AddCommand(refreshDomainCmd) 62 | 63 | attachDomainCmd := &cobra.Command{ 64 | Use: "attach NAME APP", 65 | Short: "Attach a custom domain to an existing app", 66 | Args: cobra.ExactArgs(2), 67 | RunE: WithCLIContext(h.Attach), 68 | } 69 | domainCmd.AddCommand(attachDomainCmd) 70 | 71 | detachDomainCmd := &cobra.Command{ 72 | Use: "detach NAME", 73 | Short: "Detach a custom domain from the app it is currently attached to", 74 | Args: cobra.ExactArgs(1), 75 | RunE: WithCLIContext(h.Detach), 76 | } 77 | domainCmd.AddCommand(detachDomainCmd) 78 | 79 | return domainCmd 80 | } 81 | 82 | type DomainHandler struct { 83 | } 84 | 85 | func NewDomainHandler() *DomainHandler { 86 | return &DomainHandler{} 87 | } 88 | 89 | func (h *DomainHandler) ResolveDomainArgs(ctx *CLIContext, val string) (string, error) { 90 | domainMapper := ctx.Mapper.Domain() 91 | id, err := domainMapper.ResolveID(val) 92 | if err != nil { 93 | return "", err 94 | } 95 | return id, nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/koyeb/login.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | koyeb_errors "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/manifoldco/promptui" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | "golang.org/x/term" 14 | ) 15 | 16 | func Login(cmd *cobra.Command, args []string) error { 17 | configPath := "" 18 | if cfgFile != "" { 19 | configPath = cfgFile 20 | } else { 21 | home, err := getHomeDir() 22 | if err != nil { 23 | return err 24 | } 25 | configPath = home + "/.koyeb.yaml" 26 | } 27 | viper.SetConfigFile(configPath) 28 | 29 | if !term.IsTerminal(int(os.Stdin.Fd())) { 30 | return &koyeb_errors.CLIError{ 31 | What: "Unable to start interactive mode", 32 | Why: "the command `koyeb login` requires an interactive terminal.", 33 | Additional: []string{"Make sure you are not piping the input of the command"}, 34 | Orig: nil, 35 | Solution: "Instead of calling `koyeb login`, create a configuration file manually in ~/.koyeb.yaml", 36 | } 37 | } 38 | 39 | if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { 40 | prompt := promptui.Prompt{ 41 | Label: fmt.Sprintf("Do you want to overwrite your current configuration file (%s)", configPath), 42 | IsConfirm: true, 43 | } 44 | _, err := prompt.Run() 45 | // If user cancels (ctrl+d, ctrl+c, enter) 46 | if err != nil { 47 | return nil 48 | } 49 | } 50 | 51 | validate := func(input string) error { 52 | if len(input) != 64 { 53 | return errors.New("invalid API credential. The token should be 64 characters long") 54 | } 55 | return nil 56 | } 57 | 58 | prompt := promptui.Prompt{ 59 | Label: "Enter your personal access token. You can create a new token here (https://app.koyeb.com/user/settings/api)", 60 | Validate: validate, 61 | Mask: '*', 62 | } 63 | 64 | result, err := prompt.Run() 65 | // If user cancels (ctrl+d, ctrl+c, enter) 66 | if err != nil { 67 | return nil 68 | } 69 | 70 | viper.Set("token", result) 71 | 72 | viper.SetConfigType("yaml") 73 | viper.SetConfigPermissions(os.FileMode(0o600)) 74 | err = viper.WriteConfig() 75 | if err != nil { 76 | return &koyeb_errors.CLIError{ 77 | What: "Error during login", 78 | Why: "unable to write the configuration file", 79 | Additional: nil, 80 | Orig: err, 81 | Solution: koyeb_errors.CLIErrorSolution(fmt.Sprintf("Make sure you have the right permissions to write the configuration file %s", configPath)), 82 | } 83 | } 84 | 85 | log.Infof("Creating new configuration in %s", configPath) 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/volume.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type VolumeMapper struct { 12 | ctx context.Context 13 | client *koyeb.APIClient 14 | fetched bool 15 | sidMap *IDMap 16 | nameMap *IDMap 17 | } 18 | 19 | func NewVolumeMapper(ctx context.Context, client *koyeb.APIClient) *VolumeMapper { 20 | return &VolumeMapper{ 21 | ctx: ctx, 22 | client: client, 23 | fetched: false, 24 | sidMap: NewIDMap(), 25 | nameMap: NewIDMap(), 26 | } 27 | } 28 | 29 | func (mapper *VolumeMapper) ResolveID(val string) (string, error) { 30 | if IsUUIDv4(val) { 31 | return val, nil 32 | } 33 | 34 | if !mapper.fetched { 35 | err := mapper.fetch() 36 | if err != nil { 37 | return "", err 38 | } 39 | } 40 | 41 | id, ok := mapper.sidMap.GetID(val) 42 | if ok { 43 | return id, nil 44 | } 45 | 46 | id, ok = mapper.nameMap.GetID(val) 47 | if ok { 48 | return id, nil 49 | } 50 | 51 | return "", errors.NewCLIErrorForMapperResolve( 52 | "volume", 53 | val, 54 | []string{"volume full UUID", "volume short ID (8 characters)", "volume name"}, 55 | ) 56 | } 57 | 58 | func (mapper *VolumeMapper) fetch() error { 59 | radix := NewRadixTree() 60 | 61 | page := int64(0) 62 | offset := int64(0) 63 | limit := int64(100) 64 | for { 65 | res, resp, err := mapper.client.PersistentVolumesApi.ListPersistentVolumes(mapper.ctx). 66 | Limit(strconv.FormatInt(limit, 10)). 67 | Offset(strconv.FormatInt(offset, 10)). 68 | Execute() 69 | if err != nil { 70 | return errors.NewCLIErrorFromAPIError( 71 | "Error listing volumes to resolve the provided identifier to an object ID", 72 | err, 73 | resp, 74 | ) 75 | } 76 | 77 | volumes := res.GetVolumes() 78 | 79 | if len(volumes) == 0 { 80 | break 81 | } 82 | 83 | for i := range volumes { 84 | volume := &volumes[i] 85 | radix.Insert(getKey(volume.GetId()), volume) 86 | } 87 | 88 | page++ 89 | offset = page * limit 90 | } 91 | 92 | minLength := radix.MinimalLength(8) 93 | err := radix.ForEach(func(key Key, value Value) error { 94 | volume := value.(*koyeb.PersistentVolume) 95 | id := volume.GetId() 96 | name := volume.GetName() 97 | sid := getShortID(id, minLength) 98 | 99 | mapper.sidMap.Set(id, sid) 100 | mapper.nameMap.Set(id, name) 101 | 102 | return nil 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | mapper.fetched = true 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/snapshot.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type SnapshotMapper struct { 12 | ctx context.Context 13 | client *koyeb.APIClient 14 | fetched bool 15 | sidMap *IDMap 16 | nameMap *IDMap 17 | } 18 | 19 | func NewSnapshotMapper(ctx context.Context, client *koyeb.APIClient) *SnapshotMapper { 20 | return &SnapshotMapper{ 21 | ctx: ctx, 22 | client: client, 23 | fetched: false, 24 | sidMap: NewIDMap(), 25 | nameMap: NewIDMap(), 26 | } 27 | } 28 | 29 | func (mapper *SnapshotMapper) ResolveID(val string) (string, error) { 30 | if IsUUIDv4(val) { 31 | return val, nil 32 | } 33 | 34 | if !mapper.fetched { 35 | err := mapper.fetch() 36 | if err != nil { 37 | return "", err 38 | } 39 | } 40 | 41 | id, ok := mapper.sidMap.GetID(val) 42 | if ok { 43 | return id, nil 44 | } 45 | 46 | id, ok = mapper.nameMap.GetID(val) 47 | if ok { 48 | return id, nil 49 | } 50 | 51 | return "", errors.NewCLIErrorForMapperResolve( 52 | "snapshot", 53 | val, 54 | []string{"snapshot full UUID", "snapshot short ID (8 characters)", "snapshot name"}, 55 | ) 56 | } 57 | 58 | func (mapper *SnapshotMapper) fetch() error { 59 | radix := NewRadixTree() 60 | 61 | page := int64(0) 62 | offset := int64(0) 63 | limit := int64(100) 64 | for { 65 | res, resp, err := mapper.client.SnapshotsApi.ListSnapshots(mapper.ctx). 66 | Limit(strconv.FormatInt(limit, 10)). 67 | Offset(strconv.FormatInt(offset, 10)). 68 | Execute() 69 | if err != nil { 70 | return errors.NewCLIErrorFromAPIError( 71 | "Error listing snapshots to resolve the provided identifier to an object ID", 72 | err, 73 | resp, 74 | ) 75 | } 76 | 77 | snapshots := res.GetSnapshots() 78 | 79 | if len(snapshots) == 0 { 80 | break 81 | } 82 | 83 | for i := range snapshots { 84 | snapshot := &snapshots[i] 85 | radix.Insert(getKey(snapshot.GetId()), snapshot) 86 | } 87 | 88 | page++ 89 | offset = page * limit 90 | } 91 | 92 | minLength := radix.MinimalLength(8) 93 | err := radix.ForEach(func(key Key, value Value) error { 94 | snapshot := value.(*koyeb.Snapshot) 95 | id := snapshot.GetId() 96 | name := snapshot.GetName() 97 | sid := getShortID(id, minLength) 98 | 99 | mapper.sidMap.Set(id, sid) 100 | mapper.nameMap.Set(id, name) 101 | 102 | return nil 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | mapper.fetched = true 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/koyeb/domains_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *DomainHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | list := []koyeb.Domain{} 15 | 16 | page := int64(0) 17 | offset := int64(0) 18 | limit := int64(100) 19 | for { 20 | res, resp, err := ctx.Client.DomainsApi.ListDomains(ctx.Context). 21 | Limit(strconv.FormatInt(limit, 10)). 22 | Offset(strconv.FormatInt(offset, 10)). 23 | Types([]string{string(koyeb.DOMAINTYPE_CUSTOM)}). 24 | Execute() 25 | if err != nil { 26 | return errors.NewCLIErrorFromAPIError( 27 | "Error while listing the domains", 28 | err, 29 | resp, 30 | ) 31 | } 32 | list = append(list, res.GetDomains()...) 33 | 34 | page++ 35 | offset = page * limit 36 | if offset >= res.GetCount() { 37 | break 38 | } 39 | } 40 | 41 | full := GetBoolFlags(cmd, "full") 42 | listDomainsReply := NewListDomainsReply(ctx.Mapper, &koyeb.ListDomainsReply{Domains: list}, full) 43 | ctx.Renderer.Render(listDomainsReply) 44 | return nil 45 | } 46 | 47 | type ListDomainsReply struct { 48 | mapper *idmapper.Mapper 49 | value *koyeb.ListDomainsReply 50 | full bool 51 | } 52 | 53 | func NewListDomainsReply(mapper *idmapper.Mapper, value *koyeb.ListDomainsReply, full bool) *ListDomainsReply { 54 | return &ListDomainsReply{ 55 | mapper: mapper, 56 | value: value, 57 | full: full, 58 | } 59 | } 60 | 61 | func (ListDomainsReply) Title() string { 62 | return "Domains" 63 | } 64 | 65 | func (r *ListDomainsReply) MarshalBinary() ([]byte, error) { 66 | return r.value.MarshalJSON() 67 | } 68 | 69 | func (r *ListDomainsReply) Headers() []string { 70 | return []string{"id", "name", "app", "status", "verified_at", "type", "created_at"} 71 | } 72 | 73 | func (r *ListDomainsReply) Fields() []map[string]string { 74 | items := r.value.GetDomains() 75 | resp := make([]map[string]string, 0, len(items)) 76 | 77 | for _, item := range items { 78 | fields := map[string]string{ 79 | "id": renderer.FormatID(item.GetId(), r.full), 80 | "name": item.GetName(), 81 | "app": renderer.FormatAppName(r.mapper, item.GetAppId(), r.full), 82 | "status": string(item.GetStatus()), 83 | "verified_at": formatVerifiedAt(&item), 84 | "type": string(item.GetType()), 85 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 86 | } 87 | resp = append(resp, fields) 88 | } 89 | 90 | return resp 91 | } 92 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/idmapper.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | ) 8 | 9 | type Mapper struct { 10 | app *AppMapper 11 | domain *DomainMapper 12 | service *ServiceMapper 13 | deployment *DeploymentMapper 14 | regional *RegionalDeploymentMapper 15 | instance *InstanceMapper 16 | secret *SecretMapper 17 | organization *OrganizationMapper 18 | database *DatabaseMapper 19 | volume *VolumeMapper 20 | snapshot *SnapshotMapper 21 | } 22 | 23 | func NewMapper(ctx context.Context, client *koyeb.APIClient) *Mapper { 24 | appMapper := NewAppMapper(ctx, client) 25 | domainMapper := NewDomainMapper(ctx, client) 26 | serviceMapper := NewServiceMapper(ctx, client, appMapper) 27 | deploymentMapper := NewDeploymentMapper(ctx, client) 28 | regionalMapper := NewRegionalDeploymentMapper(ctx, client) 29 | instanceMapper := NewInstanceMapper(ctx, client) 30 | secretMapper := NewSecretMapper(ctx, client) 31 | organizationMapper := NewOrganizationMapper(ctx, client) 32 | databaseMapper := NewDatabaseMapper(ctx, client, appMapper) 33 | volumeMapper := NewVolumeMapper(ctx, client) 34 | snapshotMapper := NewSnapshotMapper(ctx, client) 35 | 36 | return &Mapper{ 37 | app: appMapper, 38 | domain: domainMapper, 39 | service: serviceMapper, 40 | deployment: deploymentMapper, 41 | regional: regionalMapper, 42 | instance: instanceMapper, 43 | secret: secretMapper, 44 | organization: organizationMapper, 45 | database: databaseMapper, 46 | volume: volumeMapper, 47 | snapshot: snapshotMapper, 48 | } 49 | } 50 | 51 | func (mapper *Mapper) App() *AppMapper { 52 | return mapper.app 53 | } 54 | 55 | func (mapper *Mapper) Domain() *DomainMapper { 56 | return mapper.domain 57 | } 58 | 59 | func (mapper *Mapper) Service() *ServiceMapper { 60 | return mapper.service 61 | } 62 | 63 | func (mapper *Mapper) Deployment() *DeploymentMapper { 64 | return mapper.deployment 65 | } 66 | 67 | func (mapper *Mapper) RegionalDeployment() *RegionalDeploymentMapper { 68 | return mapper.regional 69 | } 70 | 71 | func (mapper *Mapper) Instance() *InstanceMapper { 72 | return mapper.instance 73 | } 74 | 75 | func (mapper *Mapper) Secret() *SecretMapper { 76 | return mapper.secret 77 | } 78 | 79 | func (mapper *Mapper) Organization() *OrganizationMapper { 80 | return mapper.organization 81 | } 82 | 83 | func (mapper *Mapper) Database() *DatabaseMapper { 84 | return mapper.database 85 | } 86 | 87 | func (mapper *Mapper) Volume() *VolumeMapper { 88 | return mapper.volume 89 | } 90 | 91 | func (mapper *Mapper) Snapshot() *SnapshotMapper { 92 | return mapper.snapshot 93 | } 94 | -------------------------------------------------------------------------------- /pkg/koyeb/snapshots_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *SnapshotHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | list := []koyeb.Snapshot{} 15 | 16 | page := int64(0) 17 | offset := int64(0) 18 | limit := int64(100) 19 | for { 20 | res, resp, err := ctx.Client.SnapshotsApi.ListSnapshots(ctx.Context). 21 | Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 22 | if err != nil { 23 | return errors.NewCLIErrorFromAPIError("Error while listing secrets", err, resp) 24 | } 25 | snapshots := res.GetSnapshots() 26 | if len(snapshots) == 0 { 27 | break 28 | } 29 | list = append(list, snapshots...) 30 | 31 | page++ 32 | offset = page * limit 33 | } 34 | 35 | full := GetBoolFlags(cmd, "full") 36 | listSnapshotsReply := NewListSnapshotsReply(ctx.Mapper, &koyeb.ListSnapshotsReply{Snapshots: list}, full) 37 | ctx.Renderer.Render(listSnapshotsReply) 38 | return nil 39 | } 40 | 41 | type ListSnapshotsReply struct { 42 | mapper *idmapper.Mapper 43 | value *koyeb.ListSnapshotsReply 44 | full bool 45 | } 46 | 47 | func NewListSnapshotsReply(mapper *idmapper.Mapper, value *koyeb.ListSnapshotsReply, full bool) *ListSnapshotsReply { 48 | return &ListSnapshotsReply{ 49 | mapper: mapper, 50 | value: value, 51 | full: full, 52 | } 53 | } 54 | 55 | func (ListSnapshotsReply) Title() string { 56 | return "Snapshots" 57 | } 58 | 59 | func (r *ListSnapshotsReply) MarshalBinary() ([]byte, error) { 60 | return r.value.MarshalJSON() 61 | } 62 | 63 | func (r *ListSnapshotsReply) Headers() []string { 64 | return []string{"id", "name", "region", "type", "status", "created_at", "parent_volume"} 65 | } 66 | 67 | func (r *ListSnapshotsReply) Fields() []map[string]string { 68 | items := r.value.GetSnapshots() 69 | resp := make([]map[string]string, 0, len(items)) 70 | 71 | for _, item := range items { 72 | fields := map[string]string{ 73 | "id": renderer.FormatID(item.GetId(), r.full), 74 | "name": item.GetName(), 75 | "region": item.GetRegion(), 76 | "status": formatSnapshotStatus(item.GetStatus()), 77 | "type": formatSnapshotType(item.GetType()), 78 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 79 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 80 | "parent_volume": renderer.FormatID(item.GetParentVolumeId(), r.full), 81 | } 82 | resp = append(resp, fields) 83 | } 84 | 85 | return resp 86 | } 87 | -------------------------------------------------------------------------------- /pkg/koyeb/apps_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 10 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *AppHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 15 | app, err := h.ResolveAppArgs(ctx, args[0]) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | res, resp, err := ctx.Client.AppsApi.GetApp(ctx.Context, app).Execute() 21 | if err != nil { 22 | return errors.NewCLIErrorFromAPIError( 23 | fmt.Sprintf("Error while retrieving the application `%s`", args[0]), 24 | err, 25 | resp, 26 | ) 27 | } 28 | 29 | full := GetBoolFlags(cmd, "full") 30 | getAppsReply := NewGetAppReply(ctx.Mapper, res, full) 31 | ctx.Renderer.Render(getAppsReply) 32 | return nil 33 | } 34 | 35 | type GetAppReply struct { 36 | mapper *idmapper.Mapper 37 | value *koyeb.GetAppReply 38 | full bool 39 | } 40 | 41 | func NewGetAppReply(mapper *idmapper.Mapper, value *koyeb.GetAppReply, full bool) *GetAppReply { 42 | return &GetAppReply{ 43 | mapper: mapper, 44 | value: value, 45 | full: full, 46 | } 47 | } 48 | 49 | func (GetAppReply) Title() string { 50 | return "App" 51 | } 52 | 53 | func (r *GetAppReply) MarshalBinary() ([]byte, error) { 54 | return r.value.GetApp().MarshalJSON() 55 | } 56 | 57 | func (r *GetAppReply) Headers() []string { 58 | return []string{"id", "name", "status", "domains", "created_at"} 59 | } 60 | 61 | func (r *GetAppReply) Fields() []map[string]string { 62 | item := r.value.GetApp() 63 | fields := map[string]string{ 64 | "id": renderer.FormatID(item.GetId(), r.full), 65 | "name": item.GetName(), 66 | "status": formatAppStatus(item.GetStatus()), 67 | "domains": formatDomains(item.GetDomains(), 80), 68 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 69 | } 70 | 71 | resp := []map[string]string{fields} 72 | return resp 73 | } 74 | 75 | func formatDomains(domains []koyeb.Domain, max int) string { 76 | domainNames := []string{} 77 | totalLen := 0 78 | for _, d := range domains { 79 | name := d.GetName() 80 | if max > 0 && totalLen+len(name) >= max { 81 | domainNames = append(domainNames, "...") 82 | break 83 | } 84 | 85 | domainNames = append(domainNames, name) 86 | totalLen += len(name) 87 | } 88 | 89 | data, err := json.Marshal(domainNames) 90 | // Should never happen, as we are marshalling a list of strings 91 | if err != nil { 92 | panic("failed to marshal domains") 93 | } 94 | 95 | return string(data) 96 | } 97 | 98 | func formatAppStatus(status koyeb.AppStatus) string { 99 | return string(status) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/koyeb/volumes_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 10 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *VolumeHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 15 | list := []koyeb.PersistentVolume{} 16 | 17 | page := int64(0) 18 | offset := int64(0) 19 | limit := int64(100) 20 | for { 21 | res, resp, err := ctx.Client.PersistentVolumesApi.ListPersistentVolumes(ctx.Context). 22 | Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 23 | if err != nil { 24 | return errors.NewCLIErrorFromAPIError("Error while listing secrets", err, resp) 25 | } 26 | volumes := res.GetVolumes() 27 | if len(volumes) == 0 { 28 | break 29 | } 30 | list = append(list, volumes...) 31 | 32 | page++ 33 | offset = page * limit 34 | } 35 | 36 | full := GetBoolFlags(cmd, "full") 37 | listVolumesReply := NewListVolumesReply(ctx.Mapper, &koyeb.ListPersistentVolumesReply{Volumes: list}, full) 38 | ctx.Renderer.Render(listVolumesReply) 39 | return nil 40 | } 41 | 42 | type ListVolumesReply struct { 43 | mapper *idmapper.Mapper 44 | value *koyeb.ListPersistentVolumesReply 45 | full bool 46 | } 47 | 48 | func NewListVolumesReply(mapper *idmapper.Mapper, value *koyeb.ListPersistentVolumesReply, full bool) *ListVolumesReply { 49 | return &ListVolumesReply{ 50 | mapper: mapper, 51 | value: value, 52 | full: full, 53 | } 54 | } 55 | 56 | func (ListVolumesReply) Title() string { 57 | return "Volumes" 58 | } 59 | 60 | func (r *ListVolumesReply) MarshalBinary() ([]byte, error) { 61 | return r.value.MarshalJSON() 62 | } 63 | 64 | func (r *ListVolumesReply) Headers() []string { 65 | return []string{"id", "name", "region", "type", "status", "size", "created_at", "service"} 66 | } 67 | 68 | func (r *ListVolumesReply) Fields() []map[string]string { 69 | items := r.value.GetVolumes() 70 | resp := make([]map[string]string, 0, len(items)) 71 | 72 | for _, item := range items { 73 | fields := map[string]string{ 74 | "id": renderer.FormatID(item.GetId(), r.full), 75 | "name": item.GetName(), 76 | "region": item.GetRegion(), 77 | "status": formatVolumeStatus(item.GetStatus()), 78 | "type": formatVolumeType(item.GetBackingStore()), 79 | "size": renderer.FormatSize(renderer.GBSize(item.GetMaxSize())), 80 | "read_only": fmt.Sprintf("%t", item.GetReadOnly()), 81 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 82 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 83 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 84 | } 85 | resp = append(resp, fields) 86 | } 87 | 88 | return resp 89 | } 90 | -------------------------------------------------------------------------------- /pkg/koyeb/client.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | "net/url" 8 | "reflect" 9 | "regexp" 10 | 11 | "github.com/iancoleman/strcase" 12 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 13 | log "github.com/sirupsen/logrus" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/pflag" 16 | ) 17 | 18 | var authorizationHeaderRegexp = regexp.MustCompile("(?m)^Authorization:.*$") 19 | 20 | // DebugTransport overrides the default HTTP transport to log the request and the response using our logger. 21 | type DebugTransport struct { 22 | http.RoundTripper 23 | } 24 | 25 | func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) { 26 | if reqData, dumpErr := httputil.DumpRequestOut(req, true); dumpErr == nil { 27 | var safeReqData string 28 | 29 | // Hide the token in the Authorization header 30 | if !debugFull { 31 | safeReqData = authorizationHeaderRegexp.ReplaceAllString(string(reqData), "Authorization: ") 32 | } else { 33 | safeReqData = string(reqData) 34 | } 35 | log.Debug(fmt.Sprintf("========== HTTP request ==========\n%s\n========== end of request ==========\n", safeReqData)) 36 | } 37 | 38 | resp, err := t.RoundTripper.RoundTrip(req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | if respData, dumpErr := httputil.DumpResponse(resp, true); dumpErr == nil { 44 | log.Debug(fmt.Sprintf("========== HTTP response ==========\n%s\n========== end of response ==========\n", respData)) 45 | } 46 | return resp, nil 47 | } 48 | 49 | func getApiClient() (*koyeb.APIClient, error) { 50 | u, err := url.Parse(apiurl) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | log.Debugf("Using host: %s using %s", u.Host, u.Scheme) 56 | 57 | config := koyeb.NewConfiguration() 58 | config.UserAgent = "koyeb-cli/" + Version 59 | config.Servers[0].URL = u.String() 60 | config.HTTPClient = &http.Client{ 61 | Transport: &DebugTransport{http.DefaultTransport}, 62 | } 63 | 64 | return koyeb.NewAPIClient(config), nil 65 | } 66 | 67 | func SyncFlags(cmd *cobra.Command, args []string, i interface{}) { 68 | cmd.LocalFlags().VisitAll( 69 | func(flag *pflag.Flag) { 70 | if !flag.Changed && flag.DefValue == "" { 71 | return 72 | } 73 | funcName := fmt.Sprintf("Set%s", strcase.ToCamel(flag.Name)) 74 | meth := reflect.ValueOf(i).MethodByName(funcName) 75 | if !meth.IsValid() { 76 | log.Debugf("Unable to find setter %s on %T\n", funcName, i) 77 | return 78 | } 79 | switch flag.Value.Type() { 80 | case "stringSlice": 81 | v, _ := cmd.LocalFlags().GetStringSlice(flag.Name) 82 | meth.Call([]reflect.Value{reflect.ValueOf(v)}) 83 | case "intSlice": 84 | v, _ := cmd.LocalFlags().GetIntSlice(flag.Name) 85 | meth.Call([]reflect.Value{reflect.ValueOf(v)}) 86 | default: 87 | meth.Call([]reflect.Value{reflect.ValueOf(flag.Value.String())}) 88 | } 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/flags_list.go: -------------------------------------------------------------------------------- 1 | package flags_list 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | // Flag is an interface which represents a flag passed on the command line. T is 6 | // the type of the item to update, e.g. koyeb.DeploymentEnv or 7 | // koyeb.DeploymentPort. 8 | type Flag[T any] interface { 9 | // Should return the flag as it was passed on the command line 10 | String() string 11 | // Should return true if the flag is a deletion flag (e.g. --env !KEY) 12 | IsDeletionFlag() bool 13 | // Compare a flag with an item of the list 14 | IsEqualTo(T) bool 15 | // Update the item with the flag 16 | UpdateItem(*T) 17 | // Create a new item from the flag 18 | CreateNewItem() *T 19 | } 20 | 21 | // ParseListFlags is a generic function which takes a list of flags and a list of existing items, and returns the updated list of items. 22 | // It is used to parse the flags --env, --checks, --routes, --ports, --regions and --volumes of `koyeb service update`. 23 | // The function will: 24 | // - Remove the items marked for deletion from the existingItems list 25 | // - If the flag corresponds to an existing item, update the item from the existingItems list 26 | // - Otherwise, create a new item and append it to the existingItems list 27 | func ParseListFlags[ItemType any]( 28 | flags []Flag[ItemType], 29 | existingItems []ItemType, 30 | ) []ItemType { 31 | 32 | for _, flag := range flags { 33 | if flag.IsDeletionFlag() { 34 | newItems, found := deleteFromList(flag, existingItems) 35 | if !found { 36 | logrus.Warnf("The flag \"%s\" attempts to remove an item, but this item is not configured for the service. This flag will be ignored.", flag) 37 | } 38 | existingItems = newItems 39 | } else { 40 | // Search if the flag corresponds to an existing item. If yes, update the item. 41 | found := false 42 | for idx := range existingItems { 43 | if flag.IsEqualTo(existingItems[idx]) { 44 | found = true 45 | flag.UpdateItem(&existingItems[idx]) 46 | break 47 | } 48 | } 49 | if !found { 50 | existingItems = append(existingItems, *flag.CreateNewItem()) 51 | } 52 | } 53 | } 54 | return existingItems 55 | } 56 | 57 | func deleteFromList[ItemType any](flag Flag[ItemType], existingItems []ItemType) ([]ItemType, bool) { 58 | for idx, item := range existingItems { 59 | if flag.IsEqualTo(item) { 60 | withoutItem := make([]ItemType, 0, len(existingItems)-1) 61 | withoutItem = append(withoutItem, existingItems[:idx]...) 62 | withoutItem = append(withoutItem, existingItems[idx+1:]...) 63 | return withoutItem, true 64 | } 65 | } 66 | return existingItems, false 67 | } 68 | 69 | // BaseFlag is a struct which implements the Flag interface. It is used as a base for all the flags 70 | type BaseFlag struct { 71 | cliValue string 72 | markedForDeletion bool 73 | } 74 | 75 | func (f *BaseFlag) String() string { 76 | return f.cliValue 77 | } 78 | 79 | func (f *BaseFlag) IsDeletionFlag() bool { 80 | return f.markedForDeletion 81 | } 82 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/domain.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | type DomainMapper struct { 13 | ctx context.Context 14 | client *koyeb.APIClient 15 | fetched bool 16 | sidMap *IDMap 17 | nameMap *IDMap 18 | } 19 | 20 | func NewDomainMapper(ctx context.Context, client *koyeb.APIClient) *DomainMapper { 21 | return &DomainMapper{ 22 | ctx: ctx, 23 | client: client, 24 | fetched: false, 25 | sidMap: NewIDMap(), 26 | nameMap: NewIDMap(), 27 | } 28 | } 29 | 30 | func (mapper *DomainMapper) ResolveID(val string) (string, error) { 31 | if IsUUIDv4(val) { 32 | return val, nil 33 | } 34 | 35 | if !mapper.fetched { 36 | err := mapper.fetch() 37 | if err != nil { 38 | return "", err 39 | } 40 | } 41 | 42 | id, ok := mapper.sidMap.GetID(val) 43 | if ok { 44 | return id, nil 45 | } 46 | 47 | id, ok = mapper.nameMap.GetID(val) 48 | if ok { 49 | return id, nil 50 | } 51 | 52 | return "", errors.NewCLIErrorForMapperResolve( 53 | "domain", 54 | val, 55 | []string{"object full UUID", "object short ID (8 characters)", "domain name"}, 56 | ) 57 | } 58 | 59 | func (mapper *DomainMapper) GetName(id string) (string, error) { 60 | if !mapper.fetched { 61 | err := mapper.fetch() 62 | if err != nil { 63 | return "", err 64 | } 65 | } 66 | 67 | name, ok := mapper.nameMap.GetValue(id) 68 | if !ok { 69 | return "", fmt.Errorf("domin name not found for %q", id) 70 | } 71 | 72 | return name, nil 73 | } 74 | 75 | func (mapper *DomainMapper) fetch() error { 76 | radix := NewRadixTree() 77 | 78 | page := int64(0) 79 | offset := int64(0) 80 | limit := int64(100) 81 | for { 82 | 83 | res, resp, err := mapper.client.DomainsApi.ListDomains(mapper.ctx). 84 | Limit(strconv.FormatInt(limit, 10)). 85 | Offset(strconv.FormatInt(offset, 10)). 86 | Execute() 87 | if err != nil { 88 | return errors.NewCLIErrorFromAPIError( 89 | "Error listing domains to resolve the provided identifier to an object ID", 90 | err, 91 | resp, 92 | ) 93 | } 94 | 95 | domains := res.GetDomains() 96 | for i := range domains { 97 | domain := &domains[i] 98 | radix.Insert(getKey(domain.GetId()), domain) 99 | } 100 | 101 | page++ 102 | offset = page * limit 103 | if offset >= res.GetCount() { 104 | break 105 | } 106 | } 107 | 108 | minLength := radix.MinimalLength(8) 109 | err := radix.ForEach(func(key Key, value Value) error { 110 | domain := value.(*koyeb.Domain) 111 | id := domain.GetId() 112 | name := domain.GetName() 113 | sid := getShortID(id, minLength) 114 | 115 | mapper.sidMap.Set(id, sid) 116 | mapper.nameMap.Set(id, name) 117 | 118 | return nil 119 | }) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | mapper.fetched = true 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /pkg/koyeb/snapshots.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | ) 8 | 9 | func NewSnapshotCmd() *cobra.Command { 10 | h := NewSnapshotHandler() 11 | _ = h 12 | 13 | snapshotCmd := &cobra.Command{ 14 | Use: "snapshots ACTION", 15 | Aliases: []string{"vol", "snapshot"}, 16 | Short: "Manage snapshots", 17 | } 18 | 19 | createSnapshotCmd := &cobra.Command{ 20 | Use: "create NAME PARENT_VOLUME", 21 | Short: "Create a new snapshot", 22 | Args: cobra.ExactArgs(2), 23 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 24 | req := koyeb.NewCreateSnapshotRequestWithDefaults() 25 | 26 | parentVolumeID, err := ctx.Mapper.Volume().ResolveID(args[1]) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | req.SetName(args[0]) 32 | req.SetParentVolumeId(parentVolumeID) 33 | 34 | return h.Create(ctx, cmd, args, req) 35 | }), 36 | } 37 | snapshotCmd.AddCommand(createSnapshotCmd) 38 | 39 | getSnapshotCmd := &cobra.Command{ 40 | Use: "get NAME", 41 | Short: "Get a snapshot", 42 | Args: cobra.ExactArgs(1), 43 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 44 | return h.Get(ctx, cmd, args) 45 | }), 46 | } 47 | snapshotCmd.AddCommand(getSnapshotCmd) 48 | 49 | listSnapshotCmd := &cobra.Command{ 50 | Use: "list", 51 | Short: "List snapshots", 52 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 53 | return h.List(ctx, cmd, args) 54 | }), 55 | } 56 | snapshotCmd.AddCommand(listSnapshotCmd) 57 | 58 | updateSnapshotCmd := &cobra.Command{ 59 | Use: "update NAME", 60 | Short: "Update a snapshot", 61 | Args: cobra.ExactArgs(1), 62 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 63 | req := koyeb.NewUpdateSnapshotRequestWithDefaults() 64 | 65 | name, _ := cmd.Flags().GetString("name") 66 | if name != "" { 67 | req.SetName(name) 68 | } 69 | 70 | return h.Update(ctx, cmd, args, req) 71 | }), 72 | } 73 | updateSnapshotCmd.Flags().String("name", "", "Change the snapshot name") 74 | snapshotCmd.AddCommand(updateSnapshotCmd) 75 | 76 | deleteSnapshotCmd := &cobra.Command{ 77 | Use: "delete NAME", 78 | Short: "Delete a snapshot", 79 | Args: cobra.ExactArgs(1), 80 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 81 | return h.Delete(ctx, cmd, args) 82 | }), 83 | } 84 | snapshotCmd.AddCommand(deleteSnapshotCmd) 85 | 86 | return snapshotCmd 87 | } 88 | 89 | func NewSnapshotHandler() *SnapshotHandler { 90 | return &SnapshotHandler{} 91 | } 92 | 93 | type SnapshotHandler struct { 94 | } 95 | 96 | func ResolveSnapshotArgs(ctx *CLIContext, val string) (string, error) { 97 | snapshotMapper := ctx.Mapper.Snapshot() 98 | id, err := snapshotMapper.ResolveID(val) 99 | if err != nil { 100 | return "", err 101 | } 102 | return id, nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/koyeb/snapshots_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *SnapshotHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | snapshot, err := ResolveSnapshotArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.SnapshotsApi.GetSnapshot(ctx.Context, snapshot).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the snapshot `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | getSnapshotReply := NewGetSnapshotReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(getSnapshotReply) 31 | return nil 32 | } 33 | 34 | type GetSnapshotReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetSnapshotReply 37 | full bool 38 | } 39 | 40 | func NewGetSnapshotReply(mapper *idmapper.Mapper, res *koyeb.GetSnapshotReply, full bool) *GetSnapshotReply { 41 | return &GetSnapshotReply{ 42 | mapper: mapper, 43 | value: res, 44 | full: full, 45 | } 46 | } 47 | 48 | func (GetSnapshotReply) Title() string { 49 | return "Snapshot" 50 | } 51 | 52 | func (r *GetSnapshotReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetSnapshot().MarshalJSON() 54 | } 55 | 56 | func (r *GetSnapshotReply) Headers() []string { 57 | return []string{"id", "name", "region", "type", "status", "created_at", "parent_volume"} 58 | } 59 | 60 | func (r *GetSnapshotReply) Fields() []map[string]string { 61 | item := r.value.GetSnapshot() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "name": item.GetName(), 65 | "region": item.GetRegion(), 66 | "status": formatSnapshotStatus(item.GetStatus()), 67 | "type": formatSnapshotType(item.GetType()), 68 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 69 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 70 | "parent_volume": renderer.FormatID(item.GetParentVolumeId(), r.full), 71 | } 72 | 73 | resp := []map[string]string{fields} 74 | return resp 75 | } 76 | 77 | func formatSnapshotStatus(st koyeb.SnapshotStatus) string { 78 | switch st { 79 | case koyeb.SNAPSHOTSTATUS_CREATING: 80 | return "creating" 81 | case koyeb.SNAPSHOTSTATUS_AVAILABLE: 82 | return "available" 83 | case koyeb.SNAPSHOTSTATUS_MIGRATING: 84 | return "migrating" 85 | case koyeb.SNAPSHOTSTATUS_DELETING: 86 | return "deleting" 87 | default: 88 | return "invalid" 89 | } 90 | } 91 | 92 | func formatSnapshotType(st koyeb.SnapshotType) string { 93 | switch st { 94 | case koyeb.SNAPSHOTTYPE_LOCAL: 95 | return "local" 96 | case koyeb.SNAPSHOTTYPE_REMOTE: 97 | return "remote" 98 | default: 99 | return "invalid" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/env.go: -------------------------------------------------------------------------------- 1 | // Parse the variables --env variables from `koyeb service update` and `koyeb service create`. 2 | package flags_list 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | type FlagEnv struct { 13 | BaseFlag 14 | key string 15 | isSecret bool 16 | value string 17 | } 18 | 19 | func NewEnvListFromFlags(values []string) ([]Flag[koyeb.DeploymentEnv], error) { 20 | ret := make([]Flag[koyeb.DeploymentEnv], 0, len(values)) 21 | 22 | for _, value := range values { 23 | env := &FlagEnv{BaseFlag: BaseFlag{cliValue: value}} 24 | 25 | if strings.HasPrefix(value, "!") { 26 | env.markedForDeletion = true 27 | split := strings.Split(value, "=") 28 | if len(split) != 1 { 29 | return nil, &errors.CLIError{ 30 | What: "Error while configuring the service", 31 | Why: fmt.Sprintf("unable to parse the environment variable \"%s\"", value), 32 | Additional: []string{ 33 | "To delete an environment variable, prefix it with !", 34 | "You must omit the value, e.g. !KEY and not !KEY=value", 35 | "Do not forget to escape the ! character if you are using a shell, e.g. \\!KEY or '!KEY'", 36 | }, 37 | Orig: nil, 38 | Solution: "Fix the environment variable and try again", 39 | } 40 | } 41 | env.key = split[0][1:] // Skip the ! character 42 | } else { 43 | split := strings.SplitN(value, "=", 2) 44 | // If there is no =, or the key is empty, or the value refers to a secret without a name 45 | if len(split) != 2 || split[0] == "" || split[1] == "@" { 46 | return nil, &errors.CLIError{ 47 | What: "Error while configuring the service", 48 | Why: fmt.Sprintf("unable to parse the environment variable \"%s\"", value), 49 | Additional: []string{ 50 | "Environment variables must be specified as KEY=VALUE", 51 | "To use a secret as a value, specify KEY=@SECRET_NAME", 52 | "To specify an empty value, specify KEY=", 53 | "To remove an environment variable, prefix it with !", 54 | "Do not forget to escape the ! character if you are using a shell, e.g. \\!KEY or '!KEY'", 55 | }, 56 | Orig: nil, 57 | Solution: "Fix the environment variable and try again", 58 | } 59 | } 60 | env.key = split[0] 61 | if strings.HasPrefix(split[1], "@") { 62 | env.isSecret = true 63 | env.value = split[1][1:] 64 | } else { 65 | env.value = split[1] 66 | } 67 | } 68 | ret = append(ret, env) 69 | } 70 | return ret, nil 71 | } 72 | 73 | func (f *FlagEnv) IsEqualTo(env koyeb.DeploymentEnv) bool { 74 | return f.key == *env.Key 75 | } 76 | 77 | func (f *FlagEnv) UpdateItem(env *koyeb.DeploymentEnv) { 78 | env.Key = koyeb.PtrString(f.key) 79 | if f.isSecret { 80 | env.Secret = koyeb.PtrString(f.value) 81 | env.Value = nil 82 | } else { 83 | env.Secret = nil 84 | env.Value = koyeb.PtrString(f.value) 85 | } 86 | } 87 | 88 | func (f *FlagEnv) CreateNewItem() *koyeb.DeploymentEnv { 89 | item := koyeb.NewDeploymentEnvWithDefaults() 90 | f.UpdateItem(item) 91 | return item 92 | } 93 | -------------------------------------------------------------------------------- /pkg/koyeb/volumes_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *VolumeHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | volume, err := ResolveVolumeArgs(ctx, args[0]) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | res, resp, err := ctx.Client.PersistentVolumesApi.GetPersistentVolume(ctx.Context, volume).Execute() 20 | if err != nil { 21 | return errors.NewCLIErrorFromAPIError( 22 | fmt.Sprintf("Error while retrieving the volume `%s`", args[0]), 23 | err, 24 | resp, 25 | ) 26 | } 27 | 28 | full := GetBoolFlags(cmd, "full") 29 | getVolumeReply := NewGetVolumeReply(ctx.Mapper, res, full) 30 | ctx.Renderer.Render(getVolumeReply) 31 | return nil 32 | } 33 | 34 | type GetVolumeReply struct { 35 | mapper *idmapper.Mapper 36 | value *koyeb.GetPersistentVolumeReply 37 | full bool 38 | } 39 | 40 | func NewGetVolumeReply(mapper *idmapper.Mapper, res *koyeb.GetPersistentVolumeReply, full bool) *GetVolumeReply { 41 | return &GetVolumeReply{ 42 | mapper: mapper, 43 | value: res, 44 | full: full, 45 | } 46 | } 47 | 48 | func (GetVolumeReply) Title() string { 49 | return "Volume" 50 | } 51 | 52 | func (r *GetVolumeReply) MarshalBinary() ([]byte, error) { 53 | return r.value.GetVolume().MarshalJSON() 54 | } 55 | 56 | func (r *GetVolumeReply) Headers() []string { 57 | return []string{"id", "name", "region", "type", "status", "size", "created_at", "service"} 58 | } 59 | 60 | func (r *GetVolumeReply) Fields() []map[string]string { 61 | item := r.value.GetVolume() 62 | fields := map[string]string{ 63 | "id": renderer.FormatID(item.GetId(), r.full), 64 | "name": item.GetName(), 65 | "region": item.GetRegion(), 66 | "status": formatVolumeStatus(item.GetStatus()), 67 | "type": formatVolumeType(item.GetBackingStore()), 68 | "size": renderer.FormatSize(renderer.GBSize(item.GetMaxSize())), 69 | "read_only": fmt.Sprintf("%t", item.GetReadOnly()), 70 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 71 | "updated_at": renderer.FormatTime(item.GetUpdatedAt()), 72 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 73 | } 74 | 75 | resp := []map[string]string{fields} 76 | return resp 77 | } 78 | 79 | func formatVolumeStatus(st koyeb.PersistentVolumeStatus) string { 80 | switch st { 81 | case koyeb.PERSISTENTVOLUMESTATUS_ATTACHED: 82 | return "attached" 83 | case koyeb.PERSISTENTVOLUMESTATUS_DETACHED: 84 | return "detached" 85 | case koyeb.PERSISTENTVOLUMESTATUS_DELETING: 86 | return "deleting" 87 | default: 88 | return "invalid" 89 | } 90 | } 91 | 92 | func formatVolumeType(st koyeb.PersistentVolumeBackingStore) string { 93 | switch st { 94 | case koyeb.PERSISTENTVOLUMEBACKINGSTORE_LOCAL_BLK: 95 | return "local-blk" 96 | default: 97 | return "invalid" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/koyeb/koyeb-cli 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 7 | github.com/blang/semver v3.5.1+incompatible 8 | github.com/briandowns/spinner v1.23.0 9 | github.com/ghodss/yaml v1.0.0 10 | github.com/gofrs/uuid v4.3.0+incompatible 11 | github.com/gorilla/websocket v1.5.0 12 | github.com/iancoleman/strcase v0.2.0 13 | github.com/koyeb/koyeb-api-client-go v0.0.0-20251104125902-cb19b91bf1cf 14 | github.com/logrusorgru/aurora v2.0.3+incompatible 15 | github.com/manifoldco/promptui v0.9.0 16 | github.com/mitchellh/go-homedir v1.1.0 17 | github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae 18 | github.com/olekukonko/tablewriter v0.0.5 19 | github.com/pkg/errors v0.9.1 20 | github.com/rhysd/go-github-selfupdate v1.2.3 21 | github.com/sirupsen/logrus v1.9.0 22 | github.com/spf13/cobra v1.5.0 23 | github.com/spf13/pflag v1.0.5 24 | github.com/spf13/viper v1.13.0 25 | github.com/stretchr/testify v1.8.0 26 | github.com/yudai/gojsondiff v1.0.0 27 | golang.org/x/term v0.30.0 28 | gonum.org/v1/gonum v0.13.0 29 | ) 30 | 31 | require ( 32 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 33 | github.com/chzyer/readline v1.5.1 // indirect 34 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 35 | github.com/davecgh/go-spew v1.1.1 // indirect 36 | github.com/fatih/color v1.13.0 // indirect 37 | github.com/fsnotify/fsnotify v1.5.4 // indirect 38 | github.com/google/go-github/v30 v30.1.0 // indirect 39 | github.com/google/go-querystring v1.1.0 // indirect 40 | github.com/hashicorp/hcl v1.0.0 // indirect 41 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect 42 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 43 | github.com/magiconair/properties v1.8.6 // indirect 44 | github.com/mattn/go-colorable v0.1.13 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/mattn/go-runewidth v0.0.16 // indirect 47 | github.com/mitchellh/mapstructure v1.5.0 // indirect 48 | github.com/pelletier/go-toml v1.9.5 // indirect 49 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 50 | github.com/pmezard/go-difflib v1.0.0 // indirect 51 | github.com/rivo/uniseg v0.4.7 // indirect 52 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 53 | github.com/sergi/go-diff v1.3.1 // indirect 54 | github.com/spf13/afero v1.9.2 // indirect 55 | github.com/spf13/cast v1.5.0 // indirect 56 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 57 | github.com/subosito/gotenv v1.4.1 // indirect 58 | github.com/tcnksm/go-gitconfig v0.1.2 // indirect 59 | github.com/ulikunitz/xz v0.5.10 // indirect 60 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect 61 | github.com/yudai/pp v2.0.1+incompatible // indirect 62 | golang.org/x/crypto v0.36.0 // indirect 63 | golang.org/x/net v0.37.0 // indirect 64 | golang.org/x/oauth2 v0.28.0 // indirect 65 | golang.org/x/sys v0.31.0 // indirect 66 | golang.org/x/text v0.23.0 // indirect 67 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 68 | gopkg.in/ini.v1 v1.67.0 // indirect 69 | gopkg.in/yaml.v2 v2.4.0 // indirect 70 | gopkg.in/yaml.v3 v3.0.1 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/volumes.go: -------------------------------------------------------------------------------- 1 | package flags_list 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | ) 10 | 11 | type FlagVolume struct { 12 | BaseFlag 13 | volumeId string 14 | path string 15 | } 16 | 17 | // Parse the list of values in the form VOLUME_NAME:PATH. 18 | // The function is wrapped in another function to allow the caller to provide a function to resolve the volume ID from its name. 19 | func GetNewVolumeListFromFlags(resolveVolumeId func(string) (string, error)) func(values []string) ([]Flag[koyeb.DeploymentVolume], error) { 20 | return func(values []string) ([]Flag[koyeb.DeploymentVolume], error) { 21 | ret := make([]Flag[koyeb.DeploymentVolume], 0, len(values)) 22 | 23 | for _, value := range values { 24 | hc := &FlagVolume{BaseFlag: BaseFlag{cliValue: value}} 25 | components := strings.Split(value, ":") 26 | 27 | if strings.HasPrefix(components[0], "!") { 28 | if len(components) > 1 { 29 | return nil, &errors.CLIError{ 30 | What: "Error while configuring the service", 31 | Why: fmt.Sprintf("unable to parse the volume \"%s\"", hc.cliValue), 32 | Additional: []string{ 33 | "To remove a volume from the service, prefix the volume name with '!', e.g. '!myvolume'", 34 | "The volume mount point should not be specified to removing it from the service", 35 | }, 36 | Orig: nil, 37 | Solution: "Fix the volume and try again", 38 | } 39 | } 40 | volumeId, err := resolveVolumeId(components[0][1:]) 41 | if err != nil { 42 | return nil, err 43 | } 44 | hc.markedForDeletion = true 45 | hc.volumeId = volumeId 46 | } else { 47 | if len(components) != 2 { 48 | return nil, &errors.CLIError{ 49 | What: "Error while configuring the service", 50 | Why: fmt.Sprintf("unable to parse the volume \"%s\"", hc.cliValue), 51 | Additional: []string{ 52 | "Volumes must be specified as VOLUME_ID:PATH", 53 | "To remove a volume from the service, prefix it with '!', e.g. '!myvolume'", 54 | }, 55 | Orig: nil, 56 | Solution: "Fix the volume and try again", 57 | } 58 | } 59 | volumeId, err := resolveVolumeId(components[0]) 60 | if err != nil { 61 | return nil, err 62 | } 63 | hc.volumeId = volumeId 64 | hc.path = components[1] 65 | } 66 | ret = append(ret, hc) 67 | } 68 | return ret, nil 69 | } 70 | } 71 | 72 | // IsEqualTo is called to check if a flag given by the user corresponds to a 73 | // given voolume. If the flag is a volume, e.g. "myvolume:/data", we 74 | // should return true even if the volume has a different path, as we want 75 | // to allow the user to change the path where a volume is mounted. 76 | func (f *FlagVolume) IsEqualTo(hc koyeb.DeploymentVolume) bool { 77 | return hc.GetId() == f.volumeId 78 | } 79 | 80 | func (f *FlagVolume) UpdateItem(hc *koyeb.DeploymentVolume) { 81 | hc.Id = &f.volumeId 82 | hc.Path = &f.path 83 | } 84 | 85 | func (f *FlagVolume) CreateNewItem() *koyeb.DeploymentVolume { 86 | item := koyeb.NewDeploymentVolumeWithDefaults() 87 | f.UpdateItem(item) 88 | return item 89 | } 90 | -------------------------------------------------------------------------------- /pkg/koyeb/services_redeploy.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *ServiceHandler) ReDeploy(ctx *CLIContext, cmd *cobra.Command, args []string) error { 15 | serviceName, err := h.parseServiceName(cmd, args[0]) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | service, err := h.ResolveServiceArgs(ctx, serviceName) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | useCache := GetBoolFlags(cmd, "use-cache") 26 | skipBuild := GetBoolFlags(cmd, "skip-build") 27 | wait := GetBoolFlags(cmd, "wait") 28 | waitTimeout := GetDurationFlags(cmd, "wait-timeout") 29 | 30 | redeployBody := *koyeb.NewRedeployRequestInfoWithDefaults() 31 | redeployBody.UseCache = &useCache 32 | redeployBody.SkipBuild = &skipBuild 33 | res, resp, err := ctx.Client.ServicesApi.ReDeploy(ctx.Context, service).Info(redeployBody).Execute() 34 | 35 | if err != nil { 36 | return errors.NewCLIErrorFromAPIError( 37 | fmt.Sprintf("Error while redeploying the service `%s`", serviceName), 38 | err, 39 | resp, 40 | ) 41 | } 42 | log.Infof("Service deployment in progress. To access the build logs, run: `koyeb deployment logs %s -t build`. For the runtime logs, run `koyeb deployment logs %s`", 43 | res.Deployment.GetId()[:8], 44 | res.Deployment.GetId()[:8], 45 | ) 46 | 47 | if wait { 48 | ctxd, cancel := context.WithTimeout(ctx.Context, waitTimeout) 49 | defer cancel() 50 | 51 | for range ticker(ctxd, 2*time.Second) { 52 | res, resp, err := ctx.Client.DeploymentsApi.GetDeployment(ctxd, res.Deployment.GetId()).Execute() 53 | if err != nil { 54 | return errors.NewCLIErrorFromAPIError( 55 | "Error while fetching deployment", 56 | err, 57 | resp, 58 | ) 59 | } 60 | 61 | if res.Deployment != nil && res.Deployment.Status != nil { 62 | switch status := *res.Deployment.Status; status { 63 | case koyeb.DEPLOYMENTSTATUS_ERROR, koyeb.DEPLOYMENTSTATUS_DEGRADED, koyeb.DEPLOYMENTSTATUS_UNHEALTHY, koyeb.DEPLOYMENTSTATUS_CANCELED, koyeb.DEPLOYMENTSTATUS_STOPPED, koyeb.DEPLOYMENTSTATUS_ERRORING: 64 | return fmt.Errorf("Deployment %s update ended in status: %s", res.Deployment.GetId()[:8], status) 65 | case koyeb.DEPLOYMENTSTATUS_STARTING, koyeb.DEPLOYMENTSTATUS_PENDING, koyeb.DEPLOYMENTSTATUS_PROVISIONING, koyeb.DEPLOYMENTSTATUS_ALLOCATING: 66 | break 67 | default: 68 | return nil 69 | } 70 | } 71 | } 72 | 73 | log.Infof("Service deployment still in progress, --wait timed out. To access the build logs, run: `koyeb deployment logs %s -t build`. For the runtime logs, run `koyeb deployment logs %s`", 74 | res.Deployment.GetId()[:8], 75 | res.Deployment.GetId()[:8], 76 | ) 77 | return fmt.Errorf("service deployment still in progress, --wait timed out. To access the build logs, run: `koyeb deployment logs %s -t build`. For the runtime logs, run `koyeb deployment logs %s`", 78 | res.Deployment.GetId()[:8], 79 | res.Deployment.GetId()[:8], 80 | ) 81 | } 82 | 83 | log.Infof("Service %s redeployed.", serviceName) 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/koyeb/errors/api.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 11 | ) 12 | 13 | // NewCLIErrorFromAPIError takes an error returned by an API call, for example 14 | // `ctx.Client.SecretsApi.ListSecrets(ctx).Execute()`, and returns a CLIError 15 | // which contains more context about the error. 16 | func NewCLIErrorFromAPIError(what string, err error, resp *http.Response) *CLIError { 17 | ret := &CLIError{ 18 | What: what, 19 | } 20 | 21 | if resp != nil && resp.StatusCode == 429 { 22 | ret.Why = "the Koyeb API returned an error HTTP/429: Too Many Requests because you have exceeded the rate limit" 23 | ret.Solution = "Please try again in a few seconds." 24 | return ret 25 | } 26 | 27 | ret.Orig = err 28 | 29 | var genericErr *koyeb.GenericOpenAPIError 30 | var unmarshalErr *json.UnmarshalTypeError 31 | var urlError *url.Error 32 | 33 | if errors.As(err, &genericErr) { 34 | switch genericErrModel := genericErr.Model().(type) { 35 | case koyeb.ErrorWithFields: 36 | ret.Why = fmt.Sprintf("the Koyeb API returned an error %d: %s", *genericErrModel.Status, genericErrModel.GetMessage()) 37 | ret.Solution = SolutionFixRequest 38 | for _, f := range genericErrModel.GetFields() { 39 | ret.Additional = append(ret.Additional, fmt.Sprintf("Field %s: %s", f.GetField(), f.GetDescription())) 40 | } 41 | case koyeb.Error: 42 | if genericErrModel.GetStatus() == 401 { 43 | ret.Why = "your authentication token is invalid or expired, or the selected organization (for user tokens) has been deleted." 44 | ret.Solution = "Please login again using `koyeb login`, or provide a valid token using the `--token` flag, or (if using a user token) switch to an existing organization." 45 | ret.Orig = nil // the original error contains "401 Unauthorized" which is not very useful. Remove it. 46 | } else { 47 | ret.Why = fmt.Sprintf("the Koyeb API returned an error %d: %s", *genericErrModel.Status, genericErrModel.GetMessage()) 48 | ret.Solution = SolutionFixRequest 49 | } 50 | default: 51 | if resp != nil { 52 | ret.Why = fmt.Sprintf("the Koyeb API returned an unexpected error HTTP/%d that the CLI was unable to process, likely due to a bug in the CLI", resp.StatusCode) 53 | ret.Solution = SolutionUpdateOrIssue 54 | } else { 55 | ret.Why = "the Koyeb API returned an unexpected error, not bound to an HTTP response, that the CLI was unable to process, likely due to a bug in the CLI" 56 | ret.Solution = SolutionUpdateOrIssue 57 | } 58 | } 59 | return ret 60 | } else if errors.As(err, &unmarshalErr) { 61 | ret.Why = "the Koyeb API returned an error that the CLI was unable to parse, likely due to a bug in the CLI." 62 | ret.Solution = SolutionTryAgainOrUpdateOrIssue 63 | } else if errors.As(err, &urlError) { 64 | ret.Why = "the CLI was unable to query the Koyeb API because of an issue on your machine or in your configuration" 65 | ret.Solution = SolutionFixConfig 66 | } else { 67 | ret.Why = "the Koyeb API returned an error that the CLI was unable to process, likely due to a bug in the CLI or a problem in your configuration." 68 | ret.Solution = SolutionTryAgainOrUpdateOrIssue 69 | } 70 | return ret 71 | } 72 | -------------------------------------------------------------------------------- /pkg/koyeb/deployments_get.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 10 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *DeploymentHandler) Get(ctx *CLIContext, cmd *cobra.Command, args []string) error { 15 | deployment, err := h.ResolveDeploymentArgs(ctx, args[0]) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | res, resp, err := ctx.Client.DeploymentsApi.GetDeployment(ctx.Context, deployment).Execute() 21 | if err != nil { 22 | return errors.NewCLIErrorFromAPIError( 23 | fmt.Sprintf("Error while retrieving the deployment `%s`", args[0]), 24 | err, 25 | resp, 26 | ) 27 | } 28 | 29 | full := GetBoolFlags(cmd, "full") 30 | getDeploymentsReply := NewGetDeploymentReply(ctx.Mapper, res, full) 31 | ctx.Renderer.Render(getDeploymentsReply) 32 | return nil 33 | } 34 | 35 | type GetDeploymentReply struct { 36 | mapper *idmapper.Mapper 37 | value *koyeb.GetDeploymentReply 38 | full bool 39 | } 40 | 41 | func NewGetDeploymentReply(mapper *idmapper.Mapper, value *koyeb.GetDeploymentReply, full bool) *GetDeploymentReply { 42 | return &GetDeploymentReply{ 43 | mapper: mapper, 44 | value: value, 45 | full: full, 46 | } 47 | } 48 | 49 | func (GetDeploymentReply) Title() string { 50 | return "Deployment" 51 | } 52 | 53 | func (r *GetDeploymentReply) MarshalBinary() ([]byte, error) { 54 | return r.value.GetDeployment().MarshalJSON() 55 | } 56 | 57 | func (r *GetDeploymentReply) Headers() []string { 58 | return []string{"id", "service", "git sha", "type", "status", "messages", "regions", "created_at"} 59 | } 60 | 61 | func (r *GetDeploymentReply) Fields() []map[string]string { 62 | item := r.value.GetDeployment() 63 | sha := "N/A" 64 | if item.ProvisioningInfo != nil { 65 | sha = *item.ProvisioningInfo.Sha 66 | } 67 | 68 | fields := map[string]string{ 69 | "id": renderer.FormatID(item.GetId(), r.full), 70 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 71 | "git sha": sha, 72 | "type": formatDeploymentType(item.Definition.GetType()), 73 | "status": formatDeploymentStatus(item.GetStatus()), 74 | "messages": formatDeploymentMessages(item.GetMessages(), 0), 75 | "regions": renderRegions(item.Definition.Regions), 76 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 77 | } 78 | 79 | resp := []map[string]string{fields} 80 | return resp 81 | } 82 | 83 | func formatDeploymentType(dt koyeb.DeploymentDefinitionType) string { 84 | return string(dt) 85 | } 86 | 87 | func formatDeploymentStatus(ds koyeb.DeploymentStatus) string { 88 | return string(ds) 89 | } 90 | 91 | func formatDeploymentMessages(messages []string, max int) string { 92 | concat := strings.Join(messages, " ") 93 | if max == 0 || len(concat) < max { 94 | return concat 95 | } 96 | return fmt.Sprint(concat[:max], "...") 97 | } 98 | 99 | func renderRegions(regions []string) string { 100 | if len(regions) == 0 { 101 | return "-" 102 | } 103 | 104 | return strings.Join(regions, ",") 105 | } 106 | -------------------------------------------------------------------------------- /pkg/koyeb/services_update.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *ServiceHandler) Update(ctx *CLIContext, cmd *cobra.Command, args []string, updateService *koyeb.UpdateService) error { 15 | serviceName, err := h.parseServiceName(cmd, args[0]) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | service, err := h.ResolveServiceArgs(ctx, serviceName) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | wait, _ := cmd.Flags().GetBool("wait") 26 | waitTimeout, _ := cmd.Flags().GetDuration("wait-timeout") 27 | 28 | res, resp, err := ctx.Client.ServicesApi.UpdateService(ctx.Context, service).Service(*updateService).Execute() 29 | if err != nil { 30 | return errors.NewCLIErrorFromAPIError( 31 | fmt.Sprintf("Error while updating the service `%s`", serviceName), 32 | err, 33 | resp, 34 | ) 35 | } 36 | log.Infof( 37 | "Service deployment in progress. To access the build logs, run: `koyeb service logs %s -t build`. For the runtime logs, run `koyeb service logs %s`", 38 | res.Service.GetId()[:8], 39 | res.Service.GetId()[:8], 40 | ) 41 | 42 | defer func() { 43 | res, _, err := ctx.Client.ServicesApi.GetService(ctx.Context, res.Service.GetId()).Execute() 44 | if err != nil { 45 | return 46 | } 47 | full := GetBoolFlags(cmd, "full") 48 | getServiceReply := NewGetServiceReply(ctx.Mapper, &koyeb.GetServiceReply{Service: res.Service}, full) 49 | ctx.Renderer.Render(getServiceReply) 50 | }() 51 | 52 | if wait { 53 | ctxd, cancel := context.WithTimeout(ctx.Context, waitTimeout) 54 | defer cancel() 55 | 56 | for range ticker(ctxd, 2*time.Second) { 57 | res, resp, err := ctx.Client.DeploymentsApi.GetDeployment(ctxd, res.Service.GetLatestDeploymentId()).Execute() 58 | if err != nil { 59 | return errors.NewCLIErrorFromAPIError( 60 | "Error while fetching deployment", 61 | err, 62 | resp, 63 | ) 64 | } 65 | 66 | if res.Deployment != nil && res.Deployment.Status != nil { 67 | switch status := *res.Deployment.Status; status { 68 | case koyeb.DEPLOYMENTSTATUS_ERROR, koyeb.DEPLOYMENTSTATUS_DEGRADED, koyeb.DEPLOYMENTSTATUS_UNHEALTHY, koyeb.DEPLOYMENTSTATUS_CANCELED, koyeb.DEPLOYMENTSTATUS_STOPPED, koyeb.DEPLOYMENTSTATUS_ERRORING: 69 | return fmt.Errorf("Deployment %s update ended in status: %s", res.Deployment.GetId()[:8], status) 70 | case koyeb.DEPLOYMENTSTATUS_STARTING, koyeb.DEPLOYMENTSTATUS_PENDING, koyeb.DEPLOYMENTSTATUS_PROVISIONING, koyeb.DEPLOYMENTSTATUS_ALLOCATING: 71 | break 72 | default: 73 | return nil 74 | } 75 | } 76 | } 77 | 78 | log.Infof("Service deployment still in progress, --wait timed out. To access the build logs, run: `koyeb service logs %s -t build`. For the runtime logs, run `koyeb service logs %s`", 79 | res.Service.GetId()[:8], 80 | res.Service.GetId()[:8], 81 | ) 82 | return fmt.Errorf("service deployment still in progress, --wait timed out. To access the build logs, run: `koyeb service logs %s -t build`. For the runtime logs, run `koyeb service logs %s`", 83 | res.Service.GetId()[:8], 84 | res.Service.GetId()[:8], 85 | ) 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/ports.go: -------------------------------------------------------------------------------- 1 | package flags_list 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | type FlagPort struct { 13 | BaseFlag 14 | port int64 15 | protocol string 16 | } 17 | 18 | func NewPortListFromFlags(values []string) ([]Flag[koyeb.DeploymentPort], error) { 19 | ret := make([]Flag[koyeb.DeploymentPort], 0, len(values)) 20 | 21 | for _, value := range values { 22 | port := &FlagPort{BaseFlag: BaseFlag{cliValue: value}} 23 | 24 | if strings.HasPrefix(value, "!") { 25 | port.markedForDeletion = true 26 | value = value[1:] 27 | } 28 | 29 | split := strings.Split(value, ":") 30 | portNum, err := strconv.Atoi(split[0]) 31 | if err != nil { 32 | return nil, &errors.CLIError{ 33 | What: "Error while configuring the service", 34 | Why: fmt.Sprintf("unable to parse the port \"%s\"", port.cliValue), 35 | Additional: []string{ 36 | "Ports must be specified as PORT[:PROTOCOL]", 37 | "PORT must be a valid port number (e.g. 80)", 38 | "PROTOCOL must be either \"http\", \"http2\" or \"tcp\". It can be omitted, in which case it defaults to \"http\"", 39 | "To remove a port from the service, prefix it with '!', e.g. '!80'", 40 | }, 41 | Orig: nil, 42 | Solution: "Fix the port and try again", 43 | } 44 | } 45 | port.port = int64(portNum) 46 | 47 | if port.markedForDeletion { 48 | if len(split) > 1 { 49 | return nil, &errors.CLIError{ 50 | What: "Error while configuring the service", 51 | Why: fmt.Sprintf("unable to parse the port \"%s\"", port.cliValue), 52 | Additional: []string{ 53 | "To remove a port from the service, prefix it with '!', e.g. '!80'", 54 | "The protocol should not be specified when removing a port from the service", 55 | }, 56 | Orig: nil, 57 | Solution: "Fix the port and try again", 58 | } 59 | } 60 | } else { 61 | port.protocol = *koyeb.PtrString("http") 62 | if len(split) > 1 { 63 | if strings.ToLower(split[1]) != "http" && strings.ToLower(split[1]) != "http2" && strings.ToLower(split[1]) != "tcp" { 64 | return nil, &errors.CLIError{ 65 | What: "Error while configuring the service", 66 | Why: fmt.Sprintf("unable to parse the protocol from the port \"%s\"", port.cliValue), 67 | Additional: []string{ 68 | "Ports must be specified as PORT[:PROTOCOL]", 69 | "PORT must be a valid port number (e.g. 80)", 70 | "PROTOCOL must be either \"http\", \"http2\" or \"tcp\". It can be omitted, in which case it defaults to \"http\"", 71 | }, 72 | Orig: nil, 73 | Solution: "Fix the protocol and try again", 74 | } 75 | } 76 | port.protocol = *koyeb.PtrString(split[1]) 77 | } 78 | } 79 | ret = append(ret, port) 80 | } 81 | return ret, nil 82 | } 83 | 84 | func (f *FlagPort) IsEqualTo(port koyeb.DeploymentPort) bool { 85 | return f.port == *port.Port 86 | } 87 | 88 | func (f *FlagPort) UpdateItem(port *koyeb.DeploymentPort) { 89 | port.Port = koyeb.PtrInt64(f.port) 90 | port.Protocol = koyeb.PtrString(f.protocol) 91 | } 92 | 93 | func (f *FlagPort) CreateNewItem() *koyeb.DeploymentPort { 94 | item := koyeb.NewDeploymentPortWithDefaults() 95 | f.UpdateItem(item) 96 | return item 97 | } 98 | -------------------------------------------------------------------------------- /pkg/koyeb/services_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 10 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *ServiceHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 15 | list := []koyeb.ServiceListItem{} 16 | 17 | page := int64(0) 18 | offset := int64(0) 19 | limit := int64(100) 20 | for { 21 | req := ctx.Client.ServicesApi.ListServices(ctx.Context) 22 | appId := GetStringFlags(cmd, "app") 23 | 24 | if appId != "" { 25 | app, err := h.ResolveAppArgs(ctx, appId) 26 | if err != nil { 27 | return err 28 | } 29 | req = req.AppId(app) 30 | } 31 | name := GetStringFlags(cmd, "name") 32 | if name != "" { 33 | req = req.Name(name) 34 | } 35 | res, resp, err := req.Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 36 | if err != nil { 37 | errTitle := "Error while listing services" 38 | if appId != "" { 39 | errTitle = fmt.Sprintf("Error while listing the services of the application `%s`", appId) 40 | } 41 | return errors.NewCLIErrorFromAPIError(errTitle, err, resp) 42 | } 43 | 44 | for _, svc := range res.GetServices() { 45 | // Database services are displayed in the database command 46 | if svc.GetType() == koyeb.SERVICETYPE_DATABASE { 47 | continue 48 | } 49 | list = append(list, svc) 50 | } 51 | 52 | page++ 53 | offset = page * limit 54 | if offset >= res.GetCount() { 55 | break 56 | } 57 | } 58 | 59 | full := GetBoolFlags(cmd, "full") 60 | listServicesReply := NewListServicesReply(ctx.Mapper, &koyeb.ListServicesReply{Services: list}, full) 61 | ctx.Renderer.Render(listServicesReply) 62 | return nil 63 | } 64 | 65 | type ListServicesReply struct { 66 | mapper *idmapper.Mapper 67 | value *koyeb.ListServicesReply 68 | full bool 69 | } 70 | 71 | func NewListServicesReply(mapper *idmapper.Mapper, value *koyeb.ListServicesReply, full bool) *ListServicesReply { 72 | return &ListServicesReply{ 73 | mapper: mapper, 74 | value: value, 75 | full: full, 76 | } 77 | } 78 | 79 | func (ListServicesReply) Title() string { 80 | return "Services" 81 | } 82 | 83 | func (r *ListServicesReply) MarshalBinary() ([]byte, error) { 84 | return r.value.MarshalJSON() 85 | } 86 | 87 | func (r *ListServicesReply) Headers() []string { 88 | return []string{"id", "app", "name", "type", "status", "created_at"} 89 | } 90 | 91 | func (r *ListServicesReply) Fields() []map[string]string { 92 | items := r.value.GetServices() 93 | resp := make([]map[string]string, 0, len(items)) 94 | 95 | for _, item := range items { 96 | fields := map[string]string{ 97 | "id": renderer.FormatID(item.GetId(), r.full), 98 | "app": renderer.FormatAppName(r.mapper, item.GetAppId(), r.full), 99 | "name": item.GetName(), 100 | "type": string(item.GetType()), 101 | "status": formatServiceStatus(item.GetStatus()), 102 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 103 | } 104 | resp = append(resp, fields) 105 | } 106 | 107 | return resp 108 | } 109 | -------------------------------------------------------------------------------- /pkg/koyeb/regional_deployments_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *RegionalDeploymentHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | list := []koyeb.RegionalDeploymentListItem{} 15 | 16 | deploymentId := "" 17 | if deployment, _ := cmd.Flags().GetString("deployment"); deployment != "" { 18 | var err error 19 | if deploymentId, err = h.ResolveDeploymentArgs(ctx, deployment); err != nil { 20 | return err 21 | } 22 | } 23 | 24 | page := int64(0) 25 | offset := int64(0) 26 | limit := int64(100) 27 | for { 28 | req := ctx.Client.RegionalDeploymentsApi.ListRegionalDeployments(ctx.Context) 29 | 30 | if deploymentId != "" { 31 | req = req.DeploymentId(deploymentId) 32 | } 33 | 34 | res, resp, err := req.Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 35 | if err != nil { 36 | return errors.NewCLIErrorFromAPIError( 37 | "Error while listing regional deployments", 38 | err, 39 | resp, 40 | ) 41 | } 42 | list = append(list, res.GetRegionalDeployments()...) 43 | 44 | page++ 45 | offset = page * limit 46 | if offset >= res.GetCount() { 47 | break 48 | } 49 | } 50 | 51 | full := GetBoolFlags(cmd, "full") 52 | listRegionalDeploymentsReply := NewListRegionalDeploymentsReply(ctx.Mapper, &koyeb.ListRegionalDeploymentsReply{RegionalDeployments: list}, full) 53 | ctx.Renderer.Render(listRegionalDeploymentsReply) 54 | return nil 55 | } 56 | 57 | type ListRegionalDeploymentsReply struct { 58 | mapper *idmapper.Mapper 59 | value *koyeb.ListRegionalDeploymentsReply 60 | full bool 61 | } 62 | 63 | func NewListRegionalDeploymentsReply(mapper *idmapper.Mapper, value *koyeb.ListRegionalDeploymentsReply, full bool) *ListRegionalDeploymentsReply { 64 | return &ListRegionalDeploymentsReply{ 65 | mapper: mapper, 66 | value: value, 67 | full: full, 68 | } 69 | } 70 | 71 | func (ListRegionalDeploymentsReply) Title() string { 72 | return "Regional Deployments" 73 | } 74 | 75 | func (r *ListRegionalDeploymentsReply) MarshalBinary() ([]byte, error) { 76 | return r.value.MarshalJSON() 77 | } 78 | 79 | func (r *ListRegionalDeploymentsReply) Headers() []string { 80 | return []string{"id", "region", "status", "messages", "created_at"} 81 | } 82 | 83 | func (r *ListRegionalDeploymentsReply) Fields() []map[string]string { 84 | items := r.value.GetRegionalDeployments() 85 | resp := make([]map[string]string, 0, len(items)) 86 | 87 | for _, item := range items { 88 | fields := map[string]string{ 89 | "id": renderer.FormatID(item.GetId(), r.full), 90 | "region": item.GetRegion(), 91 | "status": formatRegionalDeploymentStatus(item.GetStatus()), 92 | "messages": formatMessages(item.GetMessages()), 93 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 94 | } 95 | resp = append(resp, fields) 96 | } 97 | 98 | return resp 99 | } 100 | 101 | func formatRegionalDeploymentStatus(status koyeb.RegionalDeploymentStatus) string { 102 | return string(status) 103 | } 104 | -------------------------------------------------------------------------------- /pkg/koyeb/archive/archive.go: -------------------------------------------------------------------------------- 1 | // This package provides the ability to compress a directory into a tarball. It 2 | // is used by the CLI to upload archives to Koyeb. 3 | 4 | package archive 5 | 6 | import ( 7 | "archive/tar" 8 | "compress/gzip" 9 | "fmt" 10 | "io" 11 | "os" 12 | "path/filepath" 13 | "slices" 14 | 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | type tarball struct { 19 | File *os.File 20 | } 21 | 22 | func (t *tarball) Close() error { 23 | log.Debugf("Remove temporary archive file %s", t.File.Name()) 24 | if err := os.Remove(t.File.Name()); err != nil { 25 | return err 26 | } 27 | return t.File.Close() 28 | } 29 | 30 | // Archive compresses a directory into a tarball and returns the path to this tarball. 31 | // Some directories are ignored by default (e.g. .git, node_modules, vendor). 32 | // This is not yet configurable but could be in the future. 33 | func Archive(path string, ignoreDirectories []string) (*tarball, error) { 34 | basePath, err := filepath.Abs(path) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | tempFile, err := os.CreateTemp("", "koyeb-archive-*.tar.gz") 40 | if err != nil { 41 | return nil, err 42 | } 43 | log.Debugf("Create temporary archive file %s", tempFile.Name()) 44 | 45 | tarball := tarball{tempFile} 46 | 47 | // Compress the tarball 48 | gzipWriter := gzip.NewWriter(tempFile) 49 | defer gzipWriter.Close() 50 | 51 | // Create a new tar archive 52 | tarWriter := tar.NewWriter(gzipWriter) 53 | defer tarWriter.Close() 54 | 55 | if err := filepath.Walk(basePath, func(file string, fi os.FileInfo, err error) error { 56 | if err != nil { 57 | return err 58 | } 59 | 60 | // Ignore directories that should not be included in the archive. We 61 | // only match the base name of the directory, there is no yet support 62 | // for regex or more complex patterns, that could be useful to ignore 63 | // all the files from .gitignore or .dockerignore for example 64 | if fi.IsDir() && slices.Contains(ignoreDirectories, filepath.Base(file)) { 65 | log.Debugf("Archive: skip %s", file) 66 | return filepath.SkipDir 67 | } 68 | 69 | log.Debugf("Archive %s", file) 70 | 71 | relativePath, err := filepath.Rel(basePath, file) 72 | if err != nil { 73 | return fmt.Errorf("Unable to get relative path for file '%s': %w", file, err) 74 | } 75 | 76 | // Create header 77 | header, err := tar.FileInfoHeader(fi, "") 78 | if err != nil { 79 | return fmt.Errorf("Unable to create header for file '%s': %w", file, err) 80 | } 81 | 82 | header.Name = filepath.ToSlash(relativePath) 83 | 84 | // Write header 85 | if err := tarWriter.WriteHeader(header); err != nil { 86 | return fmt.Errorf("Unable to write header for file '%s': %w", file, err) 87 | } 88 | 89 | if header.Typeflag == tar.TypeReg { 90 | data, err := os.Open(file) 91 | if err != nil { 92 | return fmt.Errorf("Unable to open file '%s': %w", file, err) 93 | } 94 | defer data.Close() 95 | 96 | if _, err := io.Copy(tarWriter, data); err != nil { 97 | return fmt.Errorf("Unable to copy file '%s' into the tarball: %w", file, err) 98 | } 99 | } 100 | return nil 101 | }); err != nil { 102 | tarball.Close() // Remove the temporary file in case of error 103 | return nil, err 104 | } 105 | return &tarball, nil 106 | } 107 | -------------------------------------------------------------------------------- /pkg/koyeb/services_create.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func (h *ServiceHandler) Create(ctx *CLIContext, cmd *cobra.Command, args []string, createService *koyeb.CreateService) error { 15 | appID, err := h.parseAppName(cmd, args[0]) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | app, err := h.ResolveAppArgs(ctx, appID) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | wait, _ := cmd.Flags().GetBool("wait") 26 | waitTimeout, _ := cmd.Flags().GetDuration("wait-timeout") 27 | 28 | resApp, resp, err := ctx.Client.AppsApi.GetApp(ctx.Context, app).Execute() 29 | if err != nil { 30 | return errors.NewCLIErrorFromAPIError( 31 | fmt.Sprintf("Error while retrieving the application `%s`", appID), 32 | err, 33 | resp, 34 | ) 35 | } 36 | 37 | createService.SetAppId(resApp.App.GetId()) 38 | res, resp, err := ctx.Client.ServicesApi.CreateService(ctx.Context).Service(*createService).Execute() 39 | if err != nil { 40 | return errors.NewCLIErrorFromAPIError( 41 | "Error while creating the service", 42 | err, 43 | resp, 44 | ) 45 | } 46 | log.Infof( 47 | "Service deployment in progress. To access the build logs, run: `koyeb service logs %s -t build`. For the runtime logs, run `koyeb service logs %s`", 48 | res.Service.GetId()[:8], 49 | res.Service.GetId()[:8], 50 | ) 51 | defer func() { 52 | res, _, err := ctx.Client.ServicesApi.GetService(ctx.Context, res.Service.GetId()).Execute() 53 | if err != nil { 54 | return 55 | } 56 | full := GetBoolFlags(cmd, "full") 57 | getServiceReply := NewGetServiceReply(ctx.Mapper, &koyeb.GetServiceReply{Service: res.Service}, full) 58 | ctx.Renderer.Render(getServiceReply) 59 | }() 60 | 61 | if wait { 62 | ctxd, cancel := context.WithTimeout(ctx.Context, waitTimeout) 63 | defer cancel() 64 | 65 | for range ticker(ctxd, 2*time.Second) { 66 | res, resp, err := ctx.Client.ServicesApi.GetService(ctxd, res.Service.GetId()).Execute() 67 | if err != nil { 68 | return errors.NewCLIErrorFromAPIError( 69 | "Error while fetching service", 70 | err, 71 | resp, 72 | ) 73 | } 74 | 75 | if res.Service != nil && res.Service.Status != nil { 76 | switch status := *res.Service.Status; status { 77 | case koyeb.SERVICESTATUS_DELETED, koyeb.SERVICESTATUS_DEGRADED, koyeb.SERVICESTATUS_UNHEALTHY: 78 | return fmt.Errorf("Service %s deployment ended in status: %s", res.Service.GetId()[:8], status) 79 | case koyeb.SERVICESTATUS_STARTING, koyeb.SERVICESTATUS_RESUMING, koyeb.SERVICESTATUS_DELETING, koyeb.SERVICESTATUS_PAUSING: 80 | break 81 | default: 82 | return nil 83 | } 84 | } 85 | } 86 | 87 | log.Infof("Service deployment still in progress, --wait timed out. To access the build logs, run: `koyeb service logs %s -t build`. For the runtime logs, run `koyeb service logs %s`", 88 | res.Service.GetId()[:8], 89 | res.Service.GetId()[:8], 90 | ) 91 | return fmt.Errorf("service deployment still in progress, --wait timed out. To access the build logs, run: `koyeb service logs %s -t build`. For the runtime logs, run `koyeb service logs %s`", 92 | res.Service.GetId()[:8], 93 | res.Service.GetId()[:8], 94 | ) 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/koyeb/context.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type ctxkey int 13 | 14 | const ( 15 | ctx_client ctxkey = iota 16 | ctx_logs_client 17 | ctx_exec_client 18 | ctx_mapper 19 | ctx_renderer 20 | ctx_organization 21 | ) 22 | 23 | // SetupCLIContext is called by the root command to setup the context for all subcommands. 24 | // When `organization` is not empty, it should contain the ID of the organization to switch the context to. 25 | func SetupCLIContext(cmd *cobra.Command, organization string) error { 26 | apiClient, err := getApiClient() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | ctx := cmd.Context() 32 | ctx = context.WithValue(ctx, koyeb.ContextAccessToken, token) 33 | 34 | if organization != "" { 35 | token, err := GetOrganizationToken(apiClient.OrganizationApi, ctx, organization) 36 | if err != nil { 37 | return err 38 | } 39 | ctx = context.WithValue(ctx, koyeb.ContextAccessToken, token) 40 | // Update command context with the organization token. This is required 41 | // because the idmapper initialization below will use the token from the 42 | // context. 43 | cmd.SetContext(ctx) 44 | } 45 | 46 | ctx = context.WithValue(ctx, ctx_client, apiClient) 47 | 48 | logsApiClient, err := NewLogsAPIClient(apiClient, apiurl, ctx.Value(koyeb.ContextAccessToken).(string)) 49 | if err != nil { 50 | return err 51 | } 52 | ctx = context.WithValue(ctx, ctx_logs_client, logsApiClient) 53 | 54 | execApiClient, err := NewExecAPIClient(apiurl, ctx.Value(koyeb.ContextAccessToken).(string)) 55 | if err != nil { 56 | return err 57 | } 58 | ctx = context.WithValue(ctx, ctx_exec_client, execApiClient) 59 | 60 | ctx = context.WithValue(ctx, ctx_mapper, idmapper.NewMapper(ctx, apiClient)) 61 | ctx = context.WithValue(ctx, ctx_renderer, renderer.NewRenderer(outputFormat)) 62 | ctx = context.WithValue(ctx, ctx_organization, organization) 63 | cmd.SetContext(ctx) 64 | 65 | return nil 66 | } 67 | 68 | type CLIContext struct { 69 | Context context.Context 70 | Client *koyeb.APIClient 71 | LogsClient *LogsAPIClient 72 | ExecClient *ExecAPIClient 73 | Mapper *idmapper.Mapper 74 | Token string 75 | Renderer renderer.Renderer 76 | Organization string 77 | } 78 | 79 | // GetCLIContext transforms the untyped context passed to cobra commands into a CLIContext. 80 | func GetCLIContext(ctx context.Context) *CLIContext { 81 | return &CLIContext{ 82 | Context: ctx, 83 | Client: ctx.Value(ctx_client).(*koyeb.APIClient), 84 | LogsClient: ctx.Value(ctx_logs_client).(*LogsAPIClient), 85 | ExecClient: ctx.Value(ctx_exec_client).(*ExecAPIClient), 86 | Mapper: ctx.Value(ctx_mapper).(*idmapper.Mapper), 87 | Token: ctx.Value(koyeb.ContextAccessToken).(string), 88 | Renderer: ctx.Value(ctx_renderer).(renderer.Renderer), 89 | Organization: ctx.Value(ctx_organization).(string), 90 | } 91 | } 92 | 93 | // WithCLIContext is a decorator that provides a CLIContext to cobra commands. 94 | func WithCLIContext(fn func(ctx *CLIContext, cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { 95 | return func(cmd *cobra.Command, args []string) error { 96 | return fn(GetCLIContext(cmd.Context()), cmd, args) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/koyeb/idmapper/database.go: -------------------------------------------------------------------------------- 1 | package idmapper 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | type DatabaseMapper struct { 13 | ctx context.Context 14 | client *koyeb.APIClient 15 | appMapper *AppMapper 16 | fetched bool 17 | sidMap *IDMap 18 | nameMap *IDMap 19 | } 20 | 21 | func NewDatabaseMapper(ctx context.Context, client *koyeb.APIClient, appMapper *AppMapper) *DatabaseMapper { 22 | return &DatabaseMapper{ 23 | ctx: ctx, 24 | client: client, 25 | appMapper: appMapper, 26 | fetched: false, 27 | sidMap: NewIDMap(), 28 | nameMap: NewIDMap(), 29 | } 30 | } 31 | 32 | func (mapper *DatabaseMapper) ResolveID(val string) (string, error) { 33 | if IsUUIDv4(val) { 34 | return val, nil 35 | } 36 | 37 | if !mapper.fetched { 38 | err := mapper.fetch() 39 | if err != nil { 40 | return "", err 41 | } 42 | } 43 | 44 | id, ok := mapper.sidMap.GetID(val) 45 | if ok { 46 | return id, nil 47 | } 48 | 49 | id, ok = mapper.nameMap.GetID(val) 50 | if ok { 51 | return id, nil 52 | } 53 | 54 | return "", errors.NewCLIErrorForMapperResolve( 55 | "database", 56 | val, 57 | []string{"database full UUID", "service short ID (8 characters)", "the database name prefixed by the application name and a slash (e.g. my-app/my-database)"}, 58 | ) 59 | } 60 | 61 | func (mapper *DatabaseMapper) fetch() error { 62 | page := int64(0) 63 | offset := int64(0) 64 | limit := int64(100) 65 | for { 66 | res, resp, err := mapper.client.ServicesApi.ListServices(mapper.ctx). 67 | Types([]string{"DATABASE"}). 68 | Limit(strconv.FormatInt(limit, 10)). 69 | Offset(strconv.FormatInt(offset, 10)). 70 | Execute() 71 | if err != nil { 72 | return errors.NewCLIErrorFromAPIError( 73 | "Error listing databases to resolve the provided identifier to an object ID", 74 | err, 75 | resp, 76 | ) 77 | } 78 | 79 | for _, service := range res.GetServices() { 80 | mapper.sidMap.Set(service.GetId(), getShortID(service.GetId(), 8)) 81 | 82 | appName, err := mapper.appMapper.GetName(service.GetAppId()) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | // Possible values: 88 | // / 89 | // / 90 | // / 91 | // 92 | // / 93 | // / 94 | // / 95 | // 96 | // / 97 | // / 98 | // / 99 | for _, key := range []string{ 100 | fmt.Sprint(appName, "/", service.GetId()), 101 | fmt.Sprint(service.GetAppId(), "/", service.GetId()), 102 | fmt.Sprint(service.GetAppId()[:8], "/", service.GetId()), 103 | 104 | fmt.Sprint(appName, "/", getShortID(service.GetId(), 8)), 105 | fmt.Sprint(service.GetAppId(), "/", getShortID(service.GetId(), 8)), 106 | fmt.Sprint(service.GetAppId()[:8], "/", getShortID(service.GetId(), 8)), 107 | 108 | fmt.Sprint(appName, "/", service.GetName()), 109 | fmt.Sprint(service.GetAppId(), "/", service.GetName()), 110 | fmt.Sprint(service.GetAppId()[:8], "/", service.GetName()), 111 | } { 112 | mapper.nameMap.Set(service.GetId(), key) 113 | } 114 | } 115 | 116 | page++ 117 | offset = page * limit 118 | if offset >= res.GetCount() { 119 | break 120 | } 121 | } 122 | 123 | mapper.fetched = true 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /pkg/koyeb/secrets_reveal.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func (h *SecretHandler) Reveal(ctx *CLIContext, cmd *cobra.Command, args []string) error { 13 | secret, err := ResolveSecretArgs(ctx, args[0]) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | // RevealSecret require to pass an empty body 19 | body := make(map[string]interface{}) 20 | _, resp, err := ctx.Client.SecretsApi.RevealSecret(ctx.Context, secret).Body(body).Execute() 21 | 22 | // The field Value of RevealSecretReply is generated from a google.protobuf.Value type which is represented as a 23 | // map[string]interface{}. 24 | // The function RevealSecret(...).Execute() returns an error, because it is unable to unmarshal the response body. 25 | // Here, we only return the error for the case where the response status code is not 200 and compute the secret value 26 | // from the response body. 27 | if resp.StatusCode != 200 && err != nil { 28 | return errors.NewCLIErrorFromAPIError( 29 | fmt.Sprintf("Error while revealing the secret `%s`", args[0]), 30 | err, 31 | resp, 32 | ) 33 | } 34 | 35 | buffer, err := io.ReadAll(resp.Body) 36 | if err != nil { 37 | return &errors.CLIError{ 38 | What: "Error while reading the response body", 39 | Why: "the response body could not be read", 40 | Additional: []string{ 41 | "The Koyeb API to retrieve a secret value returned a response body that could not be read.", 42 | }, 43 | Orig: nil, 44 | Solution: "Try to update the CLI to the latest version. If the problem persists, please create an issue on https://github.com/koyeb/koyeb-cli/issues/new", 45 | } 46 | } 47 | 48 | output := map[string]interface{}{} 49 | if err := json.Unmarshal(buffer, &output); err != nil { 50 | return &errors.CLIError{ 51 | What: "Error while unmarshalling the response body", 52 | Why: "the response body could not be unmarshalled", 53 | Additional: []string{ 54 | "The Koyeb API to retrieve a secret value returned a response body that could not be unmarshalled.", 55 | }, 56 | Orig: nil, 57 | Solution: "Try to update the CLI to the latest version. If the problem persists, please create an issue on https://github.com/koyeb/koyeb-cli/issues/new", 58 | } 59 | } 60 | 61 | if value, ok := output["value"]; ok { 62 | switch v := value.(type) { 63 | case map[string]interface{}: 64 | for key, value := range v { 65 | fmt.Printf("%s: %v\n", key, value) 66 | } 67 | return nil 68 | case string: 69 | fmt.Printf("%s\n", v) 70 | return nil 71 | default: 72 | return &errors.CLIError{ 73 | What: "Error while reading the secret value", 74 | Why: "the secret value has an unexpected format", 75 | Additional: []string{ 76 | "The Koyeb API to retrieve a secret value returned a secret type that the CLI could not understand.", 77 | }, 78 | Orig: nil, 79 | Solution: "Try to update the CLI to the latest version. If the problem persists, please create an issue on https://github.com/koyeb/koyeb-cli/issues/new", 80 | } 81 | } 82 | } 83 | return &errors.CLIError{ 84 | What: "Error while reading the secret value", 85 | Why: "the secret value has an unexpected format", 86 | Additional: []string{ 87 | "The Koyeb API to retrieve a secret value returned a response body that the CLI could not understand.", 88 | }, 89 | Orig: nil, 90 | Solution: "Try to update the CLI to the latest version. If the problem persists, please create an issue on https://github.com/koyeb/koyeb-cli/issues/new", 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pkg/koyeb/flags_list/proxy_ports.go: -------------------------------------------------------------------------------- 1 | package flags_list 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 10 | ) 11 | 12 | type FlagProxyPort struct { 13 | BaseFlag 14 | port int64 15 | protocol string 16 | } 17 | 18 | func NewProxyPortListFromFlags(values []string) ([]Flag[koyeb.DeploymentProxyPort], error) { 19 | ret := make([]Flag[koyeb.DeploymentProxyPort], 0, len(values)) 20 | 21 | for _, value := range values { 22 | port := &FlagProxyPort{BaseFlag: BaseFlag{cliValue: value}} 23 | 24 | if strings.HasPrefix(value, "!") { 25 | port.markedForDeletion = true 26 | value = value[1:] 27 | } 28 | 29 | split := strings.Split(value, ":") 30 | portNum, err := strconv.Atoi(split[0]) 31 | if err != nil { 32 | return nil, &errors.CLIError{ 33 | What: "Error while configuring the service", 34 | Why: fmt.Sprintf("unable to parse the port \"%s\"", port.cliValue), 35 | Additional: []string{ 36 | "ProxyPorts must be specified as PORT[:PROTOCOL]", 37 | "PORT must be a valid port number (e.g. 80)", 38 | "PROTOCOL must be either \"http\", \"http2\" or \"tcp\". It can be omitted, in which case it defaults to \"http\"", 39 | "To remove a port from the service, prefix it with '!', e.g. '!80'", 40 | }, 41 | Orig: nil, 42 | Solution: "Fix the port and try again", 43 | } 44 | } 45 | port.port = int64(portNum) 46 | 47 | if port.markedForDeletion { 48 | if len(split) > 1 { 49 | return nil, &errors.CLIError{ 50 | What: "Error while configuring the service", 51 | Why: fmt.Sprintf("unable to parse the port \"%s\"", port.cliValue), 52 | Additional: []string{ 53 | "To remove a port from the service, prefix it with '!', e.g. '!80'", 54 | "The protocol should not be specified when removing a port from the service", 55 | }, 56 | Orig: nil, 57 | Solution: "Fix the port and try again", 58 | } 59 | } 60 | } else { 61 | port.protocol = *koyeb.PtrString("http") 62 | if len(split) > 1 { 63 | if strings.ToLower(split[1]) != "http" && strings.ToLower(split[1]) != "http2" && strings.ToLower(split[1]) != "tcp" { 64 | return nil, &errors.CLIError{ 65 | What: "Error while configuring the service", 66 | Why: fmt.Sprintf("unable to parse the protocol from the port \"%s\"", port.cliValue), 67 | Additional: []string{ 68 | "ProxyPorts must be specified as PORT[:PROTOCOL]", 69 | "PORT must be a valid port number (e.g. 80)", 70 | "PROTOCOL must be either \"http\", \"http2\" or \"tcp\". It can be omitted, in which case it defaults to \"http\"", 71 | }, 72 | Orig: nil, 73 | Solution: "Fix the protocol and try again", 74 | } 75 | } 76 | port.protocol = *koyeb.PtrString(split[1]) 77 | } 78 | } 79 | ret = append(ret, port) 80 | } 81 | return ret, nil 82 | } 83 | 84 | func (f *FlagProxyPort) IsEqualTo(port koyeb.DeploymentProxyPort) bool { 85 | return f.port == *port.Port 86 | } 87 | 88 | func (f *FlagProxyPort) UpdateItem(port *koyeb.DeploymentProxyPort) { 89 | port.Port = koyeb.PtrInt64(f.port) 90 | protocol, err := koyeb.NewProxyPortProtocolFromValue(f.protocol) 91 | if err != nil { 92 | protocolValue := koyeb.PROXYPORTPROTOCOL_TCP 93 | protocol = &protocolValue 94 | } 95 | port.Protocol = protocol 96 | } 97 | 98 | func (f *FlagProxyPort) CreateNewItem() *koyeb.DeploymentProxyPort { 99 | item := koyeb.NewDeploymentProxyPortWithDefaults() 100 | f.UpdateItem(item) 101 | return item 102 | } 103 | -------------------------------------------------------------------------------- /pkg/koyeb/deployments_list.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/koyeb/koyeb-cli/pkg/koyeb/idmapper" 9 | "github.com/koyeb/koyeb-cli/pkg/koyeb/renderer" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func (h *DeploymentHandler) List(ctx *CLIContext, cmd *cobra.Command, args []string) error { 14 | list := []koyeb.DeploymentListItem{} 15 | 16 | appId := "" 17 | if app, _ := cmd.Flags().GetString("app"); app != "" { 18 | var err error 19 | if appId, err = h.ResolveAppArgs(ctx, app); err != nil { 20 | return err 21 | } 22 | } 23 | serviceId := "" 24 | if service, _ := cmd.Flags().GetString("service"); service != "" { 25 | var err error 26 | if serviceId, err = h.ResolveServiceArgs(ctx, service); err != nil { 27 | return err 28 | } 29 | } 30 | 31 | page := int64(0) 32 | offset := int64(0) 33 | limit := int64(100) 34 | for { 35 | req := ctx.Client.DeploymentsApi.ListDeployments(ctx.Context) 36 | 37 | if appId != "" { 38 | req = req.AppId(appId) 39 | } 40 | if serviceId != "" { 41 | req = req.ServiceId(serviceId) 42 | } 43 | 44 | res, resp, err := req.Limit(strconv.FormatInt(limit, 10)).Offset(strconv.FormatInt(offset, 10)).Execute() 45 | if err != nil { 46 | return errors.NewCLIErrorFromAPIError( 47 | "Error while listing the deployments", 48 | err, 49 | resp, 50 | ) 51 | } 52 | list = append(list, res.GetDeployments()...) 53 | 54 | page++ 55 | offset = page * limit 56 | if offset >= res.GetCount() { 57 | break 58 | } 59 | } 60 | 61 | full := GetBoolFlags(cmd, "full") 62 | listDeploymentsReply := NewListDeploymentsReply(ctx.Mapper, &koyeb.ListDeploymentsReply{Deployments: list}, full) 63 | ctx.Renderer.Render(listDeploymentsReply) 64 | return nil 65 | } 66 | 67 | type ListDeploymentsReply struct { 68 | mapper *idmapper.Mapper 69 | value *koyeb.ListDeploymentsReply 70 | full bool 71 | } 72 | 73 | func NewListDeploymentsReply(mapper *idmapper.Mapper, value *koyeb.ListDeploymentsReply, full bool) *ListDeploymentsReply { 74 | return &ListDeploymentsReply{ 75 | mapper: mapper, 76 | value: value, 77 | full: full, 78 | } 79 | } 80 | 81 | func (ListDeploymentsReply) Title() string { 82 | return "Deployments" 83 | } 84 | 85 | func (r *ListDeploymentsReply) MarshalBinary() ([]byte, error) { 86 | return r.value.MarshalJSON() 87 | } 88 | 89 | func (r *ListDeploymentsReply) Headers() []string { 90 | return []string{"id", "service", "type", "status", "messages", "regions", "created_at"} 91 | } 92 | 93 | func (r *ListDeploymentsReply) Fields() []map[string]string { 94 | items := r.value.GetDeployments() 95 | resp := make([]map[string]string, 0, len(items)) 96 | maxMessagesLength := 80 97 | if r.full { 98 | maxMessagesLength = 0 99 | } 100 | 101 | for _, item := range items { 102 | if item.GetStatus() == koyeb.DEPLOYMENTSTATUS_STASHED { 103 | continue 104 | } 105 | 106 | fields := map[string]string{ 107 | "id": renderer.FormatID(item.GetId(), r.full), 108 | "service": renderer.FormatServiceSlug(r.mapper, item.GetServiceId(), r.full), 109 | "type": formatDeploymentType(item.Definition.GetType()), 110 | "status": formatDeploymentStatus(item.GetStatus()), 111 | "messages": formatDeploymentMessages(item.GetMessages(), maxMessagesLength), 112 | "regions": renderRegions(item.Definition.Regions), 113 | "created_at": renderer.FormatTime(item.GetCreatedAt()), 114 | } 115 | resp = append(resp, fields) 116 | } 117 | 118 | return resp 119 | } 120 | -------------------------------------------------------------------------------- /pkg/koyeb/metrics.go: -------------------------------------------------------------------------------- 1 | package koyeb 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/araddon/dateparse" 7 | "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func NewMetricsCmd() *cobra.Command { 12 | h := NewMetricsHandler() 13 | 14 | metricsCmd := &cobra.Command{ 15 | Use: "metrics ACTION", 16 | Aliases: []string{"metric"}, 17 | Short: "Metrics", 18 | } 19 | 20 | getMetricsCmd := &cobra.Command{ 21 | Use: "get", 22 | Short: "Get metrics for a service or instance", 23 | RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { 24 | service := GetStringFlags(cmd, "service") 25 | instance := GetStringFlags(cmd, "instance") 26 | 27 | if service == "" && instance == "" { 28 | return &errors.CLIError{ 29 | What: "Error while fetching metrics", 30 | Why: "you must specify --service or --instance", 31 | Additional: []string{}, 32 | Orig: nil, 33 | Solution: "Add the missing flag and try again", 34 | } 35 | } else if service != "" && instance != "" { 36 | return &errors.CLIError{ 37 | What: "Error while fetching metrics", 38 | Why: "you must specify --service or --instance, not both", 39 | Additional: []string{}, 40 | Orig: nil, 41 | Solution: "Remove the extra flag and try again", 42 | } 43 | } 44 | 45 | var start *time.Time 46 | var end *time.Time 47 | 48 | if value := GetStringFlags(cmd, "start"); value != "" { 49 | parsed, err := dateparse.ParseStrict(value) 50 | if err != nil { 51 | return &errors.CLIError{ 52 | What: "Error while fetching metrics", 53 | Why: "invalid date format for --start", 54 | Additional: []string{}, 55 | Orig: err, 56 | Solution: "Fix the date format and try again", 57 | } 58 | } 59 | start = &parsed 60 | } 61 | if value := GetStringFlags(cmd, "end"); value != "" { 62 | parsed, err := dateparse.ParseStrict(value) 63 | if err != nil { 64 | return &errors.CLIError{ 65 | What: "Error while fetching metrics", 66 | Why: "invalid date format for --end", 67 | Additional: []string{}, 68 | Orig: err, 69 | Solution: "Fix the date format and try again", 70 | } 71 | } 72 | end = &parsed 73 | } 74 | 75 | if service != "" { 76 | return h.GetForService(ctx, cmd, service, start, end) 77 | } 78 | return h.GetForInstance(ctx, cmd, instance, start, end) 79 | }), 80 | } 81 | getMetricsCmd.Flags().String("service", "", "Service name or ID") 82 | getMetricsCmd.Flags().String("instance", "", "Instance name or ID") 83 | getMetricsCmd.Flags().String("start", "", "Start date for the metrics") 84 | getMetricsCmd.Flags().String("end", "", "End date for the metrics") 85 | 86 | metricsCmd.AddCommand(getMetricsCmd) 87 | 88 | return metricsCmd 89 | } 90 | 91 | func NewMetricsHandler() *MetricsHandler { 92 | return &MetricsHandler{} 93 | } 94 | 95 | type MetricsHandler struct { 96 | } 97 | 98 | func (h *MetricsHandler) ResolveServiceArgs(ctx *CLIContext, val string) (string, error) { 99 | serviceMapper := ctx.Mapper.Service() 100 | id, err := serviceMapper.ResolveID(val) 101 | if err != nil { 102 | return "", err 103 | } 104 | return id, nil 105 | } 106 | 107 | func (h *MetricsHandler) ResolveInstanceArgs(ctx *CLIContext, val string) (string, error) { 108 | instanceMapper := ctx.Mapper.Instance() 109 | id, err := instanceMapper.ResolveID(val) 110 | if err != nil { 111 | return "", err 112 | } 113 | return id, nil 114 | } 115 | --------------------------------------------------------------------------------