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/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") diff --git a/console/console/deploy_command.go b/console/console/deploy_command.go index b30f4db73..d1febb0bf 100644 --- a/console/console/deploy_command.go +++ b/console/console/deploy_command.go @@ -1,23 +1,277 @@ package console import ( + "crypto/aes" + "encoding/base64" "fmt" + "os" "os/exec" - "runtime" + "path/filepath" + "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" + "github.com/goravel/framework/support/file" ) +/* +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) + - 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 (uploaded only during setup) + - 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 creates a timestamped zip archive of the current state under backups/. +Rollback restores from the latest archive and restarts the service. + +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 (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) + - 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): + - 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 +--------- + - --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 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 (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, 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 + +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_SSH_IP=127.0.0.1 +DEPLOY_REVERSE_PROXY_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_SSH_IP=127.0.0.1 +DEPLOY_REVERSE_PROXY_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 +``` +*/ + +// deployOptions is a struct that contains all the options for the deploy command +type deployOptions struct { + appName string + arch string + deployBaseDir string + domain string + envDecryptKey string + httpPort string + prodEnvFilePath string + reverseProxyEnabled bool + reverseProxyPort string + reverseProxyTLSEnabled bool + remoteEnvDecrypt bool + sshIp string + sshKeyPath string + sshPort string + sshUser string + staticEnv bool + targetOS string +} + +type uploadOptions struct { + hasMain bool + hasProdEnv bool + hasPublic bool + hasStorage bool + hasResources bool +} + 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, } } @@ -33,107 +287,153 @@ 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: "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"}, + Value: false, + Usage: "Rollback to previous deployment", + DisableDefaultText: true, + }, + &command.BoolFlag{ + Name: "force-setup", + Aliases: []string{"force"}, + Value: false, + Usage: "Force re-run server setup even if already configured", + DisableDefaultText: true, + }, + }, + } } // 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)) + // 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, err := r.getDeployOptions(ctx) + if err != nil { + ctx.Error(err.Error()) return nil } - } - - 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)) + if err := supportconsole.ExecuteCommand(ctx, rollbackCommand(opts), "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 - } + // check if the local host is valid, requires scp, ssh, and bash to be installed and in your path. + if err := validLocalHost(); 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 - } + // get all options + opts, err := r.getDeployOptions(ctx) + if err != nil { + ctx.Error(err.Error()) + return nil } - 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 - } + // continue normal deploy flow + + // 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 = r.artisan.Call(buildCmd); err != nil { + ctx.Error(err.Error()) + return nil } - 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 + // 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 (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 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" + } + } } } - 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)) + // 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) + 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 } + } else { + ctx.Info("Server already set up. Skipping setup.") } - // 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 - } + // Enforce: storage can only be uploaded during setup stage + if !setupNeeded { + upload.hasStorage = false } - // 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 4: upload files + if err = supportconsole.ExecuteCommand(ctx, uploadFilesCommand(opts, upload, envPathToUpload, remoteDecrypt, remoteEncName), "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 { + // 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()) return nil } @@ -143,8 +443,438 @@ 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...") +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") + // 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") + 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.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 + if opts.appName == "" { + missing = append(missing, "APP_NAME") + } + if opts.sshIp == "" { + missing = append(missing, "DEPLOY_SSH_IP") + } + if opts.reverseProxyPort == "" { + missing = append(missing, "DEPLOY_REVERSE_PROXY_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 opts.reverseProxyEnabled && opts.reverseProxyTLSEnabled && opts.domain == "" { + missing = append(missing, "DEPLOY_DOMAIN") + } + if opts.prodEnvFilePath == "" { + missing = append(missing, "DEPLOY_PROD_ENV_FILE_PATH") + } + if len(missing) > 0 { + 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 + if after, ok := strings.CutPrefix(opts.sshKeyPath, "~"); ok { + if home, herr := os.UserHomeDir(); herr == nil { + opts.sshKeyPath = filepath.Join(home, after) + } + } + + return opts, nil +} + +// 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) + 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")) + if only != "" { + parts := strings.Split(only, ",") + include := map[string]bool{} + for _, p := range parts { + include[strings.TrimSpace(strings.ToLower(p))] = true + } + if !include["main"] { + res.hasMain = false + } + if !include["env"] { + res.hasProdEnv = false + } + if !include["public"] { + res.hasPublic = false + } + if !include["storage"] { + res.hasStorage = false + } + if !include["resources"] { + res.hasResources = false + } + } + return res +} + +// validLocalHost checks if the local host is valid, requires scp, ssh, and bash to be installed and in your path. +func validLocalHost() error { + + missingBins := []string{} + if _, err := exec.LookPath("scp"); err != nil { + missingBins = append(missingBins, "scp") + } + if _, err := exec.LookPath("ssh"); err != nil { + missingBins = append(missingBins, "ssh") + } + // Shell requirements depend on OS + if env.IsWindows() { + if _, err := exec.LookPath("cmd"); err != nil { + missingBins = append(missingBins, "cmd") + } + } else { + if _, err := exec.LookPath("bash"); err != nil { + missingBins = append(missingBins, "bash") + } + } + + if len(missingBins) > 0 { + 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 nil +} + +// 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(opts deployOptions) *exec.Cmd { + // Directories and service + baseDir := opts.deployBaseDir + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + 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 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" + 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_HOST=%s +Environment=APP_PORT=%s +Restart=always +RestartSec=5 +KillSignal=SIGINT +SyslogIdentifier=%s + +[Install] +WantedBy=multi-user.target +`, opts.appName, opts.sshUser, appDir, binCurrent, listenHost, appPort, opts.appName) + + // Build Caddyfile if reverse proxy enabled + caddyfile := "" + if opts.reverseProxyEnabled { + site := ":80" + 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 +%s} +`, site, upstream, tlsLine) + } + + 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 opts.reverseProxyEnabled { + ufwCmds = append(ufwCmds, "sudo ufw allow 80") + if opts.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' +`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, + appDir, appDir, opts.sshUser, opts.sshUser, appDir, + // caddy install and config + func() string { + 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 + }(), + 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...) + return strings.Join(cmds, " && ") + }(), + "true", + ) + + 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 + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + appDir := fmt.Sprintf("%s%s", baseDir, opts.appName) + 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.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.sshIp, appDir) + cmds = append(cmds, backupCmd) + + // main binary + if up.hasMain { + // 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.sshIp, appDir, appDir, appDir), + ) + } + + if up.hasProdEnv { + // Upload env to a temp path, then atomically place as .env + 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, + 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.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.sshIp, appDir, appDir), + fmt.Sprintf("scp -o StrictHostKeyChecking=no -i %q -P %s -r %q %s", opts.sshKeyPath, opts.sshPort, filepath.Clean("resources"), remoteBase), + ) + } + + script := strings.Join(cmds, " && ") + return makeLocalCommand(script) +} + +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.sshIp, opts.appName, opts.appName) + return makeLocalCommand(script) +} + +// rollbackCommand swaps main and main.prev if available, then restarts the service +func rollbackCommand(opts deployOptions) *exec.Cmd { + baseDir := opts.deployBaseDir + if !strings.HasSuffix(baseDir, "/") { + baseDir += "/" + } + 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 +SERVICE=%q +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 + +# 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" + '`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, opts.appName) + return exec.Command("bash", "-lc", script) +} + +// isServerAlreadySetup checks if the systemd unit already exists on remote host +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 + } + return true } diff --git a/console/console/deploy_command_test.go b/console/console/deploy_command_test.go index e0d33a8dc..afaaf3383 100644 --- a/console/console/deploy_command_test.go +++ b/console/console/deploy_command_test.go @@ -1,9 +1,674 @@ package console import ( + "crypto/aes" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "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(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") + } + 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(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") + } + 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(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") + } + 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") + // 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) { + 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") + } + script := cmd.Args[2] + appDir := "/var/www/myapp" + // 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, "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'") + // 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_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", 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(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") + } + 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) { + 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") + } + script := cmd.Args[2] + // 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\"") + assert.True(t, hasExplicit || hasVariable, "expected restart line not found") +} + +func Test_getStringEnv_and_getBoolEnv(t *testing.T) { + mockConfig := mocksconfig.NewConfig(t) + // String as string + mockConfig.EXPECT().EnvString("STR").Return("value").Once() + assert.Equal(t, "value", mockConfig.EnvString("STR")) + // String as non-string type + mockConfig.EXPECT().EnvString("NUM").Return("123").Once() + assert.Equal(t, "123", mockConfig.EnvString("NUM")) + // Missing + mockConfig.EXPECT().EnvString("MISSING").Return("").Once() + assert.Equal(t, "", mockConfig.EnvString("MISSING")) + + // Bool parsing + 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) { + // Prepare temp workspace + wd, err := os.Getwd() + require.NoError(t, err) + + 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)) + 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)) + + // 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") + }) + + 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) + assert.True(t, up.hasStorage) + assert.True(t, up.hasResources) + + // Now test filter: only main and env + 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) + assert.False(t, up.hasStorage) + 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") + } + // 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", "")) + + err2 := validLocalHost() + 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) { + 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) + + if err2 := validLocalHost(); err2 != nil { + t.Fatalf("expected no error, got %v", err2) + } +} + +// -------------------------- +// Windows-specific tests +// -------------------------- + +func Test_setupServerCommand_WindowsShellWrapper(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + 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]) + 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(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]) + 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(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]) + 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(deployOptions{appName: "myapp", sshIp: "203.0.113.10", sshPort: "22", sshUser: "ubuntu", sshKeyPath: "~/.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", "")) + + err2 := validLocalHost() + 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) { + 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) + + if err2 := validLocalHost(); err2 != nil { + t.Fatalf("expected no error, got %v", err2) + } +} + +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) + 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() + 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() + 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() + 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() + mockContext.EXPECT().Info("Rollback successful.").Once() + + assert.Nil(t, cmd.Handle(mockContext)) +} + +func Test_Handle_Deploy_Success(t *testing.T) { + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) + + // Config expectations + 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() + 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() + 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() + mockContext.EXPECT().OptionBool("force-setup").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)) + + // Expect artisan build call + mockArtisan.EXPECT().Call("build --os linux --arch amd64 --name myapp").Return(nil).Once() + + // 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)) +} + +func Test_Handle_Deploy_FailureOnBuild(t *testing.T) { + mockContext := mocksconsole.NewContext(t) + mockConfig := mocksconfig.NewConfig(t) + mockArtisan := mocksconsole.NewArtisan(t) + cmd := NewDeployCommand(mockConfig, mockArtisan) + + // 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() + 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() + 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 + mockArtisan.EXPECT().Call("build --os linux --arch amd64 --name myapp").Return(fmt.Errorf("build error")).Once() + // 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)) +} + +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("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() + 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() + 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) + + 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() + 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) + 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() + 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) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required environment variables:") +} + +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))) } diff --git a/console/service_provider.go b/console/service_provider.go index 959ddb683..0b85e471a 100644 --- a/console/service_provider.go +++ b/console/service_provider.go @@ -53,6 +53,7 @@ func (r *ServiceProvider) registerCommands(app foundation.Application) { console.NewKeyGenerateCommand(configFacade), console.NewMakeCommand(), console.NewBuildCommand(configFacade), + console.NewDeployCommand(configFacade, artisanFacade), console.NewHelpCommand(), }) } 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()