diff --git a/cmd/commands/deploy/deploy.go b/cmd/commands/deploy/deploy.go new file mode 100644 index 0000000..23f7996 --- /dev/null +++ b/cmd/commands/deploy/deploy.go @@ -0,0 +1,77 @@ +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 { + stage := env.Stage() + return &cobra.Command{ + 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`, 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", stage, err) + } + }, + } +} + +// 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...]", 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. +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`, stage, stage, stage, stage, stage), + Args: cobra.MinimumNArgs(1), + Run: func(c *cobra.Command, args []string) { + common.ChDir(c) + + 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) + hasError = true + } else { + log.Successf("✓ %s", r.Name) + } + } + + if hasError { + log.Exitf(1, "one or more deploys failed") + } + }, + } +} diff --git a/cmd/commands/deploy/dev/dev.go b/cmd/commands/deploy/dev/dev.go new file mode 100644 index 0000000..a5d6ea2 --- /dev/null +++ b/cmd/commands/deploy/dev/dev.go @@ -0,0 +1,12 @@ +package dev + +import ( + "github.com/spf13/cobra" + + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" +) + +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 new file mode 100644 index 0000000..e0cd361 --- /dev/null +++ b/cmd/commands/deploy/feature/feature.go @@ -0,0 +1,12 @@ +package feature + +import ( + "github.com/spf13/cobra" + + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" +) + +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 new file mode 100644 index 0000000..bacf904 --- /dev/null +++ b/cmd/commands/deploy/func/dev/dev.go @@ -0,0 +1,12 @@ +package dev + +import ( + "github.com/spf13/cobra" + + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" +) + +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 new file mode 100644 index 0000000..1d1b6d3 --- /dev/null +++ b/cmd/commands/deploy/func/feature/feature.go @@ -0,0 +1,12 @@ +package feature + +import ( + "github.com/spf13/cobra" + + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" +) + +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 new file mode 100644 index 0000000..5d7db83 --- /dev/null +++ b/cmd/commands/deploy/func/prod/prod.go @@ -0,0 +1,12 @@ +package prod + +import ( + "github.com/spf13/cobra" + + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" +) + +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 new file mode 100644 index 0000000..db5d873 --- /dev/null +++ b/cmd/commands/deploy/prod/prod.go @@ -0,0 +1,12 @@ +package prod + +import ( + "github.com/spf13/cobra" + + deploycmd "github.com/Drafteame/draft/cmd/commands/deploy" + deployaction "github.com/Drafteame/draft/internal/actions/deploy" +) + +func GetCmd() *cobra.Command { + return deploycmd.NewServiceCmd(deployaction.ProdEnv) +} diff --git a/internal/actions/deploy/env.go b/internal/actions/deploy/env.go new file mode 100644 index 0000000..de86ad4 --- /dev/null +++ b/internal/actions/deploy/env.go @@ -0,0 +1,31 @@ +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 profilePrefix. +func (e EnvConfig) Stage() string { + return strings.TrimPrefix(e.Profile, profilePrefix) +} + +var ( + DevEnv = EnvConfig{ + Profile: profilePrefix + "dev", + } + + ProdEnv = EnvConfig{ + Profile: profilePrefix + "prod", + } + + FeatureEnv = EnvConfig{ + Profile: profilePrefix + "feature", + ExtraSLSParams: `--param="stage=feature"`, + } +) diff --git a/internal/actions/deploy/function.go b/internal/actions/deploy/function.go new file mode 100644 index 0000000..dde88dc --- /dev/null +++ b/internal/actions/deploy/function.go @@ -0,0 +1,103 @@ +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 + } + + skip, err := validateServiceDir(absPath) + if err != nil { + return err + } + if skip { + log.Warnf("Skipping %s: .deployignore found", absPath) + return nil + } + + stage := env.Stage() + + 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) + } + + 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 { + 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, 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, stage, functionName) + log.Infof("Lambda function: %s", fullLambdaName) + + zipFile := filepath.Join(absPath, ".bin", functionName+".zip") + if !files.Exists(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.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) + 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..e5ce49b --- /dev/null +++ b/internal/actions/deploy/resolve.go @@ -0,0 +1,106 @@ +package deploy + +import ( + "fmt" + "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 := filepath.Abs(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. +// The result is cached after the first call. +func getGitRoot() (string, error) { + 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. +// 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 +} + +// 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..1ebcff3 --- /dev/null +++ b/internal/actions/deploy/service.go @@ -0,0 +1,91 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + + "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" +) + +// 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)) + + 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) + + absPath, err := resolveService(arg) + if err != nil { + results = append(results, DeployResult{Name: arg, Err: err}) + continue + } + + err = deployServiceToDir(env, absPath, accountID) + 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 { + return err + } + if skip { + log.Warnf("Skipping %s: .deployignore found", absPath) + return nil + } + + stage := env.Stage() + 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, stage, accountID, 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 +} + +// 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 +} 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 2496581..f5d22de 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" dbconnect "github.com/Drafteame/draft/cmd/commands/db/connect" "github.com/Drafteame/draft/cmd/commands/local/invoke" migratedown "github.com/Drafteame/draft/cmd/commands/local/migrate/down" @@ -43,6 +49,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())