From 3403fd9561cf22c5478c5d99e53602568ba50765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20B=C4=99za?= Date: Tue, 7 May 2024 16:35:28 +0200 Subject: [PATCH] Add docker like koyeb compose functionality --- examples/koyeb-compose.yaml | 21 ++++ go.mod | 4 + go.sum | 13 +++ pkg/koyeb/compose.go | 221 ++++++++++++++++++++++++++++++++++++ pkg/koyeb/koyeb.go | 1 + 5 files changed, 260 insertions(+) create mode 100644 examples/koyeb-compose.yaml create mode 100644 pkg/koyeb/compose.go diff --git a/examples/koyeb-compose.yaml b/examples/koyeb-compose.yaml new file mode 100644 index 00000000..0f1d0307 --- /dev/null +++ b/examples/koyeb-compose.yaml @@ -0,0 +1,21 @@ +name: test-compose-service +services: + compose-go-service: + git: + repository: github.com/koyeb/example-golang + branch: main + ports: + - port: 8000 + regions: ['par'] + scalings: + - scopes: ['region:par'] + min: 1 + max: 1 + instance_types: + - scopes: ['region:par'] + type: 'nano' + envs: + - scopes: ['region:par'] + key: 'PORT' + value: '8000' + diff --git a/go.mod b/go.mod index ba7b8376..318ff8cc 100644 --- a/go.mod +++ b/go.mod @@ -27,9 +27,11 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/briandowns/spinner v1.23.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-github/v30 v30.1.0 // indirect @@ -38,6 +40,8 @@ require ( github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/magiconair/properties v1.8.6 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect diff --git a/go.sum b/go.sum index d9bdc886..e5973cd9 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhP github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= +github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= @@ -71,6 +73,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= @@ -180,6 +184,12 @@ github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamh github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= @@ -374,6 +384,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -396,6 +407,8 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/koyeb/compose.go b/pkg/koyeb/compose.go new file mode 100644 index 00000000..2cbd27ae --- /dev/null +++ b/pkg/koyeb/compose.go @@ -0,0 +1,221 @@ +package koyeb + +import ( + "fmt" + "os" + "slices" + "time" + + "github.com/briandowns/spinner" + "github.com/ghodss/yaml" + "github.com/koyeb/koyeb-api-client-go/api/v1/koyeb" + "github.com/koyeb/koyeb-cli/pkg/koyeb/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func NewComposeCmd() *cobra.Command { + return &cobra.Command{ + Use: "compose KOYEB_COMPOSE_FILE_PATH", + Short: "Create koyeb resources read from koyeb-compose.yaml file", + Example: ` + # Init koyeb-compose.yaml file + $> echo 'apps: + - name: example-app + services: + - name: example-service1 + image: nginx:latest + ports: + - 80:80 + - name: example-service2 + path: github.com/koyeb/golang-example-app + branch: main + ports: + - 8080:8080 + depends_on: + - example-service1' > koyeb-compose.yaml + # Apply compose file + $> koyeb compose koyeb-compose.yaml + `, + Args: cobra.ExactArgs(1), + RunE: WithCLIContext(func(ctx *CLIContext, cmd *cobra.Command, args []string) error { + composeFile, err := parseComposeFile(args[0]) + if err != nil { + return err + } + + // TODO (pawel) validate compose file + // TODO (pawel) better error handling and tips how to fix the errors + + return NewKoyebComposeHandler().Compose(ctx, composeFile) + }), + } +} + +func parseComposeFile(path string) (*KoyebCompose, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var config *KoyebCompose + err = yaml.Unmarshal(data, &config) + if err != nil { + return nil, err + } + + return config, nil +} + +type KoyebCompose struct { + koyeb.CreateApp `yaml:",inline"` + Services map[string]KoyebComposeService `yaml:"services"` +} + +type KoyebComposeService struct { + koyeb.DeploymentDefinition `yaml:",inline"` + DependsOn []string `json:"depends_on"` +} + +type KoyebComposeHandler struct{} + +func NewKoyebComposeHandler() *KoyebComposeHandler { + return &KoyebComposeHandler{} +} + +func (h *KoyebComposeHandler) Compose(ctx *CLIContext, koyebCompose *KoyebCompose) error { + appName := *koyebCompose.Name + appId, err := h.CreateAppIfNotExists(ctx, appName) + if err != nil { + return err + } + + for serviceName, serviceDetails := range koyebCompose.Services { + serviceDefinition := &serviceDetails.DeploymentDefinition + serviceDefinition.Name = koyeb.PtrString(serviceName) + + deploymentId, err := h.UpdateService(ctx, appId, appName, serviceDefinition) + if err != nil { + return err + } + + err = h.MonitorService(ctx, deploymentId, serviceName) + if err != nil { + return err + } + + } + + return nil +} + +func (h *KoyebComposeHandler) isMonitoringEndState(status koyeb.DeploymentStatus) bool { + return slices.Contains([]koyeb.DeploymentStatus{ + koyeb.DEPLOYMENTSTATUS_HEALTHY, + koyeb.DEPLOYMENTSTATUS_DEGRADED, + koyeb.DEPLOYMENTSTATUS_UNHEALTHY, + koyeb.DEPLOYMENTSTATUS_CANCELED, + koyeb.DEPLOYMENTSTATUS_STOPPED, + koyeb.DEPLOYMENTSTATUS_ERROR, + }, status) +} + +func (h *KoyebComposeHandler) MonitorService(ctx *CLIContext, deploymentId, serviceName string) error { + s := spinner.New(spinner.CharSets[21], 100*time.Millisecond, spinner.WithColor("green")) + s.Start() + defer s.Stop() + + previousStatus := koyeb.DeploymentStatus("") + // it's dumb as it's busy waiting but for now we don't support streaming events + for { + resDeployment, resp, err := ctx.Client.DeploymentsApi.GetDeployment(ctx.Context, deploymentId).Execute() + if err != nil { + return errors.NewCLIErrorFromAPIError( + "Error while fetching deployment status", + err, + resp, + ) + } + + currentStatus := resDeployment.Deployment.GetStatus() + if previousStatus != currentStatus { + previousStatus = currentStatus + s.Suffix = fmt.Sprintf(" Deploying service %s: %s", serviceName, currentStatus) + } + + if h.isMonitoringEndState(currentStatus) { + break + } + + time.Sleep(5 * time.Second) + } + + if previousStatus == koyeb.DEPLOYMENTSTATUS_HEALTHY { + log.Infof("\nSucccessfully deployed %v ✅", serviceName) + } else { + log.Errorf("\nFailed to deploy %v deployment status: %v ❌", serviceName, previousStatus) + return fmt.Errorf("failed to deploy %v", serviceName) + } + + return nil +} + +// Creates app if not exists and returns app id and error if any +func (h *KoyebComposeHandler) CreateAppIfNotExists(ctx *CLIContext, appName string) (string, error) { + appId, err := NewAppHandler().ResolveAppArgs(ctx, appName) + if err == nil { + return appId, nil + } + + // if we fail to fetch the app id then failover to app creation + createApp := koyeb.CreateApp{Name: &appName} + resApp, resp, err := ctx.Client.AppsApi.CreateApp(ctx.Context).App(createApp).Execute() + if err != nil { + return "", errors.NewCLIErrorFromAPIError( + fmt.Sprintf("Error while creating the application `%s`", appName), + err, + resp, + ) + } + + return resApp.App.GetId(), nil +} + +// Updates service or creates one if not exists +// returns deployment id +func (h *KoyebComposeHandler) UpdateService(ctx *CLIContext, appId, appName string, deploymentDefinition *koyeb.DeploymentDefinition) (string, error) { + fullServiceName := fmt.Sprintf("%s/%s", appName, *deploymentDefinition.Name) + serviceId, err := NewServiceHandler().ResolveServiceArgs(ctx, fullServiceName) + if err != nil { + // if we fail to fetch the service id then failover to service creation + createService := &koyeb.CreateService{ + AppId: &appId, + Definition: deploymentDefinition, + } + + resService, resp, err := ctx.Client.ServicesApi.CreateService(ctx.Context).Service(*createService).Execute() + if err != nil { + return "", errors.NewCLIErrorFromAPIError( + "Error while creating the service", + err, + resp, + ) + } + + return *resService.Service.LatestDeploymentId, nil + } + + updateService := &koyeb.UpdateService{ + Definition: deploymentDefinition, + } + resService, resp, err := ctx.Client.ServicesApi.UpdateService(ctx.Context, serviceId).Service(*updateService).Execute() + if err != nil { + return "", errors.NewCLIErrorFromAPIError( + "Error while creating the service", + err, + resp, + ) + } + + return *resService.Service.LatestDeploymentId, nil +} diff --git a/pkg/koyeb/koyeb.go b/pkg/koyeb/koyeb.go index 0fa4fee0..dc58d32a 100644 --- a/pkg/koyeb/koyeb.go +++ b/pkg/koyeb/koyeb.go @@ -112,6 +112,7 @@ func GetRootCommand() *cobra.Command { rootCmd.AddCommand(NewRegionalDeploymentCmd()) rootCmd.AddCommand(NewDatabaseCmd()) rootCmd.AddCommand(NewMetricsCmd()) + rootCmd.AddCommand(NewComposeCmd()) return rootCmd }