diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go index 457bec9..a06ce37 100644 --- a/cmd/deploy/deploy.go +++ b/cmd/deploy/deploy.go @@ -77,6 +77,12 @@ It assumes that your VPS is already configured and that your application is read if loadError != nil { panic(loadError) } + + // Check deployment type + if appConfig.DeploymentType == "compose" { + deployCompose(appConfig) + return + } replacer := strings.NewReplacer( "$service_name", appConfig.Name, "$app_port", fmt.Sprint(appConfig.Port), diff --git a/cmd/deploy/deploy_compose.go b/cmd/deploy/deploy_compose.go new file mode 100644 index 0000000..afc90c2 --- /dev/null +++ b/cmd/deploy/deploy_compose.go @@ -0,0 +1,291 @@ +/* +Copyright © 2024 Mahmoud Mousa + +Licensed under the GNU GPL License, Version 3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +https://www.gnu.org/licenses/gpl-3.0.en.html + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package deploy implements Docker Compose deployment support for Sidekick +// Author: madebycm (https://github.com/madebycm) +package deploy + +import ( + "crypto/md5" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/joho/godotenv" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mightymoud/sidekick/render" + "github.com/mightymoud/sidekick/utils" + "github.com/pterm/pterm" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +func deployCompose(appConfig utils.SidekickAppConfig) { + start := time.Now() + + // Parse the compose file + compose, err := utils.ParseComposeFile(appConfig.ComposeFile) + if err != nil { + pterm.Error.Printf("Failed to parse compose file: %s", err) + os.Exit(1) + } + + // Get services with build contexts + buildServices := utils.GetServicesWithBuildContext(compose) + + // Get main service + mainService := compose.Services[appConfig.MainService] + + // Update Traefik labels on main service + if mainService.Labels == nil { + mainService.Labels = []string{} + } + + // Update labels with new domain if changed + var updatedLabels []string + for _, label := range mainService.Labels { + if strings.HasPrefix(label, "traefik.http.routers.") && strings.Contains(label, ".rule=Host") { + updatedLabels = append(updatedLabels, fmt.Sprintf("traefik.http.routers.%s.rule=Host(`%s`)", appConfig.Name, appConfig.Url)) + } else { + updatedLabels = append(updatedLabels, label) + } + } + mainService.Labels = updatedLabels + compose.Services[appConfig.MainService] = mainService + + // Check env file changes + shouldUpdateEnv := false + if appConfig.Env.File != "" && utils.FileExists(appConfig.Env.File) { + // Calculate current env file hash + envFile, _ := os.Open(appConfig.Env.File) + envMap, _ := godotenv.Parse(envFile) + envFileContent, _ := godotenv.Marshal(envMap) + currentHash := fmt.Sprintf("%x", md5.Sum([]byte(envFileContent))) + + if currentHash != appConfig.Env.Hash { + pterm.Info.Println("Detected changes to environment file - Will re-encrypt") + shouldUpdateEnv = true + appConfig.Env.Hash = currentHash + } + } + + // Prepare stages + stages := []render.Stage{ + render.MakeStage("Checking connection with VPS", "VPS is reachable", false), + } + + // Add build stages for services with build contexts + imageMap := make(map[string]string) + for serviceName := range buildServices { + imageName := fmt.Sprintf("%s-%s:v%s", appConfig.Name, serviceName, appConfig.Version) + imageMap[serviceName] = imageName + stages = append(stages, render.MakeStage( + fmt.Sprintf("Building image for service: %s", serviceName), + fmt.Sprintf("Image built for %s", serviceName), + true, + )) + } + + if len(imageMap) > 0 { + stages = append(stages, + render.MakeStage("Preparing to deploy", "Images saved", false), + render.MakeStage("Deploying new version", "Images pushed to server", false), + ) + } + + stages = append(stages, + render.MakeStage("Running new version", "New version is up", false), + ) + + p := tea.NewProgram(render.TuiModel{ + Stages: stages, + BannerMsg: fmt.Sprintf("Deploying version %s of %s 🚀", appConfig.Version, appConfig.Name), + ActiveIndex: 0, + Quitting: false, + AllDone: false, + }) + + go func() { + // SSH connection + sshClient, err := utils.Login(viper.GetString("serverAddress"), "sidekick") + if err != nil { + p.Send(render.ErrorMsg{ErrorStr: "Something went wrong logging in to your VPS"}) + } + p.Send(render.NextStageMsg{}) + + // Build services with build contexts + stageIndex := 1 + for serviceName := range buildServices { + service := buildServices[serviceName] + imageName := imageMap[serviceName] + + // Determine build context + buildContext := "." + dockerfile := "Dockerfile" + buildArgs := []string{} + + if service.Build != nil { + if service.Build.Context != "" { + buildContext = service.Build.Context + } + if service.Build.Dockerfile != "" { + dockerfile = service.Build.Dockerfile + } + for key, value := range service.Build.Args { + buildArgs = append(buildArgs, "--build-arg", fmt.Sprintf("%s=%v", key, value)) + } + } + + // Build with cache + dockerBuildCmd := exec.Command("docker", append([]string{ + "build", + "--tag", imageName, + "--progress=plain", + "--platform=linux/amd64", + "--cache-from", fmt.Sprintf("%s-%s:v%d", appConfig.Name, serviceName, getVersionNumber(appConfig.Version)-1), + "-f", filepath.Join(buildContext, dockerfile), + }, append(buildArgs, buildContext)...)...) + + dockerBuildCmdErrPipe, _ := dockerBuildCmd.StderrPipe() + go render.SendLogsToTUI(dockerBuildCmdErrPipe, p) + + if dockerBuildErr := dockerBuildCmd.Run(); dockerBuildErr != nil { + p.Send(render.ErrorMsg{ErrorStr: fmt.Sprintf("Failed to build %s", serviceName)}) + } + + time.Sleep(time.Millisecond * 100) + p.Send(render.NextStageMsg{}) + stageIndex++ + } + + // Update compose file with new image tags + for serviceName, imageName := range imageMap { + if service, exists := compose.Services[serviceName]; exists { + service.Image = imageName + service.Build = nil + compose.Services[serviceName] = service + } + } + + // Write updated compose file + updatedComposeFile := fmt.Sprintf("sidekick-%s", appConfig.ComposeFile) + composeData, _ := yaml.Marshal(&compose) + os.WriteFile(updatedComposeFile, composeData, 0644) + defer os.Remove(updatedComposeFile) + + // Save and transfer images if any were built + if len(imageMap) > 0 { + imgFileName := fmt.Sprintf("%s-v%s.tar", appConfig.Name, appConfig.Version) + + var imageNames []string + for _, imageName := range imageMap { + imageNames = append(imageNames, imageName) + } + + imgSaveCmd := exec.Command("docker", append([]string{"save", "-o", imgFileName}, imageNames...)...) + imgSaveCmdErrPipe, _ := imgSaveCmd.StderrPipe() + go render.SendLogsToTUI(imgSaveCmdErrPipe, p) + + if imgSaveCmdErr := imgSaveCmd.Run(); imgSaveCmdErr != nil { + p.Send(render.ErrorMsg{}) + } + defer os.Remove(imgFileName) + + time.Sleep(time.Millisecond * 100) + p.Send(render.NextStageMsg{}) + + // Transfer + remoteDist := fmt.Sprintf("%s@%s:./%s", "sidekick", viper.GetString("serverAddress"), appConfig.Name) + imgMoveCmd := exec.Command("scp", "-C", imgFileName, remoteDist) + imgMoveCmdErrorPipe, _ := imgMoveCmd.StderrPipe() + go render.SendLogsToTUI(imgMoveCmdErrorPipe, p) + + if imgMovCmdErr := imgMoveCmd.Run(); imgMovCmdErr != nil { + p.Send(render.ErrorMsg{}) + } + + // Load on server + dockerLoadOutChan, _, sessionErr := utils.RunCommand(sshClient, fmt.Sprintf("cd %s && docker load -i %s && rm %s", appConfig.Name, imgFileName, imgFileName)) + go func() { + for line := range dockerLoadOutChan { + p.Send(render.LogMsg{LogLine: line + "\n"}) + } + }() + if sessionErr != nil { + p.Send(render.ErrorMsg{ErrorStr: sessionErr.Error()}) + } + + time.Sleep(time.Millisecond * 100) + p.Send(render.NextStageMsg{}) + } + + // Transfer updated compose file + rsyncCmd := exec.Command("rsync", updatedComposeFile, fmt.Sprintf("%s@%s:%s/%s", "sidekick", viper.GetString("serverAddress"), appConfig.Name, "docker-compose.yml")) + if rsyncCmErr := rsyncCmd.Run(); rsyncCmErr != nil { + p.Send(render.ErrorMsg{ErrorStr: rsyncCmErr.Error()}) + } + + // Handle env update if needed + if shouldUpdateEnv { + dockerEnvProperty := []string{} + utils.HandleEnvFile(appConfig.Env.File, &dockerEnvProperty, &appConfig.Env.Hash) + + encryptSync := exec.Command("rsync", "encrypted.env", fmt.Sprintf("%s@%s:%s", "sidekick", viper.GetString("serverAddress"), appConfig.Name)) + encryptSync.Run() + os.Remove("encrypted.env") + } + + // Deploy with zero downtime + var deployCmd string + if appConfig.Env.File != "" { + deployCmd = fmt.Sprintf(`cd %s && export SOPS_AGE_KEY=%s && sops exec-env encrypted.env 'docker compose -p %s up -d'`, + appConfig.Name, viper.GetString("secretKey"), appConfig.Name) + } else { + deployCmd = fmt.Sprintf(`cd %s && docker compose -p %s up -d`, appConfig.Name, appConfig.Name) + } + + deployOutChan, _, sessionErr := utils.RunCommand(sshClient, deployCmd) + go func() { + for line := range deployOutChan { + p.Send(render.LogMsg{LogLine: line + "\n"}) + } + }() + if sessionErr != nil { + p.Send(render.ErrorMsg{ErrorStr: sessionErr.Error()}) + } + + // Update version + appConfig.Version = fmt.Sprintf("V%d", getVersionNumber(appConfig.Version)+1) + ymlData, _ := yaml.Marshal(&appConfig) + os.WriteFile("./sidekick.yml", ymlData, 0644) + + p.Send(render.AllDoneMsg{Duration: time.Since(start).Round(time.Second), URL: appConfig.Url}) + }() + + if _, err := p.Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} + +func getVersionNumber(version string) int { + var v int + fmt.Sscanf(version, "V%d", &v) + return v +} \ No newline at end of file diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 4c15a81..800611f 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -36,8 +36,6 @@ var LaunchCmd = &cobra.Command{ Short: "Launch a new application to host on your VPS with Sidekick", Long: `This command will run you through the basic setup to add a new application to your VPS.`, Run: func(cmd *cobra.Command, args []string) { - start := time.Now() - if configErr := utils.ViperInit(); configErr != nil { render.GetLogger(log.Options{Prefix: "Sidekick Config"}).Fatalf("%s", configErr) } @@ -55,12 +53,38 @@ var LaunchCmd = &cobra.Command{ os.Exit(1) } - if utils.FileExists("./Dockerfile") { + // Check for compose file + composeFile, hasCompose := utils.DetectComposeFile() + hasDockerfile := utils.FileExists("./Dockerfile") + + // Determine deployment type + var deploymentType string + if hasCompose && hasDockerfile { + // Both exist, ask user + render.GetLogger(log.Options{Prefix: "Deploy Detection"}).Info("Found both Dockerfile and docker-compose.yml") + deploymentType = render.GenerateDeploymentTypeSelection() + } else if hasCompose { + // Only compose exists + deploymentType = "compose" + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Infof("Detected %s - using compose deployment", composeFile) + } else if hasDockerfile { + // Only Dockerfile exists + deploymentType = "dockerfile" render.GetLogger(log.Options{Prefix: "Dockerfile"}).Info("Detected - scanning file for details") } else { - render.GetLogger(log.Options{Prefix: "Dockerfile"}).Fatal("No dockerfile found in current directory.") + // Neither exists + render.GetLogger(log.Options{Prefix: "Deploy Detection"}).Fatal("No Dockerfile or docker-compose.yml found in current directory.") } + // Launch based on deployment type + if deploymentType == "compose" { + launchCompose(composeFile) + return + } + + // Original Dockerfile deployment logic continues below + start := time.Now() + res, err := os.ReadFile("./Dockerfile") if err != nil { render.GetLogger(log.Options{Prefix: "Dockerfile"}).Fatal("Unable to process your dockerfile") diff --git a/cmd/launch/launch_compose.go b/cmd/launch/launch_compose.go new file mode 100644 index 0000000..7d3fb70 --- /dev/null +++ b/cmd/launch/launch_compose.go @@ -0,0 +1,383 @@ +/* +Copyright © 2024 Mahmoud Mousa + +Licensed under the GNU GPL License, Version 3.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +https://www.gnu.org/licenses/gpl-3.0.en.html + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package launch implements Docker Compose support for Sidekick +// Author: madebycm (https://github.com/madebycm) +package launch + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/log" + "github.com/mightymoud/sidekick/render" + "github.com/mightymoud/sidekick/utils" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +func launchCompose(composeFile string) { + start := time.Now() + + // Parse the compose file + compose, err := utils.ParseComposeFile(composeFile) + if err != nil { + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Fatalf("Failed to parse compose file: %s", err) + } + + // Get services with build contexts + buildServices := utils.GetServicesWithBuildContext(compose) + if len(buildServices) == 0 { + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Info("No services with build contexts found. All services use pre-built images.") + } + + // Get services with exposed ports + servicesWithPorts := utils.GetServicesWithPorts(compose) + if len(servicesWithPorts) == 0 { + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Fatal("No services with exposed ports found in compose file") + } + + // Let user select main service + var mainServiceName string + if len(servicesWithPorts) == 1 { + mainServiceName = servicesWithPorts[0] + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Infof("Using service '%s' as the main web service", mainServiceName) + } else { + mainServiceName = render.GenerateServiceSelection(servicesWithPorts, "") + } + + selectedService := compose.Services[mainServiceName] + mainService := &selectedService + + // Get app details + appName := render.GenerateTextQuestion("Please enter your app url friendly app name", mainServiceName, "will identify your app containers") + + // Ask for port - similar to Dockerfile approach + suggestedPort := utils.ExtractPortFromService(mainService) + if suggestedPort == "" { + suggestedPort = "3000" // Default fallback + } + appPort := render.GenerateTextQuestion("Please enter the port at which the app receives requests", suggestedPort, "") + + appDomain := render.GenerateTextQuestion("Please enter the domain to point the app to", fmt.Sprintf("%s.%s.sslip.io", appName, viper.Get("serverAddress").(string)), "must point to your VPS address") + envFileName := render.GenerateTextQuestion("Please enter which env file you would like to load", ".env", "") + + // Handle environment file + hasEnvFile := false + envFileChecksum := "" + if utils.FileExists(fmt.Sprintf("./%s", envFileName)) { + hasEnvFile = true + render.GetLogger(log.Options{Prefix: "Env File"}).Infof("Detected - Loading env vars from %s", envFileName) + dockerEnvProperty := []string{} + envHandleErr := utils.HandleEnvFile(envFileName, &dockerEnvProperty, &envFileChecksum) + if envHandleErr != nil { + render.GetLogger(log.Options{Prefix: "Env File"}).Fatalf("Something went wrong %s", envHandleErr) + } + defer os.Remove("encrypted.env") + } else { + render.GetLogger(log.Options{Prefix: "Env File"}).Info("Not Detected - Skipping env parsing") + } + + // Add Traefik labels to main service if not present + if !hasTraefikLabels(mainService) { + if mainService.Labels == nil { + mainService.Labels = []string{} + } + mainService.Labels = append(mainService.Labels, + "traefik.enable=true", + fmt.Sprintf("traefik.http.routers.%s.rule=Host(`%s`)", appName, appDomain), + fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port=%s", appName, appPort), + fmt.Sprintf("traefik.http.routers.%s.tls=true", appName), + fmt.Sprintf("traefik.http.routers.%s.tls.certresolver=default", appName), + "traefik.docker.network=sidekick", + ) + } + + // Ensure sidekick network is added + if compose.Networks == nil { + compose.Networks = make(map[string]utils.DockerNetwork) + } + compose.Networks["sidekick"] = utils.DockerNetwork{External: true} + + // Add sidekick network to all services + for name, service := range compose.Services { + if service.Networks == nil { + service.Networks = []string{} + } + if !contains(service.Networks, "sidekick") { + service.Networks = append(service.Networks, "sidekick") + } + compose.Services[name] = service + } + + // Write updated compose file + updatedComposeFile := fmt.Sprintf("sidekick-%s", composeFile) + composeData, err := yaml.Marshal(&compose) + if err != nil { + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Fatalf("Failed to marshal compose file: %s", err) + } + err = os.WriteFile(updatedComposeFile, composeData, 0644) + if err != nil { + render.GetLogger(log.Options{Prefix: "Docker Compose"}).Fatalf("Failed to write updated compose file: %s", err) + } + defer os.Remove(updatedComposeFile) + + // Prepare stages for TUI + stages := []render.Stage{ + render.MakeStage("Validating connection with VPS", "VPS is reachable", false), + } + + // Add build stages for each service with build context + imageMap := make(map[string]string) + for serviceName := range buildServices { + imageName := fmt.Sprintf("%s-%s", appName, serviceName) + imageMap[serviceName] = imageName + stages = append(stages, render.MakeStage( + fmt.Sprintf("Building docker image for service: %s", serviceName), + fmt.Sprintf("Image built for %s", serviceName), + true, + )) + } + + stages = append(stages, + render.MakeStage("Saving docker images locally", "Images saved successfully", false), + render.MakeStage("Moving images to your server", "Images moved and loaded successfully", false), + render.MakeStage("Setting up your application", "Application setup successfully", false), + ) + + p := tea.NewProgram(render.TuiModel{ + Stages: stages, + BannerMsg: "Launching your docker-compose application on your VPS 🚀", + ActiveIndex: 0, + Quitting: false, + AllDone: false, + }) + + go func() { + // SSH connection + sshClient, err := utils.Login(viper.GetString("serverAddress"), "sidekick") + if err != nil { + p.Send(render.ErrorMsg{ErrorStr: "Something went wrong logging in to your VPS"}) + } + p.Send(render.NextStageMsg{}) + + // Build each service with build context + stageIndex := 1 + for serviceName := range buildServices { + service := buildServices[serviceName] + imageName := imageMap[serviceName] + + // Determine build context + buildContext := "." + dockerfile := "Dockerfile" + buildArgs := []string{} + + if service.Build != nil { + if service.Build.Context != "" { + buildContext = service.Build.Context + } + if service.Build.Dockerfile != "" { + dockerfile = service.Build.Dockerfile + } + for key, value := range service.Build.Args { + buildArgs = append(buildArgs, "--build-arg", fmt.Sprintf("%s=%v", key, value)) + } + } + + // Build the image + dockerBuildCmd := exec.Command("docker", append([]string{ + "build", + "--tag", imageName, + "--progress=plain", + "--platform=linux/amd64", + "-f", filepath.Join(buildContext, dockerfile), + }, append(buildArgs, buildContext)...)...) + + dockerBuildCmdErrPipe, _ := dockerBuildCmd.StderrPipe() + go render.SendLogsToTUI(dockerBuildCmdErrPipe, p) + + if dockerBuildErr := dockerBuildCmd.Run(); dockerBuildErr != nil { + p.Send(render.ErrorMsg{ErrorStr: fmt.Sprintf("Failed to build %s: %v", serviceName, dockerBuildErr)}) + } + + time.Sleep(time.Millisecond * 100) + p.Send(render.NextStageMsg{}) + stageIndex++ + } + + // Update compose file with built image names + for serviceName, imageName := range imageMap { + if service, exists := compose.Services[serviceName]; exists { + service.Image = imageName + service.Build = nil // Remove build section since we're using pre-built images + compose.Services[serviceName] = service + } + } + + // Write final compose file for deployment + finalComposeData, _ := yaml.Marshal(&compose) + os.WriteFile(updatedComposeFile, finalComposeData, 0644) + + // Save images + if len(imageMap) > 0 { + imgFileName := fmt.Sprintf("%s-images.tar", appName) + + // Build docker save command with all images + var imageNames []string + for _, imageName := range imageMap { + imageNames = append(imageNames, imageName) + } + + imgSaveCmd := exec.Command("docker", append([]string{"save", "-o", imgFileName}, imageNames...)...) + imgSaveCmdErrPipe, _ := imgSaveCmd.StderrPipe() + go render.SendLogsToTUI(imgSaveCmdErrPipe, p) + + if imgSaveCmdErr := imgSaveCmd.Run(); imgSaveCmdErr != nil { + p.Send(render.ErrorMsg{ErrorStr: fmt.Sprintf("Failed to save images: %v", imgSaveCmdErr)}) + } + defer os.Remove(imgFileName) + + time.Sleep(time.Millisecond * 100) + p.Send(render.NextStageMsg{}) + + // Transfer images + _, _, sessionErr := utils.RunCommand(sshClient, fmt.Sprintf("mkdir -p %s", appName)) + if sessionErr != nil { + p.Send(render.ErrorMsg{ErrorStr: sessionErr.Error()}) + } + + remoteDist := fmt.Sprintf("%s@%s:./%s", "sidekick", viper.GetString("serverAddress"), appName) + imgMoveCmd := exec.Command("scp", "-C", imgFileName, remoteDist) + imgMoveCmdErrorPipe, _ := imgMoveCmd.StderrPipe() + go render.SendLogsToTUI(imgMoveCmdErrorPipe, p) + + if imgMovCmdErr := imgMoveCmd.Run(); imgMovCmdErr != nil { + p.Send(render.ErrorMsg{ErrorStr: fmt.Sprintf("Failed to transfer images: %v", imgMovCmdErr)}) + } + + // Load images on server + dockerLoadOutChan, _, sessionErr := utils.RunCommand(sshClient, fmt.Sprintf("cd %s && docker load -i %s && rm %s", appName, imgFileName, imgFileName)) + go func() { + for line := range dockerLoadOutChan { + p.Send(render.LogMsg{LogLine: line + "\n"}) + time.Sleep(time.Millisecond * 50) + } + }() + if sessionErr != nil { + p.Send(render.ErrorMsg{ErrorStr: sessionErr.Error()}) + } + } else { + // Skip to next stage if no images to build + p.Send(render.NextStageMsg{}) + } + + time.Sleep(time.Millisecond * 100) + p.Send(render.NextStageMsg{}) + + // Transfer compose file + rsyncCmd := exec.Command("rsync", updatedComposeFile, fmt.Sprintf("%s@%s:%s/%s", "sidekick", viper.GetString("serverAddress"), appName, "docker-compose.yml")) + if rsyncCmErr := rsyncCmd.Run(); rsyncCmErr != nil { + p.Send(render.ErrorMsg{ErrorStr: rsyncCmErr.Error()}) + } + + // Transfer env file if exists + if hasEnvFile { + encryptSync := exec.Command("rsync", "encrypted.env", fmt.Sprintf("%s@%s:%s", "sidekick", viper.GetString("serverAddress"), fmt.Sprintf("./%s", appName))) + if encryptSyncErr := encryptSync.Run(); encryptSyncErr != nil { + p.Send(render.ErrorMsg{ErrorStr: encryptSyncErr.Error()}) + } + + // Run with encrypted env + runAppCmdOutChan, _, sessionErr1 := utils.RunCommand(sshClient, fmt.Sprintf(`cd %s && export SOPS_AGE_KEY=%s && sops exec-env encrypted.env 'docker compose -p %s up -d'`, appName, viper.GetString("secretKey"), appName)) + go func() { + for line := range runAppCmdOutChan { + p.Send(render.LogMsg{LogLine: line + "\n"}) + time.Sleep(time.Millisecond * 50) + } + }() + if sessionErr1 != nil { + p.Send(render.ErrorMsg{ErrorStr: sessionErr1.Error()}) + } + } else { + // Run without env + runAppCmdOutChan, _, sessionErr1 := utils.RunCommand(sshClient, fmt.Sprintf(`cd %s && docker compose -p %s up -d`, appName, appName)) + go func() { + for line := range runAppCmdOutChan { + p.Send(render.LogMsg{LogLine: line + "\n"}) + time.Sleep(time.Millisecond * 50) + } + }() + if sessionErr1 != nil { + p.Send(render.ErrorMsg{ErrorStr: sessionErr1.Error()}) + } + } + + // Save config + portNumber, _ := strconv.ParseUint(appPort, 0, 64) + envConfig := utils.SidekickAppEnvConfig{} + if hasEnvFile { + envConfig.File = envFileName + envConfig.Hash = envFileChecksum + } + + sidekickAppConfig := utils.SidekickAppConfig{ + Name: appName, + Version: "V1", + Port: portNumber, + Url: appDomain, + CreatedAt: time.Now().Format(time.UnixDate), + DeploymentType: "compose", + ComposeFile: composeFile, + MainService: mainServiceName, + Env: envConfig, + } + ymlData, _ := yaml.Marshal(&sidekickAppConfig) + os.WriteFile("./sidekick.yml", ymlData, 0644) + + p.Send(render.AllDoneMsg{Duration: time.Since(start).Round(time.Second), URL: appDomain}) + }() + + if _, err := p.Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} + +func hasTraefikLabels(service *utils.DockerService) bool { + if service.Labels == nil { + return false + } + for _, label := range service.Labels { + if strings.HasPrefix(label, "traefik.") { + return true + } + } + return false +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} \ No newline at end of file diff --git a/render/utils.go b/render/utils.go index 7be3315..20553be 100644 --- a/render/utils.go +++ b/render/utils.go @@ -103,3 +103,35 @@ func RenderKeyValidation(resultLines []string, keyHash string, hostname string) os.Exit(0) } } + +func GenerateDeploymentTypeSelection() string { + prompt := pterm.DefaultInteractiveSelect.WithOptions([]string{"Docker Compose", "Dockerfile"}) + prompt.DefaultText = "Both Dockerfile and docker-compose.yml found. Which would you like to use?" + prompt.DefaultOption = "Docker Compose" + + result, err := prompt.Show() + if err != nil { + GetLogger(log.Options{Prefix: "Input"}).Fatalf(" %s", err) + } + + if result == "Docker Compose" { + return "compose" + } + return "dockerfile" +} + +func GenerateServiceSelection(services []string, defaultService string) string { + if len(services) == 1 { + return services[0] + } + + prompt := pterm.DefaultInteractiveSelect.WithOptions(services) + prompt.DefaultText = "Please select which service should receive web traffic" + + result, err := prompt.Show() + if err != nil { + GetLogger(log.Options{Prefix: "Input"}).Fatalf(" %s", err) + } + + return result +} diff --git a/utils/types.go b/utils/types.go index 3760e7e..eeb902f 100644 --- a/utils/types.go +++ b/utils/types.go @@ -25,18 +25,26 @@ type Healthcheck struct { Retries int `yaml:"retries"` } +type DockerBuildContext struct { + Context string `yaml:"context,omitempty"` + Dockerfile string `yaml:"dockerfile,omitempty"` + Args map[string]interface{} `yaml:"args,omitempty"` +} + type DockerService struct { - Image string `yaml:"image"` - Command string `yaml:"command,omitempty"` - Restart string `yaml:"restart,omitempty"` - Ports []string `yaml:"ports,omitempty"` - Volumes []string `yaml:"volumes,omitempty"` - Labels []string `yaml:"labels,omitempty"` - Networks []string `yaml:"networks,omitempty"` - Environment []string `yaml:"environment,omitempty"` - DependsOn map[string]DependsOn `yaml:"depends_on,omitempty"` - HealthCheck Healthcheck `yaml:"healthcheck,omitempty"` - EntryPoint []string `yaml:"entrypoint,omitempty"` + Image string `yaml:"image,omitempty"` + Build *DockerBuildContext `yaml:"build,omitempty"` + Command string `yaml:"command,omitempty"` + Restart string `yaml:"restart,omitempty"` + Ports []string `yaml:"ports,omitempty"` + Volumes []string `yaml:"volumes,omitempty"` + Labels []string `yaml:"labels,omitempty"` + Networks []string `yaml:"networks,omitempty"` + Environment interface{} `yaml:"environment,omitempty"` // Can be []string or map[string]string + EnvFile interface{} `yaml:"env_file,omitempty"` // Can be string or []string + DependsOn map[string]DependsOn `yaml:"depends_on,omitempty"` + HealthCheck Healthcheck `yaml:"healthcheck,omitempty"` + EntryPoint []string `yaml:"entrypoint,omitempty"` } type DockerNetwork struct { @@ -80,10 +88,13 @@ type SidekickAppDatabaseConfig struct { type SidekickAppConfig struct { Name string `yaml:"name"` Version string `yaml:"version"` - Image string `yaml:"image"` + Image string `yaml:"image,omitempty"` Url string `yaml:"url"` Port uint64 `yaml:"port"` CreatedAt string `yaml:"createdAt"` + DeploymentType string `yaml:"deploymentType,omitempty"` // "dockerfile" or "compose" + ComposeFile string `yaml:"composeFile,omitempty"` + MainService string `yaml:"mainService,omitempty"` Env SidekickAppEnvConfig `yaml:"env,omitempty"` DatabaseConfig SidekickAppDatabaseConfig `yaml:"database,omitempty"` PreviewEnvs map[string]SidekickPreview `yaml:"previewEnvs,omitempty"` diff --git a/utils/utils.go b/utils/utils.go index 9063ce8..13ae32e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -205,3 +205,66 @@ func WriteEnvFile(filename string, env map[string]string) error { } return nil } + +// DetectComposeFile checks for docker-compose.yml or compose.yml and returns the filename if found +func DetectComposeFile() (string, bool) { + composeFiles := []string{"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"} + for _, file := range composeFiles { + if FileExists(file) { + return file, true + } + } + return "", false +} + +// ParseComposeFile reads and parses a docker-compose file +func ParseComposeFile(filename string) (*DockerComposeFile, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read compose file: %w", err) + } + + var compose DockerComposeFile + if err := yaml.Unmarshal(data, &compose); err != nil { + return nil, fmt.Errorf("failed to parse compose file: %w", err) + } + + return &compose, nil +} + +// GetServicesWithBuildContext returns services that have a build context defined +func GetServicesWithBuildContext(compose *DockerComposeFile) map[string]DockerService { + servicesWithBuild := make(map[string]DockerService) + for name, service := range compose.Services { + if service.Build != nil { + servicesWithBuild[name] = service + } + } + return servicesWithBuild +} + +// GetServicesWithPorts returns all services that expose ports +func GetServicesWithPorts(compose *DockerComposeFile) []string { + var services []string + for name, service := range compose.Services { + if len(service.Ports) > 0 { + services = append(services, name) + } + } + return services +} + +// ExtractPortFromService extracts the container port from a service's port mapping +func ExtractPortFromService(service *DockerService) string { + if len(service.Ports) == 0 { + return "" + } + + // Parse first port mapping (format: "host:container" or just "container") + portMapping := service.Ports[0] + parts := strings.Split(portMapping, ":") + if len(parts) >= 2 { + return parts[len(parts)-1] // Return container port + } + return parts[0] // Return the only part if no colon +}