Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions cmd/commands/deploy/deploy.go
Original file line number Diff line number Diff line change
@@ -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 <service|path> <function-name>", 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> [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")
}
},
}
}
12 changes: 12 additions & 0 deletions cmd/commands/deploy/dev/dev.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions cmd/commands/deploy/feature/feature.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions cmd/commands/deploy/func/dev/dev.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions cmd/commands/deploy/func/feature/feature.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions cmd/commands/deploy/func/prod/prod.go
Original file line number Diff line number Diff line change
@@ -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)
}
12 changes: 12 additions & 0 deletions cmd/commands/deploy/prod/prod.go
Original file line number Diff line number Diff line change
@@ -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)
}
31 changes: 31 additions & 0 deletions internal/actions/deploy/env.go
Original file line number Diff line number Diff line change
@@ -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"`,
}
)
103 changes: 103 additions & 0 deletions internal/actions/deploy/function.go
Original file line number Diff line number Diff line change
@@ -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")
}
106 changes: 106 additions & 0 deletions internal/actions/deploy/resolve.go
Original file line number Diff line number Diff line change
@@ -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: <arg>.
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()
}
Loading