From 837f2cfd509a7a62435099d291e80ea8a9c412f7 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 10:37:47 -0700 Subject: [PATCH 01/71] start core logic implementation --- console/console/deploy_command.go | 390 +++++++++++++++++++++++------- console/service_provider.go | 1 + 2 files changed, 310 insertions(+), 81 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index b30f4db73..9d7cb232e 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -1,9 +1,12 @@ package console import ( + "encoding/base64" "fmt" + "os" "os/exec" - "runtime" + "path/filepath" + "strings" "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" @@ -33,107 +36,157 @@ func (r *DeployCommand) Description() string { // Extend The console command extend. func (r *DeployCommand) Extend() command.Extend { - // TODO: add options in a later PR - return command.Extend{} + return command.Extend{ + Flags: []command.Flag{ + &command.StringFlag{Name: "ip", Usage: "Server IP address"}, + &command.StringFlag{Name: "port", Usage: "SSH port", Value: "22"}, + &command.StringFlag{Name: "user", Usage: "SSH user", Value: "root"}, + &command.StringFlag{Name: "key", Usage: "SSH private key path", Value: "~/.ssh/id_rsa"}, + &command.StringFlag{Name: "os", Usage: "Target OS", Value: "linux"}, + &command.StringFlag{Name: "arch", Usage: "Target arch", Value: "amd64"}, + &command.StringFlag{Name: "domain", Usage: "Domain for Caddy reverse proxy"}, + &command.StringFlag{Name: "only", Usage: "Comma-separated subset to deploy: main,public,storage,resources"}, + &command.BoolFlag{ + Name: "rollback", + Aliases: []string{"r"}, + Value: false, + Usage: "Rollback to previous deployment", + DisableDefaultText: true, + }, + &command.BoolFlag{ + Name: "static", + Aliases: []string{"s"}, + Value: false, + Usage: "Static compilation", + DisableDefaultText: true, + }, + &command.BoolFlag{ + Name: "zero-downtime", + Aliases: []string{"z"}, + Value: false, + Usage: "Zero downtime deployment", + DisableDefaultText: true, + }, + }, + } +} + +func getAllOptions(config config.Config) (appName, ipAddress, sshPort, sshUser, sshKeyPath, targetOS, arch, domain string, zeroDowntime bool, staticEnv bool, reverseProxyEnabled bool, reverseProxyTLSEnabled bool) { + appName = config.GetString("appName") + ipAddress = config.GetString("DEPLOY_IP_ADDRESS") + sshPort = config.GetString("DEPLOY_SSH_PORT") + sshUser = config.GetString("DEPLOY_SSH_USER") + sshKeyPath = config.GetString("DEPLOY_SSH_KEY_PATH") + targetOS = config.GetString("DEPLOY_OS") + arch = config.GetString("DEPLOY_ARCH") + domain = config.GetString("DEPLOY_DOMAIN") + + zeroDowntime = config.GetBool("DEPLOY_ZERO_DOWNTIME") + staticEnv = config.GetBool("DEPLOY_STATIC") + reverseProxyEnabled = config.GetBool("DEPLOY_REVERSE_PROXY_ENABLED") + reverseProxyTLSEnabled = config.GetBool("DEPLOY_REVERSE_PROXY_TLS_ENABLED") + + // expand ssh key ~ path if needed + if after, ok := strings.CutPrefix(sshKeyPath, "~"); ok { + if home, herr := os.UserHomeDir(); herr == nil { + sshKeyPath = filepath.Join(home, after) + } + } + + return appName, ipAddress, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, zeroDowntime, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled } // Handle Execute the console command. func (r *DeployCommand) Handle(ctx console.Context) error { var err error - // TODO: breakout environment variable fetching and prompting into a separate function in a later PR - - // get all environment variables - app_name := r.config.Env("APP_NAME") - ip_address := r.config.Env("DEPLOY_IP_ADDRESS") - ssh_port := r.config.Env("DEPLOY_SSH_PORT") - ssh_user := r.config.Env("DEPLOY_SSH_USER") - ssh_key_path := r.config.Env("DEPLOY_SSH_KEY_PATH") - os := r.config.Env("DEPLOY_OS") - arch := r.config.Env("DEPLOY_ARCH") - static := r.config.Env("DEPLOY_STATIC") - - // if any of the required environment variables are missing, prompt the user to enter them - if app_name == "" { - if app_name, err = ctx.Ask("Enter the app name", console.AskOption{Default: "app"}); err != nil { - ctx.Error(fmt.Sprintf("Enter the app name error: %v", err)) - return nil - } - } + // get all options + appName, ipAddress, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, zeroDowntime, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(r.config) - if ip_address == "" { - if ip_address, err = ctx.Ask("Enter the server IP address"); err != nil { - ctx.Error(fmt.Sprintf("Enter the server IP address error: %v", err)) + // Rollback flow + if ctx.OptionBool("rollback") { + if err = supportconsole.ExecuteCommand(ctx, rollbackCommand( + appName, ipAddress, sshPort, sshUser, sshKeyPath, + ), "Rolling back..."); err != nil { + ctx.Error(err.Error()) return nil } + ctx.Info("Rollback successful.") + return nil } - if ssh_port == "" { - if ssh_port, err = ctx.Ask("Enter the SSH port", console.AskOption{Default: "22"}); err != nil { - ctx.Error(fmt.Sprintf("Enter the SSH port error: %v", err)) - return nil - } + // Step 1: build the application + // Build the binary for target OS/arch + if err = supportconsole.ExecuteCommand(ctx, generateCommand(appName, targetOS, arch, staticEnv), "Building..."); err != nil { + ctx.Error(err.Error()) + return nil } - if ssh_user == "" { - if ssh_user, err = ctx.Ask("Enter the SSH user", console.AskOption{Default: "root"}); err != nil { - ctx.Error(fmt.Sprintf("Enter the SSH user error: %v", err)) - return nil - } - } + // Step 3: verify artifacts to determine which to upload + hasMain := fileExists(appName) + hasPublic := dirExists("public") + hasStorage := dirExists("storage") + hasResources := dirExists("resources") - if ssh_key_path == "" { - if ssh_key_path, err = ctx.Ask("Enter the SSH key path", console.AskOption{Default: "~/.ssh/id_rsa"}); err != nil { - ctx.Error(fmt.Sprintf("Enter the SSH key path error: %v", err)) - return nil + // Allow subset selection via --only + only := strings.TrimSpace(ctx.Option("only")) + if only != "" { + parts := strings.Split(only, ",") + include := map[string]bool{} + for _, p := range parts { + include[strings.TrimSpace(strings.ToLower(p))] = true } - } - - if os == "" { - if os, err = ctx.Choice("Select target os", []console.Choice{ - {Key: "Linux", Value: "linux"}, - {Key: "Windows", Value: "windows"}, - {Key: "Darwin", Value: "darwin"}, - }, console.ChoiceOption{Default: runtime.GOOS}); err != nil { - ctx.Error(fmt.Sprintf("Select target os error: %v", err)) - return nil + if !include["main"] { + hasMain = false } - } - - if arch == "" { - if arch, err = ctx.Choice("Select target arch", []console.Choice{ - {Key: "amd64", Value: "amd64"}, - {Key: "arm64", Value: "arm64"}, - {Key: "386", Value: "386"}, - }, console.ChoiceOption{Default: "amd64"}); err != nil { - ctx.Error(fmt.Sprintf("Select target arch error: %v", err)) - return nil + if !include["public"] { + hasPublic = false + } + if !include["storage"] { + hasStorage = false + } + if !include["resources"] { + hasResources = false } } - // if static is not set, prompt the user to enter it - if static == "" { - if !ctx.Confirm("Do you want to build a static binary?") { - static = false - } else { - static = true - } + // Step 2: set up server on first run (idempotent) + if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( + fmt.Sprintf("%v", appName), + fmt.Sprintf("%v", ipAddress), + fmt.Sprintf("%v", sshPort), + fmt.Sprintf("%v", sshUser), + fmt.Sprintf("%v", sshKeyPath), + strings.TrimSpace(domain), + zeroDowntime, + reverseProxyEnabled, + reverseProxyTLSEnabled, + ), "Setting up server (first time only)..."); err != nil { + ctx.Error(err.Error()) + return nil } - // build the application - if err = supportconsole.ExecuteCommand(ctx, generateCommand(fmt.Sprintf("%v", app_name), fmt.Sprintf("%v", os), fmt.Sprintf("%v", arch), static.(bool)), "Building..."); err != nil { + // Step 3: upload files + if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand( + fmt.Sprintf("%v", appName), + fmt.Sprintf("%v", ipAddress), + fmt.Sprintf("%v", sshPort), + fmt.Sprintf("%v", sshUser), + fmt.Sprintf("%v", sshKeyPath), + hasMain, hasPublic, hasStorage, hasResources, + ), "Uploading files..."); err != nil { ctx.Error(err.Error()) return nil } - // deploy the application - if err = supportconsole.ExecuteCommand(ctx, deployCommand( - fmt.Sprintf("%v", app_name), - fmt.Sprintf("%v", ip_address), - fmt.Sprintf("%v", ssh_port), - fmt.Sprintf("%v", ssh_user), - fmt.Sprintf("%v", ssh_key_path), - ), "Deploying..."); err != nil { + // Step 4: restart service + if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand( + fmt.Sprintf("%v", appName), + fmt.Sprintf("%v", ipAddress), + fmt.Sprintf("%v", sshPort), + fmt.Sprintf("%v", sshUser), + fmt.Sprintf("%v", sshKeyPath), + ), "Restarting service..."); err != nil { ctx.Error(err.Error()) return nil } @@ -143,8 +196,183 @@ func (r *DeployCommand) Handle(ctx console.Context) error { return nil } -// generate the deploy command -func deployCommand(binary_name, ip_address, ssh_port, ssh_user, ssh_key_path string) *exec.Cmd { - // TODO: implement deploy command - return exec.Command("echo", "Deploy command not implemented yet... exiting...") +// helpers +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs +func setupServerCommand(appName, ip, port, user, keyPath, domain string, zeroDowntime, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { + // Directories and service + appDir := fmt.Sprintf("/var/www/%s", appName) + binCurrent := fmt.Sprintf("%s/main", appDir) + + // Ports + appPort := "9000" + httpPort := "80" + // httpsPort := "443" // only used when TLS is enabled via Caddy + + // Build systemd unit based on whether reverse proxy is used + listenHost := "127.0.0.1" + if !reverseProxyEnabled { + // App listens on port 80 directly + appPort = httpPort + listenHost = "0.0.0.0" + } + + unit := fmt.Sprintf(`[Unit] +Description=Goravel App %s +After=network.target + +[Service] +User=%s +WorkingDirectory=%s +ExecStart=%s +Environment=APP_ENV=production +Environment=APP_HOST=%s +Environment=APP_PORT=%s +Restart=always +RestartSec=5 +KillSignal=SIGINT +SyslogIdentifier=%s + +[Install] +WantedBy=multi-user.target +`, appName, user, appDir, binCurrent, listenHost, appPort, appName) + + // Build Caddyfile if reverse proxy enabled + caddyfile := "" + if reverseProxyEnabled { + site := ":80" + if reverseProxyTLSEnabled && strings.TrimSpace(domain) != "" && domain != "" { + site = domain + } + upstream := fmt.Sprintf("127.0.0.1:%s", appPort) + caddyfile = fmt.Sprintf(`%s { + reverse_proxy %s { + lb_try_duration 30s + lb_try_interval 250ms + } + encode gzip +} +`, site, upstream) + } + + unitB64 := base64.StdEncoding.EncodeToString([]byte(unit)) + var caddyB64 string + if caddyfile != "" { + caddyB64 = base64.StdEncoding.EncodeToString([]byte(caddyfile)) + } + + // Firewall commands based on configuration + ufwCmds := []string{"sudo apt-get update -y && sudo apt-get install -y ufw", "sudo ufw --force enable"} + if reverseProxyEnabled { + ufwCmds = append(ufwCmds, "sudo ufw allow 80") + if reverseProxyTLSEnabled { + ufwCmds = append(ufwCmds, "sudo ufw allow 443") + } + } else { + // App listens on 80 directly + ufwCmds = append(ufwCmds, "sudo ufw allow 80") + } + + // Remote setup script: create directories, install Caddy optionally, write configs + script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' +set -e +if [ ! -d %s ]; then + sudo mkdir -p %s + sudo chown -R %s:%s %s +fi +%s +if [ ! -f /etc/systemd/system/%s.service ]; then + echo %q | base64 -d | sudo tee /etc/systemd/system/%s.service >/dev/null + sudo systemctl daemon-reload + sudo systemctl enable %s +fi +%s +%s' +`, keyPath, port, user, ip, + appDir, appDir, user, user, appDir, + // caddy install and config + func() string { + if !reverseProxyEnabled { + return "" + } + install := "sudo apt-get update -y && sudo apt-get install -y caddy" + writeCfg := fmt.Sprintf("echo %q | base64 -d | sudo tee /etc/caddy/Caddyfile >/dev/null && sudo systemctl enable --now caddy", caddyB64) + return install + " && " + writeCfg + }(), + appName, unitB64, appName, appName, + strings.Join(ufwCmds, " && "), + "true", + ) + + return exec.Command("bash", "-lc", script) +} + +// uploadFilesCommand uploads available artifacts to remote server +func uploadFilesCommand(appName, ip, port, user, keyPath string, hasMain, hasPublic, hasStorage, hasResources bool) *exec.Cmd { + appDir := fmt.Sprintf("/var/www/%s", appName) + remoteBase := fmt.Sprintf("%s@%s:%s", user, ip, appDir) + // ensure remote base exists and permissions + cmds := []string{ + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", keyPath, port, user, ip, appDir, user, user, appDir), + } + + // main binary with previous backup + if hasMain { + localMain := appName + if !fileExists(localMain) && fileExists("main") { + localMain = "main" + } + // upload to temp and atomically move, keeping previous as main.prev + cmds = append(cmds, + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", keyPath, port, filepath.Clean(localMain), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", keyPath, port, user, ip, appDir, appDir, appDir, appDir, appDir, appDir), + ) + } + + if hasPublic { + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, port, filepath.Clean("public"), remoteBase)) + } + if hasStorage { + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, port, filepath.Clean("storage"), remoteBase)) + } + if hasResources { + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, port, filepath.Clean("resources"), remoteBase)) + } + + script := strings.Join(cmds, " && ") + return exec.Command("bash", "-lc", script) +} + +func restartServiceCommand(appName, ip, port, user, keyPath string) *exec.Cmd { + script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", keyPath, port, user, ip, appName, appName) + return exec.Command("bash", "-lc", script) +} + +// rollbackCommand swaps main and main.prev if available, then restarts the service +func rollbackCommand(appName, ip, port, user, keyPath string) *exec.Cmd { + appDir := fmt.Sprintf("/var/www/%s", appName) + script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' +set -e +if [ ! -f %s/main.prev ]; then + echo "No previous deployment to rollback to." >&2 + exit 1 +fi +sudo mv %s/main %s/main.newcurrent || true +sudo mv %s/main.prev %s/main +sudo mv %s/main.newcurrent %s/main.prev || true +sudo chmod +x %s/main +sudo systemctl daemon-reload +sudo systemctl restart %s || sudo systemctl start %s +'`, keyPath, port, user, ip, + appDir, appDir, appDir, appDir, appDir, appDir, appDir, appDir, appName, appName) + return exec.Command("bash", "-lc", script) } diff --git a/console/service_provider.go b/console/service_provider.go index d00ec0e88..ef74570fc 100644 --- a/console/service_provider.go +++ b/console/service_provider.go @@ -53,5 +53,6 @@ func (r *ServiceProvider) registerCommands(app foundation.Application) { console.NewKeyGenerateCommand(configFacade), console.NewMakeCommand(), console.NewBuildCommand(configFacade), + console.NewDeployCommand(configFacade), }) } From b2f90dd0c11852a4056d978d57183bf92a95fe79 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 11:44:50 -0700 Subject: [PATCH 02/71] modify env example to have relevant deploy environment variables --- .env.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 150a83395..70b0ea497 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,15 @@ TENCENT_URL= MINIO_ACCESS_KEY_ID= MINIO_ACCESS_KEY_SECRET= MINIO_BUCKET= + +DEPLOY_IP_ADDRESS= +DEPLOY_APP_PORT= +DEPLOY_SSH_PORT= +DEPLOY_SSH_USER= +DEPLOY_SSH_KEY_PATH= +DEPLOY_OS= +DEPLOY_ARCH= +DEPLOY_DOMAIN= +DEPLOY_STATIC= +DEPLOY_REVERSE_PROXY_ENABLED= +DEPLOY_REVERSE_PROXY_TLS_ENABLED= \ No newline at end of file From da70f58655130cccdabf970aebfa79a62779a516 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 11:45:08 -0700 Subject: [PATCH 03/71] modify environment variables and deployment options --- console/console/deploy_command.go | 151 ++++++++++++++++++++++-------- 1 file changed, 110 insertions(+), 41 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 9d7cb232e..3434e2191 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -71,20 +71,87 @@ func (r *DeployCommand) Extend() command.Extend { } } -func getAllOptions(config config.Config) (appName, ipAddress, sshPort, sshUser, sshKeyPath, targetOS, arch, domain string, zeroDowntime bool, staticEnv bool, reverseProxyEnabled bool, reverseProxyTLSEnabled bool) { - appName = config.GetString("appName") - ipAddress = config.GetString("DEPLOY_IP_ADDRESS") - sshPort = config.GetString("DEPLOY_SSH_PORT") - sshUser = config.GetString("DEPLOY_SSH_USER") - sshKeyPath = config.GetString("DEPLOY_SSH_KEY_PATH") - targetOS = config.GetString("DEPLOY_OS") - arch = config.GetString("DEPLOY_ARCH") - domain = config.GetString("DEPLOY_DOMAIN") - - zeroDowntime = config.GetBool("DEPLOY_ZERO_DOWNTIME") - staticEnv = config.GetBool("DEPLOY_STATIC") - reverseProxyEnabled = config.GetBool("DEPLOY_REVERSE_PROXY_ENABLED") - reverseProxyTLSEnabled = config.GetBool("DEPLOY_REVERSE_PROXY_TLS_ENABLED") +func getAllOptions(ctx console.Context, config config.Config) (appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath string, staticEnv bool, reverseProxyEnabled bool, reverseProxyTLSEnabled bool) { + appName = config.GetString("app.name") + ipAddress = config.Env("DEPLOY_IP_ADDRESS").(string) + appPort = config.Env("DEPLOY_APP_PORT").(string) + sshPort = config.Env("DEPLOY_SSH_PORT").(string) + sshUser = config.Env("DEPLOY_SSH_USER").(string) + sshKeyPath = config.Env("DEPLOY_SSH_KEY_PATH").(string) + targetOS = config.Env("DEPLOY_OS").(string) + arch = config.Env("DEPLOY_ARCH").(string) + domain = config.Env("DEPLOY_DOMAIN").(string) + prodEnvFilePath = config.Env("DEPLOY_PROD_ENV_FILE_PATH").(string) + + staticEnv = config.Env("DEPLOY_STATIC").(bool) + reverseProxyEnabled = config.Env("DEPLOY_REVERSE_PROXY_ENABLED").(bool) + reverseProxyTLSEnabled = config.Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED").(bool) + + // if any of the options is not set, ask the user for the value + var err error + if appName == "" { + appName, err = ctx.Ask("Enter the app name") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if ipAddress == "" { + ipAddress, err = ctx.Ask("Enter the ip address") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if appPort == "" { + appPort, err = ctx.Ask("Enter the app port") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if sshPort == "" { + sshPort, err = ctx.Ask("Enter the ssh port") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if sshUser == "" { + sshUser, err = ctx.Ask("Enter the ssh user") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if sshKeyPath == "" { + sshKeyPath, err = ctx.Ask("Enter the ssh key path") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if targetOS == "" { + targetOS, err = ctx.Ask("Enter the target os") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if arch == "" { + arch, err = ctx.Ask("Enter the target arch") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } + if domain == "" { + domain, err = ctx.Ask("Enter the domain") + if err != nil { + ctx.Error(err.Error()) + os.Exit(1) + } + } // expand ssh key ~ path if needed if after, ok := strings.CutPrefix(sshKeyPath, "~"); ok { @@ -93,7 +160,7 @@ func getAllOptions(config config.Config) (appName, ipAddress, sshPort, sshUser, } } - return appName, ipAddress, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, zeroDowntime, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled + return appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled } // Handle Execute the console command. @@ -101,7 +168,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { var err error // get all options - appName, ipAddress, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, zeroDowntime, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(r.config) + appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, r.config) // Rollback flow if ctx.OptionBool("rollback") { @@ -124,6 +191,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 3: verify artifacts to determine which to upload hasMain := fileExists(appName) + hasProdEnv := fileExists(prodEnvFilePath) hasPublic := dirExists("public") hasStorage := dirExists("storage") hasResources := dirExists("resources") @@ -139,6 +207,9 @@ func (r *DeployCommand) Handle(ctx console.Context) error { if !include["main"] { hasMain = false } + if !include["prod-env"] { + hasProdEnv = false + } if !include["public"] { hasPublic = false } @@ -154,11 +225,11 @@ func (r *DeployCommand) Handle(ctx console.Context) error { if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( fmt.Sprintf("%v", appName), fmt.Sprintf("%v", ipAddress), + fmt.Sprintf("%v", appPort), fmt.Sprintf("%v", sshPort), fmt.Sprintf("%v", sshUser), fmt.Sprintf("%v", sshKeyPath), strings.TrimSpace(domain), - zeroDowntime, reverseProxyEnabled, reverseProxyTLSEnabled, ), "Setting up server (first time only)..."); err != nil { @@ -173,7 +244,8 @@ func (r *DeployCommand) Handle(ctx console.Context) error { fmt.Sprintf("%v", sshPort), fmt.Sprintf("%v", sshUser), fmt.Sprintf("%v", sshKeyPath), - hasMain, hasPublic, hasStorage, hasResources, + fmt.Sprintf("%v", prodEnvFilePath), + hasMain, hasProdEnv, hasPublic, hasStorage, hasResources, ), "Uploading files..."); err != nil { ctx.Error(err.Error()) return nil @@ -208,21 +280,16 @@ func dirExists(path string) bool { } // setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs -func setupServerCommand(appName, ip, port, user, keyPath, domain string, zeroDowntime, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { +func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { // Directories and service appDir := fmt.Sprintf("/var/www/%s", appName) binCurrent := fmt.Sprintf("%s/main", appDir) - // Ports - appPort := "9000" - httpPort := "80" - // httpsPort := "443" // only used when TLS is enabled via Caddy - // Build systemd unit based on whether reverse proxy is used listenHost := "127.0.0.1" if !reverseProxyEnabled { // App listens on port 80 directly - appPort = httpPort + appPort = "80" listenHost = "0.0.0.0" } @@ -231,7 +298,6 @@ Description=Goravel App %s After=network.target [Service] -User=%s WorkingDirectory=%s ExecStart=%s Environment=APP_ENV=production @@ -244,7 +310,7 @@ SyslogIdentifier=%s [Install] WantedBy=multi-user.target -`, appName, user, appDir, binCurrent, listenHost, appPort, appName) +`, appName, appDir, binCurrent, listenHost, appPort, appName) // Build Caddyfile if reverse proxy enabled caddyfile := "" @@ -297,8 +363,8 @@ if [ ! -f /etc/systemd/system/%s.service ]; then fi %s %s' -`, keyPath, port, user, ip, - appDir, appDir, user, user, appDir, +`, keyPath, sshPort, sshUser, ip, + appDir, appDir, sshUser, sshUser, appDir, // caddy install and config func() string { if !reverseProxyEnabled { @@ -317,12 +383,12 @@ fi } // uploadFilesCommand uploads available artifacts to remote server -func uploadFilesCommand(appName, ip, port, user, keyPath string, hasMain, hasPublic, hasStorage, hasResources bool) *exec.Cmd { +func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath string, hasMain, hasProdEnv, hasPublic, hasStorage, hasResources bool) *exec.Cmd { appDir := fmt.Sprintf("/var/www/%s", appName) - remoteBase := fmt.Sprintf("%s@%s:%s", user, ip, appDir) + remoteBase := fmt.Sprintf("%s@%s:%s", sshUser, ip, appDir) // ensure remote base exists and permissions cmds := []string{ - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", keyPath, port, user, ip, appDir, user, user, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", keyPath, sshPort, sshUser, ip, appDir, sshUser, sshUser, appDir), } // main binary with previous backup @@ -333,32 +399,35 @@ func uploadFilesCommand(appName, ip, port, user, keyPath string, hasMain, hasPub } // upload to temp and atomically move, keeping previous as main.prev cmds = append(cmds, - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", keyPath, port, filepath.Clean(localMain), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", keyPath, port, user, ip, appDir, appDir, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", keyPath, sshPort, filepath.Clean(localMain), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir, appDir, appDir), ) } + if hasProdEnv { + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean(prodEnvFilePath), remoteBase)) + } if hasPublic { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, port, filepath.Clean("public"), remoteBase)) + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("public"), remoteBase)) } if hasStorage { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, port, filepath.Clean("storage"), remoteBase)) + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("storage"), remoteBase)) } if hasResources { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, port, filepath.Clean("resources"), remoteBase)) + cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("resources"), remoteBase)) } script := strings.Join(cmds, " && ") return exec.Command("bash", "-lc", script) } -func restartServiceCommand(appName, ip, port, user, keyPath string) *exec.Cmd { - script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", keyPath, port, user, ip, appName, appName) +func restartServiceCommand(appName, ip, sshPort, sshUser, keyPath string) *exec.Cmd { + script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", keyPath, sshPort, sshUser, ip, appName, appName) return exec.Command("bash", "-lc", script) } // rollbackCommand swaps main and main.prev if available, then restarts the service -func rollbackCommand(appName, ip, port, user, keyPath string) *exec.Cmd { +func rollbackCommand(appName, ip, sshPort, sshUser, keyPath string) *exec.Cmd { appDir := fmt.Sprintf("/var/www/%s", appName) script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' set -e @@ -372,7 +441,7 @@ sudo mv %s/main.newcurrent %s/main.prev || true sudo chmod +x %s/main sudo systemctl daemon-reload sudo systemctl restart %s || sudo systemctl start %s -'`, keyPath, port, user, ip, +'`, keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir, appDir, appDir, appDir, appDir, appName, appName) return exec.Command("bash", "-lc", script) } From bdff5ff3e222af4b1bd0b540fe61542b185a9fcd Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 13:13:07 -0700 Subject: [PATCH 04/71] refine server set up --- console/console/deploy_command.go | 239 ++++++++++++++---------------- 1 file changed, 115 insertions(+), 124 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 3434e2191..cc07e551a 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -38,14 +38,10 @@ func (r *DeployCommand) Description() string { func (r *DeployCommand) Extend() command.Extend { return command.Extend{ Flags: []command.Flag{ - &command.StringFlag{Name: "ip", Usage: "Server IP address"}, - &command.StringFlag{Name: "port", Usage: "SSH port", Value: "22"}, - &command.StringFlag{Name: "user", Usage: "SSH user", Value: "root"}, - &command.StringFlag{Name: "key", Usage: "SSH private key path", Value: "~/.ssh/id_rsa"}, - &command.StringFlag{Name: "os", Usage: "Target OS", Value: "linux"}, - &command.StringFlag{Name: "arch", Usage: "Target arch", Value: "amd64"}, - &command.StringFlag{Name: "domain", Usage: "Domain for Caddy reverse proxy"}, - &command.StringFlag{Name: "only", Usage: "Comma-separated subset to deploy: main,public,storage,resources"}, + &command.StringFlag{ + Name: "only", + Usage: "Comma-separated subset to deploy: main,public,storage,resources,env. For example, to only deploy the main binary and the environment file, you can use 'main,env'", + }, &command.BoolFlag{ Name: "rollback", Aliases: []string{"r"}, @@ -53,20 +49,6 @@ func (r *DeployCommand) Extend() command.Extend { Usage: "Rollback to previous deployment", DisableDefaultText: true, }, - &command.BoolFlag{ - Name: "static", - Aliases: []string{"s"}, - Value: false, - Usage: "Static compilation", - DisableDefaultText: true, - }, - &command.BoolFlag{ - Name: "zero-downtime", - Aliases: []string{"z"}, - Value: false, - Usage: "Zero downtime deployment", - DisableDefaultText: true, - }, }, } } @@ -83,76 +65,70 @@ func getAllOptions(ctx console.Context, config config.Config) (appName, ipAddres domain = config.Env("DEPLOY_DOMAIN").(string) prodEnvFilePath = config.Env("DEPLOY_PROD_ENV_FILE_PATH").(string) - staticEnv = config.Env("DEPLOY_STATIC").(bool) - reverseProxyEnabled = config.Env("DEPLOY_REVERSE_PROXY_ENABLED").(bool) - reverseProxyTLSEnabled = config.Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED").(bool) + staticEnvRead := config.Env("DEPLOY_STATIC") + reverseProxyEnabledRead := config.Env("DEPLOY_REVERSE_PROXY_ENABLED") + reverseProxyTLSEnabledRead := config.Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED") - // if any of the options is not set, ask the user for the value - var err error + // if any of the options is not set, tell the user to set it and exit if appName == "" { - appName, err = ctx.Ask("Enter the app name") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("APP_NAME environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if ipAddress == "" { - ipAddress, err = ctx.Ask("Enter the ip address") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_IP_ADDRESS environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if appPort == "" { - appPort, err = ctx.Ask("Enter the app port") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_APP_PORT environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if sshPort == "" { - sshPort, err = ctx.Ask("Enter the ssh port") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_SSH_PORT environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if sshUser == "" { - sshUser, err = ctx.Ask("Enter the ssh user") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_SSH_USER environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if sshKeyPath == "" { - sshKeyPath, err = ctx.Ask("Enter the ssh key path") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_SSH_KEY_PATH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if targetOS == "" { - targetOS, err = ctx.Ask("Enter the target os") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_OS environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } if arch == "" { - arch, err = ctx.Ask("Enter the target arch") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_ARCH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } + if domain == "" { - domain, err = ctx.Ask("Enter the domain") - if err != nil { - ctx.Error(err.Error()) - os.Exit(1) - } + ctx.Error("DEPLOY_DOMAIN environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) + } + + if prodEnvFilePath == "" { + ctx.Error("DEPLOY_PROD_ENV_FILE_PATH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) + } + if staticEnvRead == "" { + ctx.Error("DEPLOY_STATIC environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) + } + if reverseProxyEnabledRead == "" { + ctx.Error("DEPLOY_REVERSE_PROXY_ENABLED environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) + } + if reverseProxyTLSEnabledRead == "" { + ctx.Error("DEPLOY_REVERSE_PROXY_TLS_ENABLED environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + os.Exit(1) } + staticEnv = staticEnvRead.(bool) + reverseProxyEnabled = reverseProxyEnabledRead.(bool) + reverseProxyTLSEnabled = reverseProxyTLSEnabledRead.(bool) + // expand ssh key ~ path if needed if after, ok := strings.CutPrefix(sshKeyPath, "~"); ok { if home, herr := os.UserHomeDir(); herr == nil { @@ -163,38 +139,12 @@ func getAllOptions(ctx console.Context, config config.Config) (appName, ipAddres return appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled } -// Handle Execute the console command. -func (r *DeployCommand) Handle(ctx console.Context) error { - var err error - - // get all options - appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, r.config) - - // Rollback flow - if ctx.OptionBool("rollback") { - if err = supportconsole.ExecuteCommand(ctx, rollbackCommand( - appName, ipAddress, sshPort, sshUser, sshKeyPath, - ), "Rolling back..."); err != nil { - ctx.Error(err.Error()) - return nil - } - ctx.Info("Rollback successful.") - return nil - } - - // Step 1: build the application - // Build the binary for target OS/arch - if err = supportconsole.ExecuteCommand(ctx, generateCommand(appName, targetOS, arch, staticEnv), "Building..."); err != nil { - ctx.Error(err.Error()) - return nil - } - - // Step 3: verify artifacts to determine which to upload - hasMain := fileExists(appName) - hasProdEnv := fileExists(prodEnvFilePath) - hasPublic := dirExists("public") - hasStorage := dirExists("storage") - hasResources := dirExists("resources") +func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) (hasMain, hasProdEnv, hasPublic, hasStorage, hasResources bool) { + hasMain = fileExists(appName) + hasProdEnv = fileExists(prodEnvFilePath) + hasPublic = dirExists("public") + hasStorage = dirExists("storage") + hasResources = dirExists("resources") // Allow subset selection via --only only := strings.TrimSpace(ctx.Option("only")) @@ -207,7 +157,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { if !include["main"] { hasMain = false } - if !include["prod-env"] { + if !include["env"] { hasProdEnv = false } if !include["public"] { @@ -220,24 +170,59 @@ func (r *DeployCommand) Handle(ctx console.Context) error { hasResources = false } } + return hasMain, hasProdEnv, hasPublic, hasStorage, hasResources +} - // Step 2: set up server on first run (idempotent) - if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( - fmt.Sprintf("%v", appName), - fmt.Sprintf("%v", ipAddress), - fmt.Sprintf("%v", appPort), - fmt.Sprintf("%v", sshPort), - fmt.Sprintf("%v", sshUser), - fmt.Sprintf("%v", sshKeyPath), - strings.TrimSpace(domain), - reverseProxyEnabled, - reverseProxyTLSEnabled, - ), "Setting up server (first time only)..."); err != nil { +// Handle Execute the console command. +func (r *DeployCommand) Handle(ctx console.Context) error { + var err error + + // get all options + appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, r.config) + + // Rollback if needed, then exit + if ctx.OptionBool("rollback") { + if err = supportconsole.ExecuteCommand(ctx, rollbackCommand( + appName, ipAddress, sshPort, sshUser, sshKeyPath, + ), "Rolling back..."); err != nil { + ctx.Error(err.Error()) + return nil + } + ctx.Info("Rollback successful.") + return nil + } + + // Step 1: build the application + // Build the binary for target OS/arch + if err = supportconsole.ExecuteCommand(ctx, generateCommand(appName, targetOS, arch, staticEnv), "Building..."); err != nil { ctx.Error(err.Error()) return nil } - // Step 3: upload files + // Step 2: verify which files to upload (main, env, public, storage, resources) + hasMain, hasProdEnv, hasPublic, hasStorage, hasResources := getWhichFilesToUpload(ctx, appName, prodEnvFilePath) + + // Step 3: set up server on first run (idempotent) — skip if already set up + if !isServerAlreadySetup(appName, ipAddress, sshPort, sshUser, sshKeyPath) { + if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( + fmt.Sprintf("%v", appName), + fmt.Sprintf("%v", ipAddress), + fmt.Sprintf("%v", appPort), + fmt.Sprintf("%v", sshPort), + fmt.Sprintf("%v", sshUser), + fmt.Sprintf("%v", sshKeyPath), + strings.TrimSpace(domain), + reverseProxyEnabled, + reverseProxyTLSEnabled, + ), "Setting up server (first time only)..."); err != nil { + ctx.Error(err.Error()) + return nil + } + } else { + ctx.Info("Server already set up. Skipping setup.") + } + + // Step 4: upload files if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand( fmt.Sprintf("%v", appName), fmt.Sprintf("%v", ipAddress), @@ -251,7 +236,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { return nil } - // Step 4: restart service + // Step 5: restart service if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand( fmt.Sprintf("%v", appName), fmt.Sprintf("%v", ipAddress), @@ -393,13 +378,9 @@ func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath // main binary with previous backup if hasMain { - localMain := appName - if !fileExists(localMain) && fileExists("main") { - localMain = "main" - } // upload to temp and atomically move, keeping previous as main.prev cmds = append(cmds, - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", keyPath, sshPort, filepath.Clean(localMain), remoteBase), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", keyPath, sshPort, filepath.Clean(appName), remoteBase), fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir, appDir, appDir), ) } @@ -445,3 +426,13 @@ sudo systemctl restart %s || sudo systemctl start %s appDir, appDir, appDir, appDir, appDir, appDir, appDir, appDir, appName, appName) return exec.Command("bash", "-lc", script) } + +// isServerAlreadySetup checks if the systemd unit already exists on remote host +func isServerAlreadySetup(appName, ip, sshPort, sshUser, keyPath string) bool { + checkCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'test -f /etc/systemd/system/%s.service'", keyPath, sshPort, sshUser, ip, appName) + cmd := exec.Command("bash", "-lc", checkCmd) + if err := cmd.Run(); err != nil { + return false + } + return true +} From 360e97aa99e4ec2bdccb0537f616f84cdd1c3759 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 13:24:30 -0700 Subject: [PATCH 05/71] comment changes --- console/console/deploy_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index cc07e551a..fa4eb76ac 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -202,7 +202,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 2: verify which files to upload (main, env, public, storage, resources) hasMain, hasProdEnv, hasPublic, hasStorage, hasResources := getWhichFilesToUpload(ctx, appName, prodEnvFilePath) - // Step 3: set up server on first run (idempotent) — skip if already set up + // Step 3: set up server on first run —- skip if already set up if !isServerAlreadySetup(appName, ipAddress, sshPort, sshUser, sshKeyPath) { if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( fmt.Sprintf("%v", appName), From fffc71b044341da9d0a9e86b03766ae3ada93689 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 14:50:11 -0700 Subject: [PATCH 06/71] bug fixes from audit --- console/console/deploy_command.go | 104 +++++++++++++++++++----------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index fa4eb76ac..e1fa541fc 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -49,25 +49,32 @@ func (r *DeployCommand) Extend() command.Extend { Usage: "Rollback to previous deployment", DisableDefaultText: true, }, + &command.BoolFlag{ + Name: "force-setup", + Aliases: []string{"F"}, + Value: false, + Usage: "Force re-run server setup even if already configured", + DisableDefaultText: true, + }, }, } } -func getAllOptions(ctx console.Context, config config.Config) (appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath string, staticEnv bool, reverseProxyEnabled bool, reverseProxyTLSEnabled bool) { - appName = config.GetString("app.name") - ipAddress = config.Env("DEPLOY_IP_ADDRESS").(string) - appPort = config.Env("DEPLOY_APP_PORT").(string) - sshPort = config.Env("DEPLOY_SSH_PORT").(string) - sshUser = config.Env("DEPLOY_SSH_USER").(string) - sshKeyPath = config.Env("DEPLOY_SSH_KEY_PATH").(string) - targetOS = config.Env("DEPLOY_OS").(string) - arch = config.Env("DEPLOY_ARCH").(string) - domain = config.Env("DEPLOY_DOMAIN").(string) - prodEnvFilePath = config.Env("DEPLOY_PROD_ENV_FILE_PATH").(string) - - staticEnvRead := config.Env("DEPLOY_STATIC") - reverseProxyEnabledRead := config.Env("DEPLOY_REVERSE_PROXY_ENABLED") - reverseProxyTLSEnabledRead := config.Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED") +func getAllOptions(ctx console.Context, cfg config.Config) (appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath string, staticEnv bool, reverseProxyEnabled bool, reverseProxyTLSEnabled bool) { + appName = cfg.GetString("app.name") + ipAddress = getStringEnv(cfg, "DEPLOY_IP_ADDRESS") + appPort = getStringEnv(cfg, "DEPLOY_APP_PORT") + sshPort = getStringEnv(cfg, "DEPLOY_SSH_PORT") + sshUser = getStringEnv(cfg, "DEPLOY_SSH_USER") + sshKeyPath = getStringEnv(cfg, "DEPLOY_SSH_KEY_PATH") + targetOS = getStringEnv(cfg, "DEPLOY_OS") + arch = getStringEnv(cfg, "DEPLOY_ARCH") + domain = getStringEnv(cfg, "DEPLOY_DOMAIN") + prodEnvFilePath = getStringEnv(cfg, "DEPLOY_PROD_ENV_FILE_PATH") + + staticEnv = getBoolEnv(cfg, "DEPLOY_STATIC") + reverseProxyEnabled = getBoolEnv(cfg, "DEPLOY_REVERSE_PROXY_ENABLED") + reverseProxyTLSEnabled = getBoolEnv(cfg, "DEPLOY_REVERSE_PROXY_TLS_ENABLED") // if any of the options is not set, tell the user to set it and exit if appName == "" { @@ -103,8 +110,9 @@ func getAllOptions(ctx console.Context, config config.Config) (appName, ipAddres os.Exit(1) } - if domain == "" { - ctx.Error("DEPLOY_DOMAIN environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + // domain is only required if reverse proxy TLS is enabled + if reverseProxyEnabled && reverseProxyTLSEnabled && domain == "" { + ctx.Error("DEPLOY_DOMAIN environment variable is required when reverse proxy TLS is enabled. Please set it in the .env file. Deployment cancelled. Exiting...") os.Exit(1) } @@ -112,22 +120,6 @@ func getAllOptions(ctx console.Context, config config.Config) (appName, ipAddres ctx.Error("DEPLOY_PROD_ENV_FILE_PATH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") os.Exit(1) } - if staticEnvRead == "" { - ctx.Error("DEPLOY_STATIC environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) - } - if reverseProxyEnabledRead == "" { - ctx.Error("DEPLOY_REVERSE_PROXY_ENABLED environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) - } - if reverseProxyTLSEnabledRead == "" { - ctx.Error("DEPLOY_REVERSE_PROXY_TLS_ENABLED environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) - } - - staticEnv = staticEnvRead.(bool) - reverseProxyEnabled = reverseProxyEnabledRead.(bool) - reverseProxyTLSEnabled = reverseProxyTLSEnabledRead.(bool) // expand ssh key ~ path if needed if after, ok := strings.CutPrefix(sshKeyPath, "~"); ok { @@ -264,6 +256,35 @@ func dirExists(path string) bool { return err == nil && info.IsDir() } +// helpers: safe env parsing +func getStringEnv(cfg config.Config, key string) string { + val := cfg.Env(key) + if val == nil { + return "" + } + s, ok := val.(string) + if ok { + return s + } + return fmt.Sprintf("%v", val) +} + +func getBoolEnv(cfg config.Config, key string) bool { + val := cfg.Env(key) + if val == nil { + return false + } + switch v := val.(type) { + case bool: + return v + case string: + t := strings.ToLower(strings.TrimSpace(v)) + return t == "1" || t == "true" || t == "t" || t == "yes" || t == "y" + default: + return false + } +} + // setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { // Directories and service @@ -283,6 +304,7 @@ Description=Goravel App %s After=network.target [Service] +User=%s WorkingDirectory=%s ExecStart=%s Environment=APP_ENV=production @@ -295,7 +317,7 @@ SyslogIdentifier=%s [Install] WantedBy=multi-user.target -`, appName, appDir, binCurrent, listenHost, appPort, appName) +`, appName, sshUser, appDir, binCurrent, listenHost, appPort, appName) // Build Caddyfile if reverse proxy enabled caddyfile := "" @@ -356,11 +378,15 @@ fi return "" } install := "sudo apt-get update -y && sudo apt-get install -y caddy" - writeCfg := fmt.Sprintf("echo %q | base64 -d | sudo tee /etc/caddy/Caddyfile >/dev/null && sudo systemctl enable --now caddy", caddyB64) + writeCfg := fmt.Sprintf("echo %q | base64 -d | sudo tee /etc/caddy/Caddyfile >/dev/null && sudo systemctl enable --now caddy && sudo systemctl reload caddy || sudo systemctl restart caddy", caddyB64) return install + " && " + writeCfg }(), appName, unitB64, appName, appName, - strings.Join(ufwCmds, " && "), + // Firewall: open before enabling to avoid SSH lockout + func() string { + cmds := append([]string{"sudo ufw allow OpenSSH"}, ufwCmds...) + return strings.Join(cmds, " && ") + }(), "true", ) @@ -386,7 +412,11 @@ func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath } if hasProdEnv { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean(prodEnvFilePath), remoteBase)) + // Upload env to a temp path, then atomically place as .env + cmds = append(cmds, + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", keyPath, sshPort, filepath.Clean(prodEnvFilePath), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", keyPath, sshPort, sshUser, ip, appDir, appDir), + ) } if hasPublic { cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("public"), remoteBase)) From ef5477fcd186088530ddb4413c852a4b8fbb16ce Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 15:41:35 -0700 Subject: [PATCH 07/71] add comment with documentation at the top of deploy command file --- console/console/deploy_command.go | 126 ++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index e1fa541fc..a9cd515b6 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -14,6 +14,132 @@ import ( supportconsole "github.com/goravel/framework/support/console" ) +/* +DeployCommand +=============== + +Overview +-------- +This command implements a simple, opinionated deployment pipeline for Goravel applications. +It builds the application locally, performs a one-time remote server setup, uploads the +required artifacts to the server, restarts a systemd service, and supports rollback to the +previous binary. The goal is to provide a pragmatic, single-command deploy for small-to-medium +workloads. + +Architecture assumptions +------------------------ +Two primary deployment topologies are supported: +1) Reverse proxy in front of the app (recommended) + - reverseProxyEnabled=true + - App listens on 127.0.0.1: (e.g. 9000) + - Caddy proxies public HTTP(S) traffic to the app + - If reverseProxyTLSEnabled=true and a valid domain is configured, Caddy terminates TLS + and automatically provisions certificates; otherwise Caddy serves plain HTTP on :80 + +2) No reverse proxy + - reverseProxyEnabled=false + - App listens directly on :80 (APP_HOST=0.0.0.0, APP_PORT=80) + +Artifacts & layout on server +---------------------------- +Remote base directory: /var/www/ +Files managed by this command on the remote host: + - main : current binary (running) + - main.prev : previous binary (standby for rollback) + - .env : environment file (uploaded from DEPLOY_PROD_ENV_FILE_PATH) + - public/ : optional static assets + - storage/ : optional storage directory + - resources/ : optional resources directory + +Idempotency & first-time setup +------------------------------ +The initial server setup is performed exactly once per server (per app name). The command first +checks if /etc/systemd/system/.service exists over SSH. If it exists, setup is skipped. +Otherwise, the command: + - Installs and configures Caddy (only when reverseProxyEnabled=true) + - Creates the app directory and sets ownership + - Writes the systemd unit for + - Enables the service and configures the firewall (ufw) + +Subsequent deploys skip the setup entirely for speed and safety (unless --force-setup is used). +Note: If you change proxy/TLS/domain settings later, pass --force-setup to re-apply provisioning +changes (e.g., regenerate Caddyfile, adjust firewall rules, rewrite the unit file). + +Rollback model +-------------- +Every deployment that uploads a new binary preserves the previous one as main.prev. A rollback +simply swaps main and main.prev atomically and restarts the service. Non-binary assets (.env, +public, storage, resources) are not rolled back by this command. + +Build & artifacts (local) +------------------------- +The command builds the binary (name: APP_NAME) using the configured target OS/ARCH and static +linking preference. See Goravel docs for compiling guidance, artifacts, and what to upload: +https://www.goravel.dev/getting-started/compile.html + +Configuration (env) +------------------- +Required: + - app.name : Application name (used in remote paths/service name) + - DEPLOY_IP_ADDRESS : Target server IP + - DEPLOY_APP_PORT : Backend app port when reverse proxy is used (e.g. 9000) + - DEPLOY_SSH_PORT : SSH port (e.g. 22) + - DEPLOY_SSH_USER : SSH username (user must have sudo privileges) + - DEPLOY_SSH_KEY_PATH : Path to SSH private key (e.g. ~/.ssh/id_rsa) + - DEPLOY_OS : Target OS for build (e.g. linux) + - DEPLOY_ARCH : Target arch for build (e.g. amd64) + - DEPLOY_PROD_ENV_FILE_PATH : Local path to production .env file to upload + +Optional / boolean flags (default false if unset): + - DEPLOY_STATIC : Build statically when true + - DEPLOY_REVERSE_PROXY_ENABLED : Use Caddy reverse proxy when true + - DEPLOY_REVERSE_PROXY_TLS_ENABLED : Enable TLS (requires domain) when true + - DEPLOY_DOMAIN : Domain name for TLS or HTTP vhost when using Caddy + (required only if TLS is enabled) + +CLI flags +--------- + - --only : Comma-separated subset to deploy: main,env,public,storage,resources + - -r, --rollback : Rollback to previous binary + - -F, --force-setup : Force re-run of provisioning even if already set up + +Security & firewall +------------------- +The command uses SSH with StrictHostKeyChecking=no for convenience. For production, consider +manually trusting the host key to avoid MITM risks. Firewall rules are applied via ufw with +safe ordering: allow OpenSSH and required HTTP(S) ports first, then enable ufw to avoid losing +SSH connectivity. + +Systemd service +--------------- +The unit runs under DEPLOY_SSH_USER. Environment variables are provided via the unit for host/port, +and the working directory points to /var/www/. Service restarts are used (brief downtime). +For zero-downtime swaps, a more advanced process manager or socket activation would be required. + +High-level deployment flow +-------------------------- +1) Build: compile the binary for the specified target (OS/ARCH, static optional) with name APP_NAME +2) Determine artifacts to upload: main, .env, public, storage, resources (filter via --only) +3) Setup (first deploy only, or when --force-setup): + - Create directories and permissions + - Install/configure Caddy based on reverse proxy + TLS settings + - Write systemd unit and enable service + - Configure ufw rules (OpenSSH, 80, and 443 as needed) +4) Upload: + - Binary: upload to main.new, move previous main to main.prev (if exists), atomically move main.new to main + - .env: upload to .env.new, atomically move to .env + - public, storage, resources: recursively upload if they exist locally +5) Restart service: systemctl daemon-reload, then restart (or start) the service + +Known limitations +----------------- + - No migrations or database orchestration + - Rollback covers only the binary; assets/env are not rolled back + - StrictHostKeyChecking is disabled by default for convenience + - Changing proxy/TLS/domain requires --force-setup to re-apply provisioning + - Assumes Debian/Ubuntu with apt-get and ufw available +*/ + type DeployCommand struct { config config.Config } From 438415a0a8e58adfeaf591b478c426f8a673db7c Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 16:11:27 -0700 Subject: [PATCH 08/71] update comment --- console/console/deploy_command.go | 88 +++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index a9cd515b6..35f4ccc5f 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -26,6 +26,94 @@ required artifacts to the server, restarts a systemd service, and supports rollb previous binary. The goal is to provide a pragmatic, single-command deploy for small-to-medium workloads. +Usage example (1 - with reverse proxy): + +Assuming you have the following .env file stored in the root of your project as .env.production: +``` +APP_NAME=my-app +DEPLOY_IP_ADDRESS=127.0.0.1 +DEPLOY_APP_PORT=9000 +DEPLOY_SSH_PORT=22 +DEPLOY_SSH_USER=deploy +DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa +DEPLOY_OS=linux +DEPLOY_ARCH=amd64 +DEPLOY_PROD_ENV_FILE_PATH=.env.production +DEPLOY_STATIC=true +DEPLOY_REVERSE_PROXY_ENABLED=true +DEPLOY_REVERSE_PROXY_TLS_ENABLED=true +DEPLOY_DOMAIN=my-app.com +``` +You can then deploy your application to the server with the following command: +``` +go run . artisan deploy +``` +This will: +1. Build the application +2. On the remote server: install Caddy as a reverse proxy, support TLS, configure Caddy to proxy traffic to the application on port 9000, and only allow traffic from the domain my-app.com. +3. On the remote server: install ufw, and set up the firewall to allow traffic to the application. +4. On the remote server: create the systemd unit file and enable it +5. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server +6. Restart the systemd service that manages the application + + +Usage example (2 - without reverse proxy): + +You can also deploy without a reverse proxy by setting the DEPLOY_REVERSE_PROXY_ENABLED environment variable to false. For example, +assuming you have the following .env file stored in the root of your project as .env.production and you want to deploy your application to the server without a reverse proxy: +``` +APP_NAME=my-app +DEPLOY_IP_ADDRESS=127.0.0.1 +DEPLOY_APP_PORT=80 +DEPLOY_SSH_PORT=22 +DEPLOY_SSH_USER=deploy +DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa +DEPLOY_OS=linux +DEPLOY_ARCH=amd64 +DEPLOY_PROD_ENV_FILE_PATH=.env.production +DEPLOY_STATIC=true +DEPLOY_REVERSE_PROXY_ENABLED=false +DEPLOY_REVERSE_PROXY_TLS_ENABLED=false +DEPLOY_DOMAIN= +``` + +You can then deploy your application to the server with the following command: +``` +go run . artisan deploy +``` + +This will: +1. Build the application +2. On the remote server: install ufw, and set up the firewall to allow traffic to the application that is listening on port 80 (http). +3. On the remote server: create the systemd unit file and enable it +4. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server +5. Restart the systemd service that manages the application +``` + +Usage example (3 - rollback): + +You can also rollback a deployment to the previous binary by running the following command: +``` +go run . artisan deploy --rollback +``` + + +Usage example (4 - force setup): + +You can also force the setup of the server by running the following command: +``` +go run . artisan deploy --force-setup +``` + + +Usage example (5 - only deploy subset of files): + +You can also deploy only a subset of the files (such as only the main binary and the environment file) by running the following command: +``` +go run . artisan deploy --only main,env +``` + + Architecture assumptions ------------------------ Two primary deployment topologies are supported: From 0eddab8929dab1c0c23d1a2fc77d88699cf5fdd4 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 21:02:28 -0700 Subject: [PATCH 09/71] update tests --- console/console/deploy_command_test.go | 232 ++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index e0d33a8dc..f8d06a9af 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -1,9 +1,239 @@ package console import ( + "encoding/base64" + "os" + "runtime" + "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + mocksconfig "github.com/goravel/framework/mocks/config" + mocksconsole "github.com/goravel/framework/mocks/console" ) -func TestDeployCommand(t *testing.T) { +// Helper to extract the first base64 payload used in an echo ... | base64 -d | tee ... sequence. +func extractBase64(script, teePath string) (string, bool) { + // find segment like: echo "" | base64 -d | sudo tee + pivot := " | base64 -d | sudo tee " + teePath + // search backwards for preceding echo " + idx := strings.Index(script, pivot) + if idx == -1 { + return "", false + } + // find preceding 'echo "' + pre := script[:idx] + start := strings.LastIndex(pre, "echo \"") + if start == -1 { + return "", false + } + start += len("echo \"") + // find closing quote before pivot + b64 := pre[start:] + end := strings.LastIndex(b64, "\"") + if end == -1 { + return "", false + } + return b64[:end], true +} + +func Test_setupServerCommand_NoProxy(t *testing.T) { + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "", false, false) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + require.GreaterOrEqual(t, len(cmd.Args), 3) + script := cmd.Args[2] + + // No Caddy installation + assert.NotContains(t, script, "install -y caddy") + // App listens on :80 directly + unitB64, ok := extractBase64(script, "/etc/systemd/system/myapp.service >/dev/null") + require.True(t, ok, "unit base64 not found") + unitBytes, err := base64.StdEncoding.DecodeString(unitB64) + require.NoError(t, err) + unit := string(unitBytes) + assert.Contains(t, unit, "User=ubuntu") + assert.Contains(t, unit, "APP_HOST=0.0.0.0") + assert.Contains(t, unit, "APP_PORT=80") + + // UFW ordering: allow OpenSSH then allow 80 then enable + assert.Contains(t, script, "ufw allow OpenSSH") + assert.Contains(t, script, "ufw allow 80") +} + +func Test_setupServerCommand_ProxyHTTP(t *testing.T) { + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "", true, false) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + // Caddy install present + assert.Contains(t, script, "install -y caddy") + // Caddyfile site :80, upstream 127.0.0.1:9000 + caddyB64, ok := extractBase64(script, "/etc/caddy/Caddyfile >/dev/null") + require.True(t, ok, "caddy base64 not found") + caddyBytes, err := base64.StdEncoding.DecodeString(caddyB64) + require.NoError(t, err) + caddy := string(caddyBytes) + assert.Contains(t, caddy, ":80 {") + assert.Contains(t, caddy, "reverse_proxy 127.0.0.1:9000") + // Firewall allows 80 but not 443 + assert.Contains(t, script, "ufw allow 80") + assert.NotContains(t, script, "ufw allow 443") +} + +func Test_setupServerCommand_ProxyTLS(t *testing.T) { + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "example.com", true, true) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + caddyB64, ok := extractBase64(script, "/etc/caddy/Caddyfile >/dev/null") + require.True(t, ok) + caddyBytes, err := base64.StdEncoding.DecodeString(caddyB64) + require.NoError(t, err) + caddy := string(caddyBytes) + assert.Contains(t, caddy, "example.com {") + assert.Contains(t, script, "ufw allow 80") + assert.Contains(t, script, "ufw allow 443") + // Reload on change + assert.Contains(t, script, "systemctl reload caddy || sudo systemctl restart caddy") +} + +func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", true, true, true, true, true) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + appDir := "/var/www/myapp" + // Binary upload and backup + assert.Contains(t, script, "scp -o StrictHostKeyChecking=no -i \"~/.ssh/id\" -P 22 \"myapp\" ubuntu@203.0.113.10:"+appDir+"/main.new") + assert.Contains(t, script, "if [ -f "+appDir+"/main ]; then sudo mv "+appDir+"/main "+appDir+"/main.prev; fi; sudo mv "+appDir+"/main.new "+appDir+"/main && sudo chmod +x "+appDir+"/main") + // .env atomic rename + assert.Contains(t, script, ".env.new") + assert.Contains(t, script, "/.env'") + // Directories + assert.Contains(t, script, "scp -o StrictHostKeyChecking=no -i \"~/.ssh/id\" -P 22 -r \"public\" ubuntu@203.0.113.10:"+appDir) + assert.Contains(t, script, "scp -o StrictHostKeyChecking=no -i \"~/.ssh/id\" -P 22 -r \"storage\" ubuntu@203.0.113.10:"+appDir) + assert.Contains(t, script, "scp -o StrictHostKeyChecking=no -i \"~/.ssh/id\" -P 22 -r \"resources\" ubuntu@203.0.113.10:"+appDir) +} + +func Test_restartServiceCommand(t *testing.T) { + cmd := restartServiceCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + assert.Contains(t, script, "systemctl daemon-reload") + assert.Contains(t, script, "systemctl restart myapp || sudo systemctl start myapp") +} + +func Test_rollbackCommand(t *testing.T) { + cmd := rollbackCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + assert.Contains(t, script, "main.prev") + assert.Contains(t, script, "systemctl restart myapp || sudo systemctl start myapp") +} + +func Test_getStringEnv_and_getBoolEnv(t *testing.T) { + mc := &mocksconfig.Config{} + // String as string + mc.EXPECT().Env("STR").Return("value").Once() + assert.Equal(t, "value", getStringEnv(mc, "STR")) + // String as non-string type + mc.EXPECT().Env("NUM").Return(123).Once() + assert.Equal(t, "123", getStringEnv(mc, "NUM")) + // Missing + mc.EXPECT().Env("MISSING").Return(nil).Once() + assert.Equal(t, "", getStringEnv(mc, "MISSING")) + + // Bool parsing + mc.EXPECT().Env("BOOL1").Return(true).Once() + assert.True(t, getBoolEnv(mc, "BOOL1")) + mc.EXPECT().Env("BOOL2").Return("true").Once() + assert.True(t, getBoolEnv(mc, "BOOL2")) + mc.EXPECT().Env("BOOL3").Return("1").Once() + assert.True(t, getBoolEnv(mc, "BOOL3")) + mc.EXPECT().Env("BOOL4").Return("no").Once() + assert.False(t, getBoolEnv(mc, "BOOL4")) + mc.EXPECT().Env("BOOL5").Return(nil).Once() + assert.False(t, getBoolEnv(mc, "BOOL5")) +} + +func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { + // Prepare temp workspace + wd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(wd) }) + + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + + // Create artifacts + require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) + require.NoError(t, os.WriteFile(".env.production", []byte("APP_ENV=prod"), 0o644)) + require.NoError(t, os.MkdirAll("public", 0o755)) + require.NoError(t, os.MkdirAll("storage", 0o755)) + require.NoError(t, os.MkdirAll("resources", 0o755)) + + mc := &mocksconsole.Context{} + mc.EXPECT().Option("only").Return("").Once() + hasMain, hasEnv, hasPub, hasStor, hasRes := getWhichFilesToUpload(mc, "myapp", ".env.production") + assert.True(t, hasMain) + assert.True(t, hasEnv) + assert.True(t, hasPub) + assert.True(t, hasStor) + assert.True(t, hasRes) + + // Now test filter: only main and env + mc2 := &mocksconsole.Context{} + mc2.EXPECT().Option("only").Return("main,env").Once() + hasMain, hasEnv, hasPub, hasStor, hasRes = getWhichFilesToUpload(mc2, "myapp", ".env.production") + assert.True(t, hasMain) + assert.True(t, hasEnv) + assert.False(t, hasPub) + assert.False(t, hasStor) + assert.False(t, hasRes) +} + +func Test_Handle_Rollback_ShortCircuit(t *testing.T) { + // We only test rollback path to avoid executing remote checks. + mc := &mocksconsole.Context{} + cfg := &mocksconfig.Config{} + cmd := NewDeployCommand(cfg) + + // Minimal required envs for getAllOptions (will not be used deeply due to rollback) + cfg.EXPECT().GetString("app.name").Return("myapp").Once() + cfg.EXPECT().Env("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() + cfg.EXPECT().Env("DEPLOY_APP_PORT").Return("9000").Once() + cfg.EXPECT().Env("DEPLOY_SSH_PORT").Return("22").Once() + cfg.EXPECT().Env("DEPLOY_SSH_USER").Return("ubuntu").Once() + cfg.EXPECT().Env("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() + cfg.EXPECT().Env("DEPLOY_OS").Return("linux").Once() + cfg.EXPECT().Env("DEPLOY_ARCH").Return("amd64").Once() + cfg.EXPECT().Env("DEPLOY_DOMAIN").Return("").Once() + cfg.EXPECT().Env("DEPLOY_PROD_ENV_FILE_PATH").Return(".env.production").Once() + cfg.EXPECT().Env("DEPLOY_STATIC").Return(false).Once() + cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_ENABLED").Return(false).Once() + cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED").Return(false).Once() + + mc.EXPECT().OptionBool("rollback").Return(true).Once() + mc.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() + mc.EXPECT().Info("Rollback successful.").Once() + assert.Nil(t, cmd.Handle(mc)) } From 7b719d7ad96572991f28894123362d328a7c4965 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 21:12:57 -0700 Subject: [PATCH 10/71] rearrange comment usage to bottom --- console/console/deploy_command.go | 179 +++++++++++++++--------------- 1 file changed, 92 insertions(+), 87 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 35f4ccc5f..4ce02dd8e 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -26,93 +26,6 @@ required artifacts to the server, restarts a systemd service, and supports rollb previous binary. The goal is to provide a pragmatic, single-command deploy for small-to-medium workloads. -Usage example (1 - with reverse proxy): - -Assuming you have the following .env file stored in the root of your project as .env.production: -``` -APP_NAME=my-app -DEPLOY_IP_ADDRESS=127.0.0.1 -DEPLOY_APP_PORT=9000 -DEPLOY_SSH_PORT=22 -DEPLOY_SSH_USER=deploy -DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa -DEPLOY_OS=linux -DEPLOY_ARCH=amd64 -DEPLOY_PROD_ENV_FILE_PATH=.env.production -DEPLOY_STATIC=true -DEPLOY_REVERSE_PROXY_ENABLED=true -DEPLOY_REVERSE_PROXY_TLS_ENABLED=true -DEPLOY_DOMAIN=my-app.com -``` -You can then deploy your application to the server with the following command: -``` -go run . artisan deploy -``` -This will: -1. Build the application -2. On the remote server: install Caddy as a reverse proxy, support TLS, configure Caddy to proxy traffic to the application on port 9000, and only allow traffic from the domain my-app.com. -3. On the remote server: install ufw, and set up the firewall to allow traffic to the application. -4. On the remote server: create the systemd unit file and enable it -5. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server -6. Restart the systemd service that manages the application - - -Usage example (2 - without reverse proxy): - -You can also deploy without a reverse proxy by setting the DEPLOY_REVERSE_PROXY_ENABLED environment variable to false. For example, -assuming you have the following .env file stored in the root of your project as .env.production and you want to deploy your application to the server without a reverse proxy: -``` -APP_NAME=my-app -DEPLOY_IP_ADDRESS=127.0.0.1 -DEPLOY_APP_PORT=80 -DEPLOY_SSH_PORT=22 -DEPLOY_SSH_USER=deploy -DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa -DEPLOY_OS=linux -DEPLOY_ARCH=amd64 -DEPLOY_PROD_ENV_FILE_PATH=.env.production -DEPLOY_STATIC=true -DEPLOY_REVERSE_PROXY_ENABLED=false -DEPLOY_REVERSE_PROXY_TLS_ENABLED=false -DEPLOY_DOMAIN= -``` - -You can then deploy your application to the server with the following command: -``` -go run . artisan deploy -``` - -This will: -1. Build the application -2. On the remote server: install ufw, and set up the firewall to allow traffic to the application that is listening on port 80 (http). -3. On the remote server: create the systemd unit file and enable it -4. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server -5. Restart the systemd service that manages the application -``` - -Usage example (3 - rollback): - -You can also rollback a deployment to the previous binary by running the following command: -``` -go run . artisan deploy --rollback -``` - - -Usage example (4 - force setup): - -You can also force the setup of the server by running the following command: -``` -go run . artisan deploy --force-setup -``` - - -Usage example (5 - only deploy subset of files): - -You can also deploy only a subset of the files (such as only the main binary and the environment file) by running the following command: -``` -go run . artisan deploy --only main,env -``` - Architecture assumptions ------------------------ @@ -226,6 +139,98 @@ Known limitations - StrictHostKeyChecking is disabled by default for convenience - Changing proxy/TLS/domain requires --force-setup to re-apply provisioning - Assumes Debian/Ubuntu with apt-get and ufw available + +Usage examples +-------------- + +Usage example (1 - with reverse proxy): + +Assuming you have the following .env file stored in the root of your project as .env.production: +``` +APP_NAME=my-app +DEPLOY_IP_ADDRESS=127.0.0.1 +DEPLOY_APP_PORT=9000 +DEPLOY_SSH_PORT=22 +DEPLOY_SSH_USER=deploy +DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa +DEPLOY_OS=linux +DEPLOY_ARCH=amd64 +DEPLOY_PROD_ENV_FILE_PATH=.env.production +DEPLOY_STATIC=true +DEPLOY_REVERSE_PROXY_ENABLED=true +DEPLOY_REVERSE_PROXY_TLS_ENABLED=true +DEPLOY_DOMAIN=my-app.com +``` +You can then deploy your application to the server with the following command: +``` +go run . artisan deploy +``` +This will: +1. Build the application +2. On the remote server: install Caddy as a reverse proxy, support TLS, configure Caddy to proxy traffic to the application on port 9000, and only allow traffic from the domain my-app.com. +3. On the remote server: install ufw, and set up the firewall to allow traffic to the application. +4. On the remote server: create the systemd unit file and enable it +5. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server +6. Restart the systemd service that manages the application + + +Usage example (2 - without reverse proxy): + +You can also deploy without a reverse proxy by setting the DEPLOY_REVERSE_PROXY_ENABLED environment variable to false. For example, +assuming you have the following .env file stored in the root of your project as .env.production and you want to deploy your application to the server without a reverse proxy: +``` +APP_NAME=my-app +DEPLOY_IP_ADDRESS=127.0.0.1 +DEPLOY_APP_PORT=80 +DEPLOY_SSH_PORT=22 +DEPLOY_SSH_USER=deploy +DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa +DEPLOY_OS=linux +DEPLOY_ARCH=amd64 +DEPLOY_PROD_ENV_FILE_PATH=.env.production +DEPLOY_STATIC=true +DEPLOY_REVERSE_PROXY_ENABLED=false +DEPLOY_REVERSE_PROXY_TLS_ENABLED=false +DEPLOY_DOMAIN= +``` + +You can then deploy your application to the server with the following command: +``` +go run . artisan deploy +``` + +This will: +1. Build the application +2. On the remote server: install ufw, and set up the firewall to allow traffic to the application that is listening on port 80 (http). +3. On the remote server: create the systemd unit file and enable it +4. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server +5. Restart the systemd service that manages the application +``` + +Usage example (3 - rollback): + +You can also rollback a deployment to the previous binary by running the following command: +``` +go run . artisan deploy --rollback +``` + + +Usage example (4 - force setup): + +You can also force the setup of the server by running the following command: +``` +go run . artisan deploy --force-setup +``` + + +Usage example (5 - only deploy subset of files): + +You can also deploy only a subset of the files (such as only the main binary and the environment file) by running the following command: +``` +go run . artisan deploy --only main,env +``` + + */ type DeployCommand struct { From 65e0c3b165f42612dfd8850589e63149886e06a7 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 21 Sep 2025 21:13:35 -0700 Subject: [PATCH 11/71] more comments tweaks --- console/console/deploy_command.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 4ce02dd8e..53ed06b2e 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -229,8 +229,6 @@ You can also deploy only a subset of the files (such as only the main binary and ``` go run . artisan deploy --only main,env ``` - - */ type DeployCommand struct { From a69e2671cb651bde32d07aae6a2cb154576d4def Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 19:59:57 -0700 Subject: [PATCH 12/71] clean up created files during tests --- console/console/deploy_command_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index f8d06a9af..771d8410b 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -190,6 +190,15 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { require.NoError(t, os.MkdirAll("storage", 0o755)) require.NoError(t, os.MkdirAll("resources", 0o755)) + // Cleanup created files/directories at end of test + t.Cleanup(func() { + _ = os.Remove("myapp") + _ = os.Remove(".env.production") + _ = os.RemoveAll("public") + _ = os.RemoveAll("storage") + _ = os.RemoveAll("resources") + }) + mc := &mocksconsole.Context{} mc.EXPECT().Option("only").Return("").Once() hasMain, hasEnv, hasPub, hasStor, hasRes := getWhichFilesToUpload(mc, "myapp", ".env.production") From bdf45c5c71a0a13902fef44d1d2dc6f0e505be95 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:09:24 -0700 Subject: [PATCH 13/71] add checks for runtime environment and scp, ssh, bash, dependencies --- console/console/deploy_command.go | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 53ed06b2e..60127ee51 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/goravel/framework/contracts/config" @@ -382,14 +383,44 @@ func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) return hasMain, hasProdEnv, hasPublic, hasStorage, hasResources } +// validLocalHost checks if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. +func validLocalHost(ctx console.Context) bool { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + ctx.Error("only macos and linux are supported. Please use a macos or linux machine to deploy.") + return false + } + + if err := exec.Command("scp", "-V").Run(); err != nil { + ctx.Error("scp is not installed. Please install it, add it to your path, and try again.") + return false + } + + if err := exec.Command("ssh", "-V").Run(); err != nil { + ctx.Error("ssh is not installed. Please install it, add it to your path, and try again.") + return false + } + + if err := exec.Command("bash", "-c", "echo $SHELL").Run(); err != nil { + ctx.Error("bash is not installed. Please install it, add it to your path, and try again.") + return false + } + + return true +} + // Handle Execute the console command. func (r *DeployCommand) Handle(ctx console.Context) error { - var err error + + // check if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. + if !validLocalHost(ctx) { + return nil + } // get all options appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, r.config) // Rollback if needed, then exit + var err error if ctx.OptionBool("rollback") { if err = supportconsole.ExecuteCommand(ctx, rollbackCommand( appName, ipAddress, sshPort, sshUser, sshKeyPath, From b6746a8db6812e3a65c581222e059c8be7f8e0ed Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:14:05 -0700 Subject: [PATCH 14/71] test fixes --- console/console/deploy_command.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 60127ee51..c3a59f5cf 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -410,6 +410,19 @@ func validLocalHost(ctx console.Context) bool { // Handle Execute the console command. func (r *DeployCommand) Handle(ctx console.Context) error { + // Rollback check first: allow rollback without validating local host tools + // (tests can short-circuit Spinner; real runs will still use ssh remotely) + if ctx.OptionBool("rollback") { + appName, ipAddress, _, sshPort, sshUser, sshKeyPath, _, _, _, _, _, _, _ := getAllOptions(ctx, r.config) + if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( + appName, ipAddress, sshPort, sshUser, sshKeyPath, + ), "Rolling back..."); err != nil { + ctx.Error(err.Error()) + return nil + } + ctx.Info("Rollback successful.") + return nil + } // check if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. if !validLocalHost(ctx) { @@ -419,18 +432,8 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // get all options appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, r.config) - // Rollback if needed, then exit + // continue normal deploy flow var err error - if ctx.OptionBool("rollback") { - if err = supportconsole.ExecuteCommand(ctx, rollbackCommand( - appName, ipAddress, sshPort, sshUser, sshKeyPath, - ), "Rolling back..."); err != nil { - ctx.Error(err.Error()) - return nil - } - ctx.Info("Rollback successful.") - return nil - } // Step 1: build the application // Build the binary for target OS/arch From ea9e7f2c680c4fc117e2c57062e278a774e56b5a Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:22:13 -0700 Subject: [PATCH 15/71] windows tests bug fix --- console/console/deploy_command_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 771d8410b..3e39c2f47 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -178,10 +178,11 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { // Prepare temp workspace wd, err := os.Getwd() require.NoError(t, err) - t.Cleanup(func() { _ = os.Chdir(wd) }) dir := t.TempDir() require.NoError(t, os.Chdir(dir)) + // Important: register chdir-back AFTER TempDir so it runs BEFORE TempDir's RemoveAll on Windows + t.Cleanup(func() { _ = os.Chdir(wd) }) // Create artifacts require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) From 6ab1db4e82188c8bfbad3aafb99086c34cc13f01 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:28:50 -0700 Subject: [PATCH 16/71] increase code coverage on tests --- console/console/deploy_command_test.go | 82 ++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 3e39c2f47..407fe1028 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -220,6 +220,88 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { assert.False(t, hasRes) } +func Test_getWhichFilesToUpload_onlyUnknownTokens(t *testing.T) { + // Workspace with all artifacts present + wd, err := os.Getwd() + require.NoError(t, err) + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(wd) }) + + require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) + require.NoError(t, os.WriteFile(".env.production", []byte("APP_ENV=prod"), 0o644)) + require.NoError(t, os.MkdirAll("public", 0o755)) + require.NoError(t, os.MkdirAll("storage", 0o755)) + require.NoError(t, os.MkdirAll("resources", 0o755)) + + mc := &mocksconsole.Context{} + mc.EXPECT().Option("only").Return("foo,bar").Once() + hasMain, hasEnv, hasPub, hasStor, hasRes := getWhichFilesToUpload(mc, "myapp", ".env.production") + assert.False(t, hasMain) + assert.False(t, hasEnv) + assert.False(t, hasPub) + assert.False(t, hasStor) + assert.False(t, hasRes) +} + +func Test_uploadFilesCommand_EnvOnly(t *testing.T) { + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", false, true, false, false, false) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + // No binary/public/storage/resources scp + assert.NotContains(t, script, "/main.new") + assert.NotContains(t, script, " -r \"public\"") + assert.NotContains(t, script, " -r \"storage\"") + assert.NotContains(t, script, " -r \"resources\"") + // Contains env atomic move + assert.Contains(t, script, ".env.new") + assert.Contains(t, script, "/.env'") +} + +func Test_setupServerCommand_FirewallOrder_OpenSSHBeforeEnable(t *testing.T) { + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "example.com", true, true) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + idxSSH := strings.Index(script, "ufw allow OpenSSH") + idxEnable := strings.LastIndex(script, "ufw --force enable") + assert.True(t, idxSSH != -1 && idxEnable != -1 && idxSSH < idxEnable, "OpenSSH must be allowed before enabling ufw") +} + +func Test_getAllOptions_ExpandsTildeAndBooleans(t *testing.T) { + // Validate ~ expansion for key path and boolean parsing + cfg := &mocksconfig.Config{} + ctx := &mocksconsole.Context{} + + cfg.EXPECT().GetString("app.name").Return("myapp").Once() + cfg.EXPECT().Env("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() + cfg.EXPECT().Env("DEPLOY_APP_PORT").Return("9000").Once() + cfg.EXPECT().Env("DEPLOY_SSH_PORT").Return("22").Once() + cfg.EXPECT().Env("DEPLOY_SSH_USER").Return("ubuntu").Once() + cfg.EXPECT().Env("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() + cfg.EXPECT().Env("DEPLOY_OS").Return("linux").Once() + cfg.EXPECT().Env("DEPLOY_ARCH").Return("amd64").Once() + cfg.EXPECT().Env("DEPLOY_DOMAIN").Return("example.com").Once() + cfg.EXPECT().Env("DEPLOY_PROD_ENV_FILE_PATH").Return(".env.production").Once() + cfg.EXPECT().Env("DEPLOY_STATIC").Return("true").Once() + cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_ENABLED").Return("true").Once() + cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED").Return("true").Once() + + appName, _, _, _, _, sshKeyPath, _, _, domain, _, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, cfg) + assert.Equal(t, "myapp", appName) + home, _ := os.UserHomeDir() + assert.True(t, strings.HasPrefix(sshKeyPath, home+string(os.PathSeparator))) + assert.Equal(t, "example.com", domain) + assert.True(t, staticEnv) + assert.True(t, reverseProxyEnabled) + assert.True(t, reverseProxyTLSEnabled) +} + func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. mc := &mocksconsole.Context{} From f3a4b91b2dd814b6eec32665e6205a4aded11f8b Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:40:48 -0700 Subject: [PATCH 17/71] increase code coverage --- console/console/deploy_command_test.go | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 407fe1028..2e4fa41d5 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -302,6 +302,43 @@ func Test_getAllOptions_ExpandsTildeAndBooleans(t *testing.T) { assert.True(t, reverseProxyTLSEnabled) } +func Test_fileExists_and_dirExists(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(wd) }) + + // none exists yet + assert.False(t, fileExists("bin")) + assert.False(t, dirExists("assets")) + + // create file + require.NoError(t, os.WriteFile("bin", []byte("a"), 0o644)) + assert.True(t, fileExists("bin")) + assert.False(t, dirExists("bin")) + + // create dir + require.NoError(t, os.MkdirAll("assets", 0o755)) + assert.True(t, dirExists("assets")) + assert.False(t, fileExists("assets")) +} + +func Test_uploadFilesCommand_NoArtifacts(t *testing.T) { + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", false, false, false, false, false) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + // only mkdir/chown should appear; no scp uploads + assert.Contains(t, script, "mkdir -p /var/www/myapp") + assert.Contains(t, script, "chown -R ubuntu:ubuntu /var/www/myapp") + assert.NotContains(t, script, "scp -o StrictHostKeyChecking=no") +} + +// NOTE: We avoid calling isServerAlreadySetup as it shells to ssh and can be slow on CI. + func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. mc := &mocksconsole.Context{} From 531f3ebe1ffbe7972ca943b43a8dca447a1fb3c7 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:45:28 -0700 Subject: [PATCH 18/71] Revert "increase code coverage" This reverts commit f3a4b91b2dd814b6eec32665e6205a4aded11f8b. --- console/console/deploy_command_test.go | 37 -------------------------- 1 file changed, 37 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 2e4fa41d5..407fe1028 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -302,43 +302,6 @@ func Test_getAllOptions_ExpandsTildeAndBooleans(t *testing.T) { assert.True(t, reverseProxyTLSEnabled) } -func Test_fileExists_and_dirExists(t *testing.T) { - wd, err := os.Getwd() - require.NoError(t, err) - dir := t.TempDir() - require.NoError(t, os.Chdir(dir)) - t.Cleanup(func() { _ = os.Chdir(wd) }) - - // none exists yet - assert.False(t, fileExists("bin")) - assert.False(t, dirExists("assets")) - - // create file - require.NoError(t, os.WriteFile("bin", []byte("a"), 0o644)) - assert.True(t, fileExists("bin")) - assert.False(t, dirExists("bin")) - - // create dir - require.NoError(t, os.MkdirAll("assets", 0o755)) - assert.True(t, dirExists("assets")) - assert.False(t, fileExists("assets")) -} - -func Test_uploadFilesCommand_NoArtifacts(t *testing.T) { - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", false, false, false, false, false) - require.NotNil(t, cmd) - if runtime.GOOS == "windows" { - t.Skip("Skipping script content assertions on Windows shell") - } - script := cmd.Args[2] - // only mkdir/chown should appear; no scp uploads - assert.Contains(t, script, "mkdir -p /var/www/myapp") - assert.Contains(t, script, "chown -R ubuntu:ubuntu /var/www/myapp") - assert.NotContains(t, script, "scp -o StrictHostKeyChecking=no") -} - -// NOTE: We avoid calling isServerAlreadySetup as it shells to ssh and can be slow on CI. - func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. mc := &mocksconsole.Context{} From 5af979620be17fe8367fe55580102f6440478af3 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Mon, 22 Sep 2025 20:45:53 -0700 Subject: [PATCH 19/71] Revert "increase code coverage on tests" This reverts commit 6ab1db4e82188c8bfbad3aafb99086c34cc13f01. --- console/console/deploy_command_test.go | 82 -------------------------- 1 file changed, 82 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 407fe1028..3e39c2f47 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -220,88 +220,6 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { assert.False(t, hasRes) } -func Test_getWhichFilesToUpload_onlyUnknownTokens(t *testing.T) { - // Workspace with all artifacts present - wd, err := os.Getwd() - require.NoError(t, err) - dir := t.TempDir() - require.NoError(t, os.Chdir(dir)) - t.Cleanup(func() { _ = os.Chdir(wd) }) - - require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) - require.NoError(t, os.WriteFile(".env.production", []byte("APP_ENV=prod"), 0o644)) - require.NoError(t, os.MkdirAll("public", 0o755)) - require.NoError(t, os.MkdirAll("storage", 0o755)) - require.NoError(t, os.MkdirAll("resources", 0o755)) - - mc := &mocksconsole.Context{} - mc.EXPECT().Option("only").Return("foo,bar").Once() - hasMain, hasEnv, hasPub, hasStor, hasRes := getWhichFilesToUpload(mc, "myapp", ".env.production") - assert.False(t, hasMain) - assert.False(t, hasEnv) - assert.False(t, hasPub) - assert.False(t, hasStor) - assert.False(t, hasRes) -} - -func Test_uploadFilesCommand_EnvOnly(t *testing.T) { - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", false, true, false, false, false) - require.NotNil(t, cmd) - if runtime.GOOS == "windows" { - t.Skip("Skipping script content assertions on Windows shell") - } - script := cmd.Args[2] - // No binary/public/storage/resources scp - assert.NotContains(t, script, "/main.new") - assert.NotContains(t, script, " -r \"public\"") - assert.NotContains(t, script, " -r \"storage\"") - assert.NotContains(t, script, " -r \"resources\"") - // Contains env atomic move - assert.Contains(t, script, ".env.new") - assert.Contains(t, script, "/.env'") -} - -func Test_setupServerCommand_FirewallOrder_OpenSSHBeforeEnable(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "example.com", true, true) - require.NotNil(t, cmd) - if runtime.GOOS == "windows" { - t.Skip("Skipping script content assertions on Windows shell") - } - script := cmd.Args[2] - idxSSH := strings.Index(script, "ufw allow OpenSSH") - idxEnable := strings.LastIndex(script, "ufw --force enable") - assert.True(t, idxSSH != -1 && idxEnable != -1 && idxSSH < idxEnable, "OpenSSH must be allowed before enabling ufw") -} - -func Test_getAllOptions_ExpandsTildeAndBooleans(t *testing.T) { - // Validate ~ expansion for key path and boolean parsing - cfg := &mocksconfig.Config{} - ctx := &mocksconsole.Context{} - - cfg.EXPECT().GetString("app.name").Return("myapp").Once() - cfg.EXPECT().Env("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() - cfg.EXPECT().Env("DEPLOY_APP_PORT").Return("9000").Once() - cfg.EXPECT().Env("DEPLOY_SSH_PORT").Return("22").Once() - cfg.EXPECT().Env("DEPLOY_SSH_USER").Return("ubuntu").Once() - cfg.EXPECT().Env("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() - cfg.EXPECT().Env("DEPLOY_OS").Return("linux").Once() - cfg.EXPECT().Env("DEPLOY_ARCH").Return("amd64").Once() - cfg.EXPECT().Env("DEPLOY_DOMAIN").Return("example.com").Once() - cfg.EXPECT().Env("DEPLOY_PROD_ENV_FILE_PATH").Return(".env.production").Once() - cfg.EXPECT().Env("DEPLOY_STATIC").Return("true").Once() - cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_ENABLED").Return("true").Once() - cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED").Return("true").Once() - - appName, _, _, _, _, sshKeyPath, _, _, domain, _, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, cfg) - assert.Equal(t, "myapp", appName) - home, _ := os.UserHomeDir() - assert.True(t, strings.HasPrefix(sshKeyPath, home+string(os.PathSeparator))) - assert.Equal(t, "example.com", domain) - assert.True(t, staticEnv) - assert.True(t, reverseProxyEnabled) - assert.True(t, reverseProxyTLSEnabled) -} - func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. mc := &mocksconsole.Context{} From 2f42405c0eb7a8b509c339519f74c6328c3bbbc0 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Tue, 23 Sep 2025 21:31:43 -0700 Subject: [PATCH 20/71] add missing env variable to example --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 70b0ea497..af6ce40d7 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,7 @@ DEPLOY_APP_PORT= DEPLOY_SSH_PORT= DEPLOY_SSH_USER= DEPLOY_SSH_KEY_PATH= +DEPLOY_PROD_ENV_FILE_PATH= DEPLOY_OS= DEPLOY_ARCH= DEPLOY_DOMAIN= From 9c177025275813d396b2d0d9655a982d7c9dfcad Mon Sep 17 00:00:00 2001 From: cggonzal Date: Tue, 23 Sep 2025 21:32:02 -0700 Subject: [PATCH 21/71] properly handle rollback based on deploy tests --- console/console/deploy_command.go | 62 +++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index c3a59f5cf..4a429bf16 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -390,17 +390,17 @@ func validLocalHost(ctx console.Context) bool { return false } - if err := exec.Command("scp", "-V").Run(); err != nil { + if _, err := exec.LookPath("scp"); err != nil { ctx.Error("scp is not installed. Please install it, add it to your path, and try again.") return false } - if err := exec.Command("ssh", "-V").Run(); err != nil { + if _, err := exec.LookPath("ssh"); err != nil { ctx.Error("ssh is not installed. Please install it, add it to your path, and try again.") return false } - if err := exec.Command("bash", "-c", "echo $SHELL").Run(); err != nil { + if _, err := exec.LookPath("bash"); err != nil { ctx.Error("bash is not installed. Please install it, add it to your path, and try again.") return false } @@ -663,20 +663,29 @@ func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath } if hasProdEnv { - // Upload env to a temp path, then atomically place as .env + // Upload env to a temp path, then atomically place as .env; backup previous as .env.prev if exists cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", keyPath, sshPort, filepath.Clean(prodEnvFilePath), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", keyPath, sshPort, sshUser, ip, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/.env ]; then sudo mv %s/.env %s/.env.prev; fi; sudo mv %s/.env.new %s/.env'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir, appDir), ) } if hasPublic { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("public"), remoteBase)) + cmds = append(cmds, + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public.prev; sudo mv %s/public %s/public.prev; fi'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("public"), remoteBase), + ) } if hasStorage { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("storage"), remoteBase)) + cmds = append(cmds, + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage.prev; sudo mv %s/storage %s/storage.prev; fi'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("storage"), remoteBase), + ) } if hasResources { - cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("resources"), remoteBase)) + cmds = append(cmds, + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources.prev; sudo mv %s/resources %s/resources.prev; fi'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("resources"), remoteBase), + ) } script := strings.Join(cmds, " && ") @@ -693,18 +702,39 @@ func rollbackCommand(appName, ip, sshPort, sshUser, keyPath string) *exec.Cmd { appDir := fmt.Sprintf("/var/www/%s", appName) script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' set -e -if [ ! -f %s/main.prev ]; then +APP_DIR=%q +SERVICE=%q +if [ ! -f "$APP_DIR/main.prev" ]; then echo "No previous deployment to rollback to." >&2 exit 1 fi -sudo mv %s/main %s/main.newcurrent || true -sudo mv %s/main.prev %s/main -sudo mv %s/main.newcurrent %s/main.prev || true -sudo chmod +x %s/main +sudo mv "$APP_DIR/main" "$APP_DIR/main.newcurrent" || true +sudo mv "$APP_DIR/main.prev" "$APP_DIR/main" +sudo mv "$APP_DIR/main.newcurrent" "$APP_DIR/main.prev" || true +sudo chmod +x "$APP_DIR/main" +if [ -f "$APP_DIR/.env.prev" ]; then + sudo mv "$APP_DIR/.env" "$APP_DIR/.env.newcurrent" || true + sudo mv "$APP_DIR/.env.prev" "$APP_DIR/.env" + sudo mv "$APP_DIR/.env.newcurrent" "$APP_DIR/.env.prev" || true +fi +if [ -d "$APP_DIR/public.prev" ]; then + sudo mv "$APP_DIR/public" "$APP_DIR/public.newcurrent" || true + sudo mv "$APP_DIR/public.prev" "$APP_DIR/public" + sudo mv "$APP_DIR/public.newcurrent" "$APP_DIR/public.prev" || true +fi +if [ -d "$APP_DIR/resources.prev" ]; then + sudo mv "$APP_DIR/resources" "$APP_DIR/resources.newcurrent" || true + sudo mv "$APP_DIR/resources.prev" "$APP_DIR/resources" + sudo mv "$APP_DIR/resources.newcurrent" "$APP_DIR/resources.prev" || true +fi +if [ -d "$APP_DIR/storage.prev" ]; then + sudo mv "$APP_DIR/storage" "$APP_DIR/storage.newcurrent" || true + sudo mv "$APP_DIR/storage.prev" "$APP_DIR/storage" + sudo mv "$APP_DIR/storage.newcurrent" "$APP_DIR/storage.prev" || true +fi sudo systemctl daemon-reload -sudo systemctl restart %s || sudo systemctl start %s -'`, keyPath, sshPort, sshUser, ip, - appDir, appDir, appDir, appDir, appDir, appDir, appDir, appDir, appName, appName) +sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE" + '`, keyPath, sshPort, sshUser, ip, appDir, appName) return exec.Command("bash", "-lc", script) } From 784980c15916f4fae11d13eb8006eb3afc80cc10 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Tue, 23 Sep 2025 21:37:15 -0700 Subject: [PATCH 22/71] modify tests --- console/console/deploy_command_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 3e39c2f47..949b26c95 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -146,7 +146,10 @@ func Test_rollbackCommand(t *testing.T) { } script := cmd.Args[2] assert.Contains(t, script, "main.prev") - assert.Contains(t, script, "systemctl restart myapp || sudo systemctl start myapp") + // Accept either explicit service name or variable-based restart lines + hasExplicit := strings.Contains(script, "systemctl restart myapp || sudo systemctl start myapp") + hasVariable := strings.Contains(script, "systemctl restart \"$SERVICE\" || sudo systemctl start \"$SERVICE\"") + assert.True(t, hasExplicit || hasVariable, "expected restart line not found") } func Test_getStringEnv_and_getBoolEnv(t *testing.T) { From b5c52c7b8ee66e65affaef5bf6fdf77974b3ce4b Mon Sep 17 00:00:00 2001 From: cggonzal Date: Thu, 25 Sep 2025 20:19:45 -0700 Subject: [PATCH 23/71] address comments --- console/console/deploy_command.go | 333 +++++++++++++------------ console/console/deploy_command_test.go | 48 ++-- 2 files changed, 202 insertions(+), 179 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 4a429bf16..c65f2592f 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -6,13 +6,13 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "github.com/goravel/framework/contracts/config" "github.com/goravel/framework/contracts/console" "github.com/goravel/framework/contracts/console/command" supportconsole "github.com/goravel/framework/support/console" + "github.com/goravel/framework/support/env" ) /* @@ -103,7 +103,7 @@ CLI flags --------- - --only : Comma-separated subset to deploy: main,env,public,storage,resources - -r, --rollback : Rollback to previous binary - - -F, --force-setup : Force re-run of provisioning even if already set up + - -f, --force-setup : Force re-run of provisioning even if already set up Security & firewall ------------------- @@ -232,6 +232,31 @@ go run . artisan deploy --only main,env ``` */ +// deployOptions is a struct that contains all the options for the deploy command +type deployOptions struct { + appName string + ipAddress string + appPort string + sshPort string + sshUser string + sshKeyPath string + targetOS string + arch string + domain string + prodEnvFilePath string + staticEnv bool + reverseProxyEnabled bool + reverseProxyTLSEnabled bool +} + +type uploadOptions struct { + hasMain bool + hasProdEnv bool + hasPublic bool + hasStorage bool + hasResources bool +} + type DeployCommand struct { config config.Config } @@ -269,7 +294,7 @@ func (r *DeployCommand) Extend() command.Extend { }, &command.BoolFlag{ Name: "force-setup", - Aliases: []string{"F"}, + Aliases: []string{"f"}, Value: false, Usage: "Force re-run server setup even if already configured", DisableDefaultText: true, @@ -278,83 +303,166 @@ func (r *DeployCommand) Extend() command.Extend { } } -func getAllOptions(ctx console.Context, cfg config.Config) (appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath string, staticEnv bool, reverseProxyEnabled bool, reverseProxyTLSEnabled bool) { - appName = cfg.GetString("app.name") - ipAddress = getStringEnv(cfg, "DEPLOY_IP_ADDRESS") - appPort = getStringEnv(cfg, "DEPLOY_APP_PORT") - sshPort = getStringEnv(cfg, "DEPLOY_SSH_PORT") - sshUser = getStringEnv(cfg, "DEPLOY_SSH_USER") - sshKeyPath = getStringEnv(cfg, "DEPLOY_SSH_KEY_PATH") - targetOS = getStringEnv(cfg, "DEPLOY_OS") - arch = getStringEnv(cfg, "DEPLOY_ARCH") - domain = getStringEnv(cfg, "DEPLOY_DOMAIN") - prodEnvFilePath = getStringEnv(cfg, "DEPLOY_PROD_ENV_FILE_PATH") - - staticEnv = getBoolEnv(cfg, "DEPLOY_STATIC") - reverseProxyEnabled = getBoolEnv(cfg, "DEPLOY_REVERSE_PROXY_ENABLED") - reverseProxyTLSEnabled = getBoolEnv(cfg, "DEPLOY_REVERSE_PROXY_TLS_ENABLED") - - // if any of the options is not set, tell the user to set it and exit - if appName == "" { - ctx.Error("APP_NAME environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) +// Handle Execute the console command. +func (r *DeployCommand) Handle(ctx console.Context) error { + // Rollback check first: allow rollback without validating local host tools + // (tests can short-circuit Spinner; real runs will still use ssh remotely) + if ctx.OptionBool("rollback") { + opts := r.getAllOptions(ctx) + if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( + opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath, + ), "Rolling back..."); err != nil { + ctx.Error(err.Error()) + return nil + } + ctx.Info("Rollback successful.") + return nil } - if ipAddress == "" { - ctx.Error("DEPLOY_IP_ADDRESS environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + + // check if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. + if !validLocalHost(ctx) { + return nil } - if appPort == "" { - ctx.Error("DEPLOY_APP_PORT environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + + // get all options + opts := r.getAllOptions(ctx) + + // continue normal deploy flow + var err error + + // Step 1: build the application + // Build the binary for target OS/arch + if err = supportconsole.ExecuteCommand(ctx, generateCommand(opts.appName, opts.targetOS, opts.arch, opts.staticEnv), "Building..."); err != nil { + ctx.Error(err.Error()) + return nil } - if sshPort == "" { - ctx.Error("DEPLOY_SSH_PORT environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + + // Step 2: verify which files to upload (main, env, public, storage, resources) + upload := getWhichFilesToUpload(ctx, opts.appName, opts.prodEnvFilePath) + + // Step 3: set up server on first run —- skip if already set up + if !isServerAlreadySetup(opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath) { + if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( + fmt.Sprintf("%v", opts.appName), + fmt.Sprintf("%v", opts.ipAddress), + fmt.Sprintf("%v", opts.appPort), + fmt.Sprintf("%v", opts.sshPort), + fmt.Sprintf("%v", opts.sshUser), + fmt.Sprintf("%v", opts.sshKeyPath), + strings.TrimSpace(opts.domain), + opts.reverseProxyEnabled, + opts.reverseProxyTLSEnabled, + ), "Setting up server (first time only)..."); err != nil { + ctx.Error(err.Error()) + return nil + } + } else { + ctx.Info("Server already set up. Skipping setup.") } - if sshUser == "" { - ctx.Error("DEPLOY_SSH_USER environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + + // Step 4: upload files + if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand( + fmt.Sprintf("%v", opts.appName), + fmt.Sprintf("%v", opts.ipAddress), + fmt.Sprintf("%v", opts.sshPort), + fmt.Sprintf("%v", opts.sshUser), + fmt.Sprintf("%v", opts.sshKeyPath), + fmt.Sprintf("%v", opts.prodEnvFilePath), + upload.hasMain, upload.hasProdEnv, upload.hasPublic, upload.hasStorage, upload.hasResources, + ), "Uploading files..."); err != nil { + ctx.Error(err.Error()) + return nil } - if sshKeyPath == "" { - ctx.Error("DEPLOY_SSH_KEY_PATH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + + // Step 5: restart service + if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand( + fmt.Sprintf("%v", opts.appName), + fmt.Sprintf("%v", opts.ipAddress), + fmt.Sprintf("%v", opts.sshPort), + fmt.Sprintf("%v", opts.sshUser), + fmt.Sprintf("%v", opts.sshKeyPath), + ), "Restarting service..."); err != nil { + ctx.Error(err.Error()) + return nil } - if targetOS == "" { - ctx.Error("DEPLOY_OS environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + + ctx.Info("Deploy successful.") + + return nil +} + +func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { + opts := deployOptions{} + opts.appName = r.config.GetString("app.name") + opts.ipAddress = r.config.GetString("DEPLOY_IP_ADDRESS") + opts.appPort = r.config.GetString("DEPLOY_APP_PORT") + opts.sshPort = r.config.GetString("DEPLOY_SSH_PORT") + opts.sshUser = r.config.GetString("DEPLOY_SSH_USER") + opts.sshKeyPath = r.config.GetString("DEPLOY_SSH_KEY_PATH") + opts.targetOS = r.config.GetString("DEPLOY_OS") + opts.arch = r.config.GetString("DEPLOY_ARCH") + opts.domain = r.config.GetString("DEPLOY_DOMAIN") + opts.prodEnvFilePath = r.config.GetString("DEPLOY_PROD_ENV_FILE_PATH") + + opts.staticEnv = r.config.GetBool("DEPLOY_STATIC") + opts.reverseProxyEnabled = r.config.GetBool("DEPLOY_REVERSE_PROXY_ENABLED") + opts.reverseProxyTLSEnabled = r.config.GetBool("DEPLOY_REVERSE_PROXY_TLS_ENABLED") + + // Validate required options and report all missing at once + var missing []string + if opts.appName == "" { + missing = append(missing, "APP_NAME") } - if arch == "" { - ctx.Error("DEPLOY_ARCH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + if opts.ipAddress == "" { + missing = append(missing, "DEPLOY_IP_ADDRESS") + } + if opts.appPort == "" { + missing = append(missing, "DEPLOY_APP_PORT") + } + if opts.sshPort == "" { + missing = append(missing, "DEPLOY_SSH_PORT") + } + if opts.sshUser == "" { + missing = append(missing, "DEPLOY_SSH_USER") + } + if opts.sshKeyPath == "" { + missing = append(missing, "DEPLOY_SSH_KEY_PATH") + } + if opts.targetOS == "" { + missing = append(missing, "DEPLOY_OS") + } + if opts.arch == "" { + missing = append(missing, "DEPLOY_ARCH") } - // domain is only required if reverse proxy TLS is enabled - if reverseProxyEnabled && reverseProxyTLSEnabled && domain == "" { - ctx.Error("DEPLOY_DOMAIN environment variable is required when reverse proxy TLS is enabled. Please set it in the .env file. Deployment cancelled. Exiting...") - os.Exit(1) + if opts.reverseProxyEnabled && opts.reverseProxyTLSEnabled && opts.domain == "" { + missing = append(missing, "DEPLOY_DOMAIN") } - - if prodEnvFilePath == "" { - ctx.Error("DEPLOY_PROD_ENV_FILE_PATH environment variable is required. Please set it in the .env file. Deployment cancelled. Exiting...") + if opts.prodEnvFilePath == "" { + missing = append(missing, "DEPLOY_PROD_ENV_FILE_PATH") + } + if len(missing) > 0 { + ctx.Error(fmt.Sprintf("Missing required environment variables: %s. Please set them in the .env file. Deployment cancelled. Exiting...", strings.Join(missing, ", "))) os.Exit(1) } // expand ssh key ~ path if needed - if after, ok := strings.CutPrefix(sshKeyPath, "~"); ok { + if after, ok := strings.CutPrefix(opts.sshKeyPath, "~"); ok { if home, herr := os.UserHomeDir(); herr == nil { - sshKeyPath = filepath.Join(home, after) + opts.sshKeyPath = filepath.Join(home, after) } } - return appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled + return opts } -func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) (hasMain, hasProdEnv, hasPublic, hasStorage, hasResources bool) { - hasMain = fileExists(appName) - hasProdEnv = fileExists(prodEnvFilePath) - hasPublic = dirExists("public") - hasStorage = dirExists("storage") - hasResources = dirExists("resources") +func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) uploadOptions { + res := uploadOptions{} + res.hasMain = fileExists(appName) + res.hasProdEnv = fileExists(prodEnvFilePath) + res.hasPublic = dirExists("public") + res.hasStorage = dirExists("storage") + res.hasResources = dirExists("resources") // Allow subset selection via --only only := strings.TrimSpace(ctx.Option("only")) @@ -365,27 +473,27 @@ func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) include[strings.TrimSpace(strings.ToLower(p))] = true } if !include["main"] { - hasMain = false + res.hasMain = false } if !include["env"] { - hasProdEnv = false + res.hasProdEnv = false } if !include["public"] { - hasPublic = false + res.hasPublic = false } if !include["storage"] { - hasStorage = false + res.hasStorage = false } if !include["resources"] { - hasResources = false + res.hasResources = false } } - return hasMain, hasProdEnv, hasPublic, hasStorage, hasResources + return res } // validLocalHost checks if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. func validLocalHost(ctx console.Context) bool { - if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + if !env.IsDarwin() && !env.IsLinux() { ctx.Error("only macos and linux are supported. Please use a macos or linux machine to deploy.") return false } @@ -408,94 +516,6 @@ func validLocalHost(ctx console.Context) bool { return true } -// Handle Execute the console command. -func (r *DeployCommand) Handle(ctx console.Context) error { - // Rollback check first: allow rollback without validating local host tools - // (tests can short-circuit Spinner; real runs will still use ssh remotely) - if ctx.OptionBool("rollback") { - appName, ipAddress, _, sshPort, sshUser, sshKeyPath, _, _, _, _, _, _, _ := getAllOptions(ctx, r.config) - if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( - appName, ipAddress, sshPort, sshUser, sshKeyPath, - ), "Rolling back..."); err != nil { - ctx.Error(err.Error()) - return nil - } - ctx.Info("Rollback successful.") - return nil - } - - // check if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. - if !validLocalHost(ctx) { - return nil - } - - // get all options - appName, ipAddress, appPort, sshPort, sshUser, sshKeyPath, targetOS, arch, domain, prodEnvFilePath, staticEnv, reverseProxyEnabled, reverseProxyTLSEnabled := getAllOptions(ctx, r.config) - - // continue normal deploy flow - var err error - - // Step 1: build the application - // Build the binary for target OS/arch - if err = supportconsole.ExecuteCommand(ctx, generateCommand(appName, targetOS, arch, staticEnv), "Building..."); err != nil { - ctx.Error(err.Error()) - return nil - } - - // Step 2: verify which files to upload (main, env, public, storage, resources) - hasMain, hasProdEnv, hasPublic, hasStorage, hasResources := getWhichFilesToUpload(ctx, appName, prodEnvFilePath) - - // Step 3: set up server on first run —- skip if already set up - if !isServerAlreadySetup(appName, ipAddress, sshPort, sshUser, sshKeyPath) { - if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( - fmt.Sprintf("%v", appName), - fmt.Sprintf("%v", ipAddress), - fmt.Sprintf("%v", appPort), - fmt.Sprintf("%v", sshPort), - fmt.Sprintf("%v", sshUser), - fmt.Sprintf("%v", sshKeyPath), - strings.TrimSpace(domain), - reverseProxyEnabled, - reverseProxyTLSEnabled, - ), "Setting up server (first time only)..."); err != nil { - ctx.Error(err.Error()) - return nil - } - } else { - ctx.Info("Server already set up. Skipping setup.") - } - - // Step 4: upload files - if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand( - fmt.Sprintf("%v", appName), - fmt.Sprintf("%v", ipAddress), - fmt.Sprintf("%v", sshPort), - fmt.Sprintf("%v", sshUser), - fmt.Sprintf("%v", sshKeyPath), - fmt.Sprintf("%v", prodEnvFilePath), - hasMain, hasProdEnv, hasPublic, hasStorage, hasResources, - ), "Uploading files..."); err != nil { - ctx.Error(err.Error()) - return nil - } - - // Step 5: restart service - if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand( - fmt.Sprintf("%v", appName), - fmt.Sprintf("%v", ipAddress), - fmt.Sprintf("%v", sshPort), - fmt.Sprintf("%v", sshUser), - fmt.Sprintf("%v", sshKeyPath), - ), "Restarting service..."); err != nil { - ctx.Error(err.Error()) - return nil - } - - ctx.Info("Deploy successful.") - - return nil -} - // helpers func fileExists(path string) bool { info, err := os.Stat(path) @@ -508,6 +528,8 @@ func dirExists(path string) bool { } // helpers: safe env parsing +// +//nolint:unused // used by tests in deploy_command_test.go func getStringEnv(cfg config.Config, key string) string { val := cfg.Env(key) if val == nil { @@ -520,6 +542,7 @@ func getStringEnv(cfg config.Config, key string) string { return fmt.Sprintf("%v", val) } +//nolint:unused // used by tests in deploy_command_test.go func getBoolEnv(cfg config.Config, key string) bool { val := cfg.Env(key) if val == nil { diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 949b26c95..5ef0c8fdb 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -205,22 +205,22 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { mc := &mocksconsole.Context{} mc.EXPECT().Option("only").Return("").Once() - hasMain, hasEnv, hasPub, hasStor, hasRes := getWhichFilesToUpload(mc, "myapp", ".env.production") - assert.True(t, hasMain) - assert.True(t, hasEnv) - assert.True(t, hasPub) - assert.True(t, hasStor) - assert.True(t, hasRes) + up := getWhichFilesToUpload(mc, "myapp", ".env.production") + assert.True(t, up.hasMain) + assert.True(t, up.hasProdEnv) + assert.True(t, up.hasPublic) + assert.True(t, up.hasStorage) + assert.True(t, up.hasResources) // Now test filter: only main and env mc2 := &mocksconsole.Context{} mc2.EXPECT().Option("only").Return("main,env").Once() - hasMain, hasEnv, hasPub, hasStor, hasRes = getWhichFilesToUpload(mc2, "myapp", ".env.production") - assert.True(t, hasMain) - assert.True(t, hasEnv) - assert.False(t, hasPub) - assert.False(t, hasStor) - assert.False(t, hasRes) + up = getWhichFilesToUpload(mc2, "myapp", ".env.production") + assert.True(t, up.hasMain) + assert.True(t, up.hasProdEnv) + assert.False(t, up.hasPublic) + assert.False(t, up.hasStorage) + assert.False(t, up.hasResources) } func Test_Handle_Rollback_ShortCircuit(t *testing.T) { @@ -231,18 +231,18 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // Minimal required envs for getAllOptions (will not be used deeply due to rollback) cfg.EXPECT().GetString("app.name").Return("myapp").Once() - cfg.EXPECT().Env("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() - cfg.EXPECT().Env("DEPLOY_APP_PORT").Return("9000").Once() - cfg.EXPECT().Env("DEPLOY_SSH_PORT").Return("22").Once() - cfg.EXPECT().Env("DEPLOY_SSH_USER").Return("ubuntu").Once() - cfg.EXPECT().Env("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() - cfg.EXPECT().Env("DEPLOY_OS").Return("linux").Once() - cfg.EXPECT().Env("DEPLOY_ARCH").Return("amd64").Once() - cfg.EXPECT().Env("DEPLOY_DOMAIN").Return("").Once() - cfg.EXPECT().Env("DEPLOY_PROD_ENV_FILE_PATH").Return(".env.production").Once() - cfg.EXPECT().Env("DEPLOY_STATIC").Return(false).Once() - cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_ENABLED").Return(false).Once() - cfg.EXPECT().Env("DEPLOY_REVERSE_PROXY_TLS_ENABLED").Return(false).Once() + cfg.EXPECT().GetString("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() + cfg.EXPECT().GetString("DEPLOY_APP_PORT").Return("9000").Once() + cfg.EXPECT().GetString("DEPLOY_SSH_PORT").Return("22").Once() + cfg.EXPECT().GetString("DEPLOY_SSH_USER").Return("ubuntu").Once() + cfg.EXPECT().GetString("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() + cfg.EXPECT().GetString("DEPLOY_OS").Return("linux").Once() + cfg.EXPECT().GetString("DEPLOY_ARCH").Return("amd64").Once() + cfg.EXPECT().GetString("DEPLOY_DOMAIN").Return("").Once() + cfg.EXPECT().GetString("DEPLOY_PROD_ENV_FILE_PATH").Return(".env.production").Once() + cfg.EXPECT().GetBool("DEPLOY_STATIC").Return(false).Once() + cfg.EXPECT().GetBool("DEPLOY_REVERSE_PROXY_ENABLED").Return(false).Once() + cfg.EXPECT().GetBool("DEPLOY_REVERSE_PROXY_TLS_ENABLED").Return(false).Once() mc.EXPECT().OptionBool("rollback").Return(true).Once() mc.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() From a59d6824729c296ef927483c0ee66764a3ca56c3 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 11:52:56 -0700 Subject: [PATCH 24/71] remove domain nil check --- console/console/deploy_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index c65f2592f..8ca091908 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -597,7 +597,7 @@ WantedBy=multi-user.target caddyfile := "" if reverseProxyEnabled { site := ":80" - if reverseProxyTLSEnabled && strings.TrimSpace(domain) != "" && domain != "" { + if reverseProxyTLSEnabled && strings.TrimSpace(domain) != "" { site = domain } upstream := fmt.Sprintf("127.0.0.1:%s", appPort) From 29053e7eb5ed0ebeb877ffead7663fe27a1d9474 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 11:54:42 -0700 Subject: [PATCH 25/71] use existing file existence check function --- console/console/deploy_command.go | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 8ca091908..e5b79fe2e 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -13,6 +13,7 @@ import ( "github.com/goravel/framework/contracts/console/command" supportconsole "github.com/goravel/framework/support/console" "github.com/goravel/framework/support/env" + "github.com/goravel/framework/support/file" ) /* @@ -458,11 +459,11 @@ func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) uploadOptions { res := uploadOptions{} - res.hasMain = fileExists(appName) - res.hasProdEnv = fileExists(prodEnvFilePath) - res.hasPublic = dirExists("public") - res.hasStorage = dirExists("storage") - res.hasResources = dirExists("resources") + res.hasMain = file.Exists(appName) + res.hasProdEnv = file.Exists(prodEnvFilePath) + res.hasPublic = file.Exists("public") + res.hasStorage = file.Exists("storage") + res.hasResources = file.Exists("resources") // Allow subset selection via --only only := strings.TrimSpace(ctx.Option("only")) @@ -516,17 +517,6 @@ func validLocalHost(ctx console.Context) bool { return true } -// helpers -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} - -func dirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - // helpers: safe env parsing // //nolint:unused // used by tests in deploy_command_test.go From 53da0b5d2e05dc3ff0f5df7ae230a31e8d528473 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 12:38:51 -0700 Subject: [PATCH 26/71] added EnvString() and EnvBool() functions --- config/application.go | 19 ++++ console/console/deploy_command.go | 32 ------- console/console/deploy_command_test.go | 32 +++---- contracts/config/config.go | 4 + mocks/config/Config.go | 122 +++++++++++++++++++++++++ mocks/queue/Config.go | 122 +++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 48 deletions(-) diff --git a/config/application.go b/config/application.go index d0e405beb..719b073ce 100644 --- a/config/application.go +++ b/config/application.go @@ -64,6 +64,25 @@ func (app *Application) Env(envName string, defaultValue ...any) any { return value } +// EnvString get string value from env with optional default. +func (app *Application) EnvString(envName string, defaultValue ...string) string { + value := app.Env(envName) + if cast.ToString(value) == "" { + return convert.Default(defaultValue...) + } + return cast.ToString(value) +} + +// EnvBool get bool value from env with optional default. +func (app *Application) EnvBool(envName string, defaultValue ...bool) bool { + value := app.Env(envName) + // If no value and a default provided, return default + if cast.ToString(value) == "" && len(defaultValue) > 0 { + return defaultValue[0] + } + return cast.ToBool(value) +} + // Add config to application. func (app *Application) Add(name string, configuration any) { app.vip.Set(name, configuration) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index e5b79fe2e..70dea0e85 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -517,38 +517,6 @@ func validLocalHost(ctx console.Context) bool { return true } -// helpers: safe env parsing -// -//nolint:unused // used by tests in deploy_command_test.go -func getStringEnv(cfg config.Config, key string) string { - val := cfg.Env(key) - if val == nil { - return "" - } - s, ok := val.(string) - if ok { - return s - } - return fmt.Sprintf("%v", val) -} - -//nolint:unused // used by tests in deploy_command_test.go -func getBoolEnv(cfg config.Config, key string) bool { - val := cfg.Env(key) - if val == nil { - return false - } - switch v := val.(type) { - case bool: - return v - case string: - t := strings.ToLower(strings.TrimSpace(v)) - return t == "1" || t == "true" || t == "t" || t == "yes" || t == "y" - default: - return false - } -} - // setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { // Directories and service diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 5ef0c8fdb..332976b7e 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -155,26 +155,26 @@ func Test_rollbackCommand(t *testing.T) { func Test_getStringEnv_and_getBoolEnv(t *testing.T) { mc := &mocksconfig.Config{} // String as string - mc.EXPECT().Env("STR").Return("value").Once() - assert.Equal(t, "value", getStringEnv(mc, "STR")) + mc.EXPECT().EnvString("STR").Return("value").Once() + assert.Equal(t, "value", mc.EnvString("STR")) // String as non-string type - mc.EXPECT().Env("NUM").Return(123).Once() - assert.Equal(t, "123", getStringEnv(mc, "NUM")) + mc.EXPECT().EnvString("NUM").Return("123").Once() + assert.Equal(t, "123", mc.EnvString("NUM")) // Missing - mc.EXPECT().Env("MISSING").Return(nil).Once() - assert.Equal(t, "", getStringEnv(mc, "MISSING")) + mc.EXPECT().EnvString("MISSING").Return("").Once() + assert.Equal(t, "", mc.EnvString("MISSING")) // Bool parsing - mc.EXPECT().Env("BOOL1").Return(true).Once() - assert.True(t, getBoolEnv(mc, "BOOL1")) - mc.EXPECT().Env("BOOL2").Return("true").Once() - assert.True(t, getBoolEnv(mc, "BOOL2")) - mc.EXPECT().Env("BOOL3").Return("1").Once() - assert.True(t, getBoolEnv(mc, "BOOL3")) - mc.EXPECT().Env("BOOL4").Return("no").Once() - assert.False(t, getBoolEnv(mc, "BOOL4")) - mc.EXPECT().Env("BOOL5").Return(nil).Once() - assert.False(t, getBoolEnv(mc, "BOOL5")) + mc.EXPECT().EnvBool("BOOL1").Return(true).Once() + assert.True(t, mc.EnvBool("BOOL1")) + mc.EXPECT().EnvBool("BOOL2").Return(true).Once() + assert.True(t, mc.EnvBool("BOOL2")) + mc.EXPECT().EnvBool("BOOL3").Return(true).Once() + assert.True(t, mc.EnvBool("BOOL3")) + mc.EXPECT().EnvBool("BOOL4").Return(false).Once() + assert.False(t, mc.EnvBool("BOOL4")) + mc.EXPECT().EnvBool("BOOL5").Return(false).Once() + assert.False(t, mc.EnvBool("BOOL5")) } func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { diff --git a/contracts/config/config.go b/contracts/config/config.go index 5799a3eef..79ab4f939 100644 --- a/contracts/config/config.go +++ b/contracts/config/config.go @@ -5,6 +5,10 @@ import "time" type Config interface { // Env get config from env. Env(envName string, defaultValue ...any) any + // EnvString get string value from env with optional default. + EnvString(envName string, defaultValue ...string) string + // EnvBool get bool value from env with optional default. + EnvBool(envName string, defaultValue ...bool) bool // Add config to application. Add(name string, configuration any) // Get config from application. diff --git a/mocks/config/Config.go b/mocks/config/Config.go index 567750417..ee38c11a7 100644 --- a/mocks/config/Config.go +++ b/mocks/config/Config.go @@ -114,6 +114,128 @@ func (_c *Config_Env_Call) RunAndReturn(run func(string, ...interface{}) interfa return _c } +// EnvBool provides a mock function with given fields: envName, defaultValue +func (_m *Config) EnvBool(envName string, defaultValue ...bool) bool { + _va := make([]interface{}, len(defaultValue)) + for _i := range defaultValue { + _va[_i] = defaultValue[_i] + } + var _ca []interface{} + _ca = append(_ca, envName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for EnvBool") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string, ...bool) bool); ok { + r0 = rf(envName, defaultValue...) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Config_EnvBool_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvBool' +type Config_EnvBool_Call struct { + *mock.Call +} + +// EnvBool is a helper method to define mock.On call +// - envName string +// - defaultValue ...bool +func (_e *Config_Expecter) EnvBool(envName interface{}, defaultValue ...interface{}) *Config_EnvBool_Call { + return &Config_EnvBool_Call{Call: _e.mock.On("EnvBool", + append([]interface{}{envName}, defaultValue...)...)} +} + +func (_c *Config_EnvBool_Call) Run(run func(envName string, defaultValue ...bool)) *Config_EnvBool_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]bool, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(bool) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *Config_EnvBool_Call) Return(_a0 bool) *Config_EnvBool_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Config_EnvBool_Call) RunAndReturn(run func(string, ...bool) bool) *Config_EnvBool_Call { + _c.Call.Return(run) + return _c +} + +// EnvString provides a mock function with given fields: envName, defaultValue +func (_m *Config) EnvString(envName string, defaultValue ...string) string { + _va := make([]interface{}, len(defaultValue)) + for _i := range defaultValue { + _va[_i] = defaultValue[_i] + } + var _ca []interface{} + _ca = append(_ca, envName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for EnvString") + } + + var r0 string + if rf, ok := ret.Get(0).(func(string, ...string) string); ok { + r0 = rf(envName, defaultValue...) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Config_EnvString_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvString' +type Config_EnvString_Call struct { + *mock.Call +} + +// EnvString is a helper method to define mock.On call +// - envName string +// - defaultValue ...string +func (_e *Config_Expecter) EnvString(envName interface{}, defaultValue ...interface{}) *Config_EnvString_Call { + return &Config_EnvString_Call{Call: _e.mock.On("EnvString", + append([]interface{}{envName}, defaultValue...)...)} +} + +func (_c *Config_EnvString_Call) Run(run func(envName string, defaultValue ...string)) *Config_EnvString_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *Config_EnvString_Call) Return(_a0 string) *Config_EnvString_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Config_EnvString_Call) RunAndReturn(run func(string, ...string) string) *Config_EnvString_Call { + _c.Call.Return(run) + return _c +} + // Get provides a mock function with given fields: path, defaultValue func (_m *Config) Get(path string, defaultValue ...interface{}) interface{} { var _ca []interface{} diff --git a/mocks/queue/Config.go b/mocks/queue/Config.go index 948732cca..e93b5632b 100644 --- a/mocks/queue/Config.go +++ b/mocks/queue/Config.go @@ -340,6 +340,128 @@ func (_c *Config_Env_Call) RunAndReturn(run func(string, ...interface{}) interfa return _c } +// EnvBool provides a mock function with given fields: envName, defaultValue +func (_m *Config) EnvBool(envName string, defaultValue ...bool) bool { + _va := make([]interface{}, len(defaultValue)) + for _i := range defaultValue { + _va[_i] = defaultValue[_i] + } + var _ca []interface{} + _ca = append(_ca, envName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for EnvBool") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string, ...bool) bool); ok { + r0 = rf(envName, defaultValue...) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Config_EnvBool_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvBool' +type Config_EnvBool_Call struct { + *mock.Call +} + +// EnvBool is a helper method to define mock.On call +// - envName string +// - defaultValue ...bool +func (_e *Config_Expecter) EnvBool(envName interface{}, defaultValue ...interface{}) *Config_EnvBool_Call { + return &Config_EnvBool_Call{Call: _e.mock.On("EnvBool", + append([]interface{}{envName}, defaultValue...)...)} +} + +func (_c *Config_EnvBool_Call) Run(run func(envName string, defaultValue ...bool)) *Config_EnvBool_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]bool, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(bool) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *Config_EnvBool_Call) Return(_a0 bool) *Config_EnvBool_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Config_EnvBool_Call) RunAndReturn(run func(string, ...bool) bool) *Config_EnvBool_Call { + _c.Call.Return(run) + return _c +} + +// EnvString provides a mock function with given fields: envName, defaultValue +func (_m *Config) EnvString(envName string, defaultValue ...string) string { + _va := make([]interface{}, len(defaultValue)) + for _i := range defaultValue { + _va[_i] = defaultValue[_i] + } + var _ca []interface{} + _ca = append(_ca, envName) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for EnvString") + } + + var r0 string + if rf, ok := ret.Get(0).(func(string, ...string) string); ok { + r0 = rf(envName, defaultValue...) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Config_EnvString_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvString' +type Config_EnvString_Call struct { + *mock.Call +} + +// EnvString is a helper method to define mock.On call +// - envName string +// - defaultValue ...string +func (_e *Config_Expecter) EnvString(envName interface{}, defaultValue ...interface{}) *Config_EnvString_Call { + return &Config_EnvString_Call{Call: _e.mock.On("EnvString", + append([]interface{}{envName}, defaultValue...)...)} +} + +func (_c *Config_EnvString_Call) Run(run func(envName string, defaultValue ...string)) *Config_EnvString_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *Config_EnvString_Call) Return(_a0 string) *Config_EnvString_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Config_EnvString_Call) RunAndReturn(run func(string, ...string) string) *Config_EnvString_Call { + _c.Call.Return(run) + return _c +} + // FailedDatabase provides a mock function with no fields func (_m *Config) FailedDatabase() string { ret := _m.Called() From 7e107672c53e164f7f8ada8ea4e2e397837a9eea Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 12:59:42 -0700 Subject: [PATCH 27/71] introduce windows support --- console/console/deploy_command.go | 44 ++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 70dea0e85..fad02bf75 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -494,29 +494,47 @@ func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) // validLocalHost checks if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. func validLocalHost(ctx console.Context) bool { - if !env.IsDarwin() && !env.IsLinux() { - ctx.Error("only macos and linux are supported. Please use a macos or linux machine to deploy.") - return false + var errs []string + + if !env.IsDarwin() && !env.IsLinux() && !env.IsWindows() { + errs = append(errs, "only macos, linux, and windows are supported. Please use a supported machine to deploy.") } if _, err := exec.LookPath("scp"); err != nil { - ctx.Error("scp is not installed. Please install it, add it to your path, and try again.") - return false + errs = append(errs, "scp is not installed. Please install it, add it to your path, and try again.") } if _, err := exec.LookPath("ssh"); err != nil { - ctx.Error("ssh is not installed. Please install it, add it to your path, and try again.") - return false + errs = append(errs, "ssh is not installed. Please install it, add it to your path, and try again.") + } + + // Shell requirements depend on OS + if env.IsWindows() { + if _, err := exec.LookPath("cmd"); err != nil { + errs = append(errs, "cmd is not available. Please ensure Windows command processor is accessible and try again.") + } + } else { + if _, err := exec.LookPath("bash"); err != nil { + errs = append(errs, "bash is not installed. Please install it, add it to your path, and try again.") + } } - if _, err := exec.LookPath("bash"); err != nil { - ctx.Error("bash is not installed. Please install it, add it to your path, and try again.") + if len(errs) > 0 { + ctx.Error("Environment validation errors:\n - " + strings.Join(errs, "\n - ")) return false } return true } +// makeLocalCommand chooses the appropriate local shell to execute the composed script. +func makeLocalCommand(script string) *exec.Cmd { + if env.IsWindows() { + return exec.Command("cmd", "/C", script) + } + return exec.Command("bash", "-lc", script) +} + // setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { // Directories and service @@ -622,7 +640,7 @@ fi "true", ) - return exec.Command("bash", "-lc", script) + return makeLocalCommand(script) } // uploadFilesCommand uploads available artifacts to remote server @@ -670,12 +688,12 @@ func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath } script := strings.Join(cmds, " && ") - return exec.Command("bash", "-lc", script) + return makeLocalCommand(script) } func restartServiceCommand(appName, ip, sshPort, sshUser, keyPath string) *exec.Cmd { script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", keyPath, sshPort, sshUser, ip, appName, appName) - return exec.Command("bash", "-lc", script) + return makeLocalCommand(script) } // rollbackCommand swaps main and main.prev if available, then restarts the service @@ -722,7 +740,7 @@ sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE" // isServerAlreadySetup checks if the systemd unit already exists on remote host func isServerAlreadySetup(appName, ip, sshPort, sshUser, keyPath string) bool { checkCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'test -f /etc/systemd/system/%s.service'", keyPath, sshPort, sshUser, ip, appName) - cmd := exec.Command("bash", "-lc", checkCmd) + cmd := makeLocalCommand(checkCmd) if err := cmd.Run(); err != nil { return false } From 3b3872a254859acd36de0a9a374ba46e9635d83a Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 18:48:15 -0700 Subject: [PATCH 28/71] increase test coverage for windows --- console/console/deploy_command_test.go | 175 +++++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 332976b7e..458f05d43 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -3,6 +3,7 @@ package console import ( "encoding/base64" "os" + "os/exec" "runtime" "strings" "testing" @@ -105,6 +106,10 @@ func Test_setupServerCommand_ProxyTLS(t *testing.T) { assert.Contains(t, script, "ufw allow 443") // Reload on change assert.Contains(t, script, "systemctl reload caddy || sudo systemctl restart caddy") + // Ensure we invoke via bash on non-Windows + require.GreaterOrEqual(t, len(cmd.Args), 2) + assert.Equal(t, "bash", cmd.Args[0]) + assert.Equal(t, "-lc", cmd.Args[1]) } func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { @@ -127,6 +132,25 @@ func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { assert.Contains(t, script, "scp -o StrictHostKeyChecking=no -i \"~/.ssh/id\" -P 22 -r \"resources\" ubuntu@203.0.113.10:"+appDir) } +func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", true, false, false, true, false) + require.NotNil(t, cmd) + if runtime.GOOS == "windows" { + t.Skip("Skipping script content assertions on Windows shell") + } + script := cmd.Args[2] + appDir := "/var/www/myapp" + // main present + assert.Contains(t, script, "/main.new") + // env absent + assert.NotContains(t, script, ".env.new") + // public/resources absent + assert.NotContains(t, script, " -r \"public\"") + assert.NotContains(t, script, " -r \"resources\"") + // storage present + assert.Contains(t, script, " -r \"storage\" ubuntu@203.0.113.10:"+appDir) +} + func Test_restartServiceCommand(t *testing.T) { cmd := restartServiceCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") require.NotNil(t, cmd) @@ -136,6 +160,10 @@ func Test_restartServiceCommand(t *testing.T) { script := cmd.Args[2] assert.Contains(t, script, "systemctl daemon-reload") assert.Contains(t, script, "systemctl restart myapp || sudo systemctl start myapp") + // Verify shell wrapper on non-Windows + require.GreaterOrEqual(t, len(cmd.Args), 2) + assert.Equal(t, "bash", cmd.Args[0]) + assert.Equal(t, "-lc", cmd.Args[1]) } func Test_rollbackCommand(t *testing.T) { @@ -223,6 +251,153 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { assert.False(t, up.hasResources) } +func Test_validLocalHost_ErrorAggregation_Unix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-only test") + } + // Temporarily clear PATH so scp/ssh/bash are missing + oldPath := os.Getenv("PATH") + t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) + require.NoError(t, os.Setenv("PATH", "")) + + mc := &mocksconsole.Context{} + // Expect a single aggregated error call + mc.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, "Environment validation errors:") && + strings.Contains(msg, "scp is not installed") && + strings.Contains(msg, "ssh is not installed") && + strings.Contains(msg, "bash is not installed") + })).Once() + ok := validLocalHost(mc) + assert.False(t, ok) +} + +func Test_validLocalHost_SucceedsWithTempTools_Unix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-only test") + } + // Create temp dir with fake scp, ssh, bash + dir := t.TempDir() + mkExec := func(name string) { + p := dir + string(os.PathSeparator) + name + require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + } + mkExec("scp") + mkExec("ssh") + mkExec("bash") + oldPath := os.Getenv("PATH") + t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) + require.NoError(t, os.Setenv("PATH", dir)) + + // Sanity: tools resolvable + _, err := exec.LookPath("scp") + require.NoError(t, err) + _, err = exec.LookPath("ssh") + require.NoError(t, err) + _, err = exec.LookPath("bash") + require.NoError(t, err) + + mc := &mocksconsole.Context{} + ok := validLocalHost(mc) + assert.True(t, ok) +} + +// -------------------------- +// Windows-specific tests +// -------------------------- + +func Test_setupServerCommand_WindowsShellWrapper(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "example.com", true, true) + require.NotNil(t, cmd) + require.GreaterOrEqual(t, len(cmd.Args), 2) + assert.Equal(t, "cmd", cmd.Args[0]) + assert.Equal(t, "/C", cmd.Args[1]) +} + +func Test_uploadFilesCommand_WindowsShellWrapper(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", true, true, true, true, true) + require.NotNil(t, cmd) + require.GreaterOrEqual(t, len(cmd.Args), 2) + assert.Equal(t, "cmd", cmd.Args[0]) + assert.Equal(t, "/C", cmd.Args[1]) +} + +func Test_restartServiceCommand_WindowsShellWrapper(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + cmd := restartServiceCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + require.NotNil(t, cmd) + require.GreaterOrEqual(t, len(cmd.Args), 2) + assert.Equal(t, "cmd", cmd.Args[0]) + assert.Equal(t, "/C", cmd.Args[1]) +} + +func Test_isServerAlreadySetup_WindowsShellWrapper(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + // We can't reliably assert remote state; just ensure command created uses cmd wrapper + _ = isServerAlreadySetup("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") +} + +func Test_validLocalHost_ErrorAggregation_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + // Clear PATH so scp/ssh/cmd are missing + oldPath := os.Getenv("PATH") + t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) + require.NoError(t, os.Setenv("PATH", "")) + + mc := &mocksconsole.Context{} + mc.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, "Environment validation errors:") && + strings.Contains(msg, "scp is not installed") && + strings.Contains(msg, "ssh is not installed") && + strings.Contains(msg, "cmd is not available") + })).Once() + ok := validLocalHost(mc) + assert.False(t, ok) +} + +func Test_validLocalHost_SucceedsWithTempTools_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + // Create temp dir with fake scp.exe, ssh.exe; rely on system cmd + dir := t.TempDir() + mkExec := func(name string) { + p := dir + string(os.PathSeparator) + name + // Windows will execute .exe; a plain text may not be executable, but LookPath will still find it + require.NoError(t, os.WriteFile(p, []byte("echo off\r\n"), 0o755)) + } + mkExec("scp.exe") + mkExec("ssh.exe") + oldPath := os.Getenv("PATH") + t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) + require.NoError(t, os.Setenv("PATH", dir+";"+oldPath)) + + // Sanity: tools resolvable + _, err := exec.LookPath("scp.exe") + require.NoError(t, err) + _, err = exec.LookPath("ssh.exe") + require.NoError(t, err) + // cmd should be resolvable on Windows + _, err = exec.LookPath("cmd") + require.NoError(t, err) + + mc := &mocksconsole.Context{} + ok := validLocalHost(mc) + assert.True(t, ok) +} + func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. mc := &mocksconsole.Context{} From 5049a0a441c2ff6455ead137b227996fe5d149e9 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 18:53:54 -0700 Subject: [PATCH 29/71] change DEPLOY_APP_PORT to DEPLOY_REVERSE_PROXY_PORT --- console/console/deploy_command.go | 12 ++++++------ console/console/deploy_command_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index fad02bf75..0dd4e14a8 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -34,7 +34,7 @@ Architecture assumptions Two primary deployment topologies are supported: 1) Reverse proxy in front of the app (recommended) - reverseProxyEnabled=true - - App listens on 127.0.0.1: (e.g. 9000) + - App listens on 127.0.0.1: (e.g. 9000) - Caddy proxies public HTTP(S) traffic to the app - If reverseProxyTLSEnabled=true and a valid domain is configured, Caddy terminates TLS and automatically provisions certificates; otherwise Caddy serves plain HTTP on :80 @@ -85,7 +85,7 @@ Configuration (env) Required: - app.name : Application name (used in remote paths/service name) - DEPLOY_IP_ADDRESS : Target server IP - - DEPLOY_APP_PORT : Backend app port when reverse proxy is used (e.g. 9000) + - DEPLOY_REVERSE_PROXY_PORT : Backend app port when reverse proxy is used (e.g. 9000) - DEPLOY_SSH_PORT : SSH port (e.g. 22) - DEPLOY_SSH_USER : SSH username (user must have sudo privileges) - DEPLOY_SSH_KEY_PATH : Path to SSH private key (e.g. ~/.ssh/id_rsa) @@ -151,7 +151,7 @@ Assuming you have the following .env file stored in the root of your project as ``` APP_NAME=my-app DEPLOY_IP_ADDRESS=127.0.0.1 -DEPLOY_APP_PORT=9000 +DEPLOY_REVERSE_PROXY_PORT=9000 DEPLOY_SSH_PORT=22 DEPLOY_SSH_USER=deploy DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa @@ -183,7 +183,7 @@ assuming you have the following .env file stored in the root of your project as ``` APP_NAME=my-app DEPLOY_IP_ADDRESS=127.0.0.1 -DEPLOY_APP_PORT=80 +DEPLOY_REVERSE_PROXY_PORT=80 DEPLOY_SSH_PORT=22 DEPLOY_SSH_USER=deploy DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa @@ -396,7 +396,7 @@ func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { opts := deployOptions{} opts.appName = r.config.GetString("app.name") opts.ipAddress = r.config.GetString("DEPLOY_IP_ADDRESS") - opts.appPort = r.config.GetString("DEPLOY_APP_PORT") + opts.appPort = r.config.GetString("DEPLOY_REVERSE_PROXY_PORT") opts.sshPort = r.config.GetString("DEPLOY_SSH_PORT") opts.sshUser = r.config.GetString("DEPLOY_SSH_USER") opts.sshKeyPath = r.config.GetString("DEPLOY_SSH_KEY_PATH") @@ -418,7 +418,7 @@ func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { missing = append(missing, "DEPLOY_IP_ADDRESS") } if opts.appPort == "" { - missing = append(missing, "DEPLOY_APP_PORT") + missing = append(missing, "DEPLOY_REVERSE_PROXY_PORT") } if opts.sshPort == "" { missing = append(missing, "DEPLOY_SSH_PORT") diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 458f05d43..40c5b52c3 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -407,7 +407,7 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // Minimal required envs for getAllOptions (will not be used deeply due to rollback) cfg.EXPECT().GetString("app.name").Return("myapp").Once() cfg.EXPECT().GetString("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() - cfg.EXPECT().GetString("DEPLOY_APP_PORT").Return("9000").Once() + cfg.EXPECT().GetString("DEPLOY_REVERSE_PROXY_PORT").Return("9000").Once() cfg.EXPECT().GetString("DEPLOY_SSH_PORT").Return("22").Once() cfg.EXPECT().GetString("DEPLOY_SSH_USER").Return("ubuntu").Once() cfg.EXPECT().GetString("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() From b821119a2977ed0f1dffe639152a4be731d3513c Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 18:55:12 -0700 Subject: [PATCH 30/71] cahnge DEPLOY_IP_ADDRESS to DEPLOY_SSH_IP --- console/console/deploy_command.go | 10 +++++----- console/console/deploy_command_test.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 0dd4e14a8..5cf48df70 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -84,7 +84,7 @@ Configuration (env) ------------------- Required: - app.name : Application name (used in remote paths/service name) - - DEPLOY_IP_ADDRESS : Target server IP + - DEPLOY_SSH_IP : Target server IP - DEPLOY_REVERSE_PROXY_PORT : Backend app port when reverse proxy is used (e.g. 9000) - DEPLOY_SSH_PORT : SSH port (e.g. 22) - DEPLOY_SSH_USER : SSH username (user must have sudo privileges) @@ -150,7 +150,7 @@ Usage example (1 - with reverse proxy): Assuming you have the following .env file stored in the root of your project as .env.production: ``` APP_NAME=my-app -DEPLOY_IP_ADDRESS=127.0.0.1 +DEPLOY_SSH_IP=127.0.0.1 DEPLOY_REVERSE_PROXY_PORT=9000 DEPLOY_SSH_PORT=22 DEPLOY_SSH_USER=deploy @@ -182,7 +182,7 @@ You can also deploy without a reverse proxy by setting the DEPLOY_REVERSE_PROXY_ assuming you have the following .env file stored in the root of your project as .env.production and you want to deploy your application to the server without a reverse proxy: ``` APP_NAME=my-app -DEPLOY_IP_ADDRESS=127.0.0.1 +DEPLOY_SSH_IP=127.0.0.1 DEPLOY_REVERSE_PROXY_PORT=80 DEPLOY_SSH_PORT=22 DEPLOY_SSH_USER=deploy @@ -395,7 +395,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { opts := deployOptions{} opts.appName = r.config.GetString("app.name") - opts.ipAddress = r.config.GetString("DEPLOY_IP_ADDRESS") + opts.ipAddress = r.config.GetString("DEPLOY_SSH_IP") opts.appPort = r.config.GetString("DEPLOY_REVERSE_PROXY_PORT") opts.sshPort = r.config.GetString("DEPLOY_SSH_PORT") opts.sshUser = r.config.GetString("DEPLOY_SSH_USER") @@ -415,7 +415,7 @@ func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { missing = append(missing, "APP_NAME") } if opts.ipAddress == "" { - missing = append(missing, "DEPLOY_IP_ADDRESS") + missing = append(missing, "DEPLOY_SSH_IP") } if opts.appPort == "" { missing = append(missing, "DEPLOY_REVERSE_PROXY_PORT") diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 40c5b52c3..a5319adff 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -406,7 +406,7 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // Minimal required envs for getAllOptions (will not be used deeply due to rollback) cfg.EXPECT().GetString("app.name").Return("myapp").Once() - cfg.EXPECT().GetString("DEPLOY_IP_ADDRESS").Return("203.0.113.10").Once() + cfg.EXPECT().GetString("DEPLOY_SSH_IP").Return("203.0.113.10").Once() cfg.EXPECT().GetString("DEPLOY_REVERSE_PROXY_PORT").Return("9000").Once() cfg.EXPECT().GetString("DEPLOY_SSH_PORT").Return("22").Once() cfg.EXPECT().GetString("DEPLOY_SSH_USER").Return("ubuntu").Once() From 06a3129c4aaa5857515231303e5f461edbb46994 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 19:15:08 -0700 Subject: [PATCH 31/71] remove deployment configuration from .env file and instead put in the config of app --- .env.example | 13 ------------- console/console/deploy_command.go | 26 +++++++++++++------------- console/console/deploy_command_test.go | 24 ++++++++++++------------ 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index af6ce40d7..150a83395 100644 --- a/.env.example +++ b/.env.example @@ -31,16 +31,3 @@ TENCENT_URL= MINIO_ACCESS_KEY_ID= MINIO_ACCESS_KEY_SECRET= MINIO_BUCKET= - -DEPLOY_IP_ADDRESS= -DEPLOY_APP_PORT= -DEPLOY_SSH_PORT= -DEPLOY_SSH_USER= -DEPLOY_SSH_KEY_PATH= -DEPLOY_PROD_ENV_FILE_PATH= -DEPLOY_OS= -DEPLOY_ARCH= -DEPLOY_DOMAIN= -DEPLOY_STATIC= -DEPLOY_REVERSE_PROXY_ENABLED= -DEPLOY_REVERSE_PROXY_TLS_ENABLED= \ No newline at end of file diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 5cf48df70..1aeb34326 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -395,19 +395,19 @@ func (r *DeployCommand) Handle(ctx console.Context) error { func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { opts := deployOptions{} opts.appName = r.config.GetString("app.name") - opts.ipAddress = r.config.GetString("DEPLOY_SSH_IP") - opts.appPort = r.config.GetString("DEPLOY_REVERSE_PROXY_PORT") - opts.sshPort = r.config.GetString("DEPLOY_SSH_PORT") - opts.sshUser = r.config.GetString("DEPLOY_SSH_USER") - opts.sshKeyPath = r.config.GetString("DEPLOY_SSH_KEY_PATH") - opts.targetOS = r.config.GetString("DEPLOY_OS") - opts.arch = r.config.GetString("DEPLOY_ARCH") - opts.domain = r.config.GetString("DEPLOY_DOMAIN") - opts.prodEnvFilePath = r.config.GetString("DEPLOY_PROD_ENV_FILE_PATH") - - opts.staticEnv = r.config.GetBool("DEPLOY_STATIC") - opts.reverseProxyEnabled = r.config.GetBool("DEPLOY_REVERSE_PROXY_ENABLED") - opts.reverseProxyTLSEnabled = r.config.GetBool("DEPLOY_REVERSE_PROXY_TLS_ENABLED") + opts.ipAddress = r.config.GetString("app.ssh_ip") + opts.appPort = r.config.GetString("app.reverse_proxy_port") + opts.sshPort = r.config.GetString("app.ssh_port") + opts.sshUser = r.config.GetString("app.ssh_user") + opts.sshKeyPath = r.config.GetString("app.ssh_key_path") + opts.targetOS = r.config.GetString("app.os") + opts.arch = r.config.GetString("app.arch") + opts.domain = r.config.GetString("app.domain") + opts.prodEnvFilePath = r.config.GetString("app.prod_env_file_path") + + opts.staticEnv = r.config.GetBool("app.static") + opts.reverseProxyEnabled = r.config.GetBool("app.reverse_proxy_enabled") + opts.reverseProxyTLSEnabled = r.config.GetBool("app.reverse_proxy_tls_enabled") // Validate required options and report all missing at once var missing []string diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index a5319adff..05a13020a 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -406,18 +406,18 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // Minimal required envs for getAllOptions (will not be used deeply due to rollback) cfg.EXPECT().GetString("app.name").Return("myapp").Once() - cfg.EXPECT().GetString("DEPLOY_SSH_IP").Return("203.0.113.10").Once() - cfg.EXPECT().GetString("DEPLOY_REVERSE_PROXY_PORT").Return("9000").Once() - cfg.EXPECT().GetString("DEPLOY_SSH_PORT").Return("22").Once() - cfg.EXPECT().GetString("DEPLOY_SSH_USER").Return("ubuntu").Once() - cfg.EXPECT().GetString("DEPLOY_SSH_KEY_PATH").Return("~/.ssh/id").Once() - cfg.EXPECT().GetString("DEPLOY_OS").Return("linux").Once() - cfg.EXPECT().GetString("DEPLOY_ARCH").Return("amd64").Once() - cfg.EXPECT().GetString("DEPLOY_DOMAIN").Return("").Once() - cfg.EXPECT().GetString("DEPLOY_PROD_ENV_FILE_PATH").Return(".env.production").Once() - cfg.EXPECT().GetBool("DEPLOY_STATIC").Return(false).Once() - cfg.EXPECT().GetBool("DEPLOY_REVERSE_PROXY_ENABLED").Return(false).Once() - cfg.EXPECT().GetBool("DEPLOY_REVERSE_PROXY_TLS_ENABLED").Return(false).Once() + cfg.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() + cfg.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() + cfg.EXPECT().GetString("app.ssh_port").Return("22").Once() + cfg.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() + cfg.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() + cfg.EXPECT().GetString("app.os").Return("linux").Once() + cfg.EXPECT().GetString("app.arch").Return("amd64").Once() + cfg.EXPECT().GetString("app.domain").Return("").Once() + cfg.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() + cfg.EXPECT().GetBool("app.static").Return(false).Once() + cfg.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() + cfg.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() mc.EXPECT().OptionBool("rollback").Return(true).Once() mc.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() From 67ecb2d3550aec388883f59fc5c5681682dc0c41 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 19:32:55 -0700 Subject: [PATCH 32/71] make remote base directory a variable instead of forcing to /var/www --- console/console/deploy_command.go | 27 +++++++++++++++++++------- console/console/deploy_command_test.go | 17 ++++++++-------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 1aeb34326..ae67b22c5 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -248,6 +248,7 @@ type deployOptions struct { staticEnv bool reverseProxyEnabled bool reverseProxyTLSEnabled bool + deployBaseDir string } type uploadOptions struct { @@ -311,7 +312,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { if ctx.OptionBool("rollback") { opts := r.getAllOptions(ctx) if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( - opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath, + opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath, opts.deployBaseDir, ), "Rolling back..."); err != nil { ctx.Error(err.Error()) return nil @@ -350,6 +351,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { fmt.Sprintf("%v", opts.sshPort), fmt.Sprintf("%v", opts.sshUser), fmt.Sprintf("%v", opts.sshKeyPath), + fmt.Sprintf("%v", opts.deployBaseDir), strings.TrimSpace(opts.domain), opts.reverseProxyEnabled, opts.reverseProxyTLSEnabled, @@ -369,6 +371,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { fmt.Sprintf("%v", opts.sshUser), fmt.Sprintf("%v", opts.sshKeyPath), fmt.Sprintf("%v", opts.prodEnvFilePath), + fmt.Sprintf("%v", opts.deployBaseDir), upload.hasMain, upload.hasProdEnv, upload.hasPublic, upload.hasStorage, upload.hasResources, ), "Uploading files..."); err != nil { ctx.Error(err.Error()) @@ -404,6 +407,7 @@ func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { opts.arch = r.config.GetString("app.arch") opts.domain = r.config.GetString("app.domain") opts.prodEnvFilePath = r.config.GetString("app.prod_env_file_path") + opts.deployBaseDir = r.config.GetString("app.deploy_base_dir", "/var/www/") opts.staticEnv = r.config.GetBool("app.static") opts.reverseProxyEnabled = r.config.GetBool("app.reverse_proxy_enabled") @@ -536,9 +540,12 @@ func makeLocalCommand(script string) *exec.Cmd { } // setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs -func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { +func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, baseDir, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { // Directories and service - appDir := fmt.Sprintf("/var/www/%s", appName) + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + appDir := fmt.Sprintf("%s%s", baseDir, appName) binCurrent := fmt.Sprintf("%s/main", appDir) // Build systemd unit based on whether reverse proxy is used @@ -644,8 +651,11 @@ fi } // uploadFilesCommand uploads available artifacts to remote server -func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath string, hasMain, hasProdEnv, hasPublic, hasStorage, hasResources bool) *exec.Cmd { - appDir := fmt.Sprintf("/var/www/%s", appName) +func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath, baseDir string, hasMain, hasProdEnv, hasPublic, hasStorage, hasResources bool) *exec.Cmd { + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + appDir := fmt.Sprintf("%s%s", baseDir, appName) remoteBase := fmt.Sprintf("%s@%s:%s", sshUser, ip, appDir) // ensure remote base exists and permissions cmds := []string{ @@ -697,8 +707,11 @@ func restartServiceCommand(appName, ip, sshPort, sshUser, keyPath string) *exec. } // rollbackCommand swaps main and main.prev if available, then restarts the service -func rollbackCommand(appName, ip, sshPort, sshUser, keyPath string) *exec.Cmd { - appDir := fmt.Sprintf("/var/www/%s", appName) +func rollbackCommand(appName, ip, sshPort, sshUser, keyPath, baseDir string) *exec.Cmd { + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + appDir := fmt.Sprintf("%s%s", baseDir, appName) script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' set -e APP_DIR=%q diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 05a13020a..dc8e655b5 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -42,7 +42,7 @@ func extractBase64(script, teePath string) (string, bool) { } func Test_setupServerCommand_NoProxy(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "", false, false) + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "", false, false) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -68,7 +68,7 @@ func Test_setupServerCommand_NoProxy(t *testing.T) { } func Test_setupServerCommand_ProxyHTTP(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "", true, false) + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "", true, false) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -90,7 +90,7 @@ func Test_setupServerCommand_ProxyHTTP(t *testing.T) { } func Test_setupServerCommand_ProxyTLS(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "example.com", true, true) + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "example.com", true, true) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -113,7 +113,7 @@ func Test_setupServerCommand_ProxyTLS(t *testing.T) { } func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", true, true, true, true, true) + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", "/var/www/", true, true, true, true, true) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -133,7 +133,7 @@ func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { } func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", true, false, false, true, false) + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", "/var/www/", true, false, false, true, false) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -167,7 +167,7 @@ func Test_restartServiceCommand(t *testing.T) { } func Test_rollbackCommand(t *testing.T) { - cmd := rollbackCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + cmd := rollbackCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", "/var/www/") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -310,7 +310,7 @@ func Test_setupServerCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "example.com", true, true) + cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "example.com", true, true) require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) @@ -321,7 +321,7 @@ func Test_uploadFilesCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", true, true, true, true, true) + cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", "/var/www/", true, true, true, true, true) require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) @@ -415,6 +415,7 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { cfg.EXPECT().GetString("app.arch").Return("amd64").Once() cfg.EXPECT().GetString("app.domain").Return("").Once() cfg.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() + cfg.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() cfg.EXPECT().GetBool("app.static").Return(false).Once() cfg.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() cfg.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() From 0bf622603580a2bff49db9e4328aeefa93a02e91 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 19:47:22 -0700 Subject: [PATCH 33/71] stop mocking build command and just call it directly via CLI --- console/console/deploy_command.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index ae67b22c5..62664b563 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -332,9 +332,12 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // continue normal deploy flow var err error - // Step 1: build the application - // Build the binary for target OS/arch - if err = supportconsole.ExecuteCommand(ctx, generateCommand(opts.appName, opts.targetOS, opts.arch, opts.staticEnv), "Building..."); err != nil { + // Step 1: build the application by invoking the build command directly + buildCmd := fmt.Sprintf("go run . artisan build --os %s --arch %s --name %s", opts.targetOS, opts.arch, opts.appName) + if opts.staticEnv { + buildCmd += " --static" + } + if err = supportconsole.ExecuteCommand(ctx, makeLocalCommand(buildCmd), "Building..."); err != nil { ctx.Error(err.Error()) return nil } From 0e462962582e3119e6feda5987453728469b9f96 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:10:39 -0700 Subject: [PATCH 34/71] handle encrypted env file --- console/console/deploy_command.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 62664b563..97a53b55a 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -345,6 +345,21 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 2: verify which files to upload (main, env, public, storage, resources) upload := getWhichFilesToUpload(ctx, opts.appName, opts.prodEnvFilePath) + // If the production env file is encrypted (per Goravel docs), decrypt it first + envPathToUpload := opts.prodEnvFilePath + if upload.hasProdEnv { + lower := strings.ToLower(strings.TrimSpace(opts.prodEnvFilePath)) + if strings.HasSuffix(lower, ".encrypted") || strings.HasSuffix(lower, ".safe") { + decryptCmd := fmt.Sprintf("go run . artisan env:decrypt --name %q", opts.prodEnvFilePath) + if err = supportconsole.ExecuteCommand(ctx, makeLocalCommand(decryptCmd), "Decrypting environment file..."); err != nil { + ctx.Error(err.Error()) + return nil + } + // env:decrypt writes to .env in the working directory + envPathToUpload = ".env" + } + } + // Step 3: set up server on first run —- skip if already set up if !isServerAlreadySetup(opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath) { if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( @@ -373,7 +388,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { fmt.Sprintf("%v", opts.sshPort), fmt.Sprintf("%v", opts.sshUser), fmt.Sprintf("%v", opts.sshKeyPath), - fmt.Sprintf("%v", opts.prodEnvFilePath), + fmt.Sprintf("%v", envPathToUpload), fmt.Sprintf("%v", opts.deployBaseDir), upload.hasMain, upload.hasProdEnv, upload.hasPublic, upload.hasStorage, upload.hasResources, ), "Uploading files..."); err != nil { From 8498bd63c1ea05066def81407f3bd6634e034825 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:27:03 -0700 Subject: [PATCH 35/71] change getAllOptions to getDeployOptions --- console/console/deploy_command.go | 6 +++--- console/console/deploy_command_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 97a53b55a..a8f4f34db 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -310,7 +310,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Rollback check first: allow rollback without validating local host tools // (tests can short-circuit Spinner; real runs will still use ssh remotely) if ctx.OptionBool("rollback") { - opts := r.getAllOptions(ctx) + opts := r.getDeployOptions(ctx) if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath, opts.deployBaseDir, ), "Rolling back..."); err != nil { @@ -327,7 +327,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // get all options - opts := r.getAllOptions(ctx) + opts := r.getDeployOptions(ctx) // continue normal deploy flow var err error @@ -413,7 +413,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { return nil } -func (r *DeployCommand) getAllOptions(ctx console.Context) deployOptions { +func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { opts := deployOptions{} opts.appName = r.config.GetString("app.name") opts.ipAddress = r.config.GetString("app.ssh_ip") diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index dc8e655b5..babd4cd32 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -404,7 +404,7 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { cfg := &mocksconfig.Config{} cmd := NewDeployCommand(cfg) - // Minimal required envs for getAllOptions (will not be used deeply due to rollback) + // Minimal required envs for getDeployOptions (will not be used deeply due to rollback) cfg.EXPECT().GetString("app.name").Return("myapp").Once() cfg.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() cfg.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() From d14c87431d42d3701b251c4537212a35efa1ce55 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:29:20 -0700 Subject: [PATCH 36/71] change getWhichFilesToUpload() to getUploadOptions() --- console/console/deploy_command.go | 4 ++-- console/console/deploy_command_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index a8f4f34db..198ef4f39 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -343,7 +343,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // Step 2: verify which files to upload (main, env, public, storage, resources) - upload := getWhichFilesToUpload(ctx, opts.appName, opts.prodEnvFilePath) + upload := getUploadOptions(ctx, opts.appName, opts.prodEnvFilePath) // If the production env file is encrypted (per Goravel docs), decrypt it first envPathToUpload := opts.prodEnvFilePath @@ -479,7 +479,7 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { return opts } -func getWhichFilesToUpload(ctx console.Context, appName, prodEnvFilePath string) uploadOptions { +func getUploadOptions(ctx console.Context, appName, prodEnvFilePath string) uploadOptions { res := uploadOptions{} res.hasMain = file.Exists(appName) res.hasProdEnv = file.Exists(prodEnvFilePath) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index babd4cd32..87db5f00a 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -233,7 +233,7 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { mc := &mocksconsole.Context{} mc.EXPECT().Option("only").Return("").Once() - up := getWhichFilesToUpload(mc, "myapp", ".env.production") + up := getUploadOptions(mc, "myapp", ".env.production") assert.True(t, up.hasMain) assert.True(t, up.hasProdEnv) assert.True(t, up.hasPublic) @@ -243,7 +243,7 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { // Now test filter: only main and env mc2 := &mocksconsole.Context{} mc2.EXPECT().Option("only").Return("main,env").Once() - up = getWhichFilesToUpload(mc2, "myapp", ".env.production") + up = getUploadOptions(mc2, "myapp", ".env.production") assert.True(t, up.hasMain) assert.True(t, up.hasProdEnv) assert.False(t, up.hasPublic) From a0c39c8efa4371e47b92cc6867123fabeace6e2b Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:34:54 -0700 Subject: [PATCH 37/71] make setupServerCommand only take in 1 value --- console/console/deploy_command.go | 41 ++++++++++---------------- console/console/deploy_command_test.go | 8 ++--- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 198ef4f39..4011b4cb3 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -362,18 +362,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 3: set up server on first run —- skip if already set up if !isServerAlreadySetup(opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath) { - if err = supportconsole.ExecuteCommand(ctx, setupServerCommand( - fmt.Sprintf("%v", opts.appName), - fmt.Sprintf("%v", opts.ipAddress), - fmt.Sprintf("%v", opts.appPort), - fmt.Sprintf("%v", opts.sshPort), - fmt.Sprintf("%v", opts.sshUser), - fmt.Sprintf("%v", opts.sshKeyPath), - fmt.Sprintf("%v", opts.deployBaseDir), - strings.TrimSpace(opts.domain), - opts.reverseProxyEnabled, - opts.reverseProxyTLSEnabled, - ), "Setting up server (first time only)..."); err != nil { + if err = supportconsole.ExecuteCommand(ctx, setupServerCommand(opts), "Setting up server (first time only)..."); err != nil { ctx.Error(err.Error()) return nil } @@ -558,17 +547,19 @@ func makeLocalCommand(script string) *exec.Cmd { } // setupServerCommand ensures Caddy and a systemd service are installed; no-op on subsequent runs -func setupServerCommand(appName, ip, appPort, sshPort, sshUser, keyPath, baseDir, domain string, reverseProxyEnabled, reverseProxyTLSEnabled bool) *exec.Cmd { +func setupServerCommand(opts deployOptions) *exec.Cmd { // Directories and service + baseDir := opts.deployBaseDir if !strings.HasSuffix(baseDir, "/") { baseDir += "/" } - appDir := fmt.Sprintf("%s%s", baseDir, appName) + appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) binCurrent := fmt.Sprintf("%s/main", appDir) // Build systemd unit based on whether reverse proxy is used listenHost := "127.0.0.1" - if !reverseProxyEnabled { + appPort := opts.appPort + if !opts.reverseProxyEnabled { // App listens on port 80 directly appPort = "80" listenHost = "0.0.0.0" @@ -592,14 +583,14 @@ SyslogIdentifier=%s [Install] WantedBy=multi-user.target -`, appName, sshUser, appDir, binCurrent, listenHost, appPort, appName) +`, opts.appName, opts.sshUser, appDir, binCurrent, listenHost, appPort, opts.appName) // Build Caddyfile if reverse proxy enabled caddyfile := "" - if reverseProxyEnabled { + if opts.reverseProxyEnabled { site := ":80" - if reverseProxyTLSEnabled && strings.TrimSpace(domain) != "" { - site = domain + if opts.reverseProxyTLSEnabled && strings.TrimSpace(opts.domain) != "" { + site = opts.domain } upstream := fmt.Sprintf("127.0.0.1:%s", appPort) caddyfile = fmt.Sprintf(`%s { @@ -620,9 +611,9 @@ WantedBy=multi-user.target // Firewall commands based on configuration ufwCmds := []string{"sudo apt-get update -y && sudo apt-get install -y ufw", "sudo ufw --force enable"} - if reverseProxyEnabled { + if opts.reverseProxyEnabled { ufwCmds = append(ufwCmds, "sudo ufw allow 80") - if reverseProxyTLSEnabled { + if opts.reverseProxyTLSEnabled { ufwCmds = append(ufwCmds, "sudo ufw allow 443") } } else { @@ -645,18 +636,18 @@ if [ ! -f /etc/systemd/system/%s.service ]; then fi %s %s' -`, keyPath, sshPort, sshUser, ip, - appDir, appDir, sshUser, sshUser, appDir, +`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, + appDir, appDir, opts.sshUser, opts.sshUser, appDir, // caddy install and config func() string { - if !reverseProxyEnabled { + if !opts.reverseProxyEnabled { return "" } install := "sudo apt-get update -y && sudo apt-get install -y caddy" writeCfg := fmt.Sprintf("echo %q | base64 -d | sudo tee /etc/caddy/Caddyfile >/dev/null && sudo systemctl enable --now caddy && sudo systemctl reload caddy || sudo systemctl restart caddy", caddyB64) return install + " && " + writeCfg }(), - appName, unitB64, appName, appName, + opts.appName, unitB64, opts.appName, opts.appName, // Firewall: open before enabling to avoid SSH lockout func() string { cmds := append([]string{"sudo ufw allow OpenSSH"}, ufwCmds...) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 87db5f00a..fbe4e3347 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -42,7 +42,7 @@ func extractBase64(script, teePath string) (string, bool) { } func Test_setupServerCommand_NoProxy(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "", false, false) + cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "", reverseProxyEnabled: false, reverseProxyTLSEnabled: false}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -68,7 +68,7 @@ func Test_setupServerCommand_NoProxy(t *testing.T) { } func Test_setupServerCommand_ProxyHTTP(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "", true, false) + cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "", reverseProxyEnabled: true, reverseProxyTLSEnabled: false}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -90,7 +90,7 @@ func Test_setupServerCommand_ProxyHTTP(t *testing.T) { } func Test_setupServerCommand_ProxyTLS(t *testing.T) { - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "example.com", true, true) + cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "example.com", reverseProxyEnabled: true, reverseProxyTLSEnabled: true}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -310,7 +310,7 @@ func Test_setupServerCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := setupServerCommand("myapp", "203.0.113.10", "9000", "22", "ubuntu", "~/.ssh/id", "/var/www/", "example.com", true, true) + cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "example.com", reverseProxyEnabled: true, reverseProxyTLSEnabled: true}) require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) From 75256d68a8841abf2307c17a8cdce3c8ecae4d2f Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:42:21 -0700 Subject: [PATCH 38/71] change parameters into commands so they are minimized --- console/console/deploy_command.go | 62 ++++++++++---------------- console/console/deploy_command_test.go | 10 ++--- 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 4011b4cb3..aaef92dd2 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -371,28 +371,13 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // Step 4: upload files - if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand( - fmt.Sprintf("%v", opts.appName), - fmt.Sprintf("%v", opts.ipAddress), - fmt.Sprintf("%v", opts.sshPort), - fmt.Sprintf("%v", opts.sshUser), - fmt.Sprintf("%v", opts.sshKeyPath), - fmt.Sprintf("%v", envPathToUpload), - fmt.Sprintf("%v", opts.deployBaseDir), - upload.hasMain, upload.hasProdEnv, upload.hasPublic, upload.hasStorage, upload.hasResources, - ), "Uploading files..."); err != nil { + if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand(opts, upload, envPathToUpload), "Uploading files..."); err != nil { ctx.Error(err.Error()) return nil } // Step 5: restart service - if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand( - fmt.Sprintf("%v", opts.appName), - fmt.Sprintf("%v", opts.ipAddress), - fmt.Sprintf("%v", opts.sshPort), - fmt.Sprintf("%v", opts.sshUser), - fmt.Sprintf("%v", opts.sshKeyPath), - ), "Restarting service..."); err != nil { + if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand(opts), "Restarting service..."); err != nil { ctx.Error(err.Error()) return nil } @@ -660,49 +645,50 @@ fi } // uploadFilesCommand uploads available artifacts to remote server -func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath, baseDir string, hasMain, hasProdEnv, hasPublic, hasStorage, hasResources bool) *exec.Cmd { +func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload string) *exec.Cmd { + baseDir := opts.deployBaseDir if !strings.HasSuffix(baseDir, "/") { baseDir += "/" } - appDir := fmt.Sprintf("%s%s", baseDir, appName) - remoteBase := fmt.Sprintf("%s@%s:%s", sshUser, ip, appDir) + appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) + remoteBase := fmt.Sprintf("%s@%s:%s", opts.sshUser, opts.ipAddress, appDir) // ensure remote base exists and permissions cmds := []string{ - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", keyPath, sshPort, sshUser, ip, appDir, sshUser, sshUser, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, opts.sshUser, opts.sshUser, appDir), } // main binary with previous backup - if hasMain { + if up.hasMain { // upload to temp and atomically move, keeping previous as main.prev cmds = append(cmds, - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", keyPath, sshPort, filepath.Clean(appName), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(opts.appName), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir, appDir, appDir), ) } - if hasProdEnv { + if up.hasProdEnv { // Upload env to a temp path, then atomically place as .env; backup previous as .env.prev if exists cmds = append(cmds, - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", keyPath, sshPort, filepath.Clean(prodEnvFilePath), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/.env ]; then sudo mv %s/.env %s/.env.prev; fi; sudo mv %s/.env.new %s/.env'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(envPathToUpload), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/.env ]; then sudo mv %s/.env %s/.env.prev; fi; sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir, appDir), ) } - if hasPublic { + if up.hasPublic { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public.prev; sudo mv %s/public %s/public.prev; fi'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir), - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("public"), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public.prev; sudo mv %s/public %s/public.prev; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("public"), remoteBase), ) } - if hasStorage { + if up.hasStorage { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage.prev; sudo mv %s/storage %s/storage.prev; fi'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir), - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("storage"), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage.prev; sudo mv %s/storage %s/storage.prev; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("storage"), remoteBase), ) } - if hasResources { + if up.hasResources { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources.prev; sudo mv %s/resources %s/resources.prev; fi'", keyPath, sshPort, sshUser, ip, appDir, appDir, appDir, appDir), - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", keyPath, sshPort, filepath.Clean("resources"), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources.prev; sudo mv %s/resources %s/resources.prev; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("resources"), remoteBase), ) } @@ -710,8 +696,8 @@ func uploadFilesCommand(appName, ip, sshPort, sshUser, keyPath, prodEnvFilePath, return makeLocalCommand(script) } -func restartServiceCommand(appName, ip, sshPort, sshUser, keyPath string) *exec.Cmd { - script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", keyPath, sshPort, sshUser, ip, appName, appName) +func restartServiceCommand(opts deployOptions) *exec.Cmd { + script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, opts.appName, opts.appName) return makeLocalCommand(script) } diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index fbe4e3347..0d935c844 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -113,7 +113,7 @@ func Test_setupServerCommand_ProxyTLS(t *testing.T) { } func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", "/var/www/", true, true, true, true, true) + cmd := uploadFilesCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -133,7 +133,7 @@ func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { } func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", "/var/www/", true, false, false, true, false) + cmd := uploadFilesCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: false, hasPublic: false, hasStorage: true, hasResources: false}, ".env.production") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -152,7 +152,7 @@ func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { } func Test_restartServiceCommand(t *testing.T) { - cmd := restartServiceCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + cmd := restartServiceCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -321,7 +321,7 @@ func Test_uploadFilesCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := uploadFilesCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", ".env.production", "/var/www/", true, true, true, true, true) + cmd := uploadFilesCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) @@ -332,7 +332,7 @@ func Test_restartServiceCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := restartServiceCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + cmd := restartServiceCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) From 0120621c7c611c54a7603323e11650004c6da384 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:47:06 -0700 Subject: [PATCH 39/71] mock config and mock context initialization --- console/console/deploy_command_test.go | 112 ++++++++++++------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 0d935c844..b85124b55 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -181,28 +181,28 @@ func Test_rollbackCommand(t *testing.T) { } func Test_getStringEnv_and_getBoolEnv(t *testing.T) { - mc := &mocksconfig.Config{} + mockConfig := mocksconfig.NewConfig(t) // String as string - mc.EXPECT().EnvString("STR").Return("value").Once() - assert.Equal(t, "value", mc.EnvString("STR")) + mockConfig.EXPECT().EnvString("STR").Return("value").Once() + assert.Equal(t, "value", mockConfig.EnvString("STR")) // String as non-string type - mc.EXPECT().EnvString("NUM").Return("123").Once() - assert.Equal(t, "123", mc.EnvString("NUM")) + mockConfig.EXPECT().EnvString("NUM").Return("123").Once() + assert.Equal(t, "123", mockConfig.EnvString("NUM")) // Missing - mc.EXPECT().EnvString("MISSING").Return("").Once() - assert.Equal(t, "", mc.EnvString("MISSING")) + mockConfig.EXPECT().EnvString("MISSING").Return("").Once() + assert.Equal(t, "", mockConfig.EnvString("MISSING")) // Bool parsing - mc.EXPECT().EnvBool("BOOL1").Return(true).Once() - assert.True(t, mc.EnvBool("BOOL1")) - mc.EXPECT().EnvBool("BOOL2").Return(true).Once() - assert.True(t, mc.EnvBool("BOOL2")) - mc.EXPECT().EnvBool("BOOL3").Return(true).Once() - assert.True(t, mc.EnvBool("BOOL3")) - mc.EXPECT().EnvBool("BOOL4").Return(false).Once() - assert.False(t, mc.EnvBool("BOOL4")) - mc.EXPECT().EnvBool("BOOL5").Return(false).Once() - assert.False(t, mc.EnvBool("BOOL5")) + mockConfig.EXPECT().EnvBool("BOOL1").Return(true).Once() + assert.True(t, mockConfig.EnvBool("BOOL1")) + mockConfig.EXPECT().EnvBool("BOOL2").Return(true).Once() + assert.True(t, mockConfig.EnvBool("BOOL2")) + mockConfig.EXPECT().EnvBool("BOOL3").Return(true).Once() + assert.True(t, mockConfig.EnvBool("BOOL3")) + mockConfig.EXPECT().EnvBool("BOOL4").Return(false).Once() + assert.False(t, mockConfig.EnvBool("BOOL4")) + mockConfig.EXPECT().EnvBool("BOOL5").Return(false).Once() + assert.False(t, mockConfig.EnvBool("BOOL5")) } func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { @@ -231,9 +231,9 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { _ = os.RemoveAll("resources") }) - mc := &mocksconsole.Context{} - mc.EXPECT().Option("only").Return("").Once() - up := getUploadOptions(mc, "myapp", ".env.production") + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Option("only").Return("").Once() + up := getUploadOptions(mockContext, "myapp", ".env.production") assert.True(t, up.hasMain) assert.True(t, up.hasProdEnv) assert.True(t, up.hasPublic) @@ -241,9 +241,9 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { assert.True(t, up.hasResources) // Now test filter: only main and env - mc2 := &mocksconsole.Context{} - mc2.EXPECT().Option("only").Return("main,env").Once() - up = getUploadOptions(mc2, "myapp", ".env.production") + mockContext2 := mocksconsole.NewContext(t) + mockContext2.EXPECT().Option("only").Return("main,env").Once() + up = getUploadOptions(mockContext2, "myapp", ".env.production") assert.True(t, up.hasMain) assert.True(t, up.hasProdEnv) assert.False(t, up.hasPublic) @@ -260,15 +260,15 @@ func Test_validLocalHost_ErrorAggregation_Unix(t *testing.T) { t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) require.NoError(t, os.Setenv("PATH", "")) - mc := &mocksconsole.Context{} + mockContext := mocksconsole.NewContext(t) // Expect a single aggregated error call - mc.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Environment validation errors:") && strings.Contains(msg, "scp is not installed") && strings.Contains(msg, "ssh is not installed") && strings.Contains(msg, "bash is not installed") })).Once() - ok := validLocalHost(mc) + ok := validLocalHost(mockContext) assert.False(t, ok) } @@ -297,8 +297,8 @@ func Test_validLocalHost_SucceedsWithTempTools_Unix(t *testing.T) { _, err = exec.LookPath("bash") require.NoError(t, err) - mc := &mocksconsole.Context{} - ok := validLocalHost(mc) + mockContext := mocksconsole.NewContext(t) + ok := validLocalHost(mockContext) assert.True(t, ok) } @@ -356,14 +356,14 @@ func Test_validLocalHost_ErrorAggregation_Windows(t *testing.T) { t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) require.NoError(t, os.Setenv("PATH", "")) - mc := &mocksconsole.Context{} - mc.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Environment validation errors:") && strings.Contains(msg, "scp is not installed") && strings.Contains(msg, "ssh is not installed") && strings.Contains(msg, "cmd is not available") })).Once() - ok := validLocalHost(mc) + ok := validLocalHost(mockContext) assert.False(t, ok) } @@ -393,36 +393,36 @@ func Test_validLocalHost_SucceedsWithTempTools_Windows(t *testing.T) { _, err = exec.LookPath("cmd") require.NoError(t, err) - mc := &mocksconsole.Context{} - ok := validLocalHost(mc) + mockContext := mocksconsole.NewContext(t) + ok := validLocalHost(mockContext) assert.True(t, ok) } func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. - mc := &mocksconsole.Context{} - cfg := &mocksconfig.Config{} - cmd := NewDeployCommand(cfg) + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + cmd := NewDeployCommand(mockConfig) // Minimal required envs for getDeployOptions (will not be used deeply due to rollback) - cfg.EXPECT().GetString("app.name").Return("myapp").Once() - cfg.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() - cfg.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() - cfg.EXPECT().GetString("app.ssh_port").Return("22").Once() - cfg.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() - cfg.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() - cfg.EXPECT().GetString("app.os").Return("linux").Once() - cfg.EXPECT().GetString("app.arch").Return("amd64").Once() - cfg.EXPECT().GetString("app.domain").Return("").Once() - cfg.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() - cfg.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() - cfg.EXPECT().GetBool("app.static").Return(false).Once() - cfg.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() - cfg.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() - - mc.EXPECT().OptionBool("rollback").Return(true).Once() - mc.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() - mc.EXPECT().Info("Rollback successful.").Once() - - assert.Nil(t, cmd.Handle(mc)) + mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() + mockConfig.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.ssh_port").Return("22").Once() + mockConfig.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() + mockConfig.EXPECT().GetBool("app.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() + + mockContext.EXPECT().OptionBool("rollback").Return(true).Once() + mockContext.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() + mockContext.EXPECT().Info("Rollback successful.").Once() + + assert.Nil(t, cmd.Handle(mockContext)) } From f72a80b4d244df3343aa605429d1f8f80dbe7ff6 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sat, 27 Sep 2025 20:58:35 -0700 Subject: [PATCH 40/71] add more deploy success and failure tests --- console/console/deploy_command_test.go | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index b85124b55..729a46cfb 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -2,6 +2,7 @@ package console import ( "encoding/base64" + "fmt" "os" "os/exec" "runtime" @@ -426,3 +427,77 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { assert.Nil(t, cmd.Handle(mockContext)) } + +func Test_Handle_Deploy_Success(t *testing.T) { + if runtime.GOOS == "windows" { + // Skip due to shell content assertions; Spinner wraps execution + } + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + cmd := NewDeployCommand(mockConfig) + + // Config expectations + mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() + // Use fast-fail SSH settings to avoid any network delay + mockConfig.EXPECT().GetString("app.ssh_ip").Return("127.0.0.1").Once() + mockConfig.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.ssh_port").Return("0").Once() + mockConfig.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() + mockConfig.EXPECT().GetBool("app.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() + + // Context expectations + mockContext.EXPECT().OptionBool("rollback").Return(false).Once() + mockContext.EXPECT().Option("only").Return("").Once() + + // Ensure artifacts exist for getUploadOptions + wd, _ := os.Getwd() + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(wd) }) + require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) + require.NoError(t, os.WriteFile(".env.production", []byte("APP_ENV=prod"), 0o644)) + + // Force all Spinner-wrapped commands (build/upload/restart/setup) to return immediately + mockContext.EXPECT().Spinner(mock.Anything, mock.Anything).Return(nil).Maybe() + mockContext.EXPECT().Info("Server already set up. Skipping setup.").Maybe() + mockContext.EXPECT().Info("Deploy successful.").Once() + + assert.Nil(t, cmd.Handle(mockContext)) +} + +func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + cmd := NewDeployCommand(mockConfig) + + // Minimal config + mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() + mockConfig.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.ssh_port").Return("22").Once() + mockConfig.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() + mockConfig.EXPECT().GetBool("app.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() + + mockContext.EXPECT().OptionBool("rollback").Return(false).Once() + // Only stage we hit is build; simulate failure via Spinner return + mockContext.EXPECT().Spinner(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Building") }), mock.Anything).Return(fmt.Errorf("build error")).Once() + mockContext.EXPECT().Error("build error").Once() + + assert.Nil(t, cmd.Handle(mockContext)) +} From 2009d155e8e9876c0a844baadb5258cf7c4d6194 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 11:38:00 -0700 Subject: [PATCH 41/71] put config keys inside of app.deploy.*** and app.build.*** --- console/console/deploy_command.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index aaef92dd2..ddcf07711 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -390,20 +390,20 @@ func (r *DeployCommand) Handle(ctx console.Context) error { func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { opts := deployOptions{} opts.appName = r.config.GetString("app.name") - opts.ipAddress = r.config.GetString("app.ssh_ip") - opts.appPort = r.config.GetString("app.reverse_proxy_port") - opts.sshPort = r.config.GetString("app.ssh_port") - opts.sshUser = r.config.GetString("app.ssh_user") - opts.sshKeyPath = r.config.GetString("app.ssh_key_path") - opts.targetOS = r.config.GetString("app.os") - opts.arch = r.config.GetString("app.arch") - opts.domain = r.config.GetString("app.domain") - opts.prodEnvFilePath = r.config.GetString("app.prod_env_file_path") - opts.deployBaseDir = r.config.GetString("app.deploy_base_dir", "/var/www/") - - opts.staticEnv = r.config.GetBool("app.static") - opts.reverseProxyEnabled = r.config.GetBool("app.reverse_proxy_enabled") - opts.reverseProxyTLSEnabled = r.config.GetBool("app.reverse_proxy_tls_enabled") + opts.ipAddress = r.config.GetString("app.deploy.ssh_ip") + opts.appPort = r.config.GetString("app.deploy.reverse_proxy_port") + opts.sshPort = r.config.GetString("app.deploy.ssh_port") + opts.sshUser = r.config.GetString("app.deploy.ssh_user") + opts.sshKeyPath = r.config.GetString("app.deploy.ssh_key_path") + opts.targetOS = r.config.GetString("app.build.os") + opts.arch = r.config.GetString("app.build.arch") + opts.domain = r.config.GetString("app.deploy.domain") + opts.prodEnvFilePath = r.config.GetString("app.deploy.prod_env_file_path") + opts.deployBaseDir = r.config.GetString("app.deploy.base_dir", "/var/www/") + + opts.staticEnv = r.config.GetBool("app.build.static") + opts.reverseProxyEnabled = r.config.GetBool("app.deploy.reverse_proxy_enabled") + opts.reverseProxyTLSEnabled = r.config.GetBool("app.deploy.reverse_proxy_tls_enabled") // Validate required options and report all missing at once var missing []string From 867645e185b2b5d078e66ae2dc52c7ba996e11e5 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 11:42:03 -0700 Subject: [PATCH 42/71] update tests --- console/console/deploy_command_test.go | 78 +++++++++++++------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 729a46cfb..776ef54e8 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -407,19 +407,19 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // Minimal required envs for getDeployOptions (will not be used deeply due to rollback) mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() - mockConfig.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() - mockConfig.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() - mockConfig.EXPECT().GetString("app.ssh_port").Return("22").Once() - mockConfig.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() - mockConfig.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() - mockConfig.EXPECT().GetString("app.os").Return("linux").Once() - mockConfig.EXPECT().GetString("app.arch").Return("amd64").Once() - mockConfig.EXPECT().GetString("app.domain").Return("").Once() - mockConfig.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() - mockConfig.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() - mockConfig.EXPECT().GetBool("app.static").Return(false).Once() - mockConfig.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() - mockConfig.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("22").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.build.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.build.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.deploy.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy.base_dir", "/var/www/").Return("/var/www/").Once() + mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() mockContext.EXPECT().OptionBool("rollback").Return(true).Once() mockContext.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() @@ -439,19 +439,19 @@ func Test_Handle_Deploy_Success(t *testing.T) { // Config expectations mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() // Use fast-fail SSH settings to avoid any network delay - mockConfig.EXPECT().GetString("app.ssh_ip").Return("127.0.0.1").Once() - mockConfig.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() - mockConfig.EXPECT().GetString("app.ssh_port").Return("0").Once() - mockConfig.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() - mockConfig.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() - mockConfig.EXPECT().GetString("app.os").Return("linux").Once() - mockConfig.EXPECT().GetString("app.arch").Return("amd64").Once() - mockConfig.EXPECT().GetString("app.domain").Return("").Once() - mockConfig.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() - mockConfig.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() - mockConfig.EXPECT().GetBool("app.static").Return(false).Once() - mockConfig.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() - mockConfig.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("127.0.0.1").Once() + mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("0").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.build.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.build.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.deploy.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy.base_dir", "/var/www/").Return("/var/www/").Once() + mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() // Context expectations mockContext.EXPECT().OptionBool("rollback").Return(false).Once() @@ -480,19 +480,19 @@ func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { // Minimal config mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() - mockConfig.EXPECT().GetString("app.ssh_ip").Return("203.0.113.10").Once() - mockConfig.EXPECT().GetString("app.reverse_proxy_port").Return("9000").Once() - mockConfig.EXPECT().GetString("app.ssh_port").Return("22").Once() - mockConfig.EXPECT().GetString("app.ssh_user").Return("ubuntu").Once() - mockConfig.EXPECT().GetString("app.ssh_key_path").Return("~/.ssh/id").Once() - mockConfig.EXPECT().GetString("app.os").Return("linux").Once() - mockConfig.EXPECT().GetString("app.arch").Return("amd64").Once() - mockConfig.EXPECT().GetString("app.domain").Return("").Once() - mockConfig.EXPECT().GetString("app.prod_env_file_path").Return(".env.production").Once() - mockConfig.EXPECT().GetString("app.deploy_base_dir", "/var/www/").Return("/var/www/").Once() - mockConfig.EXPECT().GetBool("app.static").Return(false).Once() - mockConfig.EXPECT().GetBool("app.reverse_proxy_enabled").Return(false).Once() - mockConfig.EXPECT().GetBool("app.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("22").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.build.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.build.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.deploy.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy.base_dir", "/var/www/").Return("/var/www/").Once() + mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() mockContext.EXPECT().OptionBool("rollback").Return(false).Once() // Only stage we hit is build; simulate failure via Spinner return From eb82a46744a92ffa550f45e5edf5847f7ba617a3 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 12:02:41 -0700 Subject: [PATCH 43/71] zip backups and .prev safety --- console/console/deploy_command.go | 74 ++++++++++++++------------ console/console/deploy_command_test.go | 11 ++-- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index ddcf07711..322647843 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -657,37 +657,42 @@ func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload st fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, opts.sshUser, opts.sshUser, appDir), } - // main binary with previous backup + // Create a timestamped backup zip of existing deploy artifacts before replacing any of them + // Backup includes: main, .env, public, storage, resources (if present) + backupCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'set -e; APP_DIR=%q; BACKUP_DIR=\"$APP_DIR/backups\"; TS=\"$(date +%%Y%%m%%d%%H%%M%%S)\"; sudo mkdir -p \"$BACKUP_DIR\"; if ! command -v zip >/dev/null 2>&1; then sudo apt-get update -y && sudo apt-get install -y zip; fi; cd \"$APP_DIR\"; INCLUDE=\"\"; [ -f main ] && INCLUDE=\"$INCLUDE main\"; [ -f .env ] && INCLUDE=\"$INCLUDE .env\"; [ -d public ] && INCLUDE=\"$INCLUDE public\"; [ -d storage ] && INCLUDE=\"$INCLUDE storage\"; [ -d resources ] && INCLUDE=\"$INCLUDE resources\"; if [ -n \"$INCLUDE\" ]; then zip -r \"$BACKUP_DIR/$TS.zip\" $INCLUDE >/dev/null; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir) + cmds = append(cmds, backupCmd) + + // main binary if up.hasMain { - // upload to temp and atomically move, keeping previous as main.prev + // upload to temp and atomically move cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(opts.appName), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/main ]; then sudo mv %s/main %s/main.prev; fi; sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir), ) } if up.hasProdEnv { - // Upload env to a temp path, then atomically place as .env; backup previous as .env.prev if exists + // Upload env to a temp path, then atomically place as .env cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(envPathToUpload), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -f %s/.env ]; then sudo mv %s/.env %s/.env.prev; fi; sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), ) } if up.hasPublic { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public.prev; sudo mv %s/public %s/public.prev; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("public"), remoteBase), ) } if up.hasStorage { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage.prev; sudo mv %s/storage %s/storage.prev; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("storage"), remoteBase), ) } if up.hasResources { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources.prev; sudo mv %s/resources %s/resources.prev; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("resources"), remoteBase), ) } @@ -711,34 +716,35 @@ func rollbackCommand(appName, ip, sshPort, sshUser, keyPath, baseDir string) *ex set -e APP_DIR=%q SERVICE=%q -if [ ! -f "$APP_DIR/main.prev" ]; then - echo "No previous deployment to rollback to." >&2 +BACKUP_DIR="$APP_DIR/backups" + +# Ensure we have at least one backup to roll back to +TARGET_ZIP="$(ls -1t "$BACKUP_DIR"/*.zip 2>/dev/null | head -n1)" +if [ -z "$TARGET_ZIP" ]; then + echo "No previous deployment backup to rollback to." >&2 exit 1 fi -sudo mv "$APP_DIR/main" "$APP_DIR/main.newcurrent" || true -sudo mv "$APP_DIR/main.prev" "$APP_DIR/main" -sudo mv "$APP_DIR/main.newcurrent" "$APP_DIR/main.prev" || true -sudo chmod +x "$APP_DIR/main" -if [ -f "$APP_DIR/.env.prev" ]; then - sudo mv "$APP_DIR/.env" "$APP_DIR/.env.newcurrent" || true - sudo mv "$APP_DIR/.env.prev" "$APP_DIR/.env" - sudo mv "$APP_DIR/.env.newcurrent" "$APP_DIR/.env.prev" || true -fi -if [ -d "$APP_DIR/public.prev" ]; then - sudo mv "$APP_DIR/public" "$APP_DIR/public.newcurrent" || true - sudo mv "$APP_DIR/public.prev" "$APP_DIR/public" - sudo mv "$APP_DIR/public.newcurrent" "$APP_DIR/public.prev" || true -fi -if [ -d "$APP_DIR/resources.prev" ]; then - sudo mv "$APP_DIR/resources" "$APP_DIR/resources.newcurrent" || true - sudo mv "$APP_DIR/resources.prev" "$APP_DIR/resources" - sudo mv "$APP_DIR/resources.newcurrent" "$APP_DIR/resources.prev" || true -fi -if [ -d "$APP_DIR/storage.prev" ]; then - sudo mv "$APP_DIR/storage" "$APP_DIR/storage.newcurrent" || true - sudo mv "$APP_DIR/storage.prev" "$APP_DIR/storage" - sudo mv "$APP_DIR/storage.newcurrent" "$APP_DIR/storage.prev" || true -fi + +# Backup current state before rollback (so we can roll forward if needed) +TS="$(date +%%Y%%m%%d%%H%%M%%S)" +mkdir -p "$BACKUP_DIR" +if ! command -v zip >/dev/null 2>&1; then sudo apt-get update -y && sudo apt-get install -y zip; fi +cd "$APP_DIR" +INCLUDE="" +[ -f main ] && INCLUDE="$INCLUDE main" +[ -f .env ] && INCLUDE="$INCLUDE .env" +[ -d public ] && INCLUDE="$INCLUDE public" +[ -d storage ] && INCLUDE="$INCLUDE storage" +[ -d resources ] && INCLUDE="$INCLUDE resources" +if [ -n "$INCLUDE" ]; then zip -r "$BACKUP_DIR/rollback-$TS.zip" $INCLUDE >/dev/null; fi + +# Restore from latest backup +if ! command -v unzip >/dev/null 2>&1; then sudo apt-get update -y && sudo apt-get install -y unzip; fi +unzip -o "$TARGET_ZIP" -d "$APP_DIR" >/dev/null + +# Cleanup any *.newcurrent artifacts from previous failed operations (move this to the end as suggested) +find "$APP_DIR" -maxdepth 1 -name "*.newcurrent" -type f -exec sudo rm -f {} + || true + sudo systemctl daemon-reload sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE" '`, keyPath, sshPort, sshUser, ip, appDir, appName) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 776ef54e8..9f3ab6a3a 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -121,9 +121,12 @@ func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { } script := cmd.Args[2] appDir := "/var/www/myapp" - // Binary upload and backup + // Binary upload and backup (now uses zip backups + atomic replace without .prev) assert.Contains(t, script, "scp -o StrictHostKeyChecking=no -i \"~/.ssh/id\" -P 22 \"myapp\" ubuntu@203.0.113.10:"+appDir+"/main.new") - assert.Contains(t, script, "if [ -f "+appDir+"/main ]; then sudo mv "+appDir+"/main "+appDir+"/main.prev; fi; sudo mv "+appDir+"/main.new "+appDir+"/main && sudo chmod +x "+appDir+"/main") + assert.Contains(t, script, "sudo mv "+appDir+"/main.new "+appDir+"/main && sudo chmod +x "+appDir+"/main") + // Ensure backup zip is created + assert.Contains(t, script, "backups") + assert.Contains(t, script, "zip -r") // .env atomic rename assert.Contains(t, script, ".env.new") assert.Contains(t, script, "/.env'") @@ -174,7 +177,9 @@ func Test_rollbackCommand(t *testing.T) { t.Skip("Skipping script content assertions on Windows shell") } script := cmd.Args[2] - assert.Contains(t, script, "main.prev") + // Ensure rollback uses backup zip restore + assert.Contains(t, script, "backups") + assert.Contains(t, script, "unzip -o") // Accept either explicit service name or variable-based restart lines hasExplicit := strings.Contains(script, "systemctl restart myapp || sudo systemctl start myapp") hasVariable := strings.Contains(script, "systemctl restart \"$SERVICE\" || sudo systemctl start \"$SERVICE\"") From 9da7d81d9634c3338c7a074417566caa1a5069d1 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 12:10:35 -0700 Subject: [PATCH 44/71] only upload storage on initial setup --- console/console/deploy_command.go | 11 +++++++++-- console/console/deploy_command_test.go | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 322647843..0280e0604 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -360,8 +360,10 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } } - // Step 3: set up server on first run —- skip if already set up - if !isServerAlreadySetup(opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath) { + // Step 3: set up server on first run —- skip if already set up unless --force-setup is used + forceSetup := ctx.OptionBool("force-setup") + setupNeeded := forceSetup || !isServerAlreadySetup(opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath) + if setupNeeded { if err = supportconsole.ExecuteCommand(ctx, setupServerCommand(opts), "Setting up server (first time only)..."); err != nil { ctx.Error(err.Error()) return nil @@ -370,6 +372,11 @@ func (r *DeployCommand) Handle(ctx console.Context) error { ctx.Info("Server already set up. Skipping setup.") } + // Enforce: storage can only be uploaded during setup stage + if !setupNeeded { + upload.hasStorage = false + } + // Step 4: upload files if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand(opts, upload, envPathToUpload), "Uploading files..."); err != nil { ctx.Error(err.Error()) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 9f3ab6a3a..d5a0ec8c5 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -460,6 +460,7 @@ func Test_Handle_Deploy_Success(t *testing.T) { // Context expectations mockContext.EXPECT().OptionBool("rollback").Return(false).Once() + mockContext.EXPECT().OptionBool("force-setup").Return(false).Once() mockContext.EXPECT().Option("only").Return("").Once() // Ensure artifacts exist for getUploadOptions From dcdf32641a71e0a3403292119aef5a36a1a3ae83 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 12:17:48 -0700 Subject: [PATCH 45/71] add tests for new EnvString() and EnvBool() config functions --- config/application_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/config/application_test.go b/config/application_test.go index 8e09c1820..35763b1b1 100644 --- a/config/application_test.go +++ b/config/application_test.go @@ -178,6 +178,42 @@ func (s *ApplicationTestSuite) TestGetDuration() { s.Equal(time.Duration(0), s.config.GetDuration("INVALID_DURATION", time.Second)) } +func (s *ApplicationTestSuite) TestEnvStringFunction() { + // existing value + s.T().Setenv("ENVSTRING_VAR", "hello") + s.Equal("hello", s.config.EnvString("ENVSTRING_VAR")) + s.Equal("hello", s.customConfig.EnvString("ENVSTRING_VAR")) + + // default used when not set + s.Equal("fallback", s.config.EnvString("ENVSTRING_NOT_SET", "fallback")) + + // empty string -> use provided default, otherwise empty string + s.T().Setenv("ENVSTRING_EMPTY", "") + s.Equal("fallback", s.config.EnvString("ENVSTRING_EMPTY", "fallback")) + s.Equal("", s.config.EnvString("ENVSTRING_EMPTY")) +} + +func (s *ApplicationTestSuite) TestEnvBoolFunction() { + // true/false values + s.T().Setenv("ENVBOOL_TRUE", "true") + s.True(s.config.EnvBool("ENVBOOL_TRUE")) + s.T().Setenv("ENVBOOL_FALSE", "false") + s.False(s.config.EnvBool("ENVBOOL_FALSE")) + + // not set -> default respected + s.True(s.config.EnvBool("ENVBOOL_NOT_SET", true)) + s.False(s.config.EnvBool("ENVBOOL_NOT_SET2", false)) + + // empty string -> use default if provided; otherwise cast to false + s.T().Setenv("ENVBOOL_EMPTY", "") + s.True(s.config.EnvBool("ENVBOOL_EMPTY", true)) + s.False(s.config.EnvBool("ENVBOOL_EMPTY")) + + // invalid -> false + s.T().Setenv("ENVBOOL_INVALID", "invalid") + s.False(s.config.EnvBool("ENVBOOL_INVALID")) +} + func TestOsVariables(t *testing.T) { t.Setenv("APP_KEY", "12345678901234567890123456789013") t.Setenv("APP_NAME", "goravel") From b757797277ad04af8003c7c9839d630ace5c6186 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 12:27:30 -0700 Subject: [PATCH 46/71] update deploy command documentation comment --- console/console/deploy_command.go | 52 +++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 0280e0604..2fe7c4fa3 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -34,7 +34,7 @@ Architecture assumptions Two primary deployment topologies are supported: 1) Reverse proxy in front of the app (recommended) - reverseProxyEnabled=true - - App listens on 127.0.0.1: (e.g. 9000) + - App listens on 127.0.0.1: (e.g. 9000) - Caddy proxies public HTTP(S) traffic to the app - If reverseProxyTLSEnabled=true and a valid domain is configured, Caddy terminates TLS and automatically provisions certificates; otherwise Caddy serves plain HTTP on :80 @@ -48,10 +48,10 @@ Artifacts & layout on server Remote base directory: /var/www/ Files managed by this command on the remote host: - main : current binary (running) - - main.prev : previous binary (standby for rollback) - - .env : environment file (uploaded from DEPLOY_PROD_ENV_FILE_PATH) + - backups/ : timestamped zip archives of previous states (used for rollback) + - .env : environment file (uploaded from app.deploy.prod_env_file_path) - public/ : optional static assets - - storage/ : optional storage directory + - storage/ : optional storage directory (uploaded only during setup) - resources/ : optional resources directory Idempotency & first-time setup @@ -70,9 +70,8 @@ changes (e.g., regenerate Caddyfile, adjust firewall rules, rewrite the unit fil Rollback model -------------- -Every deployment that uploads a new binary preserves the previous one as main.prev. A rollback -simply swaps main and main.prev atomically and restarts the service. Non-binary assets (.env, -public, storage, resources) are not rolled back by this command. +Every deployment creates a timestamped zip archive of the current state under backups/. +Rollback restores from the latest archive and restarts the service. Build & artifacts (local) ------------------------- @@ -80,25 +79,26 @@ The command builds the binary (name: APP_NAME) using the configured target OS/AR linking preference. See Goravel docs for compiling guidance, artifacts, and what to upload: https://www.goravel.dev/getting-started/compile.html -Configuration (env) -------------------- +Configuration (app.config) +-------------------------- +This command reads from application configuration (see app/config/app.go), not directly from .env. Required: - - app.name : Application name (used in remote paths/service name) - - DEPLOY_SSH_IP : Target server IP - - DEPLOY_REVERSE_PROXY_PORT : Backend app port when reverse proxy is used (e.g. 9000) - - DEPLOY_SSH_PORT : SSH port (e.g. 22) - - DEPLOY_SSH_USER : SSH username (user must have sudo privileges) - - DEPLOY_SSH_KEY_PATH : Path to SSH private key (e.g. ~/.ssh/id_rsa) - - DEPLOY_OS : Target OS for build (e.g. linux) - - DEPLOY_ARCH : Target arch for build (e.g. amd64) - - DEPLOY_PROD_ENV_FILE_PATH : Local path to production .env file to upload + - app.name : Application name (used in remote paths/service name) + - app.deploy.ssh_ip : Target server IP + - app.deploy.reverse_proxy_port : Backend app port when reverse proxy is used (e.g. 9000) + - app.deploy.ssh_port : SSH port (e.g. 22) + - app.deploy.ssh_user : SSH username (user must have sudo privileges) + - app.deploy.ssh_key_path : Path to SSH private key (e.g. ~/.ssh/id_rsa) + - app.build.os : Target OS for build (e.g. linux) + - app.build.arch : Target arch for build (e.g. amd64) + - app.deploy.prod_env_file_path : Local path to production .env file to upload Optional / boolean flags (default false if unset): - - DEPLOY_STATIC : Build statically when true - - DEPLOY_REVERSE_PROXY_ENABLED : Use Caddy reverse proxy when true - - DEPLOY_REVERSE_PROXY_TLS_ENABLED : Enable TLS (requires domain) when true - - DEPLOY_DOMAIN : Domain name for TLS or HTTP vhost when using Caddy - (required only if TLS is enabled) + - app.build.static : Build statically when true + - app.deploy.reverse_proxy_enabled : Use Caddy reverse proxy when true + - app.deploy.reverse_proxy_tls_enabled : Enable TLS (requires domain) when true + - app.deploy.domain : Domain name for TLS or HTTP vhost when using Caddy + (required only if TLS is enabled) CLI flags --------- @@ -115,21 +115,21 @@ SSH connectivity. Systemd service --------------- -The unit runs under DEPLOY_SSH_USER. Environment variables are provided via the unit for host/port, +The unit runs under app.deploy.ssh_user. Environment variables are provided via the unit for host/port, and the working directory points to /var/www/. Service restarts are used (brief downtime). For zero-downtime swaps, a more advanced process manager or socket activation would be required. High-level deployment flow -------------------------- 1) Build: compile the binary for the specified target (OS/ARCH, static optional) with name APP_NAME -2) Determine artifacts to upload: main, .env, public, storage, resources (filter via --only) +2) Determine artifacts to upload: main, .env, public, storage (setup only), resources (filter via --only) 3) Setup (first deploy only, or when --force-setup): - Create directories and permissions - Install/configure Caddy based on reverse proxy + TLS settings - Write systemd unit and enable service - Configure ufw rules (OpenSSH, 80, and 443 as needed) 4) Upload: - - Binary: upload to main.new, move previous main to main.prev (if exists), atomically move main.new to main + - Binary: upload to main.new, atomically move main.new to main - .env: upload to .env.new, atomically move to .env - public, storage, resources: recursively upload if they exist locally 5) Restart service: systemctl daemon-reload, then restart (or start) the service From 5a5f37d415c9cb8f35777b97214fc3e03d7760bc Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 12:30:58 -0700 Subject: [PATCH 47/71] change ipAddress to sshIP and appPort to reverseProxyPort --- console/console/deploy_command.go | 46 +++++++++++++------------- console/console/deploy_command_test.go | 18 +++++----- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 2fe7c4fa3..f62421d50 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -236,8 +236,8 @@ go run . artisan deploy --only main,env // deployOptions is a struct that contains all the options for the deploy command type deployOptions struct { appName string - ipAddress string - appPort string + sshIp string + reverseProxyPort string sshPort string sshUser string sshKeyPath string @@ -312,7 +312,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { if ctx.OptionBool("rollback") { opts := r.getDeployOptions(ctx) if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( - opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath, opts.deployBaseDir, + opts.appName, opts.sshIp, opts.sshPort, opts.sshUser, opts.sshKeyPath, opts.deployBaseDir, ), "Rolling back..."); err != nil { ctx.Error(err.Error()) return nil @@ -362,7 +362,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 3: set up server on first run —- skip if already set up unless --force-setup is used forceSetup := ctx.OptionBool("force-setup") - setupNeeded := forceSetup || !isServerAlreadySetup(opts.appName, opts.ipAddress, opts.sshPort, opts.sshUser, opts.sshKeyPath) + setupNeeded := forceSetup || !isServerAlreadySetup(opts.appName, opts.sshIp, opts.sshPort, opts.sshUser, opts.sshKeyPath) if setupNeeded { if err = supportconsole.ExecuteCommand(ctx, setupServerCommand(opts), "Setting up server (first time only)..."); err != nil { ctx.Error(err.Error()) @@ -397,8 +397,8 @@ func (r *DeployCommand) Handle(ctx console.Context) error { func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { opts := deployOptions{} opts.appName = r.config.GetString("app.name") - opts.ipAddress = r.config.GetString("app.deploy.ssh_ip") - opts.appPort = r.config.GetString("app.deploy.reverse_proxy_port") + opts.sshIp = r.config.GetString("app.deploy.ssh_ip") + opts.reverseProxyPort = r.config.GetString("app.deploy.reverse_proxy_port") opts.sshPort = r.config.GetString("app.deploy.ssh_port") opts.sshUser = r.config.GetString("app.deploy.ssh_user") opts.sshKeyPath = r.config.GetString("app.deploy.ssh_key_path") @@ -417,10 +417,10 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { if opts.appName == "" { missing = append(missing, "APP_NAME") } - if opts.ipAddress == "" { + if opts.sshIp == "" { missing = append(missing, "DEPLOY_SSH_IP") } - if opts.appPort == "" { + if opts.reverseProxyPort == "" { missing = append(missing, "DEPLOY_REVERSE_PROXY_PORT") } if opts.sshPort == "" { @@ -550,7 +550,7 @@ func setupServerCommand(opts deployOptions) *exec.Cmd { // Build systemd unit based on whether reverse proxy is used listenHost := "127.0.0.1" - appPort := opts.appPort + appPort := opts.reverseProxyPort if !opts.reverseProxyEnabled { // App listens on port 80 directly appPort = "80" @@ -628,7 +628,7 @@ if [ ! -f /etc/systemd/system/%s.service ]; then fi %s %s' -`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, +`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir, opts.sshUser, opts.sshUser, appDir, // caddy install and config func() string { @@ -658,15 +658,15 @@ func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload st baseDir += "/" } appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) - remoteBase := fmt.Sprintf("%s@%s:%s", opts.sshUser, opts.ipAddress, appDir) + remoteBase := fmt.Sprintf("%s@%s:%s", opts.sshUser, opts.sshIp, appDir) // ensure remote base exists and permissions cmds := []string{ - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, opts.sshUser, opts.sshUser, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mkdir -p %s && sudo chown -R %s:%s %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, opts.sshUser, opts.sshUser, appDir), } // Create a timestamped backup zip of existing deploy artifacts before replacing any of them // Backup includes: main, .env, public, storage, resources (if present) - backupCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'set -e; APP_DIR=%q; BACKUP_DIR=\"$APP_DIR/backups\"; TS=\"$(date +%%Y%%m%%d%%H%%M%%S)\"; sudo mkdir -p \"$BACKUP_DIR\"; if ! command -v zip >/dev/null 2>&1; then sudo apt-get update -y && sudo apt-get install -y zip; fi; cd \"$APP_DIR\"; INCLUDE=\"\"; [ -f main ] && INCLUDE=\"$INCLUDE main\"; [ -f .env ] && INCLUDE=\"$INCLUDE .env\"; [ -d public ] && INCLUDE=\"$INCLUDE public\"; [ -d storage ] && INCLUDE=\"$INCLUDE storage\"; [ -d resources ] && INCLUDE=\"$INCLUDE resources\"; if [ -n \"$INCLUDE\" ]; then zip -r \"$BACKUP_DIR/$TS.zip\" $INCLUDE >/dev/null; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir) + backupCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'set -e; APP_DIR=%q; BACKUP_DIR=\"$APP_DIR/backups\"; TS=\"$(date +%%Y%%m%%d%%H%%M%%S)\"; sudo mkdir -p \"$BACKUP_DIR\"; if ! command -v zip >/dev/null 2>&1; then sudo apt-get update -y && sudo apt-get install -y zip; fi; cd \"$APP_DIR\"; INCLUDE=\"\"; [ -f main ] && INCLUDE=\"$INCLUDE main\"; [ -f .env ] && INCLUDE=\"$INCLUDE .env\"; [ -d public ] && INCLUDE=\"$INCLUDE public\"; [ -d storage ] && INCLUDE=\"$INCLUDE storage\"; [ -d resources ] && INCLUDE=\"$INCLUDE resources\"; if [ -n \"$INCLUDE\" ]; then zip -r \"$BACKUP_DIR/$TS.zip\" $INCLUDE >/dev/null; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir) cmds = append(cmds, backupCmd) // main binary @@ -674,7 +674,7 @@ func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload st // upload to temp and atomically move cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/main.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(opts.appName), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/main.new %s/main && sudo chmod +x %s/main'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir, appDir), ) } @@ -682,24 +682,24 @@ func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload st // Upload env to a temp path, then atomically place as .env cmds = append(cmds, fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(envPathToUpload), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir), ) } if up.hasPublic { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/public ]; then sudo rm -rf %s/public; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir), fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("public"), remoteBase), ) } if up.hasStorage { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/storage ]; then sudo rm -rf %s/storage; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir), fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("storage"), remoteBase), ) } if up.hasResources { cmds = append(cmds, - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, appDir, appDir), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'if [ -d %s/resources ]; then sudo rm -rf %s/resources; fi'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir), fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("resources"), remoteBase), ) } @@ -709,12 +709,12 @@ func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload st } func restartServiceCommand(opts deployOptions) *exec.Cmd { - script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.ipAddress, opts.appName, opts.appName) + script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo systemctl daemon-reload && sudo systemctl restart %s || sudo systemctl start %s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, opts.appName, opts.appName) return makeLocalCommand(script) } // rollbackCommand swaps main and main.prev if available, then restarts the service -func rollbackCommand(appName, ip, sshPort, sshUser, keyPath, baseDir string) *exec.Cmd { +func rollbackCommand(appName, sshIp, sshPort, sshUser, keyPath, baseDir string) *exec.Cmd { if !strings.HasSuffix(baseDir, "/") { baseDir += "/" } @@ -754,13 +754,13 @@ find "$APP_DIR" -maxdepth 1 -name "*.newcurrent" -type f -exec sudo rm -f {} + | sudo systemctl daemon-reload sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE" - '`, keyPath, sshPort, sshUser, ip, appDir, appName) + '`, keyPath, sshPort, sshUser, sshIp, appDir, appName) return exec.Command("bash", "-lc", script) } // isServerAlreadySetup checks if the systemd unit already exists on remote host -func isServerAlreadySetup(appName, ip, sshPort, sshUser, keyPath string) bool { - checkCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'test -f /etc/systemd/system/%s.service'", keyPath, sshPort, sshUser, ip, appName) +func isServerAlreadySetup(appName, sshIp, sshPort, sshUser, keyPath string) bool { + checkCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'test -f /etc/systemd/system/%s.service'", keyPath, sshPort, sshUser, sshIp, appName) cmd := makeLocalCommand(checkCmd) if err := cmd.Run(); err != nil { return false diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index d5a0ec8c5..75e7c5460 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -43,7 +43,7 @@ func extractBase64(script, teePath string) (string, bool) { } func Test_setupServerCommand_NoProxy(t *testing.T) { - cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "", reverseProxyEnabled: false, reverseProxyTLSEnabled: false}) + cmd := setupServerCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", reverseProxyPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "", reverseProxyEnabled: false, reverseProxyTLSEnabled: false}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -69,7 +69,7 @@ func Test_setupServerCommand_NoProxy(t *testing.T) { } func Test_setupServerCommand_ProxyHTTP(t *testing.T) { - cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "", reverseProxyEnabled: true, reverseProxyTLSEnabled: false}) + cmd := setupServerCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", reverseProxyPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "", reverseProxyEnabled: true, reverseProxyTLSEnabled: false}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -91,7 +91,7 @@ func Test_setupServerCommand_ProxyHTTP(t *testing.T) { } func Test_setupServerCommand_ProxyTLS(t *testing.T) { - cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "example.com", reverseProxyEnabled: true, reverseProxyTLSEnabled: true}) + cmd := setupServerCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", reverseProxyPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "example.com", reverseProxyEnabled: true, reverseProxyTLSEnabled: true}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -114,7 +114,7 @@ func Test_setupServerCommand_ProxyTLS(t *testing.T) { } func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { - cmd := uploadFilesCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") + cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -137,7 +137,7 @@ func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { } func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { - cmd := uploadFilesCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: false, hasPublic: false, hasStorage: true, hasResources: false}, ".env.production") + cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: false, hasPublic: false, hasStorage: true, hasResources: false}, ".env.production") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -156,7 +156,7 @@ func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { } func Test_restartServiceCommand(t *testing.T) { - cmd := restartServiceCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) + cmd := restartServiceCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -316,7 +316,7 @@ func Test_setupServerCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := setupServerCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", appPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "example.com", reverseProxyEnabled: true, reverseProxyTLSEnabled: true}) + cmd := setupServerCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", reverseProxyPort: "9000", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/", domain: "example.com", reverseProxyEnabled: true, reverseProxyTLSEnabled: true}) require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) @@ -327,7 +327,7 @@ func Test_uploadFilesCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := uploadFilesCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") + cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) @@ -338,7 +338,7 @@ func Test_restartServiceCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := restartServiceCommand(deployOptions{appName: "myapp", ipAddress: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) + cmd := restartServiceCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) From d5b3e2a47c80ab0de6e5efecd718f237b25cb5e3 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 12:34:56 -0700 Subject: [PATCH 48/71] make rollback command take opts as the only parameter --- console/console/deploy_command.go | 11 +++++------ console/console/deploy_command_test.go | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index f62421d50..ee91125d0 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -311,9 +311,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // (tests can short-circuit Spinner; real runs will still use ssh remotely) if ctx.OptionBool("rollback") { opts := r.getDeployOptions(ctx) - if err := supportconsole.ExecuteCommand(ctx, rollbackCommand( - opts.appName, opts.sshIp, opts.sshPort, opts.sshUser, opts.sshKeyPath, opts.deployBaseDir, - ), "Rolling back..."); err != nil { + if err := supportconsole.ExecuteCommand(ctx, rollbackCommand(opts), "Rolling back..."); err != nil { ctx.Error(err.Error()) return nil } @@ -714,11 +712,12 @@ func restartServiceCommand(opts deployOptions) *exec.Cmd { } // rollbackCommand swaps main and main.prev if available, then restarts the service -func rollbackCommand(appName, sshIp, sshPort, sshUser, keyPath, baseDir string) *exec.Cmd { +func rollbackCommand(opts deployOptions) *exec.Cmd { + baseDir := opts.deployBaseDir if !strings.HasSuffix(baseDir, "/") { baseDir += "/" } - appDir := fmt.Sprintf("%s%s", baseDir, appName) + appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' set -e APP_DIR=%q @@ -754,7 +753,7 @@ find "$APP_DIR" -maxdepth 1 -name "*.newcurrent" -type f -exec sudo rm -f {} + | sudo systemctl daemon-reload sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE" - '`, keyPath, sshPort, sshUser, sshIp, appDir, appName) + '`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, opts.appName) return exec.Command("bash", "-lc", script) } diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 75e7c5460..7d396edef 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -171,7 +171,7 @@ func Test_restartServiceCommand(t *testing.T) { } func Test_rollbackCommand(t *testing.T) { - cmd := rollbackCommand("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id", "/var/www/") + cmd := rollbackCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}) require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") From a0c76554814b2cf8ebc538da1740ae560c17ce2b Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 13:21:07 -0700 Subject: [PATCH 49/71] use artisan facade for build command --- console/console/deploy_command.go | 17 +++++++++-------- console/console/deploy_command_test.go | 18 +++++++++++++----- console/service_provider.go | 2 +- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index ee91125d0..172a35926 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -260,12 +260,14 @@ type uploadOptions struct { } type DeployCommand struct { - config config.Config + config config.Config + artisan console.Artisan } -func NewDeployCommand(config config.Config) *DeployCommand { +func NewDeployCommand(config config.Config, artisan console.Artisan) *DeployCommand { return &DeployCommand{ - config: config, + config: config, + artisan: artisan, } } @@ -330,12 +332,12 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // continue normal deploy flow var err error - // Step 1: build the application by invoking the build command directly - buildCmd := fmt.Sprintf("go run . artisan build --os %s --arch %s --name %s", opts.targetOS, opts.arch, opts.appName) + // Step 1: build the application by invoking the build command via Artisan (no shell exec) + buildCmd := fmt.Sprintf("build --os %s --arch %s --name %s", opts.targetOS, opts.arch, opts.appName) if opts.staticEnv { buildCmd += " --static" } - if err = supportconsole.ExecuteCommand(ctx, makeLocalCommand(buildCmd), "Building..."); err != nil { + if err = r.artisan.Call(buildCmd); err != nil { ctx.Error(err.Error()) return nil } @@ -348,8 +350,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { if upload.hasProdEnv { lower := strings.ToLower(strings.TrimSpace(opts.prodEnvFilePath)) if strings.HasSuffix(lower, ".encrypted") || strings.HasSuffix(lower, ".safe") { - decryptCmd := fmt.Sprintf("go run . artisan env:decrypt --name %q", opts.prodEnvFilePath) - if err = supportconsole.ExecuteCommand(ctx, makeLocalCommand(decryptCmd), "Decrypting environment file..."); err != nil { + if err = r.artisan.Call(fmt.Sprintf("env:decrypt --name %q", opts.prodEnvFilePath)); err != nil { ctx.Error(err.Error()) return nil } diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 7d396edef..c8198c587 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -408,7 +408,8 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // We only test rollback path to avoid executing remote checks. mockContext := mocksconsole.NewContext(t) mockConfig := mocksconfig.NewConfig(t) - cmd := NewDeployCommand(mockConfig) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) // Minimal required envs for getDeployOptions (will not be used deeply due to rollback) mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() @@ -439,7 +440,8 @@ func Test_Handle_Deploy_Success(t *testing.T) { } mockContext := mocksconsole.NewContext(t) mockConfig := mocksconfig.NewConfig(t) - cmd := NewDeployCommand(mockConfig) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) // Config expectations mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() @@ -471,6 +473,9 @@ func Test_Handle_Deploy_Success(t *testing.T) { require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) require.NoError(t, os.WriteFile(".env.production", []byte("APP_ENV=prod"), 0o644)) + // Expect artisan build call + mockArtisan.EXPECT().Call("build --os linux --arch amd64 --name myapp").Return(nil).Once() + // Force all Spinner-wrapped commands (build/upload/restart/setup) to return immediately mockContext.EXPECT().Spinner(mock.Anything, mock.Anything).Return(nil).Maybe() mockContext.EXPECT().Info("Server already set up. Skipping setup.").Maybe() @@ -482,7 +487,8 @@ func Test_Handle_Deploy_Success(t *testing.T) { func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockConfig := mocksconfig.NewConfig(t) - cmd := NewDeployCommand(mockConfig) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) // Minimal config mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() @@ -501,8 +507,10 @@ func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() mockContext.EXPECT().OptionBool("rollback").Return(false).Once() - // Only stage we hit is build; simulate failure via Spinner return - mockContext.EXPECT().Spinner(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Building") }), mock.Anything).Return(fmt.Errorf("build error")).Once() + // Build fails via artisan + mockArtisan.EXPECT().Call("build --os linux --arch amd64 --name myapp").Return(fmt.Errorf("build error")).Once() + // Spinner used for messaging but not the cause of failure now + mockContext.EXPECT().Spinner(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Building") }), mock.Anything).Return(nil).Maybe() mockContext.EXPECT().Error("build error").Once() assert.Nil(t, cmd.Handle(mockContext)) diff --git a/console/service_provider.go b/console/service_provider.go index ef74570fc..bdfb357a9 100644 --- a/console/service_provider.go +++ b/console/service_provider.go @@ -53,6 +53,6 @@ func (r *ServiceProvider) registerCommands(app foundation.Application) { console.NewKeyGenerateCommand(configFacade), console.NewMakeCommand(), console.NewBuildCommand(configFacade), - console.NewDeployCommand(configFacade), + console.NewDeployCommand(configFacade, artisanFacade), }) } From dee98c73f23e08d5c03ea25944d156fbcf8f6ac2 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 13:28:06 -0700 Subject: [PATCH 50/71] use content to check if env file is encrypted --- console/console/deploy_command.go | 37 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 172a35926..63a7abe00 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -1,6 +1,7 @@ package console import ( + "crypto/aes" "encoding/base64" "fmt" "os" @@ -348,14 +349,16 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // If the production env file is encrypted (per Goravel docs), decrypt it first envPathToUpload := opts.prodEnvFilePath if upload.hasProdEnv { - lower := strings.ToLower(strings.TrimSpace(opts.prodEnvFilePath)) - if strings.HasSuffix(lower, ".encrypted") || strings.HasSuffix(lower, ".safe") { - if err = r.artisan.Call(fmt.Sprintf("env:decrypt --name %q", opts.prodEnvFilePath)); err != nil { - ctx.Error(err.Error()) - return nil + // Detect encrypted env by content (base64 + AES block structure with IV) + if data, readErr := os.ReadFile(opts.prodEnvFilePath); readErr == nil { + if isEncryptedEnvContent(data) { + if err = r.artisan.Call(fmt.Sprintf("env:decrypt --name %q", opts.prodEnvFilePath)); err != nil { + ctx.Error(err.Error()) + return nil + } + // env:decrypt writes to .env in the working directory + envPathToUpload = ".env" } - // env:decrypt writes to .env in the working directory - envPathToUpload = ".env" } } @@ -459,6 +462,26 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { return opts } +// isEncryptedEnvContent determines whether the provided bytes likely represent an encrypted env +// according to Goravel's env:encrypt format (base64 of IV||ciphertext using AES with 16-byte IV). +// Heuristic: +// - Base64-decodable +// - Decoded length >= aes.BlockSize*2 (IV + at least one block) +// - Decoded length % aes.BlockSize == 0 +func isEncryptedEnvContent(raw []byte) bool { + decoded, err := base64.StdEncoding.DecodeString(string(raw)) + if err != nil { + return false + } + if len(decoded) < aes.BlockSize*2 { + return false + } + if len(decoded)%aes.BlockSize != 0 { + return false + } + return true +} + func getUploadOptions(ctx console.Context, appName, prodEnvFilePath string) uploadOptions { res := uploadOptions{} res.hasMain = file.Exists(appName) From 1c9b17b71b05b6eb961776f3891614eff0c5b738 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 13:40:27 -0700 Subject: [PATCH 51/71] modify isServerAlreadySetup() to take in opts as the only parameter --- console/console/deploy_command.go | 6 +++--- console/console/deploy_command_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 63a7abe00..3b2f665ec 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -364,7 +364,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 3: set up server on first run —- skip if already set up unless --force-setup is used forceSetup := ctx.OptionBool("force-setup") - setupNeeded := forceSetup || !isServerAlreadySetup(opts.appName, opts.sshIp, opts.sshPort, opts.sshUser, opts.sshKeyPath) + setupNeeded := forceSetup || !isServerAlreadySetup(opts) if setupNeeded { if err = supportconsole.ExecuteCommand(ctx, setupServerCommand(opts), "Setting up server (first time only)..."); err != nil { ctx.Error(err.Error()) @@ -782,8 +782,8 @@ sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE" } // isServerAlreadySetup checks if the systemd unit already exists on remote host -func isServerAlreadySetup(appName, sshIp, sshPort, sshUser, keyPath string) bool { - checkCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'test -f /etc/systemd/system/%s.service'", keyPath, sshPort, sshUser, sshIp, appName) +func isServerAlreadySetup(opts deployOptions) bool { + checkCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'test -f /etc/systemd/system/%s.service'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, opts.appName) cmd := makeLocalCommand(checkCmd) if err := cmd.Run(); err != nil { return false diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index c8198c587..009c410ce 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -350,7 +350,7 @@ func Test_isServerAlreadySetup_WindowsShellWrapper(t *testing.T) { t.Skip("Windows-only test") } // We can't reliably assert remote state; just ensure command created uses cmd wrapper - _ = isServerAlreadySetup("myapp", "203.0.113.10", "22", "ubuntu", "~/.ssh/id") + _ = isServerAlreadySetup(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id"}) } func Test_validLocalHost_ErrorAggregation_Windows(t *testing.T) { From c3e7f0001b3aed1d0d93ec949a975b57ee61b13f Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 13:43:00 -0700 Subject: [PATCH 52/71] remove os check --- console/console/deploy_command.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 3b2f665ec..10f9419da 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -521,10 +521,6 @@ func getUploadOptions(ctx console.Context, appName, prodEnvFilePath string) uplo func validLocalHost(ctx console.Context) bool { var errs []string - if !env.IsDarwin() && !env.IsLinux() && !env.IsWindows() { - errs = append(errs, "only macos, linux, and windows are supported. Please use a supported machine to deploy.") - } - if _, err := exec.LookPath("scp"); err != nil { errs = append(errs, "scp is not installed. Please install it, add it to your path, and try again.") } From 98103e87317547edfd8b3ea6c798902b2d1b5e92 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 13:49:10 -0700 Subject: [PATCH 53/71] update wording for local host validation --- console/console/deploy_command.go | 17 ++++++++--------- console/console/deploy_command_test.go | 16 ++++++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 10f9419da..3181aeb00 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -519,29 +519,28 @@ func getUploadOptions(ctx console.Context, appName, prodEnvFilePath string) uplo // validLocalHost checks if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. func validLocalHost(ctx console.Context) bool { - var errs []string + missingBins := []string{} if _, err := exec.LookPath("scp"); err != nil { - errs = append(errs, "scp is not installed. Please install it, add it to your path, and try again.") + missingBins = append(missingBins, "scp") } - if _, err := exec.LookPath("ssh"); err != nil { - errs = append(errs, "ssh is not installed. Please install it, add it to your path, and try again.") + missingBins = append(missingBins, "ssh") } - // Shell requirements depend on OS if env.IsWindows() { if _, err := exec.LookPath("cmd"); err != nil { - errs = append(errs, "cmd is not available. Please ensure Windows command processor is accessible and try again.") + missingBins = append(missingBins, "cmd") } } else { if _, err := exec.LookPath("bash"); err != nil { - errs = append(errs, "bash is not installed. Please install it, add it to your path, and try again.") + missingBins = append(missingBins, "bash") } } - if len(errs) > 0 { - ctx.Error("Environment validation errors:\n - " + strings.Join(errs, "\n - ")) + if len(missingBins) > 0 { + msg := fmt.Sprintf("Environment validation errors:\n - the following binaries were not found on your path: %s\n - Please install them, add them to your path, and try again.", strings.Join(missingBins, ", ")) + ctx.Error(msg) return false } diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 009c410ce..8f40c7b74 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -270,9 +270,11 @@ func Test_validLocalHost_ErrorAggregation_Unix(t *testing.T) { // Expect a single aggregated error call mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Environment validation errors:") && - strings.Contains(msg, "scp is not installed") && - strings.Contains(msg, "ssh is not installed") && - strings.Contains(msg, "bash is not installed") + strings.Contains(msg, "the following binaries were not found on your path:") && + strings.Contains(msg, "scp") && + strings.Contains(msg, "ssh") && + strings.Contains(msg, "bash") && + strings.Contains(msg, "Please install them, add them to your path, and try again.") })).Once() ok := validLocalHost(mockContext) assert.False(t, ok) @@ -365,9 +367,11 @@ func Test_validLocalHost_ErrorAggregation_Windows(t *testing.T) { mockContext := mocksconsole.NewContext(t) mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Environment validation errors:") && - strings.Contains(msg, "scp is not installed") && - strings.Contains(msg, "ssh is not installed") && - strings.Contains(msg, "cmd is not available") + strings.Contains(msg, "the following binaries were not found on your path:") && + strings.Contains(msg, "scp") && + strings.Contains(msg, "ssh") && + strings.Contains(msg, "cmd") && + strings.Contains(msg, "Please install them, add them to your path, and try again.") })).Once() ok := validLocalHost(mockContext) assert.False(t, ok) From abe4d5a6d0f567573658ef6b1a0219ad32d18906 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 13:54:36 -0700 Subject: [PATCH 54/71] modify test maybe checks --- console/console/deploy_command_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 8f40c7b74..f9e5d1a5a 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -480,9 +480,10 @@ func Test_Handle_Deploy_Success(t *testing.T) { // Expect artisan build call mockArtisan.EXPECT().Call("build --os linux --arch amd64 --name myapp").Return(nil).Once() - // Force all Spinner-wrapped commands (build/upload/restart/setup) to return immediately - mockContext.EXPECT().Spinner(mock.Anything, mock.Anything).Return(nil).Maybe() - mockContext.EXPECT().Info("Server already set up. Skipping setup.").Maybe() + // Spinner-wrapped commands (explicit messages) + mockContext.EXPECT().Spinner("Setting up server (first time only)...", mock.Anything).Return(nil).Once() + mockContext.EXPECT().Spinner("Uploading files...", mock.Anything).Return(nil).Once() + mockContext.EXPECT().Spinner("Restarting service...", mock.Anything).Return(nil).Once() mockContext.EXPECT().Info("Deploy successful.").Once() assert.Nil(t, cmd.Handle(mockContext)) @@ -513,8 +514,10 @@ func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { mockContext.EXPECT().OptionBool("rollback").Return(false).Once() // Build fails via artisan mockArtisan.EXPECT().Call("build --os linux --arch amd64 --name myapp").Return(fmt.Errorf("build error")).Once() - // Spinner used for messaging but not the cause of failure now - mockContext.EXPECT().Spinner(mock.MatchedBy(func(msg string) bool { return strings.Contains(msg, "Building") }), mock.Anything).Return(nil).Maybe() + // Spinner messages that may appear later if build passed (not expected here) + mockContext.EXPECT().Spinner("Setting up server (first time only)...", mock.Anything).Return(nil).Maybe() + mockContext.EXPECT().Spinner("Uploading files...", mock.Anything).Return(nil).Maybe() + mockContext.EXPECT().Spinner("Restarting service...", mock.Anything).Return(nil).Maybe() mockContext.EXPECT().Error("build error").Once() assert.Nil(t, cmd.Handle(mockContext)) From 34018a680594b0288d24e0b5da9cc151eecb6257 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 14:12:47 -0700 Subject: [PATCH 55/71] resolve lint error --- console/console/deploy_command_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index f9e5d1a5a..a9671d724 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -439,9 +439,6 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { } func Test_Handle_Deploy_Success(t *testing.T) { - if runtime.GOOS == "windows" { - // Skip due to shell content assertions; Spinner wraps execution - } mockContext := mocksconsole.NewContext(t) mockConfig := mocksconfig.NewConfig(t) mockArtisan := mocksconsole.NewArtisan(t) From cfacc1d3c1c1985555433fd1d89b787c6cf917e0 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 14:21:13 -0700 Subject: [PATCH 56/71] add tests --- console/console/deploy_command_test.go | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index a9671d724..6ff22cdb7 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -519,3 +519,94 @@ func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { assert.Nil(t, cmd.Handle(mockContext)) } + +func Test_getDeployOptions_Success(t *testing.T) { + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) + + // Config expectations (all present) + mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("22").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_key_path").Return("~/.ssh/id").Once() + mockConfig.EXPECT().GetString("app.build.os").Return("linux").Once() + mockConfig.EXPECT().GetString("app.build.arch").Return("amd64").Once() + mockConfig.EXPECT().GetString("app.deploy.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.prod_env_file_path").Return(".env.production").Once() + mockConfig.EXPECT().GetString("app.deploy.base_dir", "/var/www/").Return("/var/www/").Once() + + mockConfig.EXPECT().GetBool("app.build.static").Return(true).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(true).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + + opts := cmd.getDeployOptions(mockContext) + + assert.Equal(t, "myapp", opts.appName) + assert.Equal(t, "203.0.113.10", opts.sshIp) + assert.Equal(t, "9000", opts.reverseProxyPort) + assert.Equal(t, "22", opts.sshPort) + assert.Equal(t, "ubuntu", opts.sshUser) + // ssh key path should expand '~' to the home directory + home, _ := os.UserHomeDir() + assert.Equal(t, home+"/.ssh/id", opts.sshKeyPath) + assert.Equal(t, "linux", opts.targetOS) + assert.Equal(t, "amd64", opts.arch) + assert.Equal(t, ".env.production", opts.prodEnvFilePath) + assert.Equal(t, "/var/www/", opts.deployBaseDir) + assert.True(t, opts.staticEnv) + assert.True(t, opts.reverseProxyEnabled) + assert.False(t, opts.reverseProxyTLSEnabled) +} + +// helper used by subprocess to trigger os.Exit via getDeployOptions missing validation +func TestHelper_GetDeployOptions_Missing(t *testing.T) { + if os.Getenv("EXPECT_GETDEPLOYOPTIONS_EXIT") != "1" { + return + } + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) + + // Return empty for required strings + mockConfig.EXPECT().GetString("app.name").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.ssh_key_path").Return("").Once() + mockConfig.EXPECT().GetString("app.build.os").Return("").Once() + mockConfig.EXPECT().GetString("app.build.arch").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.domain").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.prod_env_file_path").Return("").Once() + mockConfig.EXPECT().GetString("app.deploy.base_dir", "/var/www/").Return("/var/www/").Once() + + mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() + mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + + // Expect an error message before exit + mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, "Missing required environment variables:") + })).Once() + + // This will call os.Exit(1) + _ = cmd.getDeployOptions(mockContext) + t.Fatalf("expected os.Exit to be called") +} + +func Test_getDeployOptions_Missing_Exits(t *testing.T) { + if runtime.GOOS == "windows" { + // Subprocess exit code detection differs; still okay but keep consistent behavior + } + cmd := exec.Command(os.Args[0], "-test.run", "TestHelper_GetDeployOptions_Missing") + cmd.Env = append(os.Environ(), "EXPECT_GETDEPLOYOPTIONS_EXIT=1") + err := cmd.Run() + if err == nil { + t.Fatalf("expected subprocess to exit with error due to os.Exit(1)") + } +} From aac4ad1daa18cfa74b4ae0b2af0b499291c88e3a Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 14:23:11 -0700 Subject: [PATCH 57/71] update tests --- console/console/deploy_command_test.go | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 6ff22cdb7..0a7b82779 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -257,6 +257,42 @@ func Test_getWhichFilesToUpload_and_onlyFilter(t *testing.T) { assert.False(t, up.hasResources) } +func Test_getUploadOptions_MissingMainOrEnv(t *testing.T) { + // Prepare isolated workspace with no artifacts + wd, err := os.Getwd() + require.NoError(t, err) + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(wd) }) + + // Case 1: No artifacts present + mockContext := mocksconsole.NewContext(t) + mockContext.EXPECT().Option("only").Return("").Once() + up := getUploadOptions(mockContext, "myapp", ".env.production") + assert.False(t, up.hasMain) + assert.False(t, up.hasProdEnv) + assert.False(t, up.hasPublic) + assert.False(t, up.hasStorage) + assert.False(t, up.hasResources) + + // Case 2: Only main present + require.NoError(t, os.WriteFile("myapp", []byte("bin"), 0o755)) + mockContext2 := mocksconsole.NewContext(t) + mockContext2.EXPECT().Option("only").Return("").Once() + up = getUploadOptions(mockContext2, "myapp", ".env.production") + assert.True(t, up.hasMain) + assert.False(t, up.hasProdEnv) + + // Case 3: Only env present + require.NoError(t, os.Remove("myapp")) + require.NoError(t, os.WriteFile(".env.production", []byte("APP_ENV=prod"), 0o644)) + mockContext3 := mocksconsole.NewContext(t) + mockContext3.EXPECT().Option("only").Return("").Once() + up = getUploadOptions(mockContext3, "myapp", ".env.production") + assert.False(t, up.hasMain) + assert.True(t, up.hasProdEnv) +} + func Test_validLocalHost_ErrorAggregation_Unix(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix-only test") From 44ab652387e5ac19626b3bb312bb8849fb9aff56 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 14:25:43 -0700 Subject: [PATCH 58/71] fix lint error --- console/console/deploy_command_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 0a7b82779..7b3b4a443 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -636,9 +636,6 @@ func TestHelper_GetDeployOptions_Missing(t *testing.T) { } func Test_getDeployOptions_Missing_Exits(t *testing.T) { - if runtime.GOOS == "windows" { - // Subprocess exit code detection differs; still okay but keep consistent behavior - } cmd := exec.Command(os.Args[0], "-test.run", "TestHelper_GetDeployOptions_Missing") cmd.Env = append(os.Environ(), "EXPECT_GETDEPLOYOPTIONS_EXIT=1") err := cmd.Run() From e3bdad05b54ebdfb77d8c1b8d5be26f77c5969ee Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 5 Oct 2025 14:31:20 -0700 Subject: [PATCH 59/71] fix failing CI --- console/console/deploy_command_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 7b3b4a443..2db1020b8 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "runtime" "strings" "testing" @@ -588,7 +589,8 @@ func Test_getDeployOptions_Success(t *testing.T) { assert.Equal(t, "ubuntu", opts.sshUser) // ssh key path should expand '~' to the home directory home, _ := os.UserHomeDir() - assert.Equal(t, home+"/.ssh/id", opts.sshKeyPath) + expectedKey := filepath.Join(home, ".ssh", "id") + assert.Equal(t, filepath.Clean(expectedKey), filepath.Clean(opts.sshKeyPath)) assert.Equal(t, "linux", opts.targetOS) assert.Equal(t, "amd64", opts.arch) assert.Equal(t, ".env.production", opts.prodEnvFilePath) From ef23b28f41ab58a44ee4cf0313aa93f9beb2d260 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 09:57:20 -0700 Subject: [PATCH 60/71] sort deployOptions struct alphabetically --- console/console/deploy_command.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 3181aeb00..27a7115e3 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -237,19 +237,19 @@ go run . artisan deploy --only main,env // deployOptions is a struct that contains all the options for the deploy command type deployOptions struct { appName string - sshIp string - reverseProxyPort string - sshPort string - sshUser string - sshKeyPath string - targetOS string arch string + deployBaseDir string domain string prodEnvFilePath string - staticEnv bool reverseProxyEnabled bool + reverseProxyPort string reverseProxyTLSEnabled bool - deployBaseDir string + sshIp string + sshKeyPath string + sshPort string + sshUser string + staticEnv bool + targetOS string } type uploadOptions struct { From f81516d7878e3716096e7a19eb3c8a8192670d67 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 09:58:04 -0700 Subject: [PATCH 61/71] comment update --- console/console/deploy_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 27a7115e3..d2f8aeb48 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -322,7 +322,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { return nil } - // check if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. + // check if the local host is valid, requires scp, ssh, and bash to be installed and in your path. if !validLocalHost(ctx) { return nil } From 87edad1d9961947321bf7c85c65aa29c4cc99282 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 10:12:32 -0700 Subject: [PATCH 62/71] modify error handling in validLocalHost --- console/console/deploy_command.go | 13 ++++--- console/console/deploy_command_test.go | 49 +++++++++++++------------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index d2f8aeb48..7ff7940ae 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -323,7 +323,8 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // check if the local host is valid, requires scp, ssh, and bash to be installed and in your path. - if !validLocalHost(ctx) { + if err := validLocalHost(ctx); err != nil { + ctx.Error(err.Error()) return nil } @@ -517,8 +518,8 @@ func getUploadOptions(ctx console.Context, appName, prodEnvFilePath string) uplo return res } -// validLocalHost checks if the local host is valid, currently only support macos and linux. Also requires scp, ssh, and bash to be installed and in your path. -func validLocalHost(ctx console.Context) bool { +// validLocalHost checks if the local host is valid, requires scp, ssh, and bash to be installed and in your path. +func validLocalHost(ctx console.Context) error { missingBins := []string{} if _, err := exec.LookPath("scp"); err != nil { @@ -539,12 +540,10 @@ func validLocalHost(ctx console.Context) bool { } if len(missingBins) > 0 { - msg := fmt.Sprintf("Environment validation errors:\n - the following binaries were not found on your path: %s\n - Please install them, add them to your path, and try again.", strings.Join(missingBins, ", ")) - ctx.Error(msg) - return false + return fmt.Errorf("environment validation errors:\n - the following binaries were not found on your path: %s\n - Please install them, add them to your path, and try again", strings.Join(missingBins, ", ")) } - return true + return nil } // makeLocalCommand chooses the appropriate local shell to execute the composed script. diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 2db1020b8..e3b60547a 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -304,17 +304,15 @@ func Test_validLocalHost_ErrorAggregation_Unix(t *testing.T) { require.NoError(t, os.Setenv("PATH", "")) mockContext := mocksconsole.NewContext(t) - // Expect a single aggregated error call - mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, "Environment validation errors:") && - strings.Contains(msg, "the following binaries were not found on your path:") && - strings.Contains(msg, "scp") && - strings.Contains(msg, "ssh") && - strings.Contains(msg, "bash") && - strings.Contains(msg, "Please install them, add them to your path, and try again.") - })).Once() - ok := validLocalHost(mockContext) - assert.False(t, ok) + err2 := validLocalHost(mockContext) + require.Error(t, err2) + msg := err2.Error() + assert.Contains(t, msg, "environment validation errors:") + assert.Contains(t, msg, "the following binaries were not found on your path:") + assert.Contains(t, msg, "scp") + assert.Contains(t, msg, "ssh") + assert.Contains(t, msg, "bash") + assert.Contains(t, msg, "Please install them, add them to your path, and try again") } func Test_validLocalHost_SucceedsWithTempTools_Unix(t *testing.T) { @@ -343,8 +341,9 @@ func Test_validLocalHost_SucceedsWithTempTools_Unix(t *testing.T) { require.NoError(t, err) mockContext := mocksconsole.NewContext(t) - ok := validLocalHost(mockContext) - assert.True(t, ok) + if err2 := validLocalHost(mockContext); err2 != nil { + t.Fatalf("expected no error, got %v", err2) + } } // -------------------------- @@ -402,16 +401,15 @@ func Test_validLocalHost_ErrorAggregation_Windows(t *testing.T) { require.NoError(t, os.Setenv("PATH", "")) mockContext := mocksconsole.NewContext(t) - mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, "Environment validation errors:") && - strings.Contains(msg, "the following binaries were not found on your path:") && - strings.Contains(msg, "scp") && - strings.Contains(msg, "ssh") && - strings.Contains(msg, "cmd") && - strings.Contains(msg, "Please install them, add them to your path, and try again.") - })).Once() - ok := validLocalHost(mockContext) - assert.False(t, ok) + err2 := validLocalHost(mockContext) + require.Error(t, err2) + msg := err2.Error() + assert.Contains(t, msg, "environment validation errors:") + assert.Contains(t, msg, "the following binaries were not found on your path:") + assert.Contains(t, msg, "scp") + assert.Contains(t, msg, "ssh") + assert.Contains(t, msg, "cmd") + assert.Contains(t, msg, "Please install them, add them to your path, and try again") } func Test_validLocalHost_SucceedsWithTempTools_Windows(t *testing.T) { @@ -441,8 +439,9 @@ func Test_validLocalHost_SucceedsWithTempTools_Windows(t *testing.T) { require.NoError(t, err) mockContext := mocksconsole.NewContext(t) - ok := validLocalHost(mockContext) - assert.True(t, ok) + if err2 := validLocalHost(mockContext); err2 != nil { + t.Fatalf("expected no error, got %v", err2) + } } func Test_Handle_Rollback_ShortCircuit(t *testing.T) { From 2dfbdf1ef562a7b8a74d7c8aae6c3b7f3a3c5346 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 10:16:28 -0700 Subject: [PATCH 63/71] remove unused param --- console/console/deploy_command.go | 4 ++-- console/console/deploy_command_test.go | 12 ++++-------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 7ff7940ae..06ab2e552 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -323,7 +323,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // check if the local host is valid, requires scp, ssh, and bash to be installed and in your path. - if err := validLocalHost(ctx); err != nil { + if err := validLocalHost(); err != nil { ctx.Error(err.Error()) return nil } @@ -519,7 +519,7 @@ func getUploadOptions(ctx console.Context, appName, prodEnvFilePath string) uplo } // validLocalHost checks if the local host is valid, requires scp, ssh, and bash to be installed and in your path. -func validLocalHost(ctx console.Context) error { +func validLocalHost() error { missingBins := []string{} if _, err := exec.LookPath("scp"); err != nil { diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index e3b60547a..323f259e1 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -303,8 +303,7 @@ func Test_validLocalHost_ErrorAggregation_Unix(t *testing.T) { t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) require.NoError(t, os.Setenv("PATH", "")) - mockContext := mocksconsole.NewContext(t) - err2 := validLocalHost(mockContext) + err2 := validLocalHost() require.Error(t, err2) msg := err2.Error() assert.Contains(t, msg, "environment validation errors:") @@ -340,8 +339,7 @@ func Test_validLocalHost_SucceedsWithTempTools_Unix(t *testing.T) { _, err = exec.LookPath("bash") require.NoError(t, err) - mockContext := mocksconsole.NewContext(t) - if err2 := validLocalHost(mockContext); err2 != nil { + if err2 := validLocalHost(); err2 != nil { t.Fatalf("expected no error, got %v", err2) } } @@ -400,8 +398,7 @@ func Test_validLocalHost_ErrorAggregation_Windows(t *testing.T) { t.Cleanup(func() { _ = os.Setenv("PATH", oldPath) }) require.NoError(t, os.Setenv("PATH", "")) - mockContext := mocksconsole.NewContext(t) - err2 := validLocalHost(mockContext) + err2 := validLocalHost() require.Error(t, err2) msg := err2.Error() assert.Contains(t, msg, "environment validation errors:") @@ -438,8 +435,7 @@ func Test_validLocalHost_SucceedsWithTempTools_Windows(t *testing.T) { _, err = exec.LookPath("cmd") require.NoError(t, err) - mockContext := mocksconsole.NewContext(t) - if err2 := validLocalHost(mockContext); err2 != nil { + if err2 := validLocalHost(); err2 != nil { t.Fatalf("expected no error, got %v", err2) } } From da7fd2240492ded3e3a50fad557ac60725ac4a10 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 10:23:55 -0700 Subject: [PATCH 64/71] modify getDeployOptions error handling to not exit --- console/console/deploy_command.go | 20 +++++++++++++------- console/console/deploy_command_test.go | 24 ++++++++---------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 06ab2e552..9fceaecc1 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -313,7 +313,11 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Rollback check first: allow rollback without validating local host tools // (tests can short-circuit Spinner; real runs will still use ssh remotely) if ctx.OptionBool("rollback") { - opts := r.getDeployOptions(ctx) + opts, err := r.getDeployOptions(ctx) + if err != nil { + ctx.Error(err.Error()) + return nil + } if err := supportconsole.ExecuteCommand(ctx, rollbackCommand(opts), "Rolling back..."); err != nil { ctx.Error(err.Error()) return nil @@ -329,10 +333,13 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // get all options - opts := r.getDeployOptions(ctx) + opts, err := r.getDeployOptions(ctx) + if err != nil { + ctx.Error(err.Error()) + return nil + } // continue normal deploy flow - var err error // Step 1: build the application by invoking the build command via Artisan (no shell exec) buildCmd := fmt.Sprintf("build --os %s --arch %s --name %s", opts.targetOS, opts.arch, opts.appName) @@ -397,7 +404,7 @@ func (r *DeployCommand) Handle(ctx console.Context) error { return nil } -func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { +func (r *DeployCommand) getDeployOptions(ctx console.Context) (deployOptions, error) { opts := deployOptions{} opts.appName = r.config.GetString("app.name") opts.sshIp = r.config.GetString("app.deploy.ssh_ip") @@ -449,8 +456,7 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { missing = append(missing, "DEPLOY_PROD_ENV_FILE_PATH") } if len(missing) > 0 { - ctx.Error(fmt.Sprintf("Missing required environment variables: %s. Please set them in the .env file. Deployment cancelled. Exiting...", strings.Join(missing, ", "))) - os.Exit(1) + return deployOptions{}, fmt.Errorf("missing required environment variables: %s. Please set them in the .env file. Deployment cancelled", strings.Join(missing, ", ")) } // expand ssh key ~ path if needed @@ -460,7 +466,7 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) deployOptions { } } - return opts + return opts, nil } // isEncryptedEnvContent determines whether the provided bytes likely represent an encrypted env diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 323f259e1..7b44ed528 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -575,7 +575,8 @@ func Test_getDeployOptions_Success(t *testing.T) { mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(true).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() - opts := cmd.getDeployOptions(mockContext) + opts, err := cmd.getDeployOptions(mockContext) + require.NoError(t, err) assert.Equal(t, "myapp", opts.appName) assert.Equal(t, "203.0.113.10", opts.sshIp) @@ -622,21 +623,12 @@ func TestHelper_GetDeployOptions_Missing(t *testing.T) { mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() - // Expect an error message before exit - mockContext.EXPECT().Error(mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, "Missing required environment variables:") - })).Once() - - // This will call os.Exit(1) - _ = cmd.getDeployOptions(mockContext) - t.Fatalf("expected os.Exit to be called") + // Now expecting an error to be returned + _, err := cmd.getDeployOptions(mockContext) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required environment variables:") } -func Test_getDeployOptions_Missing_Exits(t *testing.T) { - cmd := exec.Command(os.Args[0], "-test.run", "TestHelper_GetDeployOptions_Missing") - cmd.Env = append(os.Environ(), "EXPECT_GETDEPLOYOPTIONS_EXIT=1") - err := cmd.Run() - if err == nil { - t.Fatalf("expected subprocess to exit with error due to os.Exit(1)") - } +func Test_getDeployOptions_Missing_ReturnsError(t *testing.T) { + TestHelper_GetDeployOptions_Missing(t) } From d2d754f8ed18d48aa1b288dd62c78a56e12795f7 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 10:26:10 -0700 Subject: [PATCH 65/71] add tests for isEncryptedEnvContent function --- console/console/deploy_command_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 7b44ed528..5324b6043 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -1,6 +1,7 @@ package console import ( + "crypto/aes" "encoding/base64" "fmt" "os" @@ -632,3 +633,28 @@ func TestHelper_GetDeployOptions_Missing(t *testing.T) { func Test_getDeployOptions_Missing_ReturnsError(t *testing.T) { TestHelper_GetDeployOptions_Missing(t) } + +func Test_isEncryptedEnvContent(t *testing.T) { + // Not base64-decoded (plain text env) -> false + assert.False(t, isEncryptedEnvContent([]byte("APP_ENV=prod"))) + + // Base64 but decoded length < 2*aes.BlockSize (16) -> false + short := make([]byte, aes.BlockSize) + shortB64 := base64.StdEncoding.EncodeToString(short) + assert.False(t, isEncryptedEnvContent([]byte(shortB64))) + + // Base64 but decoded length not multiple of aes.BlockSize -> false + notMultiple := make([]byte, aes.BlockSize+1) + notMultipleB64 := base64.StdEncoding.EncodeToString(notMultiple) + assert.False(t, isEncryptedEnvContent([]byte(notMultipleB64))) + + // Decoded length == 2*aes.BlockSize and multiple -> true + valid := make([]byte, aes.BlockSize*2) + validB64 := base64.StdEncoding.EncodeToString(valid) + assert.True(t, isEncryptedEnvContent([]byte(validB64))) + + // Decoded length == 3*aes.BlockSize and multiple -> true + valid3 := make([]byte, aes.BlockSize*3) + valid3B64 := base64.StdEncoding.EncodeToString(valid3) + assert.True(t, isEncryptedEnvContent([]byte(valid3B64))) +} From b1a291aa6409be36501e4b41666a9d3404a5f3a6 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 11:03:05 -0700 Subject: [PATCH 66/71] support env decrypt deploys --- console/console/deploy_command.go | 66 +++++++++++++++++++++----- console/console/deploy_command_test.go | 16 +++++-- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 9fceaecc1..d2ebd1878 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -241,9 +241,11 @@ type deployOptions struct { deployBaseDir string domain string prodEnvFilePath string + envDecryptKey string reverseProxyEnabled bool reverseProxyPort string reverseProxyTLSEnabled bool + remoteEnvDecrypt bool sshIp string sshKeyPath string sshPort string @@ -354,18 +356,30 @@ func (r *DeployCommand) Handle(ctx console.Context) error { // Step 2: verify which files to upload (main, env, public, storage, resources) upload := getUploadOptions(ctx, opts.appName, opts.prodEnvFilePath) - // If the production env file is encrypted (per Goravel docs), decrypt it first + // If the production env file is encrypted (per Goravel docs), decrypt it first (locally or remotely) envPathToUpload := opts.prodEnvFilePath + remoteDecrypt := false + remoteEncName := "" if upload.hasProdEnv { // Detect encrypted env by content (base64 + AES block structure with IV) if data, readErr := os.ReadFile(opts.prodEnvFilePath); readErr == nil { if isEncryptedEnvContent(data) { - if err = r.artisan.Call(fmt.Sprintf("env:decrypt --name %q", opts.prodEnvFilePath)); err != nil { - ctx.Error(err.Error()) - return nil + if opts.remoteEnvDecrypt { + remoteDecrypt = true + remoteEncName = filepath.Base(opts.prodEnvFilePath) + envPathToUpload = opts.prodEnvFilePath + } else { + cmd := fmt.Sprintf("env:decrypt --name %q", opts.prodEnvFilePath) + if strings.TrimSpace(opts.envDecryptKey) != "" { + cmd += fmt.Sprintf(" --key %q", opts.envDecryptKey) + } + if err = r.artisan.Call(cmd); err != nil { + ctx.Error(err.Error()) + return nil + } + // env:decrypt writes to .env in the working directory + envPathToUpload = ".env" } - // env:decrypt writes to .env in the working directory - envPathToUpload = ".env" } } } @@ -388,11 +402,29 @@ func (r *DeployCommand) Handle(ctx console.Context) error { } // Step 4: upload files - if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand(opts, upload, envPathToUpload), "Uploading files..."); err != nil { + if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand(opts, upload, envPathToUpload, remoteDecrypt, remoteEncName), "Uploading files..."); err != nil { ctx.Error(err.Error()) return nil } + // Optional: decrypt env remotely after upload + if remoteDecrypt && upload.hasProdEnv { + baseDir := opts.deployBaseDir + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) + decryptCmd := fmt.Sprintf("cd %s && ./main artisan env:decrypt --name %q", appDir, remoteEncName) + if strings.TrimSpace(opts.envDecryptKey) != "" { + decryptCmd += fmt.Sprintf(" --key %q", opts.envDecryptKey) + } + script := fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s '%s'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, decryptCmd) + if err = supportconsole.ExecuteCommand(ctx, makeLocalCommand(script), "Decrypting environment on remote..."); err != nil { + ctx.Error(err.Error()) + return nil + } + } + // Step 5: restart service if err = supportconsole.ExecuteCommand(ctx, restartServiceCommand(opts), "Restarting service..."); err != nil { ctx.Error(err.Error()) @@ -417,10 +449,12 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) (deployOptions, er opts.domain = r.config.GetString("app.deploy.domain") opts.prodEnvFilePath = r.config.GetString("app.deploy.prod_env_file_path") opts.deployBaseDir = r.config.GetString("app.deploy.base_dir", "/var/www/") + opts.envDecryptKey = r.config.GetString("app.deploy.env_decrypt_key") opts.staticEnv = r.config.GetBool("app.build.static") opts.reverseProxyEnabled = r.config.GetBool("app.deploy.reverse_proxy_enabled") opts.reverseProxyTLSEnabled = r.config.GetBool("app.deploy.reverse_proxy_tls_enabled") + opts.remoteEnvDecrypt = r.config.GetBool("app.deploy.remote_env_decrypt") // Validate required options and report all missing at once var missing []string @@ -674,7 +708,7 @@ fi } // uploadFilesCommand uploads available artifacts to remote server -func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload string) *exec.Cmd { +func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload string, remoteDecrypt bool, remoteEncName string) *exec.Cmd { baseDir := opts.deployBaseDir if !strings.HasSuffix(baseDir, "/") { baseDir += "/" @@ -702,10 +736,18 @@ func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload st if up.hasProdEnv { // Upload env to a temp path, then atomically place as .env - cmds = append(cmds, - fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(envPathToUpload), remoteBase), - fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir), - ) + if remoteDecrypt { + // upload encrypted file as provided path name + destName := filepath.Base(envPathToUpload) + cmds = append(cmds, + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/%s", opts.sshKeyPath, opts.sshPort, filepath.Clean(envPathToUpload), remoteBase, destName), + ) + } else { + cmds = append(cmds, + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s %q %s/.env.new", opts.sshKeyPath, opts.sshPort, filepath.Clean(envPathToUpload), remoteBase), + fmt.Sprintf("ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s 'sudo mv %s/.env.new %s/.env'", opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, appDir), + ) + } } if up.hasPublic { cmds = append(cmds, diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index 5324b6043..e9af8f705 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -116,7 +116,7 @@ func Test_setupServerCommand_ProxyTLS(t *testing.T) { } func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { - cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") + cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production", false, "") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -139,7 +139,7 @@ func Test_uploadFilesCommand_AllArtifacts(t *testing.T) { } func Test_uploadFilesCommand_SubsetArtifacts(t *testing.T) { - cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: false, hasPublic: false, hasStorage: true, hasResources: false}, ".env.production") + cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: false, hasPublic: false, hasStorage: true, hasResources: false}, ".env.production", false, "") require.NotNil(t, cmd) if runtime.GOOS == "windows" { t.Skip("Skipping script content assertions on Windows shell") @@ -364,7 +364,7 @@ func Test_uploadFilesCommand_WindowsShellWrapper(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Windows-only test") } - cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production") + cmd := uploadFilesCommand(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.ssh/id", deployBaseDir: "/var/www/"}, uploadOptions{hasMain: true, hasProdEnv: true, hasPublic: true, hasStorage: true, hasResources: true}, ".env.production", false, "") require.NotNil(t, cmd) require.GreaterOrEqual(t, len(cmd.Args), 2) assert.Equal(t, "cmd", cmd.Args[0]) @@ -463,6 +463,8 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.env_decrypt_key").Return("").Once() + mockConfig.EXPECT().GetBool("app.deploy.remote_env_decrypt").Return(false).Once() mockContext.EXPECT().OptionBool("rollback").Return(true).Once() mockContext.EXPECT().Spinner("Rolling back...", mock.Anything).Return(nil).Once() @@ -493,6 +495,8 @@ func Test_Handle_Deploy_Success(t *testing.T) { mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.env_decrypt_key").Return("").Once() + mockConfig.EXPECT().GetBool("app.deploy.remote_env_decrypt").Return(false).Once() // Context expectations mockContext.EXPECT().OptionBool("rollback").Return(false).Once() @@ -540,6 +544,8 @@ func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.env_decrypt_key").Return("").Once() + mockConfig.EXPECT().GetBool("app.deploy.remote_env_decrypt").Return(false).Once() mockContext.EXPECT().OptionBool("rollback").Return(false).Once() // Build fails via artisan @@ -575,6 +581,8 @@ func Test_getDeployOptions_Success(t *testing.T) { mockConfig.EXPECT().GetBool("app.build.static").Return(true).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(true).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.env_decrypt_key").Return("").Once() + mockConfig.EXPECT().GetBool("app.deploy.remote_env_decrypt").Return(false).Once() opts, err := cmd.getDeployOptions(mockContext) require.NoError(t, err) @@ -623,6 +631,8 @@ func TestHelper_GetDeployOptions_Missing(t *testing.T) { mockConfig.EXPECT().GetBool("app.build.static").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_enabled").Return(false).Once() mockConfig.EXPECT().GetBool("app.deploy.reverse_proxy_tls_enabled").Return(false).Once() + mockConfig.EXPECT().GetString("app.deploy.env_decrypt_key").Return("").Once() + mockConfig.EXPECT().GetBool("app.deploy.remote_env_decrypt").Return(false).Once() // Now expecting an error to be returned _, err := cmd.getDeployOptions(mockContext) From bc61a901a4748c9643c02691ef503e0002f78d81 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 11:17:51 -0700 Subject: [PATCH 67/71] change force-setup flag to --force --- console/console/deploy_command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index d2ebd1878..a97b37dac 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -301,7 +301,7 @@ func (r *DeployCommand) Extend() command.Extend { }, &command.BoolFlag{ Name: "force-setup", - Aliases: []string{"f"}, + Aliases: []string{"force"}, Value: false, Usage: "Force re-run server setup even if already configured", DisableDefaultText: true, From ab9fe0d6db2a4dd73fe9cb3842281d2ca0349c8f Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 11:26:22 -0700 Subject: [PATCH 68/71] get APP_ENV from .env file instead of putting it into systemd environment --- console/console/deploy_command.go | 1 - 1 file changed, 1 deletion(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index a97b37dac..56f105d32 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -621,7 +621,6 @@ After=network.target User=%s WorkingDirectory=%s ExecStart=%s -Environment=APP_ENV=production Environment=APP_HOST=%s Environment=APP_PORT=%s Restart=always From da6002ca0cb8277dc158da1b6dcf2903ba921e4a Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 11:34:33 -0700 Subject: [PATCH 69/71] have app prefer the http.port for the reverse proxy setting --- console/console/deploy_command.go | 12 ++++++++++-- console/console/deploy_command_test.go | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 56f105d32..90092a91a 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -240,8 +240,9 @@ type deployOptions struct { arch string deployBaseDir string domain string - prodEnvFilePath string envDecryptKey string + httpPort string + prodEnvFilePath string reverseProxyEnabled bool reverseProxyPort string reverseProxyTLSEnabled bool @@ -440,6 +441,9 @@ func (r *DeployCommand) getDeployOptions(ctx console.Context) (deployOptions, er opts := deployOptions{} opts.appName = r.config.GetString("app.name") opts.sshIp = r.config.GetString("app.deploy.ssh_ip") + // Preferred: use HTTP server port (APP_PORT via http.port) + opts.httpPort = r.config.GetString("http.port") + // Back-compat: allow explicit reverse proxy backend port if provided, else fall back to http.port opts.reverseProxyPort = r.config.GetString("app.deploy.reverse_proxy_port") opts.sshPort = r.config.GetString("app.deploy.ssh_port") opts.sshUser = r.config.GetString("app.deploy.ssh_user") @@ -606,7 +610,11 @@ func setupServerCommand(opts deployOptions) *exec.Cmd { // Build systemd unit based on whether reverse proxy is used listenHost := "127.0.0.1" - appPort := opts.reverseProxyPort + // If reverse proxy is enabled, app should listen on http.port (APP_PORT). If not set, fallback to reverseProxyPort for BC. + appPort := opts.httpPort + if strings.TrimSpace(appPort) == "" { + appPort = opts.reverseProxyPort + } if !opts.reverseProxyEnabled { // App listens on port 80 directly appPort = "80" diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index e9af8f705..afaaf3383 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -451,6 +451,7 @@ func Test_Handle_Rollback_ShortCircuit(t *testing.T) { // Minimal required envs for getDeployOptions (will not be used deeply due to rollback) mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("http.port").Return("").Once() mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("22").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() @@ -483,6 +484,7 @@ func Test_Handle_Deploy_Success(t *testing.T) { mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() // Use fast-fail SSH settings to avoid any network delay mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("127.0.0.1").Once() + mockConfig.EXPECT().GetString("http.port").Return("").Once() mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("0").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() @@ -532,6 +534,7 @@ func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { // Minimal config mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("http.port").Return("").Once() mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("22").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() @@ -568,6 +571,7 @@ func Test_getDeployOptions_Success(t *testing.T) { // Config expectations (all present) mockConfig.EXPECT().GetString("app.name").Return("myapp").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_ip").Return("203.0.113.10").Once() + mockConfig.EXPECT().GetString("http.port").Return("").Once() mockConfig.EXPECT().GetString("app.deploy.reverse_proxy_port").Return("9000").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_port").Return("22").Once() mockConfig.EXPECT().GetString("app.deploy.ssh_user").Return("ubuntu").Once() From fc3d197f829c857f706a1fce1b809b24c4a14fff Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 11:46:54 -0700 Subject: [PATCH 70/71] address force setup comments --- console/console/deploy_command.go | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index 90092a91a..c47470168 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -389,6 +389,12 @@ func (r *DeployCommand) Handle(ctx console.Context) error { forceSetup := ctx.OptionBool("force-setup") setupNeeded := forceSetup || !isServerAlreadySetup(opts) if setupNeeded { + if forceSetup { + if err = supportconsole.ExecuteCommand(ctx, teardownServerCommand(opts), "Removing previous server configuration..."); err != nil { + ctx.Error(err.Error()) + return nil + } + } if err = supportconsole.ExecuteCommand(ctx, setupServerCommand(opts), "Setting up server (first time only)..."); err != nil { ctx.Error(err.Error()) return nil @@ -714,6 +720,35 @@ fi return makeLocalCommand(script) } +// teardownServerCommand removes prior Caddy and systemd service configuration to allow re-provisioning +func teardownServerCommand(opts deployOptions) *exec.Cmd { + baseDir := opts.deployBaseDir + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) + // Remove Caddyfile and disable/stop Caddy if present; remove service unit and disable/stop service + script := fmt.Sprintf(`ssh -o StrictHostKeyChecking=no -i %q -p %s %s@%s ' +set -e +# Remove Caddy config if exists +if [ -f /etc/caddy/Caddyfile ]; then + sudo rm -f /etc/caddy/Caddyfile || true + sudo systemctl reload caddy || sudo systemctl restart caddy || true +fi +# Remove systemd unit if exists +if [ -f /etc/systemd/system/%s.service ]; then + sudo systemctl stop %s || true + sudo systemctl disable %s || true + sudo rm -f /etc/systemd/system/%s.service || true + sudo systemctl daemon-reload +fi +# Ensure app directory exists and permissions are consistent +sudo mkdir -p %s +sudo chown -R %s:%s %s +'`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, opts.appName, opts.appName, opts.appName, opts.appName, appDir, opts.sshUser, opts.sshUser, appDir) + return makeLocalCommand(script) +} + // uploadFilesCommand uploads available artifacts to remote server func uploadFilesCommand(opts deployOptions, up uploadOptions, envPathToUpload string, remoteDecrypt bool, remoteEncName string) *exec.Cmd { baseDir := opts.deployBaseDir From 2b5e664fd3e023f7c0dce55171cbcd906a4c1069 Mon Sep 17 00:00:00 2001 From: cggonzal Date: Sun, 26 Oct 2025 11:50:46 -0700 Subject: [PATCH 71/71] allow caddy to support tls off mode --- console/console/deploy_command.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index c47470168..d1febb0bf 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -650,18 +650,22 @@ WantedBy=multi-user.target caddyfile := "" if opts.reverseProxyEnabled { site := ":80" - if opts.reverseProxyTLSEnabled && strings.TrimSpace(opts.domain) != "" { + if strings.TrimSpace(opts.domain) != "" { site = opts.domain } upstream := fmt.Sprintf("127.0.0.1:%s", appPort) + tlsLine := "" + if !opts.reverseProxyTLSEnabled { + tlsLine = " tls off\n" + } caddyfile = fmt.Sprintf(`%s { reverse_proxy %s { lb_try_duration 30s lb_try_interval 250ms } encode gzip -} -`, site, upstream) +%s} +`, site, upstream, tlsLine) } unitB64 := base64.StdEncoding.EncodeToString([]byte(unit))