From 830aacb9461696cbf179838370d527bb220bb798 Mon Sep 17 00:00:00 2001 From: Ariel Santos Date: Wed, 11 Mar 2026 19:23:31 -0300 Subject: [PATCH 1/5] feat: add deploy:dev/prod/feature and deploy:func commands --- cmd/commands/deploy/dev/dev.go | 51 ++++++++++ cmd/commands/deploy/feature/feature.go | 51 ++++++++++ cmd/commands/deploy/func/dev/dev.go | 33 ++++++ cmd/commands/deploy/func/feature/feature.go | 33 ++++++ cmd/commands/deploy/func/prod/prod.go | 33 ++++++ cmd/commands/deploy/prod/prod.go | 51 ++++++++++ internal/actions/deploy/env.go | 30 ++++++ internal/actions/deploy/function.go | 101 +++++++++++++++++++ internal/actions/deploy/resolve.go | 106 ++++++++++++++++++++ internal/actions/deploy/service.go | 70 +++++++++++++ internal/pkg/aws/account.go | 6 +- internal/pkg/aws/local_config.go | 2 +- main.go | 12 +++ 13 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 cmd/commands/deploy/dev/dev.go create mode 100644 cmd/commands/deploy/feature/feature.go create mode 100644 cmd/commands/deploy/func/dev/dev.go create mode 100644 cmd/commands/deploy/func/feature/feature.go create mode 100644 cmd/commands/deploy/func/prod/prod.go create mode 100644 cmd/commands/deploy/prod/prod.go create mode 100644 internal/actions/deploy/env.go create mode 100644 internal/actions/deploy/function.go create mode 100644 internal/actions/deploy/resolve.go create mode 100644 internal/actions/deploy/service.go diff --git a/cmd/commands/deploy/dev/dev.go b/cmd/commands/deploy/dev/dev.go new file mode 100644 index 0000000..c85f6c6 --- /dev/null +++ b/cmd/commands/deploy/dev/dev.go @@ -0,0 +1,51 @@ +package dev + +import ( + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +var cmd = &cobra.Command{ + Use: "deploy:dev [service|path...]", + Short: "Deploy one or more services to dev", + Long: `Deploy Serverless services to dev without changing directories. + +Accepts service names (from serverless.yml service: field) or paths. +Searches the git repository root to resolve service names. + +Examples: + draft deploy:dev gamestats + draft deploy:dev gamestats notification-engine-v2 usertracking + draft deploy:dev services/gamestats + draft deploy:dev gamestats services/other-service`, + Run: run, + Args: cobra.MinimumNArgs(1), +} + +func GetCmd() *cobra.Command { return cmd } + +func run(c *cobra.Command, args []string) { + common.ChDir(c) + + results := deploy.DeployService(deploy.DevEnv, args) + + if len(results) > 1 { + log.Info("\n─── Deploy Summary ───") + for _, r := range results { + if r.Err != nil { + log.Errorf("✗ %s: %v", r.Name, r.Err) + } else { + log.Successf("✓ %s", r.Name) + } + } + } + + for _, r := range results { + if r.Err != nil { + log.Exitf(1, "one or more deploys failed") + } + } +} diff --git a/cmd/commands/deploy/feature/feature.go b/cmd/commands/deploy/feature/feature.go new file mode 100644 index 0000000..b215d0e --- /dev/null +++ b/cmd/commands/deploy/feature/feature.go @@ -0,0 +1,51 @@ +package feature + +import ( + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +var cmd = &cobra.Command{ + Use: "deploy:feature [service|path...]", + Short: "Deploy one or more services to feature", + Long: `Deploy Serverless services to feature without changing directories. + +Accepts service names (from serverless.yml service: field) or paths. +Searches the git repository root to resolve service names. + +Examples: + draft deploy:feature gamestats + draft deploy:feature gamestats notification-engine-v2 usertracking + draft deploy:feature services/gamestats + draft deploy:feature gamestats services/other-service`, + Run: run, + Args: cobra.MinimumNArgs(1), +} + +func GetCmd() *cobra.Command { return cmd } + +func run(c *cobra.Command, args []string) { + common.ChDir(c) + + results := deploy.DeployService(deploy.FeatureEnv, args) + + if len(results) > 1 { + log.Info("\n─── Deploy Summary ───") + for _, r := range results { + if r.Err != nil { + log.Errorf("✗ %s: %v", r.Name, r.Err) + } else { + log.Successf("✓ %s", r.Name) + } + } + } + + for _, r := range results { + if r.Err != nil { + log.Exitf(1, "one or more deploys failed") + } + } +} diff --git a/cmd/commands/deploy/func/dev/dev.go b/cmd/commands/deploy/func/dev/dev.go new file mode 100644 index 0000000..3cd5f0b --- /dev/null +++ b/cmd/commands/deploy/func/dev/dev.go @@ -0,0 +1,33 @@ +package dev + +import ( + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +var cmd = &cobra.Command{ + Use: "deploy:func:dev ", + Short: "Deploy a single Lambda function to dev", + Long: `Package and deploy a single Lambda function to dev. + +The service argument can be a service name (from serverless.yml) or a path. + +Examples: + draft deploy:func:dev gamestats storegamestats + draft deploy:func:dev services/gamestats storegamestats`, + Run: run, + Args: cobra.ExactArgs(2), +} + +func GetCmd() *cobra.Command { return cmd } + +func run(c *cobra.Command, args []string) { + common.ChDir(c) + + if err := deploy.DeployFunction(deploy.DevEnv, args[0], args[1]); err != nil { + log.Exitf(1, "deploy:func:dev failed: %v", err) + } +} diff --git a/cmd/commands/deploy/func/feature/feature.go b/cmd/commands/deploy/func/feature/feature.go new file mode 100644 index 0000000..c429b1e --- /dev/null +++ b/cmd/commands/deploy/func/feature/feature.go @@ -0,0 +1,33 @@ +package feature + +import ( + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +var cmd = &cobra.Command{ + Use: "deploy:func:feature ", + Short: "Deploy a single Lambda function to feature", + Long: `Package and deploy a single Lambda function to feature. + +The service argument can be a service name (from serverless.yml) or a path. + +Examples: + draft deploy:func:feature gamestats storegamestats + draft deploy:func:feature services/gamestats storegamestats`, + Run: run, + Args: cobra.ExactArgs(2), +} + +func GetCmd() *cobra.Command { return cmd } + +func run(c *cobra.Command, args []string) { + common.ChDir(c) + + if err := deploy.DeployFunction(deploy.FeatureEnv, args[0], args[1]); err != nil { + log.Exitf(1, "deploy:func:feature failed: %v", err) + } +} diff --git a/cmd/commands/deploy/func/prod/prod.go b/cmd/commands/deploy/func/prod/prod.go new file mode 100644 index 0000000..439506f --- /dev/null +++ b/cmd/commands/deploy/func/prod/prod.go @@ -0,0 +1,33 @@ +package prod + +import ( + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +var cmd = &cobra.Command{ + Use: "deploy:func:prod ", + Short: "Deploy a single Lambda function to prod", + Long: `Package and deploy a single Lambda function to prod. + +The service argument can be a service name (from serverless.yml) or a path. + +Examples: + draft deploy:func:prod gamestats storegamestats + draft deploy:func:prod services/gamestats storegamestats`, + Run: run, + Args: cobra.ExactArgs(2), +} + +func GetCmd() *cobra.Command { return cmd } + +func run(c *cobra.Command, args []string) { + common.ChDir(c) + + if err := deploy.DeployFunction(deploy.ProdEnv, args[0], args[1]); err != nil { + log.Exitf(1, "deploy:func:prod failed: %v", err) + } +} diff --git a/cmd/commands/deploy/prod/prod.go b/cmd/commands/deploy/prod/prod.go new file mode 100644 index 0000000..c30f18e --- /dev/null +++ b/cmd/commands/deploy/prod/prod.go @@ -0,0 +1,51 @@ +package prod + +import ( + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +var cmd = &cobra.Command{ + Use: "deploy:prod [service|path...]", + Short: "Deploy one or more services to prod", + Long: `Deploy Serverless services to prod without changing directories. + +Accepts service names (from serverless.yml service: field) or paths. +Searches the git repository root to resolve service names. + +Examples: + draft deploy:prod gamestats + draft deploy:prod gamestats notification-engine-v2 usertracking + draft deploy:prod services/gamestats + draft deploy:prod gamestats services/other-service`, + Run: run, + Args: cobra.MinimumNArgs(1), +} + +func GetCmd() *cobra.Command { return cmd } + +func run(c *cobra.Command, args []string) { + common.ChDir(c) + + results := deploy.DeployService(deploy.ProdEnv, args) + + if len(results) > 1 { + log.Info("\n─── Deploy Summary ───") + for _, r := range results { + if r.Err != nil { + log.Errorf("✗ %s: %v", r.Name, r.Err) + } else { + log.Successf("✓ %s", r.Name) + } + } + } + + for _, r := range results { + if r.Err != nil { + log.Exitf(1, "one or more deploys failed") + } + } +} diff --git a/internal/actions/deploy/env.go b/internal/actions/deploy/env.go new file mode 100644 index 0000000..5da6277 --- /dev/null +++ b/internal/actions/deploy/env.go @@ -0,0 +1,30 @@ +package deploy + +// EnvConfig holds the deployment configuration for a target environment. +type EnvConfig struct { + Stage string + AWSAccount string + AWSProfile string + ExtraSLSParams string // non-empty only for feature: --params="stage=feature" +} + +var ( + DevEnv = EnvConfig{ + Stage: "dev", + AWSAccount: "776658659836", + AWSProfile: "draftea-dev", + } + + ProdEnv = EnvConfig{ + Stage: "prod", + AWSAccount: "632258128187", + AWSProfile: "draftea-prod", + } + + FeatureEnv = EnvConfig{ + Stage: "feature", + AWSAccount: "636385746594", + AWSProfile: "draftea-feature", + ExtraSLSParams: `--params="stage=feature"`, + } +) diff --git a/internal/actions/deploy/function.go b/internal/actions/deploy/function.go new file mode 100644 index 0000000..b7f1455 --- /dev/null +++ b/internal/actions/deploy/function.go @@ -0,0 +1,101 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Drafteame/draft/internal/pkg/aws" + "github.com/Drafteame/draft/internal/pkg/exec" + "github.com/Drafteame/draft/internal/pkg/files" + "github.com/Drafteame/draft/internal/pkg/log" +) + +const deployRegion = "us-east-2" + +// DeployFunction packages and deploys a single Lambda function using the given env config. +// serviceArg can be a service name or a path. +func DeployFunction(env EnvConfig, serviceArg, functionName string) error { + absPath, err := resolveService(serviceArg) + if err != nil { + return err + } + + if fileExists(filepath.Join(absPath, ".deployignore")) { + log.Warnf("Skipping %s: .deployignore found", absPath) + return nil + } + + slsFile := filepath.Join(absPath, "serverless.yml") + if !fileExists(slsFile) { + return fmt.Errorf("serverless.yml not found in %s", absPath) + } + + log.Info("Fetching AWS Account ID...") + accountID, err := aws.GetAccountID(env.AWSProfile) + if err != nil { + return fmt.Errorf("failed to get AWS account ID: %w", err) + } + + if fileExists(filepath.Join(absPath, "package.json")) { + log.Info("Installing dependencies...") + installScript := fmt.Sprintf("cd %q && npm install", absPath) + if _, err := exec.Command(installScript, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)); err != nil { + return fmt.Errorf("npm install failed: %w", err) + } + } + + log.Info("Packaging service...") + packageScript := fmt.Sprintf( + `cd %q && env STAGE=%s AWS_ACCOUNT=%s sls package --stage %s --verbose --aws-profile %s`, + absPath, env.Stage, accountID, env.Stage, env.AWSProfile, + ) + if _, err := exec.Command(packageScript, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)); err != nil { + return fmt.Errorf("sls package failed: %w", err) + } + + serviceName, err := parseServiceName(slsFile) + if err != nil { + return err + } + + fullLambdaName := fmt.Sprintf("%s-%s-%s", serviceName, env.Stage, functionName) + log.Infof("Lambda function: %s", fullLambdaName) + + zipFile := filepath.Join(absPath, ".bin", functionName+".zip") + if !fileExists(zipFile) { + return fmt.Errorf(".bin/%s.zip not found — check that the function name matches serverless.yml", functionName) + } + + log.Info("Updating Lambda code...") + updateScript := fmt.Sprintf( + `aws lambda update-function-code --function-name %q --zip-file "fileb://%s" --profile %s --region %s --output json`, + fullLambdaName, zipFile, env.AWSProfile, deployRegion, + ) + out, err := exec.Command(updateScript) + if err != nil { + return fmt.Errorf("lambda update failed: %w\n%s", err, out) + } + + log.Success("✓ Lambda deployed:", fullLambdaName) + return nil +} + +func parseServiceName(slsFile string) (string, error) { + content, err := files.Read(slsFile) + if err != nil { + return "", fmt.Errorf("failed to read serverless.yml: %w", err) + } + + for _, line := range strings.Split(string(content), "\n") { + if strings.HasPrefix(line, "service:") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]), nil + } + } + } + + return "", fmt.Errorf("could not find 'service:' field in serverless.yml") +} diff --git a/internal/actions/deploy/resolve.go b/internal/actions/deploy/resolve.go new file mode 100644 index 0000000..61ebeeb --- /dev/null +++ b/internal/actions/deploy/resolve.go @@ -0,0 +1,106 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Drafteame/draft/internal/pkg/exec" +) + +// resolveService resolves a service name or path to an absolute directory path. +// If arg is an existing directory, it returns the absolute path directly. +// Otherwise it searches the git root for a serverless.yml with service: . +func resolveService(arg string) (string, error) { + // Try as path first + absPath, err := toAbsPath(arg) + if err == nil && isDir(absPath) { + return absPath, nil + } + + // Treat as service name: find git root and search + gitRoot, err := getGitRoot() + if err != nil { + return "", fmt.Errorf("failed to find git root: %w", err) + } + + matches, err := findServiceByName(gitRoot, arg) + if err != nil { + return "", err + } + + switch len(matches) { + case 0: + return "", fmt.Errorf("service %q not found (no serverless.yml with service: %s)", arg, arg) + case 1: + return matches[0], nil + default: + return "", fmt.Errorf("service %q is ambiguous, found in:\n %s", arg, strings.Join(matches, "\n ")) + } +} + +// getGitRoot returns the absolute path of the git repository root. +func getGitRoot() (string, error) { + out, err := exec.Command("git rev-parse --show-toplevel") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// findServiceByName walks root looking for serverless.yml files where service: == name. +// Returns a list of absolute directory paths that match. +func findServiceByName(root, name string) ([]string, error) { + var matches []string + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries + } + + // Skip directories that won't contain serverless.yml + if d.IsDir() { + base := d.Name() + if base == ".git" || base == "node_modules" || base == ".serverless" || base == ".bin" { + return filepath.SkipDir + } + return nil + } + + if d.Name() != "serverless.yml" { + return nil + } + + serviceName, err := parseServiceName(path) + if err != nil { + return nil // skip unparseable files + } + + if serviceName == name { + matches = append(matches, filepath.Dir(path)) + } + + return nil + }) + + return matches, err +} + +// toAbsPath converts a relative or absolute path to absolute using CWD. +func toAbsPath(p string) (string, error) { + if filepath.IsAbs(p) { + return p, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(cwd, p), nil +} + +// isDir returns true if path exists and is a directory. +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/internal/actions/deploy/service.go b/internal/actions/deploy/service.go new file mode 100644 index 0000000..365ee9b --- /dev/null +++ b/internal/actions/deploy/service.go @@ -0,0 +1,70 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Drafteame/draft/internal/pkg/exec" + "github.com/Drafteame/draft/internal/pkg/log" +) + +// DeployResult holds the outcome of deploying a single service. +type DeployResult struct { + Name string // original arg (name or path as passed by user) + Err error +} + +// DeployService deploys one or more services (by name or path) using the given env config. +func DeployService(env EnvConfig, args []string) []DeployResult { + results := make([]DeployResult, 0, len(args)) + + for _, arg := range args { + log.Infof("\n─── Deploying: %s ───", arg) + + absPath, err := resolveService(arg) + if err != nil { + results = append(results, DeployResult{Name: arg, Err: err}) + continue + } + + err = deployServiceToDir(env, absPath) + results = append(results, DeployResult{Name: arg, Err: err}) + } + + return results +} + +func deployServiceToDir(env EnvConfig, absPath string) error { + if fileExists(filepath.Join(absPath, ".deployignore")) { + log.Warnf("Skipping %s: .deployignore found", absPath) + return nil + } + + if !fileExists(filepath.Join(absPath, "serverless.yml")) { + return fmt.Errorf("serverless.yml not found in %s", absPath) + } + + slsParams := fmt.Sprintf("--aws-profile=%s", env.AWSProfile) + if env.ExtraSLSParams != "" { + slsParams = fmt.Sprintf("%s %s", slsParams, env.ExtraSLSParams) + } + + script := fmt.Sprintf( + `cd %q && npm install && env STAGE=%s AWS_ACCOUNT=%s SLS_PARAMS=%q npm run deploy`, + absPath, env.Stage, env.AWSAccount, slsParams, + ) + + _, err := exec.Command(script, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)) + if err != nil { + return fmt.Errorf("deploy failed: %w", err) + } + + log.Successf("✓ Deployed: %s", absPath) + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/pkg/aws/account.go b/internal/pkg/aws/account.go index 14329f2..4d4b045 100644 --- a/internal/pkg/aws/account.go +++ b/internal/pkg/aws/account.go @@ -1,11 +1,13 @@ package aws //nolint:typecheck import ( + "fmt" + "github.com/Drafteame/draft/internal/pkg/exec" ) -func GetAccountID() (string, error) { - cmd := "aws sts get-caller-identity --query Account --output text --profile draftea-dev" +func GetAccountID(profile string) (string, error) { + cmd := fmt.Sprintf("aws sts get-caller-identity --query Account --output text --profile %s", profile) output, err := exec.Command(cmd) if err != nil { diff --git a/internal/pkg/aws/local_config.go b/internal/pkg/aws/local_config.go index f58b2e1..9b883be 100644 --- a/internal/pkg/aws/local_config.go +++ b/internal/pkg/aws/local_config.go @@ -13,7 +13,7 @@ func GetLocalCredentials() (map[string]string, error) { return nil, err } - accountID, err := GetAccountID() + accountID, err := GetAccountID("draftea-dev") if err != nil { return nil, err } diff --git a/main.go b/main.go index 7e0504e..72879a3 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,12 @@ import ( "github.com/Drafteame/draft/cmd/commands" "github.com/Drafteame/draft/cmd/commands/config" + deploydev "github.com/Drafteame/draft/cmd/commands/deploy/dev" + deployfeature "github.com/Drafteame/draft/cmd/commands/deploy/feature" + deployfuncdev "github.com/Drafteame/draft/cmd/commands/deploy/func/dev" + deployfuncfeature "github.com/Drafteame/draft/cmd/commands/deploy/func/feature" + deployfuncprod "github.com/Drafteame/draft/cmd/commands/deploy/func/prod" + deployprod "github.com/Drafteame/draft/cmd/commands/deploy/prod" "github.com/Drafteame/draft/cmd/commands/local/invoke" migratedown "github.com/Drafteame/draft/cmd/commands/local/migrate/down" migrateforce "github.com/Drafteame/draft/cmd/commands/local/migrate/force" @@ -41,6 +47,12 @@ func main() { cmd.AddCommand(migratedown.GetCmd()) cmd.AddCommand(testsetup.GetCmd()) cmd.AddCommand(mockery.GetCmd()) + cmd.AddCommand(deploydev.GetCmd()) + cmd.AddCommand(deployprod.GetCmd()) + cmd.AddCommand(deployfeature.GetCmd()) + cmd.AddCommand(deployfuncdev.GetCmd()) + cmd.AddCommand(deployfuncprod.GetCmd()) + cmd.AddCommand(deployfuncfeature.GetCmd()) if err := cmd.ExecuteContext(ctx); err != nil { log.Exit(1, err.Error()) From 167de416e3ee060f568d9735b2f8e83fb3673512 Mon Sep 17 00:00:00 2001 From: Ariel Santos Date: Thu, 12 Mar 2026 12:40:21 -0300 Subject: [PATCH 2/5] fix: params --- internal/actions/deploy/env.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/actions/deploy/env.go b/internal/actions/deploy/env.go index 5da6277..37348b2 100644 --- a/internal/actions/deploy/env.go +++ b/internal/actions/deploy/env.go @@ -25,6 +25,6 @@ var ( Stage: "feature", AWSAccount: "636385746594", AWSProfile: "draftea-feature", - ExtraSLSParams: `--params="stage=feature"`, + ExtraSLSParams: `--param="stage=feature"`, } ) From 5fca7acc4cf60bbec3548fb434d9d05c8e2a34e5 Mon Sep 17 00:00:00 2001 From: Ariel Santos Date: Thu, 12 Mar 2026 15:05:52 -0300 Subject: [PATCH 3/5] refactor: extract shared command factory to eliminate duplication --- cmd/commands/deploy/deploy.go | 75 +++++++++++++++++++++ cmd/commands/deploy/dev/dev.go | 47 ++----------- cmd/commands/deploy/feature/feature.go | 47 ++----------- cmd/commands/deploy/func/dev/dev.go | 29 ++------ cmd/commands/deploy/func/feature/feature.go | 29 ++------ cmd/commands/deploy/func/prod/prod.go | 29 ++------ cmd/commands/deploy/prod/prod.go | 47 ++----------- 7 files changed, 99 insertions(+), 204 deletions(-) create mode 100644 cmd/commands/deploy/deploy.go diff --git a/cmd/commands/deploy/deploy.go b/cmd/commands/deploy/deploy.go new file mode 100644 index 0000000..bab23e7 --- /dev/null +++ b/cmd/commands/deploy/deploy.go @@ -0,0 +1,75 @@ +package deploy + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Drafteame/draft/cmd/commands/internal/common" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" + "github.com/Drafteame/draft/internal/pkg/log" +) + +// NewFuncCmd returns a deploy function command for the given environment. +func NewFuncCmd(env deployaction.EnvConfig) *cobra.Command { + return &cobra.Command{ + Use: fmt.Sprintf("deploy:func:%s ", env.Stage), + Short: fmt.Sprintf("Deploy a single Lambda function to %s", env.Stage), + Long: fmt.Sprintf(`Package and deploy a single Lambda function to %s. + +The service argument can be a service name (from serverless.yml) or a path. + +Examples: + draft deploy:func:%s gamestats storegamestats + draft deploy:func:%s services/gamestats storegamestats`, env.Stage, env.Stage, env.Stage), + Args: cobra.ExactArgs(2), + Run: func(c *cobra.Command, args []string) { + common.ChDir(c) + + if err := deployaction.DeployFunction(env, args[0], args[1]); err != nil { + log.Exitf(1, "deploy:func:%s failed: %v", env.Stage, err) + } + }, + } +} + +// NewServiceCmd returns a deploy service command for the given environment. +func NewServiceCmd(env deployaction.EnvConfig) *cobra.Command { + return &cobra.Command{ + Use: fmt.Sprintf("deploy:%s [service|path...]", env.Stage), + Short: fmt.Sprintf("Deploy one or more services to %s", env.Stage), + Long: fmt.Sprintf(`Deploy Serverless services to %s without changing directories. + +Accepts service names (from serverless.yml service: field) or paths. +Searches the git repository root to resolve service names. + +Examples: + draft deploy:%s gamestats + draft deploy:%s gamestats notification-engine-v2 usertracking + draft deploy:%s services/gamestats + draft deploy:%s gamestats services/other-service`, env.Stage, env.Stage, env.Stage, env.Stage, env.Stage), + Args: cobra.MinimumNArgs(1), + Run: func(c *cobra.Command, args []string) { + common.ChDir(c) + + results := deployaction.DeployService(env, args) + + if len(results) > 1 { + log.Info("\n─── Deploy Summary ───") + for _, r := range results { + if r.Err != nil { + log.Errorf("✗ %s: %v", r.Name, r.Err) + } else { + log.Successf("✓ %s", r.Name) + } + } + } + + for _, r := range results { + if r.Err != nil { + log.Exitf(1, "one or more deploys failed") + } + } + }, + } +} diff --git a/cmd/commands/deploy/dev/dev.go b/cmd/commands/deploy/dev/dev.go index c85f6c6..a5d6ea2 100644 --- a/cmd/commands/deploy/dev/dev.go +++ b/cmd/commands/deploy/dev/dev.go @@ -3,49 +3,10 @@ package dev import ( "github.com/spf13/cobra" - "github.com/Drafteame/draft/cmd/commands/internal/common" - "github.com/Drafteame/draft/internal/actions/deploy" - "github.com/Drafteame/draft/internal/pkg/log" + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" ) -var cmd = &cobra.Command{ - Use: "deploy:dev [service|path...]", - Short: "Deploy one or more services to dev", - Long: `Deploy Serverless services to dev without changing directories. - -Accepts service names (from serverless.yml service: field) or paths. -Searches the git repository root to resolve service names. - -Examples: - draft deploy:dev gamestats - draft deploy:dev gamestats notification-engine-v2 usertracking - draft deploy:dev services/gamestats - draft deploy:dev gamestats services/other-service`, - Run: run, - Args: cobra.MinimumNArgs(1), -} - -func GetCmd() *cobra.Command { return cmd } - -func run(c *cobra.Command, args []string) { - common.ChDir(c) - - results := deploy.DeployService(deploy.DevEnv, args) - - if len(results) > 1 { - log.Info("\n─── Deploy Summary ───") - for _, r := range results { - if r.Err != nil { - log.Errorf("✗ %s: %v", r.Name, r.Err) - } else { - log.Successf("✓ %s", r.Name) - } - } - } - - for _, r := range results { - if r.Err != nil { - log.Exitf(1, "one or more deploys failed") - } - } +func GetCmd() *cobra.Command { + return deploycmd.NewServiceCmd(deployaction.DevEnv) } diff --git a/cmd/commands/deploy/feature/feature.go b/cmd/commands/deploy/feature/feature.go index b215d0e..e0cd361 100644 --- a/cmd/commands/deploy/feature/feature.go +++ b/cmd/commands/deploy/feature/feature.go @@ -3,49 +3,10 @@ package feature import ( "github.com/spf13/cobra" - "github.com/Drafteame/draft/cmd/commands/internal/common" - "github.com/Drafteame/draft/internal/actions/deploy" - "github.com/Drafteame/draft/internal/pkg/log" + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" ) -var cmd = &cobra.Command{ - Use: "deploy:feature [service|path...]", - Short: "Deploy one or more services to feature", - Long: `Deploy Serverless services to feature without changing directories. - -Accepts service names (from serverless.yml service: field) or paths. -Searches the git repository root to resolve service names. - -Examples: - draft deploy:feature gamestats - draft deploy:feature gamestats notification-engine-v2 usertracking - draft deploy:feature services/gamestats - draft deploy:feature gamestats services/other-service`, - Run: run, - Args: cobra.MinimumNArgs(1), -} - -func GetCmd() *cobra.Command { return cmd } - -func run(c *cobra.Command, args []string) { - common.ChDir(c) - - results := deploy.DeployService(deploy.FeatureEnv, args) - - if len(results) > 1 { - log.Info("\n─── Deploy Summary ───") - for _, r := range results { - if r.Err != nil { - log.Errorf("✗ %s: %v", r.Name, r.Err) - } else { - log.Successf("✓ %s", r.Name) - } - } - } - - for _, r := range results { - if r.Err != nil { - log.Exitf(1, "one or more deploys failed") - } - } +func GetCmd() *cobra.Command { + return deploycmd.NewServiceCmd(deployaction.FeatureEnv) } diff --git a/cmd/commands/deploy/func/dev/dev.go b/cmd/commands/deploy/func/dev/dev.go index 3cd5f0b..bacf904 100644 --- a/cmd/commands/deploy/func/dev/dev.go +++ b/cmd/commands/deploy/func/dev/dev.go @@ -3,31 +3,10 @@ package dev import ( "github.com/spf13/cobra" - "github.com/Drafteame/draft/cmd/commands/internal/common" - "github.com/Drafteame/draft/internal/actions/deploy" - "github.com/Drafteame/draft/internal/pkg/log" + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" ) -var cmd = &cobra.Command{ - Use: "deploy:func:dev ", - Short: "Deploy a single Lambda function to dev", - Long: `Package and deploy a single Lambda function to dev. - -The service argument can be a service name (from serverless.yml) or a path. - -Examples: - draft deploy:func:dev gamestats storegamestats - draft deploy:func:dev services/gamestats storegamestats`, - Run: run, - Args: cobra.ExactArgs(2), -} - -func GetCmd() *cobra.Command { return cmd } - -func run(c *cobra.Command, args []string) { - common.ChDir(c) - - if err := deploy.DeployFunction(deploy.DevEnv, args[0], args[1]); err != nil { - log.Exitf(1, "deploy:func:dev failed: %v", err) - } +func GetCmd() *cobra.Command { + return deploycmd.NewFuncCmd(deployaction.DevEnv) } diff --git a/cmd/commands/deploy/func/feature/feature.go b/cmd/commands/deploy/func/feature/feature.go index c429b1e..1d1b6d3 100644 --- a/cmd/commands/deploy/func/feature/feature.go +++ b/cmd/commands/deploy/func/feature/feature.go @@ -3,31 +3,10 @@ package feature import ( "github.com/spf13/cobra" - "github.com/Drafteame/draft/cmd/commands/internal/common" - "github.com/Drafteame/draft/internal/actions/deploy" - "github.com/Drafteame/draft/internal/pkg/log" + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" ) -var cmd = &cobra.Command{ - Use: "deploy:func:feature ", - Short: "Deploy a single Lambda function to feature", - Long: `Package and deploy a single Lambda function to feature. - -The service argument can be a service name (from serverless.yml) or a path. - -Examples: - draft deploy:func:feature gamestats storegamestats - draft deploy:func:feature services/gamestats storegamestats`, - Run: run, - Args: cobra.ExactArgs(2), -} - -func GetCmd() *cobra.Command { return cmd } - -func run(c *cobra.Command, args []string) { - common.ChDir(c) - - if err := deploy.DeployFunction(deploy.FeatureEnv, args[0], args[1]); err != nil { - log.Exitf(1, "deploy:func:feature failed: %v", err) - } +func GetCmd() *cobra.Command { + return deploycmd.NewFuncCmd(deployaction.FeatureEnv) } diff --git a/cmd/commands/deploy/func/prod/prod.go b/cmd/commands/deploy/func/prod/prod.go index 439506f..5d7db83 100644 --- a/cmd/commands/deploy/func/prod/prod.go +++ b/cmd/commands/deploy/func/prod/prod.go @@ -3,31 +3,10 @@ package prod import ( "github.com/spf13/cobra" - "github.com/Drafteame/draft/cmd/commands/internal/common" - "github.com/Drafteame/draft/internal/actions/deploy" - "github.com/Drafteame/draft/internal/pkg/log" + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" ) -var cmd = &cobra.Command{ - Use: "deploy:func:prod ", - Short: "Deploy a single Lambda function to prod", - Long: `Package and deploy a single Lambda function to prod. - -The service argument can be a service name (from serverless.yml) or a path. - -Examples: - draft deploy:func:prod gamestats storegamestats - draft deploy:func:prod services/gamestats storegamestats`, - Run: run, - Args: cobra.ExactArgs(2), -} - -func GetCmd() *cobra.Command { return cmd } - -func run(c *cobra.Command, args []string) { - common.ChDir(c) - - if err := deploy.DeployFunction(deploy.ProdEnv, args[0], args[1]); err != nil { - log.Exitf(1, "deploy:func:prod failed: %v", err) - } +func GetCmd() *cobra.Command { + return deploycmd.NewFuncCmd(deployaction.ProdEnv) } diff --git a/cmd/commands/deploy/prod/prod.go b/cmd/commands/deploy/prod/prod.go index c30f18e..db5d873 100644 --- a/cmd/commands/deploy/prod/prod.go +++ b/cmd/commands/deploy/prod/prod.go @@ -3,49 +3,10 @@ package prod import ( "github.com/spf13/cobra" - "github.com/Drafteame/draft/cmd/commands/internal/common" - "github.com/Drafteame/draft/internal/actions/deploy" - "github.com/Drafteame/draft/internal/pkg/log" + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" ) -var cmd = &cobra.Command{ - Use: "deploy:prod [service|path...]", - Short: "Deploy one or more services to prod", - Long: `Deploy Serverless services to prod without changing directories. - -Accepts service names (from serverless.yml service: field) or paths. -Searches the git repository root to resolve service names. - -Examples: - draft deploy:prod gamestats - draft deploy:prod gamestats notification-engine-v2 usertracking - draft deploy:prod services/gamestats - draft deploy:prod gamestats services/other-service`, - Run: run, - Args: cobra.MinimumNArgs(1), -} - -func GetCmd() *cobra.Command { return cmd } - -func run(c *cobra.Command, args []string) { - common.ChDir(c) - - results := deploy.DeployService(deploy.ProdEnv, args) - - if len(results) > 1 { - log.Info("\n─── Deploy Summary ───") - for _, r := range results { - if r.Err != nil { - log.Errorf("✗ %s: %v", r.Name, r.Err) - } else { - log.Successf("✓ %s", r.Name) - } - } - } - - for _, r := range results { - if r.Err != nil { - log.Exitf(1, "one or more deploys failed") - } - } +func GetCmd() *cobra.Command { + return deploycmd.NewServiceCmd(deployaction.ProdEnv) } From 4ee8ac5a919caa566f6a9612befdb00c4d01f8ea Mon Sep 17 00:00:00 2001 From: Ariel Santos Date: Thu, 12 Mar 2026 15:45:26 -0300 Subject: [PATCH 4/5] refactor: simplify EnvConfig to derive stage from profile name --- cmd/commands/deploy/deploy.go | 16 +++++++++------- internal/actions/deploy/env.go | 25 ++++++++++++------------- internal/actions/deploy/function.go | 10 +++++----- internal/actions/deploy/service.go | 13 ++++++++++--- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/cmd/commands/deploy/deploy.go b/cmd/commands/deploy/deploy.go index bab23e7..78ffd38 100644 --- a/cmd/commands/deploy/deploy.go +++ b/cmd/commands/deploy/deploy.go @@ -12,22 +12,23 @@ import ( // NewFuncCmd returns a deploy function command for the given environment. func NewFuncCmd(env deployaction.EnvConfig) *cobra.Command { + stage := env.Stage() return &cobra.Command{ - Use: fmt.Sprintf("deploy:func:%s ", env.Stage), - Short: fmt.Sprintf("Deploy a single Lambda function to %s", env.Stage), + Use: fmt.Sprintf("deploy:func:%s ", stage), + Short: fmt.Sprintf("Deploy a single Lambda function to %s", stage), Long: fmt.Sprintf(`Package and deploy a single Lambda function to %s. The service argument can be a service name (from serverless.yml) or a path. Examples: draft deploy:func:%s gamestats storegamestats - draft deploy:func:%s services/gamestats storegamestats`, env.Stage, env.Stage, env.Stage), + draft deploy:func:%s services/gamestats storegamestats`, stage, stage, stage), Args: cobra.ExactArgs(2), Run: func(c *cobra.Command, args []string) { common.ChDir(c) if err := deployaction.DeployFunction(env, args[0], args[1]); err != nil { - log.Exitf(1, "deploy:func:%s failed: %v", env.Stage, err) + log.Exitf(1, "deploy:func:%s failed: %v", stage, err) } }, } @@ -35,9 +36,10 @@ Examples: // NewServiceCmd returns a deploy service command for the given environment. func NewServiceCmd(env deployaction.EnvConfig) *cobra.Command { + stage := env.Stage() return &cobra.Command{ - Use: fmt.Sprintf("deploy:%s [service|path...]", env.Stage), - Short: fmt.Sprintf("Deploy one or more services to %s", env.Stage), + 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. Accepts service names (from serverless.yml service: field) or paths. @@ -47,7 +49,7 @@ Examples: draft deploy:%s gamestats draft deploy:%s gamestats notification-engine-v2 usertracking draft deploy:%s services/gamestats - draft deploy:%s gamestats services/other-service`, env.Stage, env.Stage, env.Stage, env.Stage, env.Stage), + draft deploy:%s gamestats services/other-service`, stage, stage, stage, stage, stage), Args: cobra.MinimumNArgs(1), Run: func(c *cobra.Command, args []string) { common.ChDir(c) diff --git a/internal/actions/deploy/env.go b/internal/actions/deploy/env.go index 37348b2..60bf8e7 100644 --- a/internal/actions/deploy/env.go +++ b/internal/actions/deploy/env.go @@ -1,30 +1,29 @@ package deploy +import "strings" + // EnvConfig holds the deployment configuration for a target environment. type EnvConfig struct { - Stage string - AWSAccount string - AWSProfile string - ExtraSLSParams string // non-empty only for feature: --params="stage=feature" + Profile string // e.g. "draftea-dev", "draftea-prod", "draftea-feature" + ExtraSLSParams string // non-empty only for feature: --param="stage=feature" +} + +// Stage derives the stage name from the profile by stripping the "draftea-" prefix. +func (e EnvConfig) Stage() string { + return strings.TrimPrefix(e.Profile, "draftea-") } var ( DevEnv = EnvConfig{ - Stage: "dev", - AWSAccount: "776658659836", - AWSProfile: "draftea-dev", + Profile: "draftea-dev", } ProdEnv = EnvConfig{ - Stage: "prod", - AWSAccount: "632258128187", - AWSProfile: "draftea-prod", + Profile: "draftea-prod", } FeatureEnv = EnvConfig{ - Stage: "feature", - AWSAccount: "636385746594", - AWSProfile: "draftea-feature", + Profile: "draftea-feature", ExtraSLSParams: `--param="stage=feature"`, } ) diff --git a/internal/actions/deploy/function.go b/internal/actions/deploy/function.go index b7f1455..dee4602 100644 --- a/internal/actions/deploy/function.go +++ b/internal/actions/deploy/function.go @@ -33,7 +33,7 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { } log.Info("Fetching AWS Account ID...") - accountID, err := aws.GetAccountID(env.AWSProfile) + accountID, err := aws.GetAccountID(env.Profile) if err != nil { return fmt.Errorf("failed to get AWS account ID: %w", err) } @@ -49,7 +49,7 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { log.Info("Packaging service...") packageScript := fmt.Sprintf( `cd %q && env STAGE=%s AWS_ACCOUNT=%s sls package --stage %s --verbose --aws-profile %s`, - absPath, env.Stage, accountID, env.Stage, env.AWSProfile, + absPath, env.Stage(), accountID, env.Stage(), env.Profile, ) if _, err := exec.Command(packageScript, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)); err != nil { return fmt.Errorf("sls package failed: %w", err) @@ -60,7 +60,7 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { return err } - fullLambdaName := fmt.Sprintf("%s-%s-%s", serviceName, env.Stage, functionName) + fullLambdaName := fmt.Sprintf("%s-%s-%s", serviceName, env.Stage(), functionName) log.Infof("Lambda function: %s", fullLambdaName) zipFile := filepath.Join(absPath, ".bin", functionName+".zip") @@ -71,14 +71,14 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { log.Info("Updating Lambda code...") updateScript := fmt.Sprintf( `aws lambda update-function-code --function-name %q --zip-file "fileb://%s" --profile %s --region %s --output json`, - fullLambdaName, zipFile, env.AWSProfile, deployRegion, + fullLambdaName, zipFile, env.Profile, deployRegion, ) out, err := exec.Command(updateScript) if err != nil { return fmt.Errorf("lambda update failed: %w\n%s", err, out) } - log.Success("✓ Lambda deployed:", fullLambdaName) + log.Success("✓ Lambda deployed: ", fullLambdaName) return nil } diff --git a/internal/actions/deploy/service.go b/internal/actions/deploy/service.go index 365ee9b..dc971b0 100644 --- a/internal/actions/deploy/service.go +++ b/internal/actions/deploy/service.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/Drafteame/draft/internal/pkg/aws" "github.com/Drafteame/draft/internal/pkg/exec" "github.com/Drafteame/draft/internal/pkg/log" ) @@ -45,17 +46,23 @@ func deployServiceToDir(env EnvConfig, absPath string) error { return fmt.Errorf("serverless.yml not found in %s", absPath) } - slsParams := fmt.Sprintf("--aws-profile=%s", env.AWSProfile) + log.Info("Fetching AWS Account ID...") + accountID, err := aws.GetAccountID(env.Profile) + if err != nil { + return fmt.Errorf("failed to get AWS account ID: %w", err) + } + + slsParams := fmt.Sprintf("--aws-profile=%s", env.Profile) if env.ExtraSLSParams != "" { slsParams = fmt.Sprintf("%s %s", slsParams, env.ExtraSLSParams) } script := fmt.Sprintf( `cd %q && npm install && env STAGE=%s AWS_ACCOUNT=%s SLS_PARAMS=%q npm run deploy`, - absPath, env.Stage, env.AWSAccount, slsParams, + absPath, env.Stage(), accountID, slsParams, ) - _, err := exec.Command(script, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)) + _, err = exec.Command(script, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)) if err != nil { return fmt.Errorf("deploy failed: %w", err) } From db72212dd49ccdf8642addb3c9ff1415eeb23781 Mon Sep 17 00:00:00 2001 From: Ariel Santos Date: Thu, 12 Mar 2026 16:19:38 -0300 Subject: [PATCH 5/5] refactor: improvements --- cmd/commands/deploy/deploy.go | 18 +++++------ internal/actions/deploy/env.go | 12 +++++--- internal/actions/deploy/function.go | 20 ++++++------ internal/actions/deploy/resolve.go | 36 +++++++++++----------- internal/actions/deploy/service.go | 48 +++++++++++++++++++---------- 5 files changed, 76 insertions(+), 58 deletions(-) diff --git a/cmd/commands/deploy/deploy.go b/cmd/commands/deploy/deploy.go index 78ffd38..23f7996 100644 --- a/cmd/commands/deploy/deploy.go +++ b/cmd/commands/deploy/deploy.go @@ -56,22 +56,22 @@ Examples: results := deployaction.DeployService(env, args) + hasError := false if len(results) > 1 { log.Info("\n─── Deploy Summary ───") - for _, r := range results { - if r.Err != nil { - log.Errorf("✗ %s: %v", r.Name, r.Err) - } else { - log.Successf("✓ %s", r.Name) - } - } } - for _, r := range results { if r.Err != nil { - log.Exitf(1, "one or more deploys failed") + log.Errorf("✗ %s: %v", r.Name, r.Err) + hasError = true + } else { + log.Successf("✓ %s", r.Name) } } + + if hasError { + log.Exitf(1, "one or more deploys failed") + } }, } } diff --git a/internal/actions/deploy/env.go b/internal/actions/deploy/env.go index 60bf8e7..de86ad4 100644 --- a/internal/actions/deploy/env.go +++ b/internal/actions/deploy/env.go @@ -2,28 +2,30 @@ package deploy import "strings" +const profilePrefix = "draftea-" + // EnvConfig holds the deployment configuration for a target environment. type EnvConfig struct { Profile string // e.g. "draftea-dev", "draftea-prod", "draftea-feature" ExtraSLSParams string // non-empty only for feature: --param="stage=feature" } -// Stage derives the stage name from the profile by stripping the "draftea-" prefix. +// Stage derives the stage name from the profile by stripping the profilePrefix. func (e EnvConfig) Stage() string { - return strings.TrimPrefix(e.Profile, "draftea-") + return strings.TrimPrefix(e.Profile, profilePrefix) } var ( DevEnv = EnvConfig{ - Profile: "draftea-dev", + Profile: profilePrefix + "dev", } ProdEnv = EnvConfig{ - Profile: "draftea-prod", + Profile: profilePrefix + "prod", } FeatureEnv = EnvConfig{ - Profile: "draftea-feature", + Profile: profilePrefix + "feature", ExtraSLSParams: `--param="stage=feature"`, } ) diff --git a/internal/actions/deploy/function.go b/internal/actions/deploy/function.go index dee4602..dde88dc 100644 --- a/internal/actions/deploy/function.go +++ b/internal/actions/deploy/function.go @@ -22,15 +22,16 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { return err } - if fileExists(filepath.Join(absPath, ".deployignore")) { + skip, err := validateServiceDir(absPath) + if err != nil { + return err + } + if skip { log.Warnf("Skipping %s: .deployignore found", absPath) return nil } - slsFile := filepath.Join(absPath, "serverless.yml") - if !fileExists(slsFile) { - return fmt.Errorf("serverless.yml not found in %s", absPath) - } + stage := env.Stage() log.Info("Fetching AWS Account ID...") accountID, err := aws.GetAccountID(env.Profile) @@ -38,7 +39,7 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { return fmt.Errorf("failed to get AWS account ID: %w", err) } - if fileExists(filepath.Join(absPath, "package.json")) { + if files.Exists(filepath.Join(absPath, "package.json")) { log.Info("Installing dependencies...") installScript := fmt.Sprintf("cd %q && npm install", absPath) if _, err := exec.Command(installScript, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)); err != nil { @@ -49,22 +50,23 @@ func DeployFunction(env EnvConfig, serviceArg, functionName string) error { log.Info("Packaging service...") packageScript := fmt.Sprintf( `cd %q && env STAGE=%s AWS_ACCOUNT=%s sls package --stage %s --verbose --aws-profile %s`, - absPath, env.Stage(), accountID, env.Stage(), env.Profile, + absPath, stage, accountID, stage, env.Profile, ) if _, err := exec.Command(packageScript, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)); err != nil { return fmt.Errorf("sls package failed: %w", err) } + slsFile := filepath.Join(absPath, "serverless.yml") serviceName, err := parseServiceName(slsFile) if err != nil { return err } - fullLambdaName := fmt.Sprintf("%s-%s-%s", serviceName, env.Stage(), functionName) + fullLambdaName := fmt.Sprintf("%s-%s-%s", serviceName, stage, functionName) log.Infof("Lambda function: %s", fullLambdaName) zipFile := filepath.Join(absPath, ".bin", functionName+".zip") - if !fileExists(zipFile) { + if !files.Exists(zipFile) { return fmt.Errorf(".bin/%s.zip not found — check that the function name matches serverless.yml", functionName) } diff --git a/internal/actions/deploy/resolve.go b/internal/actions/deploy/resolve.go index 61ebeeb..e5ce49b 100644 --- a/internal/actions/deploy/resolve.go +++ b/internal/actions/deploy/resolve.go @@ -5,16 +5,23 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/Drafteame/draft/internal/pkg/exec" ) +var ( + gitRootOnce sync.Once + cachedGitRoot string + cachedGitRootErr error +) + // resolveService resolves a service name or path to an absolute directory path. // If arg is an existing directory, it returns the absolute path directly. // Otherwise it searches the git root for a serverless.yml with service: . func resolveService(arg string) (string, error) { // Try as path first - absPath, err := toAbsPath(arg) + absPath, err := filepath.Abs(arg) if err == nil && isDir(absPath) { return absPath, nil } @@ -41,12 +48,17 @@ func resolveService(arg string) (string, error) { } // getGitRoot returns the absolute path of the git repository root. +// The result is cached after the first call. func getGitRoot() (string, error) { - out, err := exec.Command("git rev-parse --show-toplevel") - if err != nil { - return "", err - } - return strings.TrimSpace(out), nil + gitRootOnce.Do(func() { + out, err := exec.Command("git rev-parse --show-toplevel") + if err != nil { + cachedGitRootErr = err + return + } + cachedGitRoot = strings.TrimSpace(out) + }) + return cachedGitRoot, cachedGitRootErr } // findServiceByName walks root looking for serverless.yml files where service: == name. @@ -87,18 +99,6 @@ func findServiceByName(root, name string) ([]string, error) { return matches, err } -// toAbsPath converts a relative or absolute path to absolute using CWD. -func toAbsPath(p string) (string, error) { - if filepath.IsAbs(p) { - return p, nil - } - cwd, err := os.Getwd() - if err != nil { - return "", err - } - return filepath.Join(cwd, p), nil -} - // isDir returns true if path exists and is a directory. func isDir(path string) bool { info, err := os.Stat(path) diff --git a/internal/actions/deploy/service.go b/internal/actions/deploy/service.go index dc971b0..1ebcff3 100644 --- a/internal/actions/deploy/service.go +++ b/internal/actions/deploy/service.go @@ -7,6 +7,7 @@ import ( "github.com/Drafteame/draft/internal/pkg/aws" "github.com/Drafteame/draft/internal/pkg/exec" + "github.com/Drafteame/draft/internal/pkg/files" "github.com/Drafteame/draft/internal/pkg/log" ) @@ -20,6 +21,16 @@ type DeployResult struct { func DeployService(env EnvConfig, args []string) []DeployResult { results := make([]DeployResult, 0, len(args)) + log.Info("Fetching AWS Account ID...") + accountID, err := aws.GetAccountID(env.Profile) + if err != nil { + err = fmt.Errorf("failed to get AWS account ID: %w", err) + for _, arg := range args { + results = append(results, DeployResult{Name: arg, Err: err}) + } + return results + } + for _, arg := range args { log.Infof("\n─── Deploying: %s ───", arg) @@ -29,29 +40,24 @@ func DeployService(env EnvConfig, args []string) []DeployResult { continue } - err = deployServiceToDir(env, absPath) + err = deployServiceToDir(env, absPath, accountID) results = append(results, DeployResult{Name: arg, Err: err}) } return results } -func deployServiceToDir(env EnvConfig, absPath string) error { - if fileExists(filepath.Join(absPath, ".deployignore")) { +func deployServiceToDir(env EnvConfig, absPath, accountID string) error { + skip, err := validateServiceDir(absPath) + if err != nil { + return err + } + if skip { log.Warnf("Skipping %s: .deployignore found", absPath) return nil } - if !fileExists(filepath.Join(absPath, "serverless.yml")) { - return fmt.Errorf("serverless.yml not found in %s", absPath) - } - - log.Info("Fetching AWS Account ID...") - accountID, err := aws.GetAccountID(env.Profile) - if err != nil { - return fmt.Errorf("failed to get AWS account ID: %w", err) - } - + stage := env.Stage() slsParams := fmt.Sprintf("--aws-profile=%s", env.Profile) if env.ExtraSLSParams != "" { slsParams = fmt.Sprintf("%s %s", slsParams, env.ExtraSLSParams) @@ -59,7 +65,7 @@ func deployServiceToDir(env EnvConfig, absPath string) error { script := fmt.Sprintf( `cd %q && npm install && env STAGE=%s AWS_ACCOUNT=%s SLS_PARAMS=%q npm run deploy`, - absPath, env.Stage(), accountID, slsParams, + absPath, stage, accountID, slsParams, ) _, err = exec.Command(script, exec.WithStdout(os.Stdout), exec.WithStderr(os.Stderr)) @@ -71,7 +77,15 @@ func deployServiceToDir(env EnvConfig, absPath string) error { return nil } -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == 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 + } + if !files.Exists(filepath.Join(absPath, "serverless.yml")) { + return false, fmt.Errorf("serverless.yml not found in %s", absPath) + } + return false, nil }