diff --git a/cmd/commands/deploy/deploy.go b/cmd/commands/deploy/deploy.go index 5541963..95949c6 100644 --- a/cmd/commands/deploy/deploy.go +++ b/cmd/commands/deploy/deploy.go @@ -10,10 +10,13 @@ import ( "github.com/Drafteame/draft/internal/pkg/log" ) +const forceFlagUsage = "Bypass .deployignore checks and deploy regardless" + // NewFuncCmd returns a deploy function command for the given environment. func NewFuncCmd(env deployaction.EnvConfig) *cobra.Command { stage := env.Stage() - return &cobra.Command{ + var force bool + cmd := &cobra.Command{ Use: fmt.Sprintf("deploy:func:%s [function-name...]", stage), Short: fmt.Sprintf("Deploy one or more Lambda functions to %s", stage), Long: fmt.Sprintf(`Package and deploy one or more Lambda functions to %s. @@ -24,22 +27,25 @@ All functions must belong to the same service; the service is packaged once. Examples: draft deploy:func:%s gamestats storegamestats draft deploy:func:%s gamestats storegamestats otherlambda - draft deploy:func:%s services/gamestats storegamestats`, stage, stage, stage, stage), + draft deploy:func:%s services/gamestats`, stage, stage, stage, stage), Args: cobra.MinimumNArgs(2), Run: func(c *cobra.Command, args []string) { common.ChDir(c) - if err := deployaction.DeployFunction(env, args[0], args[1:]); err != nil { + if err := deployaction.DeployFunction(env, args[0], args[1:], force); err != nil { log.Exitf(1, "deploy:func:%s failed: %v", stage, err) } }, } + cmd.Flags().BoolVar(&force, "force", false, forceFlagUsage) + return cmd } // NewServiceCmd returns a deploy service command for the given environment. func NewServiceCmd(env deployaction.EnvConfig) *cobra.Command { stage := env.Stage() - return &cobra.Command{ + var force bool + cmd := &cobra.Command{ Use: fmt.Sprintf("deploy:%s [service|path...]", stage), Short: fmt.Sprintf("Deploy one or more services to %s", stage), Long: fmt.Sprintf(`Deploy Serverless services to %s without changing directories. @@ -56,7 +62,7 @@ Examples: Run: func(c *cobra.Command, args []string) { common.ChDir(c) - results := deployaction.DeployService(env, args) + results := deployaction.DeployService(env, args, force) hasError := false if len(results) > 1 { @@ -76,4 +82,6 @@ Examples: } }, } + cmd.Flags().BoolVar(&force, "force", false, forceFlagUsage) + return cmd } diff --git a/internal/actions/deploy/deployignore.go b/internal/actions/deploy/deployignore.go new file mode 100644 index 0000000..be87cc7 --- /dev/null +++ b/internal/actions/deploy/deployignore.go @@ -0,0 +1,54 @@ +package deploy + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/Drafteame/draft/internal/pkg/files" +) + +// shouldSkipForStage decides whether a service should be skipped for the given +// stage based on its .deployignore file. The semantics mirror +// scripts/deploy/check_deploy_ignore.sh in the api repo: +// +// - File missing -> deploy (skip=false) +// - File present but empty/whitespace -> skip every stage +// - File with content -> one stage per line; skip if the +// current stage matches any non-empty trimmed line (case-insensitive). +// +// An empty stage is treated as "unknown" and never matches a listed stage; an +// empty file still skips everything in that case (preserves the legacy blunt +// behaviour for callers that do not yet pass a stage). +func shouldSkipForStage(absPath, stage string) (skip bool, reason string, err error) { + ignorePath := filepath.Join(absPath, ".deployignore") + if !files.Exists(ignorePath) { + return false, "", nil + } + + content, err := files.Read(ignorePath) + if err != nil { + return false, "", fmt.Errorf("failed to read %s: %w", ignorePath, err) + } + + stages := make([]string, 0) + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + stages = append(stages, trimmed) + } + } + + if len(stages) == 0 { + return true, ".deployignore is empty (skipping all stages)", nil + } + + stageLC := strings.ToLower(stage) + for _, s := range stages { + if strings.ToLower(s) == stageLC { + return true, fmt.Sprintf("stage %q listed in .deployignore", stage), nil + } + } + + return false, "", nil +} diff --git a/internal/actions/deploy/function.go b/internal/actions/deploy/function.go index f357c28..fe3f5f1 100644 --- a/internal/actions/deploy/function.go +++ b/internal/actions/deploy/function.go @@ -17,23 +17,30 @@ const deployRegion = "us-east-2" // DeployFunction packages and deploys one or more Lambda functions using the given env config. // serviceArg can be a service name or a path. All functions must belong to the same service. -func DeployFunction(env EnvConfig, serviceArg string, functionNames []string) error { +// If force is true, .deployignore is ignored. +func DeployFunction(env EnvConfig, serviceArg string, functionNames []string, force bool) error { absPath, err := resolveService(serviceArg) if err != nil { return err } - skip, err := validateServiceDir(absPath) - if err != nil { + if err := validateServiceDir(absPath); err != nil { return err } - if skip { - log.Warnf("Skipping %s: .deployignore found", absPath) - return nil - } stage := env.Stage() + if !force { + skip, reason, err := shouldSkipForStage(absPath, stage) + if err != nil { + return err + } + if skip { + log.Warnf("Skipping %s: %s (use --force to override)", absPath, reason) + return nil + } + } + log.Info("Fetching AWS Account ID...") accountID, err := aws.GetAccountID(env.Profile) if err != nil { diff --git a/internal/actions/deploy/service.go b/internal/actions/deploy/service.go index 0c9298f..ab6e0de 100644 --- a/internal/actions/deploy/service.go +++ b/internal/actions/deploy/service.go @@ -18,7 +18,8 @@ type DeployResult struct { } // DeployService deploys one or more services (by name or path) using the given env config. -func DeployService(env EnvConfig, args []string) []DeployResult { +// If force is true, .deployignore is ignored. +func DeployService(env EnvConfig, args []string, force bool) []DeployResult { results := make([]DeployResult, 0, len(args)) log.Info("Fetching AWS Account ID...") @@ -40,21 +41,27 @@ func DeployService(env EnvConfig, args []string) []DeployResult { continue } - err = deployServiceToDir(env, absPath, accountID) + err = deployServiceToDir(env, absPath, accountID, force) results = append(results, DeployResult{Name: arg, Err: err}) } return results } -func deployServiceToDir(env EnvConfig, absPath, accountID string) error { - skip, err := validateServiceDir(absPath) - if err != nil { +func deployServiceToDir(env EnvConfig, absPath, accountID string, force bool) error { + if err := validateServiceDir(absPath); err != nil { return err } - if skip { - log.Warnf("Skipping %s: .deployignore found", absPath) - return nil + + if !force { + skip, reason, err := shouldSkipForStage(absPath, env.Stage()) + if err != nil { + return err + } + if skip { + log.Warnf("Skipping %s: %s (use --force to override)", absPath, reason) + return nil + } } stage := env.Stage() @@ -73,8 +80,7 @@ func deployServiceToDir(env EnvConfig, absPath, accountID string) error { absPath, stage, accountID, slsParams, syncSecretsDry, ) - _, err = exec.Command(script, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)) - if err != nil { + if _, err := exec.Command(script, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)); err != nil { return fmt.Errorf("deploy failed: %w", err) } @@ -82,15 +88,14 @@ func deployServiceToDir(env EnvConfig, absPath, accountID string) error { return nil } -// validateServiceDir checks whether a directory is eligible for deployment. -// Returns (true, nil) if the directory should be skipped (.deployignore present), -// (false, err) if serverless.yml is missing, or (false, nil) if ready to deploy. -func validateServiceDir(absPath string) (skip bool, err error) { - if files.Exists(filepath.Join(absPath, ".deployignore")) { - return true, nil - } +// validateServiceDir asserts that absPath looks like a deployable serverless +// service. It returns nil when serverless.yml is present and a descriptive +// error otherwise. .deployignore handling is done separately by +// shouldSkipForStage so that --force can bypass the skip without bypassing +// this structural check. +func validateServiceDir(absPath string) error { if !files.Exists(filepath.Join(absPath, "serverless.yml")) { - return false, fmt.Errorf("serverless.yml not found in %s", absPath) + return fmt.Errorf("serverless.yml not found in %s", absPath) } - return false, nil + return nil }