From 6465487a7c5453157cfd5366d180d279946c257d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 11 Jun 2026 01:34:16 -0700 Subject: [PATCH 1/4] chore(deploy): remove orphaned internal/deploy package The internal/deploy package (Docker build/push, ECR login/repo management, frontend S3+CloudFront upload, deployment profiles) has zero importers outside its own test files. Actual deployment is handled by the GitHub workflows and terraform/. The dead code was still compiled, tested in CI, and re-assessed in every security scan for no benefit (ARCH-13, P3). Verification that the package is orphaned: - repo-wide grep for the import path "internal/deploy" across all file types (Go, workflows, scripts, docs, Makefiles): zero hits outside the package itself - repo-wide grep for distinctive exported identifiers (NewDockerService, NewECRService, NewFrontendService, DeploymentConfig, LoginToPublicECR, BuildAndUpload, ...): zero hits outside the package itself Also runs go mod tidy: the cloudfront, ecr, ecrpublic and s3 AWS SDK modules were only used by this package and drop out of go.mod. go build ./..., go vet ./... and go test ./internal/... all pass after removal. Closes #1202 --- go.mod | 6 - go.sum | 12 - internal/deploy/coverage_extra_test.go | 211 ----------- internal/deploy/docker.go | 114 ------ internal/deploy/docker_test.go | 313 ---------------- internal/deploy/ecr.go | 149 -------- internal/deploy/ecr_test.go | 389 -------------------- internal/deploy/frontend.go | 294 --------------- internal/deploy/frontend_test.go | 472 ------------------------- internal/deploy/mocks.go | 123 ------- internal/deploy/profiles.go | 263 -------------- internal/deploy/profiles_test.go | 393 -------------------- internal/deploy/types.go | 65 ---- 13 files changed, 2804 deletions(-) delete mode 100644 internal/deploy/coverage_extra_test.go delete mode 100644 internal/deploy/docker.go delete mode 100644 internal/deploy/docker_test.go delete mode 100644 internal/deploy/ecr.go delete mode 100644 internal/deploy/ecr_test.go delete mode 100644 internal/deploy/frontend.go delete mode 100644 internal/deploy/frontend_test.go delete mode 100644 internal/deploy/mocks.go delete mode 100644 internal/deploy/profiles.go delete mode 100644 internal/deploy/profiles_test.go delete mode 100644 internal/deploy/types.go diff --git a/go.mod b/go.mod index 7a73efa53..16f703161 100644 --- a/go.mod +++ b/go.mod @@ -94,13 +94,9 @@ require ( github.com/LeanerCloud/CUDly/providers/azure v0.0.0 github.com/LeanerCloud/CUDly/providers/gcp v0.0.0 github.com/aws/aws-lambda-go v1.47.0 - github.com/aws/aws-sdk-go-v2/service/cloudfront v1.58.2 - github.com/aws/aws-sdk-go-v2/service/ecr v1.54.2 - github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7 github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3 - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.3 github.com/aws/aws-sdk-go-v2/service/sesv2 v1.42.0 github.com/aws/aws-sdk-go-v2/service/sns v1.34.0 @@ -135,8 +131,6 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect diff --git a/go.sum b/go.sum index 2209cbeb5..05d25e30b 100644 --- a/go.sum +++ b/go.sum @@ -118,26 +118,16 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= -github.com/aws/aws-sdk-go-v2/service/cloudfront v1.58.2 h1:Sm/sQAe/54oCaXj5/xOtMkMvpDafNZhQ38DsyarIBR0= -github.com/aws/aws-sdk-go-v2/service/cloudfront v1.58.2/go.mod h1:SxEwhpfvzjK0vR8LfHeOkHeIcpaFU5ZgVbuBo3J4w2A= github.com/aws/aws-sdk-go-v2/service/costexplorer v1.61.0 h1:T9Ms/lReZ3iRFdAtXS9IlhLbWoM2fKUOjJwcgmjT7ig= github.com/aws/aws-sdk-go-v2/service/costexplorer v1.61.0/go.mod h1:AFQ/jaLX9hhiVPxyNKowOchXlpwIYSfYg8bzuXi2gBA= github.com/aws/aws-sdk-go-v2/service/ec2 v1.251.2 h1:6TssXFfLHcwUS5E3MdYKkCFeOrYVBlDhJjs5kRJp0ic= github.com/aws/aws-sdk-go-v2/service/ec2 v1.251.2/go.mod h1:MXJiLJZtMqb2dVXgEIn35d5+7MqLd4r8noLen881kpk= -github.com/aws/aws-sdk-go-v2/service/ecr v1.54.2 h1:2Mdcg3Rphkj48toLpLrckQ9T0ce08GrMfaL0l2anzLY= -github.com/aws/aws-sdk-go-v2/service/ecr v1.54.2/go.mod h1:gwUKatMqrynzKA8L0MDAlvAvGB7LIzmTm6uFRqD+4CU= -github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7 h1:NHy1+Jq8gVp8fSLF6Z8SazA+R4Qzsbla/0SbHHReH4Y= -github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.7/go.mod h1:KxsaVRXo+DeRMHVp65WqyM49XZiS6n74lEGQindkdgA= github.com/aws/aws-sdk-go-v2/service/elasticache v1.50.3 h1:uiWSUtTWqpvhP7KSEpVpIm0LqOtXtzOx049rmukP/gI= github.com/aws/aws-sdk-go-v2/service/elasticache v1.50.3/go.mod h1:igTRxVYuxplMPKS5J1AEThtbeFJQhUz845YtDRDzJhY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 h1:PgD1y0ZagPokGIZPmejCBUySBzOFDN+leZxCOfb1OEQ= github.com/aws/aws-sdk-go-v2/service/kms v1.50.4/go.mod h1:FfXDb5nXrsoGgxsBFxwxr3vdHXheC2tV+6lmuLghhjQ= github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0 h1:e4NAllPs/ygQ7W4dTlAuP5N7QpCT+rTij3S8UOv2DD4= @@ -152,8 +142,6 @@ github.com/aws/aws-sdk-go-v2/service/rds v1.97.3 h1:YBcCzc0S/DQN6Mg1sUtcyd8TY6T3 github.com/aws/aws-sdk-go-v2/service/rds v1.97.3/go.mod h1:Xe+NMlf/DY/XTXSevASAjGRika9Qt2LnuCDLtos03ms= github.com/aws/aws-sdk-go-v2/service/redshift v1.58.3 h1:rXoN3hvwUimq8Z6uu2lsYncGPDQS+i70Rp1G0c0C/zk= github.com/aws/aws-sdk-go-v2/service/redshift v1.58.3/go.mod h1:OfB6wMvsEozZQbEjgqe6J68wF5u7wXNEAdG4FLKLk/Y= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/savingsplans v1.31.0 h1:cGxQBpfDQZNtMjGlCd2ALGnKJCjPskOTdCRR+dZceRU= github.com/aws/aws-sdk-go-v2/service/savingsplans v1.31.0/go.mod h1:Osfg3coILx7t46vKS5OWoov989SA4fCoGwSuYrztNEg= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.3 h1:QYBY43OlvzRPww1gSZ1kihyqzXg32rweA3fql5ubSLA= diff --git a/internal/deploy/coverage_extra_test.go b/internal/deploy/coverage_extra_test.go deleted file mode 100644 index 365eb5abb..000000000 --- a/internal/deploy/coverage_extra_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package deploy - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestUploadDirectory exercises the uploadDirectory code path using a real -// temporary directory populated with test files. -func TestUploadDirectory_Success(t *testing.T) { - // Create a temp dist directory with a few files - distDir := t.TempDir() - - // HTML file (should get no-cache header) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte(""), 0644)) - // JS file (should get 1-year cache) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.js"), []byte("console.log('hi')"), 0644)) - // Sub-directory with asset - subDir := filepath.Join(distDir, "assets") - require.NoError(t, os.MkdirAll(subDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(subDir, "logo.png"), []byte("PNG"), 0644)) - // JSON file (no-cache) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "manifest.json"), []byte("{}"), 0644)) - // Webmanifest file (no-cache) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.webmanifest"), []byte("{}"), 0644)) - // Binary file with unknown extension (octet-stream) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "data.bin"), []byte{0x01, 0x02}, 0644)) - - var uploaded []string - mockS3 := &MockS3Client{ - PutObjectFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - uploaded = append(uploaded, *params.Key) - return &s3.PutObjectOutput{}, nil - }, - } - - service := NewFrontendService(mockS3, nil, nil) - err := service.uploadDirectory(context.Background(), distDir, "test-bucket") - require.NoError(t, err) - - assert.Len(t, uploaded, 6) - assert.Contains(t, uploaded, "index.html") - assert.Contains(t, uploaded, "app.js") - assert.Contains(t, uploaded, "assets/logo.png") - assert.Contains(t, uploaded, "manifest.json") - assert.Contains(t, uploaded, "app.webmanifest") - assert.Contains(t, uploaded, "data.bin") -} - -func TestUploadDirectory_PutObjectError(t *testing.T) { - distDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte(""), 0644)) - - mockS3 := &MockS3Client{ - PutObjectFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - return nil, errors.New("upload failed") - }, - } - - service := NewFrontendService(mockS3, nil, nil) - err := service.uploadDirectory(context.Background(), distDir, "test-bucket") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to upload") -} - -func TestUploadDirectory_NonExistentDir(t *testing.T) { - mockS3 := &MockS3Client{} - service := NewFrontendService(mockS3, nil, nil) - err := service.uploadDirectory(context.Background(), "/nonexistent/path/that/does/not/exist", "test-bucket") - assert.Error(t, err) -} - -// TestBuildAndUpload_SuccessWithFrontendDirEnv exercises BuildAndUpload using -// CUDLY_FRONTEND_DIR pointing to a directory with a package.json and a prebuilt dist/. -func TestBuildAndUpload_SuccessWithFrontendDirEnv(t *testing.T) { - // Create a fake frontend directory structure - frontendDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(`{"name":"cudly"}`), 0644)) - - // Create dist directory with a file so uploadDirectory has something to upload - distDir := filepath.Join(frontendDir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "index.html"), []byte(""), 0644)) - - t.Setenv("CUDLY_FRONTEND_DIR", frontendDir) - - var cmdRun [][]string - mockCmd := &MockCommandRunner{ - RunFunc: func(name string, args ...string) error { - cmdRun = append(cmdRun, append([]string{name}, args...)) - return nil // npm install and build succeed - }, - } - - var uploaded []string - mockS3 := &MockS3Client{ - PutObjectFunc: func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - uploaded = append(uploaded, *params.Key) - return &s3.PutObjectOutput{}, nil - }, - } - - // CloudFront list returns empty (no invalidation needed) - mockCF := &MockCloudFrontClient{} - - service := NewFrontendService(mockS3, mockCF, mockCmd) - err := service.BuildAndUpload(context.Background(), "test-bucket", "https://dashboard.example.com") - require.NoError(t, err) - - // npm install was called - assert.True(t, len(cmdRun) >= 2, "expected at least 2 npm commands") - assert.Contains(t, uploaded, "index.html") -} - -// TestFindFrontendDir_EnvVar_NotFound tests the env var path when package.json is absent. -func TestFindFrontendDir_EnvVar_NotFound(t *testing.T) { - tmpDir := t.TempDir() - // Point env var to dir without package.json - t.Setenv("CUDLY_FRONTEND_DIR", tmpDir) - - service := NewFrontendService(nil, nil, nil) - // Should fall through to path search (which will also fail in test env) - _, err := service.FindFrontendDir() - // May succeed if there's a frontend dir in the search path, or fail - // Either way, we just verify it doesn't panic - _ = err -} - -// TestFindFrontendDir_EnvVar_Found tests the env var path when package.json exists. -func TestFindFrontendDir_EnvVar_Found(t *testing.T) { - tmpDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{}`), 0644)) - t.Setenv("CUDLY_FRONTEND_DIR", tmpDir) - - service := NewFrontendService(nil, nil, nil) - dir, err := service.FindFrontendDir() - require.NoError(t, err) - assert.NotEmpty(t, dir) -} - -// TestGetConfigPath_HomeDirError tests GetConfigPath when HOME is unset. -func TestGetConfigPath_HomeError(t *testing.T) { - original := os.Getenv("HOME") - os.Unsetenv("HOME") - // On macOS, UserHomeDir may use a different mechanism, so just - // verify it doesn't panic and returns a string. - result := GetConfigPath() - _ = result - os.Setenv("HOME", original) -} - -// TestGetConfigDir_HomeError tests GetConfigDir when HOME is unset. -func TestGetConfigDir_HomeError(t *testing.T) { - original := os.Getenv("HOME") - os.Unsetenv("HOME") - result := GetConfigDir() - _ = result - os.Setenv("HOME", original) -} - -// TestLoadConfig_ParseError tests LoadConfig when the config file contains invalid YAML. -func TestLoadConfig_ParseError(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", tmpDir) - - // Create the config directory and file - configDir := filepath.Join(tmpDir, ".cudly") - require.NoError(t, os.MkdirAll(configDir, 0700)) - configPath := filepath.Join(configDir, "deployment.yaml") - require.NoError(t, os.WriteFile(configPath, []byte(":\n - invalid: yaml: ["), 0600)) - - _, err := LoadConfig() - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse config file") -} - -// TestEmptyBucket_DeleteWithErrors covers the per-object error handling branch. -func TestFrontendService_EmptyBucket_DeleteWithErrors(t *testing.T) { - errKey := "locked-file.html" - errMsg := "access denied" - - mockS3 := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - k := errKey - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{{Key: &k}}, - }, nil - }, - DeleteObjectsFunc: func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - k := errKey - m := errMsg - return &s3.DeleteObjectsOutput{ - Errors: []s3types.Error{{Key: &k, Message: &m}}, - }, nil - }, - } - - service := NewFrontendService(mockS3, nil, nil) - err := service.EmptyBucket(context.Background(), "test-bucket") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to delete some objects") - assert.Contains(t, err.Error(), errKey) -} diff --git a/internal/deploy/docker.go b/internal/deploy/docker.go deleted file mode 100644 index 647d3ee8f..000000000 --- a/internal/deploy/docker.go +++ /dev/null @@ -1,114 +0,0 @@ -package deploy - -import ( - "fmt" - "log" - "os" - "os/exec" - "regexp" - "strings" -) - -// validDockerRef matches safe Docker image reference components: must start -// with an alphanumeric character (no leading hyphen -- leading hyphens would -// be interpreted as flags by docker) and may then contain alphanumerics plus -// the characters that appear in valid registry hosts, repository paths, tags, -// and digest prefixes (dots, colons, slashes, at-signs, hyphens). -var validDockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@-]*$`) - -// validateDockerRef returns an error if ref is empty or contains characters -// outside the safe set (including a leading hyphen). -func validateDockerRef(label, ref string) error { - if ref == "" { - return fmt.Errorf("docker %s must not be empty", label) - } - if !validDockerRef.MatchString(ref) { - return fmt.Errorf("docker %s contains invalid characters: %q", label, ref) - } - return nil -} - -// DockerService handles Docker operations. -type DockerService struct { - CmdRunner CommandRunner -} - -// NewDockerService creates a new DockerService. -func NewDockerService(cmdRunner CommandRunner) *DockerService { - return &DockerService{ - CmdRunner: cmdRunner, - } -} - -// BuildImage builds a Docker image with the specified architecture and tag. -func (s *DockerService) BuildImage(architecture, tag, imageName string) error { - if err := validateDockerRef("tag", tag); err != nil { - return err - } - if err := validateDockerRef("image name", imageName); err != nil { - return err - } - platform := fmt.Sprintf("linux/%s", architecture) - return s.CmdRunner.Run("docker", "build", - "--platform", platform, - "-t", fmt.Sprintf("%s:%s", imageName, tag), - ".") -} - -// TagImage tags a Docker image with a new name. -func (s *DockerService) TagImage(sourceTag, targetTag string) error { - if err := validateDockerRef("source tag", sourceTag); err != nil { - return err - } - if err := validateDockerRef("target tag", targetTag); err != nil { - return err - } - return s.CmdRunner.Run("docker", "tag", sourceTag, targetTag) -} - -// PushImage pushes a Docker image to a registry. -func (s *DockerService) PushImage(imageTag string) error { - if err := validateDockerRef("image tag", imageTag); err != nil { - return err - } - return s.CmdRunner.Run("docker", "push", imageTag) -} - -// PushToECR tags and pushes an image to ECR. -func (s *DockerService) PushToECR(localImage, remoteTag string) error { - if err := s.TagImage(localImage, remoteTag); err != nil { - return fmt.Errorf("docker tag failed: %w", err) - } - - if err := s.PushImage(remoteTag); err != nil { - return fmt.Errorf("docker push failed: %w", err) - } - - log.Printf("Image pushed: %s", remoteTag) - return nil -} - -// DefaultCommandRunner is the default implementation of CommandRunner. -type DefaultCommandRunner struct{} - -// NewDefaultCommandRunner creates a new DefaultCommandRunner. -func NewDefaultCommandRunner() *DefaultCommandRunner { - return &DefaultCommandRunner{} -} - -// Run runs a command and streams output to stdout/stderr. -func (r *DefaultCommandRunner) Run(name string, args ...string) error { - cmd := exec.Command(name, args...) // #nosec G204 -- deploy tooling: callers hardcode binary names (npm, docker, aws); no user input reaches this function - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// RunWithStdin runs a command with stdin input and streams output to stdout/stderr. -func (r *DefaultCommandRunner) RunWithStdin(name, stdin string, args ...string) error { - cmd := exec.Command(name, args...) // #nosec G204 -- deploy tooling: callers hardcode binary names (npm, docker, aws); no user input reaches this function - cmd.Stdin = strings.NewReader(stdin) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/internal/deploy/docker_test.go b/internal/deploy/docker_test.go deleted file mode 100644 index 3cdfb788c..000000000 --- a/internal/deploy/docker_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package deploy - -import ( - "errors" - "strings" - "testing" -) - -func TestDockerService_BuildImage(t *testing.T) { - mockRunner := &MockCommandRunner{} - service := NewDockerService(mockRunner) - - err := service.BuildImage("arm64", "latest", "myimage") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(mockRunner.Commands) != 1 { - t.Fatalf("expected 1 command, got %d", len(mockRunner.Commands)) - } - - cmd := mockRunner.Commands[0] - expected := []string{"docker", "build", "--platform", "linux/arm64", "-t", "myimage:latest", "."} - - if len(cmd) != len(expected) { - t.Errorf("expected %v, got %v", expected, cmd) - } - for i, v := range expected { - if cmd[i] != v { - t.Errorf("expected %s at position %d, got %s", v, i, cmd[i]) - } - } -} - -func TestDockerService_BuildImage_x86(t *testing.T) { - mockRunner := &MockCommandRunner{} - service := NewDockerService(mockRunner) - - err := service.BuildImage("x86_64", "v1.0", "myimage") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - cmd := mockRunner.Commands[0] - // Check platform is linux/x86_64 - if cmd[3] != "linux/x86_64" { - t.Errorf("expected platform linux/x86_64, got %s", cmd[3]) - } - // Check tag - if cmd[5] != "myimage:v1.0" { - t.Errorf("expected tag myimage:v1.0, got %s", cmd[5]) - } -} - -func TestDockerService_TagImage(t *testing.T) { - mockRunner := &MockCommandRunner{} - service := NewDockerService(mockRunner) - - err := service.TagImage("source:tag", "target:tag") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(mockRunner.Commands) != 1 { - t.Fatalf("expected 1 command, got %d", len(mockRunner.Commands)) - } - - cmd := mockRunner.Commands[0] - expected := []string{"docker", "tag", "source:tag", "target:tag"} - - for i, v := range expected { - if cmd[i] != v { - t.Errorf("expected %s at position %d, got %s", v, i, cmd[i]) - } - } -} - -func TestDockerService_PushImage(t *testing.T) { - mockRunner := &MockCommandRunner{} - service := NewDockerService(mockRunner) - - err := service.PushImage("myrepo:latest") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(mockRunner.Commands) != 1 { - t.Fatalf("expected 1 command, got %d", len(mockRunner.Commands)) - } - - cmd := mockRunner.Commands[0] - expected := []string{"docker", "push", "myrepo:latest"} - - for i, v := range expected { - if cmd[i] != v { - t.Errorf("expected %s at position %d, got %s", v, i, cmd[i]) - } - } -} - -func TestDockerService_PushToECR(t *testing.T) { - mockRunner := &MockCommandRunner{} - service := NewDockerService(mockRunner) - - err := service.PushToECR("local:latest", "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo:latest") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should have called tag and push - if len(mockRunner.Commands) != 2 { - t.Fatalf("expected 2 commands, got %d", len(mockRunner.Commands)) - } - - // First command should be tag - if mockRunner.Commands[0][0] != "docker" || mockRunner.Commands[0][1] != "tag" { - t.Errorf("expected docker tag, got %v", mockRunner.Commands[0]) - } - - // Second command should be push - if mockRunner.Commands[1][0] != "docker" || mockRunner.Commands[1][1] != "push" { - t.Errorf("expected docker push, got %v", mockRunner.Commands[1]) - } -} - -func TestDockerService_PushToECR_TagError(t *testing.T) { - mockRunner := &MockCommandRunner{ - RunFunc: func(name string, args ...string) error { - if args[0] == "tag" { - return errors.New("tag failed") - } - return nil - }, - } - service := NewDockerService(mockRunner) - - err := service.PushToECR("local:latest", "remote:latest") - if err == nil { - t.Error("expected error, got nil") - } - if err.Error() != "docker tag failed: tag failed" { - t.Errorf("unexpected error message: %v", err) - } -} - -func TestDockerService_PushToECR_PushError(t *testing.T) { - mockRunner := &MockCommandRunner{ - RunFunc: func(name string, args ...string) error { - if args[0] == "push" { - return errors.New("push failed") - } - return nil - }, - } - service := NewDockerService(mockRunner) - - err := service.PushToECR("local:latest", "remote:latest") - if err == nil { - t.Error("expected error, got nil") - } - if err.Error() != "docker push failed: push failed" { - t.Errorf("unexpected error message: %v", err) - } -} - -func TestDefaultCommandRunner_Run_Success(t *testing.T) { - runner := NewDefaultCommandRunner() - - // Use 'true' command which always succeeds - err := runner.Run("true") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDefaultCommandRunner_Run_WithArgs(t *testing.T) { - runner := NewDefaultCommandRunner() - - // Use 'echo' command with arguments - err := runner.Run("echo", "hello", "world") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDefaultCommandRunner_Run_Failure(t *testing.T) { - runner := NewDefaultCommandRunner() - - // Use 'false' command which always fails - err := runner.Run("false") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestDefaultCommandRunner_RunWithStdin_Success(t *testing.T) { - runner := NewDefaultCommandRunner() - - // Use 'cat' to echo back stdin - err := runner.RunWithStdin("cat", "test input") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestDefaultCommandRunner_RunWithStdin_Failure(t *testing.T) { - runner := NewDefaultCommandRunner() - - // Use a command that will fail - err := runner.RunWithStdin("false", "test input") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestNewDefaultCommandRunner(t *testing.T) { - runner := NewDefaultCommandRunner() - if runner == nil { - t.Error("expected non-nil runner") - } -} - -// TestDockerService_TagValidation verifies that leading-dash and other unsafe -// values are rejected before being passed to exec.Command (07-N1). -func TestDockerService_TagValidation(t *testing.T) { - cases := []struct { - name string - fn func(*DockerService) error - wantErrFrag string - }{ - { - name: "BuildImage leading-dash tag", - fn: func(s *DockerService) error { return s.BuildImage("arm64", "-v", "myimage") }, - wantErrFrag: "invalid characters", - }, - { - name: "BuildImage leading-dash imageName", - fn: func(s *DockerService) error { return s.BuildImage("arm64", "latest", "-rm") }, - wantErrFrag: "invalid characters", - }, - { - name: "BuildImage empty tag", - fn: func(s *DockerService) error { return s.BuildImage("arm64", "", "myimage") }, - wantErrFrag: "must not be empty", - }, - { - name: "TagImage invalid source", - fn: func(s *DockerService) error { return s.TagImage("--no-trunc", "target:tag") }, - wantErrFrag: "invalid characters", - }, - { - name: "TagImage invalid target", - fn: func(s *DockerService) error { return s.TagImage("source:tag", "--rm") }, - wantErrFrag: "invalid characters", - }, - { - name: "PushImage invalid tag", - fn: func(s *DockerService) error { return s.PushImage("--all-tags") }, - wantErrFrag: "invalid characters", - }, - { - name: "PushImage empty tag", - fn: func(s *DockerService) error { return s.PushImage("") }, - wantErrFrag: "must not be empty", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - service := NewDockerService(&MockCommandRunner{}) - err := tc.fn(service) - if err == nil { - t.Fatal("expected validation error, got nil") - } - if !strings.Contains(err.Error(), tc.wantErrFrag) { - t.Errorf("expected error containing %q, got: %v", tc.wantErrFrag, err) - } - }) - } -} - -// TestDockerService_TagValidation_ValidRefs confirms that legitimate image -// references (with dots, colons, slashes, hyphens) are accepted. -func TestDockerService_TagValidation_ValidRefs(t *testing.T) { - cases := []struct { - fn func(*DockerService) error - name string - }{ - { - name: "ECR-style tag", - fn: func(s *DockerService) error { - return s.PushImage("123456789012.dkr.ecr.us-east-1.amazonaws.com/myrepo:v1.2.3") - }, - }, - { - name: "simple tag with hyphen", - fn: func(s *DockerService) error { return s.PushImage("myimage:v1-rc1") }, - }, - { - name: "digest reference", - fn: func(s *DockerService) error { return s.PushImage("myimage@sha256:abc123") }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - service := NewDockerService(&MockCommandRunner{}) - if err := tc.fn(service); err != nil { - t.Errorf("unexpected validation error for valid ref: %v", err) - } - }) - } -} diff --git a/internal/deploy/ecr.go b/internal/deploy/ecr.go deleted file mode 100644 index 8bc7c6579..000000000 --- a/internal/deploy/ecr.go +++ /dev/null @@ -1,149 +0,0 @@ -package deploy - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "log" - "strings" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecr" - ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/aws/aws-sdk-go-v2/service/ecrpublic" -) - -// ECRService handles ECR operations. -type ECRService struct { - Client ECRClient - PublicClient ECRPublicClient - CmdRunner CommandRunner -} - -// NewECRService creates a new ECRService. -func NewECRService(client ECRClient, publicClient ECRPublicClient, cmdRunner CommandRunner) *ECRService { - return &ECRService{ - Client: client, - PublicClient: publicClient, - CmdRunner: cmdRunner, - } -} - -// EnsureRepository ensures the ECR repository exists, creating it if necessary. -// Returns the repository URI. -func (s *ECRService) EnsureRepository(ctx context.Context, repoName, accountID, region string) (string, error) { - uri, err := s.describeRepository(ctx, repoName) - if err != nil { - return "", err - } - if uri != "" { - return uri, nil - } - return s.createRepository(ctx, repoName, accountID, region) -} - -// describeRepository returns the URI of an existing ECR repository, or "" if it doesn't exist. -// Returns an error for any failure other than RepositoryNotFoundException. -func (s *ECRService) describeRepository(ctx context.Context, repoName string) (string, error) { - out, err := s.Client.DescribeRepositories(ctx, &ecr.DescribeRepositoriesInput{ - RepositoryNames: []string{repoName}, - }) - if err != nil { - var notFound *ecrtypes.RepositoryNotFoundException - if errors.As(err, ¬Found) { - return "", nil // repository does not exist yet - } - return "", fmt.Errorf("failed to describe ECR repository: %w", err) - } - if out != nil && len(out.Repositories) > 0 && out.Repositories[0].RepositoryUri != nil { - return *out.Repositories[0].RepositoryUri, nil - } - return "", nil -} - -// createRepository creates an ECR repository and returns its URI. -func (s *ECRService) createRepository(ctx context.Context, repoName, accountID, region string) (string, error) { - log.Printf("Creating ECR repository: %s", repoName) - out, err := s.Client.CreateRepository(ctx, &ecr.CreateRepositoryInput{ - RepositoryName: aws.String(repoName), - ImageScanningConfiguration: &ecrtypes.ImageScanningConfiguration{ - ScanOnPush: true, - }, - }) - var repoExists *ecrtypes.RepositoryAlreadyExistsException - if err != nil && !errors.As(err, &repoExists) { - return "", fmt.Errorf("failed to create ECR repository: %w", err) - } - if out != nil && out.Repository != nil && out.Repository.RepositoryUri != nil { - return *out.Repository.RepositoryUri, nil - } - return fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s", accountID, region, repoName), nil -} - -// LoginToPublicECR authenticates to public ECR for pulling base images. -func (s *ECRService) LoginToPublicECR(ctx context.Context) error { - result, err := s.PublicClient.GetAuthorizationToken(ctx, &ecrpublic.GetAuthorizationTokenInput{}) - if err != nil { - return fmt.Errorf("failed to get public ECR auth token: %w", err) - } - - if result.AuthorizationData == nil || result.AuthorizationData.AuthorizationToken == nil { - return fmt.Errorf("no authorization data returned from public ECR") - } - - password, err := decodeBase64Token(*result.AuthorizationData.AuthorizationToken) - if err != nil { - return fmt.Errorf("failed to decode public ECR auth token: %w", err) - } - - // Login to Docker using the token - if err := s.CmdRunner.RunWithStdin("docker", password, "login", "--username", "AWS", "--password-stdin", "public.ecr.aws"); err != nil { - return fmt.Errorf("docker login failed: %w", err) - } - - return nil -} - -// LoginToECR authenticates to private ECR. -func (s *ECRService) LoginToECR(ctx context.Context, accountID, region string) error { - result, err := s.Client.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{}) - if err != nil { - return fmt.Errorf("failed to get ECR auth token: %w", err) - } - - if len(result.AuthorizationData) == 0 { - return fmt.Errorf("no authorization data returned") - } - - if result.AuthorizationData[0].AuthorizationToken == nil { - return fmt.Errorf("authorization token is nil in ECR response") - } - - authToken := *result.AuthorizationData[0].AuthorizationToken - registryURL := fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com", accountID, region) - - password, err := decodeBase64Token(authToken) - if err != nil { - return fmt.Errorf("failed to decode ECR auth token: %w", err) - } - - if err := s.CmdRunner.RunWithStdin("docker", password, "login", "--username", "AWS", "--password-stdin", registryURL); err != nil { - return fmt.Errorf("docker login failed: %w", err) - } - - return nil -} - -// decodeBase64Token decodes a base64-encoded auth token and returns the password. -func decodeBase64Token(token string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(token) - if err != nil { - return "", fmt.Errorf("failed to decode base64 token: %w", err) - } - parts := strings.SplitN(string(decoded), ":", 2) - if len(parts) != 2 { - return "", fmt.Errorf("invalid token format: expected 'username:password'") - } - return parts[1], nil -} diff --git a/internal/deploy/ecr_test.go b/internal/deploy/ecr_test.go deleted file mode 100644 index fad8af4c2..000000000 --- a/internal/deploy/ecr_test.go +++ /dev/null @@ -1,389 +0,0 @@ -package deploy - -import ( - "context" - "encoding/base64" - "errors" - "strings" - "testing" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ecr" - ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" - "github.com/aws/aws-sdk-go-v2/service/ecrpublic" - ecrpublictypes "github.com/aws/aws-sdk-go-v2/service/ecrpublic/types" -) - -func TestECRService_EnsureRepository_Exists(t *testing.T) { - mockClient := &MockECRClient{ - DescribeRepositoriesFunc: func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { - return &ecr.DescribeRepositoriesOutput{ - Repositories: []ecrtypes.Repository{ - {RepositoryName: aws.String("test-repo")}, - }, - }, nil - }, - } - - service := NewECRService(mockClient, nil, nil) - uri, err := service.EnsureRepository(context.Background(), "test-repo", "123456789012", "us-east-1") - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expected := "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo" - if uri != expected { - t.Errorf("expected URI %s, got %s", expected, uri) - } -} - -func TestECRService_EnsureRepository_Creates(t *testing.T) { - createCalled := false - mockClient := &MockECRClient{ - DescribeRepositoriesFunc: func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { - return nil, &ecrtypes.RepositoryNotFoundException{Message: aws.String("repository not found")} - }, - CreateRepositoryFunc: func(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) { - createCalled = true - if *params.RepositoryName != "test-repo" { - t.Errorf("expected repo name test-repo, got %s", *params.RepositoryName) - } - return &ecr.CreateRepositoryOutput{}, nil - }, - } - - service := NewECRService(mockClient, nil, nil) - uri, err := service.EnsureRepository(context.Background(), "test-repo", "123456789012", "us-east-1") - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !createCalled { - t.Error("expected CreateRepository to be called") - } - - expected := "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo" - if uri != expected { - t.Errorf("expected URI %s, got %s", expected, uri) - } -} - -func TestECRService_LoginToPublicECR(t *testing.T) { - token := base64.StdEncoding.EncodeToString([]byte("AWS:testpassword")) - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return &ecrpublic.GetAuthorizationTokenOutput{ - AuthorizationData: &ecrpublictypes.AuthorizationData{ - AuthorizationToken: aws.String(token), - }, - }, nil - }, - } - - mockCmdRunner := &MockCommandRunner{} - service := NewECRService(nil, mockPublicClient, mockCmdRunner) - - err := service.LoginToPublicECR(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify docker login was called - if len(mockCmdRunner.Commands) != 1 { - t.Fatalf("expected 1 command, got %d", len(mockCmdRunner.Commands)) - } - - cmd := mockCmdRunner.Commands[0] - if cmd[0] != "docker" || cmd[1] != "login" { - t.Errorf("expected docker login command, got %v", cmd) - } -} - -func TestECRService_LoginToECR(t *testing.T) { - token := base64.StdEncoding.EncodeToString([]byte("AWS:testpassword")) - mockClient := &MockECRClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { - return &ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []ecrtypes.AuthorizationData{ - {AuthorizationToken: aws.String(token)}, - }, - }, nil - }, - } - - mockCmdRunner := &MockCommandRunner{} - service := NewECRService(mockClient, nil, mockCmdRunner) - - err := service.LoginToECR(context.Background(), "123456789012", "us-east-1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify docker login was called with correct registry - if len(mockCmdRunner.Commands) != 1 { - t.Fatalf("expected 1 command, got %d", len(mockCmdRunner.Commands)) - } - - cmd := mockCmdRunner.Commands[0] - if cmd[0] != "docker" || cmd[1] != "login" { - t.Errorf("expected docker login command, got %v", cmd) - } -} - -func TestDecodeBase64Token(t *testing.T) { - tests := []struct { - name string - token string - expected string - expectError bool - }{ - { - name: "valid token", - token: base64.StdEncoding.EncodeToString([]byte("AWS:mypassword")), - expected: "mypassword", - expectError: false, - }, - { - name: "invalid base64", - token: "not-valid-base64!!!", - expected: "", - expectError: true, - }, - { - name: "missing colon", - token: base64.StdEncoding.EncodeToString([]byte("nopassword")), - expected: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := decodeBase64Token(tt.token) - if tt.expectError && err == nil { - t.Error("expected error, got nil") - } - if !tt.expectError && err != nil { - t.Errorf("unexpected error: %v", err) - } - if result != tt.expected { - t.Errorf("expected %s, got %s", tt.expected, result) - } - }) - } -} - -func TestECRService_LoginToPublicECR_AuthError(t *testing.T) { - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return nil, errors.New("auth error") - }, - } - - service := NewECRService(nil, mockPublicClient, nil) - err := service.LoginToPublicECR(context.Background()) - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToPublicECR_DecodeError(t *testing.T) { - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return &ecrpublic.GetAuthorizationTokenOutput{ - AuthorizationData: &ecrpublictypes.AuthorizationData{ - AuthorizationToken: aws.String("invalid-base64!!!"), - }, - }, nil - }, - } - - service := NewECRService(nil, mockPublicClient, nil) - err := service.LoginToPublicECR(context.Background()) - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToPublicECR_DockerLoginError(t *testing.T) { - token := base64.StdEncoding.EncodeToString([]byte("AWS:testpassword")) - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return &ecrpublic.GetAuthorizationTokenOutput{ - AuthorizationData: &ecrpublictypes.AuthorizationData{ - AuthorizationToken: aws.String(token), - }, - }, nil - }, - } - - mockCmdRunner := &MockCommandRunner{ - RunWithStdinFunc: func(name string, stdinInput string, args ...string) error { - return errors.New("docker login failed") - }, - } - service := NewECRService(nil, mockPublicClient, mockCmdRunner) - - err := service.LoginToPublicECR(context.Background()) - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToECR_AuthError(t *testing.T) { - mockClient := &MockECRClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { - return nil, errors.New("auth error") - }, - } - - service := NewECRService(mockClient, nil, nil) - err := service.LoginToECR(context.Background(), "123456789012", "us-east-1") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToECR_NoAuthData(t *testing.T) { - mockClient := &MockECRClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { - return &ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []ecrtypes.AuthorizationData{}, - }, nil - }, - } - - service := NewECRService(mockClient, nil, nil) - err := service.LoginToECR(context.Background(), "123456789012", "us-east-1") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToECR_DecodeError(t *testing.T) { - mockClient := &MockECRClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { - return &ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []ecrtypes.AuthorizationData{ - {AuthorizationToken: aws.String("invalid-base64!!!")}, - }, - }, nil - }, - } - - service := NewECRService(mockClient, nil, nil) - err := service.LoginToECR(context.Background(), "123456789012", "us-east-1") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToECR_DockerLoginError(t *testing.T) { - token := base64.StdEncoding.EncodeToString([]byte("AWS:testpassword")) - mockClient := &MockECRClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { - return &ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []ecrtypes.AuthorizationData{ - {AuthorizationToken: aws.String(token)}, - }, - }, nil - }, - } - - mockCmdRunner := &MockCommandRunner{ - RunWithStdinFunc: func(name string, stdinInput string, args ...string) error { - return errors.New("docker login failed") - }, - } - service := NewECRService(mockClient, nil, mockCmdRunner) - - err := service.LoginToECR(context.Background(), "123456789012", "us-east-1") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_EnsureRepository_CreateError(t *testing.T) { - mockClient := &MockECRClient{ - DescribeRepositoriesFunc: func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { - return nil, &ecrtypes.RepositoryNotFoundException{Message: aws.String("repository not found")} - }, - CreateRepositoryFunc: func(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) { - return nil, errors.New("create failed") - }, - } - - service := NewECRService(mockClient, nil, nil) - _, err := service.EnsureRepository(context.Background(), "test-repo", "123456789012", "us-east-1") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestECRService_LoginToPublicECR_NilAuthData(t *testing.T) { - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return &ecrpublic.GetAuthorizationTokenOutput{ - AuthorizationData: nil, - }, nil - }, - } - - service := NewECRService(nil, mockPublicClient, nil) - err := service.LoginToPublicECR(context.Background()) - if err == nil { - t.Error("expected error for nil AuthorizationData, got nil") - } - if err.Error() != "no authorization data returned from public ECR" { - t.Errorf("unexpected error message: %v", err) - } -} - -func TestECRService_LoginToPublicECR_NilAuthToken(t *testing.T) { - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return &ecrpublic.GetAuthorizationTokenOutput{ - AuthorizationData: &ecrpublictypes.AuthorizationData{ - AuthorizationToken: nil, - }, - }, nil - }, - } - - service := NewECRService(nil, mockPublicClient, nil) - err := service.LoginToPublicECR(context.Background()) - if err == nil { - t.Error("expected error for nil AuthorizationToken, got nil") - } - if err.Error() != "no authorization data returned from public ECR" { - t.Errorf("unexpected error message: %v", err) - } -} - -func TestECRService_LoginToPublicECR_InvalidTokenFormat(t *testing.T) { - // Valid base64 but no colon in the decoded string. - // The refactored code delegates to decodeBase64Token, which returns a more - // descriptive error wrapped by the caller (07-L4). - token := base64.StdEncoding.EncodeToString([]byte("invalidformat")) - mockPublicClient := &MockECRPublicClient{ - GetAuthorizationTokenFunc: func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - return &ecrpublic.GetAuthorizationTokenOutput{ - AuthorizationData: &ecrpublictypes.AuthorizationData{ - AuthorizationToken: aws.String(token), - }, - }, nil - }, - } - - service := NewECRService(nil, mockPublicClient, nil) - err := service.LoginToPublicECR(context.Background()) - if err == nil { - t.Error("expected error for invalid token format, got nil") - } - // decodeBase64Token now produces the precise message; caller wraps it. - if !strings.Contains(err.Error(), "invalid token format") { - t.Errorf("expected error to mention 'invalid token format', got: %v", err) - } -} diff --git a/internal/deploy/frontend.go b/internal/deploy/frontend.go deleted file mode 100644 index bfb69dfb9..000000000 --- a/internal/deploy/frontend.go +++ /dev/null @@ -1,294 +0,0 @@ -package deploy - -import ( - "bytes" - "context" - "fmt" - "io/fs" - "log" - "mime" - "os" - "path/filepath" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/cloudfront" - cftypes "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" - "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" -) - -// FrontendService handles frontend build and deployment operations. -type FrontendService struct { - S3Client S3Client - CloudFrontClient CloudFrontClient - CmdRunner CommandRunner -} - -// NewFrontendService creates a new FrontendService. -func NewFrontendService(s3Client S3Client, cfClient CloudFrontClient, cmdRunner CommandRunner) *FrontendService { - return &FrontendService{ - S3Client: s3Client, - CloudFrontClient: cfClient, - CmdRunner: cmdRunner, - } -} - -// BuildAndUpload builds the frontend and uploads it to S3. -func (s *FrontendService) BuildAndUpload(ctx context.Context, bucketName, dashboardURL string) error { - // Find frontend directory - frontendDir, err := s.FindFrontendDir() - if err != nil { - return fmt.Errorf("frontend directory not found: %w", err) - } - - // Run npm install - log.Println("Running npm install...") - if err := s.CmdRunner.Run("npm", "--prefix", frontendDir, "install"); err != nil { - return fmt.Errorf("npm install failed: %w", err) - } - - // Run npm run build - log.Println("Running npm run build...") - if err := s.CmdRunner.Run("npm", "--prefix", frontendDir, "run", "build"); err != nil { - return fmt.Errorf("npm run build failed: %w", err) - } - - // Upload dist folder to S3 - distDir := filepath.Join(frontendDir, "dist") - log.Printf("Uploading frontend from %s to s3://%s/", distDir, bucketName) - - if err := s.uploadDirectory(ctx, distDir, bucketName); err != nil { - return fmt.Errorf("failed to upload files: %w", err) - } - - log.Println("Frontend uploaded successfully") - - // Invalidate CloudFront cache - if err := s.InvalidateCloudFrontCache(ctx, bucketName); err != nil { - log.Printf("Warning: CloudFront cache invalidation failed: %v", err) - // Don't fail deployment for this - } - - return nil -} - -func (s *FrontendService) uploadDirectory(ctx context.Context, distDir, bucketName string) error { - return filepath.WalkDir(distDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - // Reject symlinks: filepath.WalkDir does not traverse symlinks but - // os.ReadFile will dereference them. A symlinked file inside the build - // dir could point outside it, so we skip any symlink entry explicitly. - if d.Type()&fs.ModeSymlink != 0 { - log.Printf("deploy: skipping symlink %s", path) - return nil - } - return s.uploadFile(ctx, distDir, bucketName, path) - }) -} - -// uploadFile reads a single regular file and puts it in the S3 bucket. -// path must be a non-symlink descendant of distDir (enforced by the caller). -func (s *FrontendService) uploadFile(ctx context.Context, distDir, bucketName, path string) error { - relPath, err := filepath.Rel(distDir, path) - if err != nil { - return err - } - key := strings.ReplaceAll(relPath, string(filepath.Separator), "/") - - content, err := os.ReadFile(path) // #nosec G304 -- path is from WalkDir callback, symlinks rejected by caller; always a regular file descendant of distDir (npm build output under operator control) - if err != nil { - return fmt.Errorf("failed to read %s: %w", path, err) - } - - contentType := mime.TypeByExtension(filepath.Ext(path)) - if contentType == "" { - contentType = "application/octet-stream" - } - - cacheControl := "max-age=31536000" // 1 year for assets - if strings.HasSuffix(key, ".html") || strings.HasSuffix(key, ".json") || strings.HasSuffix(key, ".webmanifest") { - cacheControl = "no-cache, no-store, must-revalidate" - } - - _, err = s.S3Client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - Body: bytes.NewReader(content), - ContentType: aws.String(contentType), - CacheControl: aws.String(cacheControl), - }) - if err != nil { - return fmt.Errorf("failed to upload %s: %w", key, err) - } - return nil -} - -// FindFrontendDir finds the frontend directory. -// Search order: -// - Relative paths from current directory (frontend, ../frontend, ../../frontend) -// - Relative to executable directory (for deployed binaries) -// - CUDLY_FRONTEND_DIR environment variable (highest priority) -func (s *FrontendService) FindFrontendDir() (string, error) { - // Check environment variable first for deployed binaries - if envPath := os.Getenv("CUDLY_FRONTEND_DIR"); envPath != "" { - packageJSON := filepath.Join(envPath, "package.json") - if _, err := os.Stat(packageJSON); err == nil { // #nosec G703 -- CUDLY_FRONTEND_DIR is an operator-set deployment config env var, not user input - return filepath.Abs(envPath) - } - } - - paths := []string{ - "frontend", - "../frontend", - "../../frontend", - } - - if execPath, err := os.Executable(); err == nil { - execDir := filepath.Dir(execPath) - paths = append(paths, - filepath.Join(execDir, "frontend"), - filepath.Join(execDir, "..", "frontend"), - ) - } - - for _, path := range paths { - packageJSON := filepath.Join(path, "package.json") - if _, err := os.Stat(packageJSON); err == nil { - return filepath.Abs(path) - } - } - - return "", fmt.Errorf("frontend directory not found; please run from the CUDly directory") -} - -// InvalidateCloudFrontCache invalidates the CloudFront cache for a bucket. -func (s *FrontendService) InvalidateCloudFrontCache(ctx context.Context, bucketName string) error { - distributionID, err := s.findDistributionForBucket(ctx, bucketName) - if err != nil { - return err - } - - if distributionID == "" { - return nil // No distribution found for this bucket - } - - return s.createCacheInvalidation(ctx, distributionID) -} - -func (s *FrontendService) findDistributionForBucket(ctx context.Context, bucketName string) (string, error) { - paginator := cloudfront.NewListDistributionsPaginator(s.CloudFrontClient, &cloudfront.ListDistributionsInput{}) - - for paginator.HasMorePages() { - result, err := paginator.NextPage(ctx) - if err != nil { - return "", fmt.Errorf("failed to list distributions: %w", err) - } - - if result.DistributionList == nil || result.DistributionList.Items == nil { - continue - } - - for _rvc := range result.DistributionList.Items { - dist := result.DistributionList.Items[_rvc] - if distID := s.checkDistributionOrigins(dist, bucketName); distID != "" { - return distID, nil - } - } - } - - return "", nil -} - -func (s *FrontendService) checkDistributionOrigins(dist cftypes.DistributionSummary, bucketName string) string { - if dist.Origins == nil || dist.Origins.Items == nil { - return "" - } - - expectedDomain := bucketName + ".s3." - for _, origin := range dist.Origins.Items { - if origin.DomainName != nil && strings.HasPrefix(*origin.DomainName, expectedDomain) { - return *dist.Id - } - } - - return "" -} - -func (s *FrontendService) createCacheInvalidation(ctx context.Context, distributionID string) error { - log.Printf("Invalidating CloudFront distribution: %s", distributionID) - - _, err := s.CloudFrontClient.CreateInvalidation(ctx, &cloudfront.CreateInvalidationInput{ - DistributionId: aws.String(distributionID), - InvalidationBatch: &cftypes.InvalidationBatch{ - CallerReference: aws.String(fmt.Sprintf("cudly-deploy-%d", time.Now().Unix())), - Paths: &cftypes.Paths{ - Quantity: aws.Int32(1), - Items: []string{"/*"}, - }, - }, - }) - if err != nil { - return fmt.Errorf("failed to create invalidation: %w", err) - } - - log.Println("CloudFront cache invalidation created") - return nil -} - -// EmptyBucket empties an S3 bucket (used before deletion). -func (s *FrontendService) EmptyBucket(ctx context.Context, bucketName string) error { - // List all objects using pagination - var continuationToken *string - - for { - result, err := s.S3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ - Bucket: aws.String(bucketName), - ContinuationToken: continuationToken, - }) - if err != nil { - return err - } - - if len(result.Contents) == 0 { - break - } - - // Delete objects - var objects []s3types.ObjectIdentifier - for _, obj := range result.Contents { - objects = append(objects, s3types.ObjectIdentifier{Key: obj.Key}) - } - - deleteResult, err := s.S3Client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ - Bucket: aws.String(bucketName), - Delete: &s3types.Delete{Objects: objects}, - }) - if err != nil { - return err - } - - // Check for per-object errors. Key and Message are *string and can be - // nil when only the Code field is set; use aws.ToString for safe access. - if len(deleteResult.Errors) > 0 { - var errMsgs []string - for _, delErr := range deleteResult.Errors { - errMsgs = append(errMsgs, aws.ToString(delErr.Key)+": "+aws.ToString(delErr.Message)) - } - return fmt.Errorf("failed to delete some objects: %s", strings.Join(errMsgs, "; ")) - } - - if !aws.ToBool(result.IsTruncated) { - break - } - continuationToken = result.NextContinuationToken - } - - return nil -} diff --git a/internal/deploy/frontend_test.go b/internal/deploy/frontend_test.go deleted file mode 100644 index 4193f2dda..000000000 --- a/internal/deploy/frontend_test.go +++ /dev/null @@ -1,472 +0,0 @@ -package deploy - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/cloudfront" - cftypes "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" - "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" -) - -func TestFrontendService_InvalidateCloudFrontCache(t *testing.T) { - invalidateCalled := false - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: &cftypes.DistributionList{ - Items: []cftypes.DistributionSummary{ - { - Id: aws.String("E1234567890"), - Origins: &cftypes.Origins{ - Items: []cftypes.Origin{ - {DomainName: aws.String("my-bucket.s3.amazonaws.com")}, - }, - }, - }, - }, - }, - }, nil - }, - CreateInvalidationFunc: func(ctx context.Context, params *cloudfront.CreateInvalidationInput, optFns ...func(*cloudfront.Options)) (*cloudfront.CreateInvalidationOutput, error) { - invalidateCalled = true - if *params.DistributionId != "E1234567890" { - t.Errorf("expected distribution E1234567890, got %s", *params.DistributionId) - } - return &cloudfront.CreateInvalidationOutput{}, nil - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !invalidateCalled { - t.Error("expected CreateInvalidation to be called") - } -} - -func TestFrontendService_InvalidateCloudFrontCache_NoDistributions(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: nil, - }, nil - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestFrontendService_EmptyBucket(t *testing.T) { - deleteCalled := false - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{ - {Key: aws.String("file1.html")}, - {Key: aws.String("file2.js")}, - }, - IsTruncated: aws.Bool(false), - }, nil - }, - DeleteObjectsFunc: func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - deleteCalled = true - if len(params.Delete.Objects) != 2 { - t.Errorf("expected 2 objects to delete, got %d", len(params.Delete.Objects)) - } - return &s3.DeleteObjectsOutput{}, nil - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - err := service.EmptyBucket(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !deleteCalled { - t.Error("expected DeleteObjects to be called") - } -} - -func TestFrontendService_EmptyBucket_Paginated(t *testing.T) { - callCount := 0 - deleteCallCount := 0 - - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - callCount++ - if callCount == 1 { - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{ - {Key: aws.String("file1.html")}, - }, - IsTruncated: aws.Bool(true), - NextContinuationToken: aws.String("token1"), - }, nil - } - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{ - {Key: aws.String("file2.js")}, - }, - IsTruncated: aws.Bool(false), - }, nil - }, - DeleteObjectsFunc: func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - deleteCallCount++ - return &s3.DeleteObjectsOutput{}, nil - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - err := service.EmptyBucket(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if callCount != 2 { - t.Errorf("expected 2 ListObjectsV2 calls, got %d", callCount) - } - if deleteCallCount != 2 { - t.Errorf("expected 2 DeleteObjects calls, got %d", deleteCallCount) - } -} - -func TestFrontendService_EmptyBucket_EmptyBucket(t *testing.T) { - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{}, - }, nil - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - err := service.EmptyBucket(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestFrontendService_InvalidateCloudFrontCache_ListError(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return nil, errors.New("list error") - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestFrontendService_InvalidateCloudFrontCache_CreateInvalidationError(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: &cftypes.DistributionList{ - Items: []cftypes.DistributionSummary{ - { - Id: aws.String("E1234567890"), - Origins: &cftypes.Origins{ - Items: []cftypes.Origin{ - {DomainName: aws.String("my-bucket.s3.amazonaws.com")}, - }, - }, - }, - }, - }, - }, nil - }, - CreateInvalidationFunc: func(ctx context.Context, params *cloudfront.CreateInvalidationInput, optFns ...func(*cloudfront.Options)) (*cloudfront.CreateInvalidationOutput, error) { - return nil, errors.New("invalidation error") - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestFrontendService_InvalidateCloudFrontCache_NoMatchingDistribution(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: &cftypes.DistributionList{ - Items: []cftypes.DistributionSummary{ - { - Id: aws.String("E1234567890"), - Origins: &cftypes.Origins{ - Items: []cftypes.Origin{ - {DomainName: aws.String("different-bucket.s3.amazonaws.com")}, - }, - }, - }, - }, - }, - }, nil - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestFrontendService_InvalidateCloudFrontCache_NilOrigins(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: &cftypes.DistributionList{ - Items: []cftypes.DistributionSummary{ - { - Id: aws.String("E1234567890"), - Origins: nil, - }, - }, - }, - }, nil - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestFrontendService_EmptyBucket_ListError(t *testing.T) { - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - return nil, errors.New("list error") - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - err := service.EmptyBucket(context.Background(), "my-bucket") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestFrontendService_EmptyBucket_DeleteError(t *testing.T) { - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{ - {Key: aws.String("file1.html")}, - }, - }, nil - }, - DeleteObjectsFunc: func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - return nil, errors.New("delete error") - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - err := service.EmptyBucket(context.Background(), "my-bucket") - if err == nil { - t.Error("expected error, got nil") - } -} - -// TestFrontendService_EmptyBucket_PerObjectErrorNilFields is a regression test -// for the nil-deref bug (07-M4): S3 DeleteObjects can return an Error with a -// nil Key or nil Message (e.g. when only the Code field is set). The old code -// dereferenced both unconditionally, causing a panic during bucket teardown. -// aws.ToString is now used so nil fields produce an empty string instead. -func TestFrontendService_EmptyBucket_PerObjectErrorNilFields(t *testing.T) { - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{ - {Key: aws.String("file1.html")}, - }, - IsTruncated: aws.Bool(false), - }, nil - }, - DeleteObjectsFunc: func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - // Simulate an S3 per-object error where Key and Message are nil - // (only Code is set), which the old code would panic on. - return &s3.DeleteObjectsOutput{ - Errors: []s3types.Error{ - {Code: aws.String("InternalError"), Key: nil, Message: nil}, - }, - }, nil - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - // Must return an error (not panic) even with nil Key/Message fields. - err := service.EmptyBucket(context.Background(), "my-bucket") - if err == nil { - t.Fatal("expected error from per-object S3 error, got nil") - } - if !strings.Contains(err.Error(), "failed to delete some objects") { - t.Errorf("unexpected error message: %v", err) - } -} - -// TestFrontendService_EmptyBucket_PerObjectErrorWithFields confirms that when -// Key and Message are populated the error message includes them. -func TestFrontendService_EmptyBucket_PerObjectErrorWithFields(t *testing.T) { - mockS3Client := &MockS3Client{ - ListObjectsV2Func: func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - return &s3.ListObjectsV2Output{ - Contents: []s3types.Object{ - {Key: aws.String("file2.js")}, - }, - IsTruncated: aws.Bool(false), - }, nil - }, - DeleteObjectsFunc: func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - return &s3.DeleteObjectsOutput{ - Errors: []s3types.Error{ - { - Code: aws.String("AccessDenied"), - Key: aws.String("file2.js"), - Message: aws.String("access denied"), - }, - }, - }, nil - }, - } - - service := NewFrontendService(mockS3Client, nil, nil) - - err := service.EmptyBucket(context.Background(), "my-bucket") - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "file2.js") { - t.Errorf("expected error to contain object key, got: %v", err) - } -} - -func TestFrontendService_FindFrontendDir_Found(t *testing.T) { - // The project has a frontend directory, so this should succeed - service := NewFrontendService(nil, nil, nil) - - dir, err := service.FindFrontendDir() - if err != nil { - // This test may fail in some environments, just skip it - t.Skip("Frontend directory not found in this environment") - } - - if dir == "" { - t.Error("expected non-empty directory path") - } -} - -func TestFrontendService_BuildAndUpload_NpmInstallFails(t *testing.T) { - mockCmdRunner := &MockCommandRunner{ - RunFunc: func(name string, args ...string) error { - if name == "npm" && len(args) > 0 && args[len(args)-1] == "install" { - return errors.New("npm install failed") - } - return nil - }, - } - - service := NewFrontendService(nil, nil, mockCmdRunner) - - // This will fail during FindFrontendDir or npm install - err := service.BuildAndUpload(context.Background(), "test-bucket", "https://example.com") - if err == nil { - t.Skip("Skipping test in environment where frontend dir exists") - } - // The error should be about frontend directory not found or npm install failed -} - -func TestFrontendService_BuildAndUpload_NpmBuildFails(t *testing.T) { - mockCmdRunner := &MockCommandRunner{ - RunFunc: func(name string, args ...string) error { - if name == "npm" && len(args) > 0 && args[len(args)-1] == "build" { - return errors.New("npm run build failed") - } - return nil - }, - } - - service := NewFrontendService(nil, nil, mockCmdRunner) - - err := service.BuildAndUpload(context.Background(), "test-bucket", "https://example.com") - if err == nil { - t.Skip("Skipping test in environment where frontend dir not found") - } -} - -func TestFrontendService_InvalidateCloudFrontCache_NilItems(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: &cftypes.DistributionList{ - Items: nil, - }, - }, nil - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestFrontendService_InvalidateCloudFrontCache_NilOriginItems(t *testing.T) { - mockCFClient := &MockCloudFrontClient{ - ListDistributionsFunc: func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - return &cloudfront.ListDistributionsOutput{ - DistributionList: &cftypes.DistributionList{ - Items: []cftypes.DistributionSummary{ - { - Id: aws.String("E1234567890"), - Origins: &cftypes.Origins{ - Items: nil, - }, - }, - }, - }, - }, nil - }, - } - - service := NewFrontendService(nil, mockCFClient, nil) - - err := service.InvalidateCloudFrontCache(context.Background(), "my-bucket") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/deploy/mocks.go b/internal/deploy/mocks.go deleted file mode 100644 index 4ae1d4bb8..000000000 --- a/internal/deploy/mocks.go +++ /dev/null @@ -1,123 +0,0 @@ -package deploy - -import ( - "context" - - "github.com/aws/aws-sdk-go-v2/service/cloudfront" - "github.com/aws/aws-sdk-go-v2/service/ecr" - "github.com/aws/aws-sdk-go-v2/service/ecrpublic" - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -// MockECRClient is a mock implementation of ECRClient. -type MockECRClient struct { - DescribeRepositoriesFunc func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) - CreateRepositoryFunc func(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) - GetAuthorizationTokenFunc func(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) -} - -func (m *MockECRClient) DescribeRepositories(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) { - if m.DescribeRepositoriesFunc != nil { - return m.DescribeRepositoriesFunc(ctx, params, optFns...) - } - return &ecr.DescribeRepositoriesOutput{}, nil -} - -func (m *MockECRClient) CreateRepository(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) { - if m.CreateRepositoryFunc != nil { - return m.CreateRepositoryFunc(ctx, params, optFns...) - } - return &ecr.CreateRepositoryOutput{}, nil -} - -func (m *MockECRClient) GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { - if m.GetAuthorizationTokenFunc != nil { - return m.GetAuthorizationTokenFunc(ctx, params, optFns...) - } - return &ecr.GetAuthorizationTokenOutput{}, nil -} - -// MockECRPublicClient is a mock implementation of ECRPublicClient. -type MockECRPublicClient struct { - GetAuthorizationTokenFunc func(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) -} - -func (m *MockECRPublicClient) GetAuthorizationToken(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) { - if m.GetAuthorizationTokenFunc != nil { - return m.GetAuthorizationTokenFunc(ctx, params, optFns...) - } - return &ecrpublic.GetAuthorizationTokenOutput{}, nil -} - -// MockS3Client is a mock implementation of S3Client. -type MockS3Client struct { - PutObjectFunc func(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) - DeleteObjectsFunc func(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) - ListObjectsV2Func func(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) -} - -func (m *MockS3Client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - if m.PutObjectFunc != nil { - return m.PutObjectFunc(ctx, params, optFns...) - } - return &s3.PutObjectOutput{}, nil -} - -func (m *MockS3Client) DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { - if m.DeleteObjectsFunc != nil { - return m.DeleteObjectsFunc(ctx, params, optFns...) - } - return &s3.DeleteObjectsOutput{}, nil -} - -func (m *MockS3Client) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { - if m.ListObjectsV2Func != nil { - return m.ListObjectsV2Func(ctx, params, optFns...) - } - return &s3.ListObjectsV2Output{}, nil -} - -// MockCloudFrontClient is a mock implementation of CloudFrontClient. -type MockCloudFrontClient struct { - ListDistributionsFunc func(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) - CreateInvalidationFunc func(ctx context.Context, params *cloudfront.CreateInvalidationInput, optFns ...func(*cloudfront.Options)) (*cloudfront.CreateInvalidationOutput, error) -} - -func (m *MockCloudFrontClient) ListDistributions(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) { - if m.ListDistributionsFunc != nil { - return m.ListDistributionsFunc(ctx, params, optFns...) - } - return &cloudfront.ListDistributionsOutput{}, nil -} - -func (m *MockCloudFrontClient) CreateInvalidation(ctx context.Context, params *cloudfront.CreateInvalidationInput, optFns ...func(*cloudfront.Options)) (*cloudfront.CreateInvalidationOutput, error) { - if m.CreateInvalidationFunc != nil { - return m.CreateInvalidationFunc(ctx, params, optFns...) - } - return &cloudfront.CreateInvalidationOutput{}, nil -} - -// MockCommandRunner is a mock implementation of CommandRunner. -type MockCommandRunner struct { - RunFunc func(name string, args ...string) error - RunWithStdinFunc func(name string, stdin string, args ...string) error - Commands [][]string // Records all commands run -} - -func (m *MockCommandRunner) Run(name string, args ...string) error { - cmd := append([]string{name}, args...) - m.Commands = append(m.Commands, cmd) - if m.RunFunc != nil { - return m.RunFunc(name, args...) - } - return nil -} - -func (m *MockCommandRunner) RunWithStdin(name, stdin string, args ...string) error { - cmd := append([]string{name}, args...) - m.Commands = append(m.Commands, cmd) - if m.RunWithStdinFunc != nil { - return m.RunWithStdinFunc(name, stdin, args...) - } - return nil -} diff --git a/internal/deploy/profiles.go b/internal/deploy/profiles.go deleted file mode 100644 index 134f8e695..000000000 --- a/internal/deploy/profiles.go +++ /dev/null @@ -1,263 +0,0 @@ -package deploy - -import ( - "errors" - "fmt" - "log" - "os" - "path/filepath" - "sort" - - "gopkg.in/yaml.v3" -) - -// ProfileConfig holds configuration for a single deployment profile. -type ProfileConfig struct { - DashboardDomain string `yaml:"dashboard_domain,omitempty"` - HostedZoneID string `yaml:"hosted_zone_id,omitempty"` - StackName string `yaml:"stack_name"` - RampSchedule string `yaml:"ramp_schedule"` - AWSProfile string `yaml:"aws_profile"` - Email string `yaml:"email"` - AdminEmail string `yaml:"admin_email,omitempty"` - PaymentOption string `yaml:"payment_option"` - ComputePlatform string `yaml:"compute_platform"` - CORSAllowedOrigin string `yaml:"cors_allowed_origin,omitempty"` - Region string `yaml:"region"` - ImageTag string `yaml:"image_tag,omitempty"` - Provider string `yaml:"provider"` - Architecture string `yaml:"architecture"` - Coverage float64 `yaml:"coverage"` - MemorySize int `yaml:"memory_size"` - NotifyDays int `yaml:"notify_days"` - Term int `yaml:"term"` - EnableDashboard bool `yaml:"enable_dashboard"` -} - -// DeploymentConfig holds all deployment profiles. -type DeploymentConfig struct { - Profiles map[string]ProfileConfig `yaml:"profiles"` - ActiveProfile string `yaml:"active_profile"` -} - -// GetConfigPath returns the path to the deployment configuration file. -func GetConfigPath() string { - homeDir, err := os.UserHomeDir() - if err != nil { - log.Printf("WARNING: could not determine home directory: %v", err) - return "" - } - return filepath.Join(homeDir, ".cudly", "deployment.yaml") -} - -// GetConfigDir returns the directory containing the deployment configuration. -func GetConfigDir() string { - homeDir, err := os.UserHomeDir() - if err != nil { - log.Printf("WARNING: could not determine home directory: %v", err) - return "" - } - return filepath.Join(homeDir, ".cudly") -} - -// LoadConfig loads the deployment configuration from disk. -func LoadConfig() (*DeploymentConfig, error) { - configPath := GetConfigPath() - - // If config doesn't exist, return empty config - if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { - return &DeploymentConfig{ - Profiles: make(map[string]ProfileConfig), - }, nil - } - - data, err := os.ReadFile(configPath) // #nosec G304 -- configPath is GetConfigPath() = ~/.cudly/deployment.yaml (os.UserHomeDir + hardcoded subpath); not user input - if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } - - var config DeploymentConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) - } - - // Initialize profiles map if nil - if config.Profiles == nil { - config.Profiles = make(map[string]ProfileConfig) - } - - return &config, nil -} - -// SaveConfig saves the deployment configuration to disk. -func SaveConfig(config *DeploymentConfig) error { - configDir := GetConfigDir() - configPath := GetConfigPath() - - // Ensure the config directory exists with restrictive permissions - if err := os.MkdirAll(configDir, 0700); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - data, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - if err := os.WriteFile(configPath, data, 0600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - return nil -} - -// InitConfig creates a default configuration file. -func InitConfig() error { - configPath := GetConfigPath() - - // Don't overwrite existing config - if _, err := os.Stat(configPath); err == nil { - return fmt.Errorf("configuration file already exists at %s", configPath) - } - - // Create default profile - defaultProfile := ProfileConfig{ - StackName: "cudly", - Region: "us-east-1", - Term: 3, - PaymentOption: "no-upfront", - Coverage: 80, - RampSchedule: "immediate", - NotifyDays: 3, - EnableDashboard: true, - Architecture: "arm64", - MemorySize: 512, - } - - config := &DeploymentConfig{ - ActiveProfile: "default", - Profiles: map[string]ProfileConfig{ - "default": defaultProfile, - }, - } - - return SaveConfig(config) -} - -// GetActiveProfile returns the active profile configuration. -func (c *DeploymentConfig) GetActiveProfile() (*ProfileConfig, error) { - if c.ActiveProfile == "" { - return nil, fmt.Errorf("no active profile set") - } - - profile, ok := c.Profiles[c.ActiveProfile] - if !ok { - return nil, fmt.Errorf("active profile %q not found", c.ActiveProfile) - } - - return &profile, nil -} - -// GetProfile returns a specific profile by name. -func (c *DeploymentConfig) GetProfile(name string) (*ProfileConfig, error) { - profile, ok := c.Profiles[name] - if !ok { - return nil, fmt.Errorf("profile %q not found", name) - } - return &profile, nil -} - -// SetActiveProfile sets the active profile. -func (c *DeploymentConfig) SetActiveProfile(name string) error { - if _, ok := c.Profiles[name]; !ok { - return fmt.Errorf("profile %q not found", name) - } - c.ActiveProfile = name - return nil -} - -// AddProfile adds a new profile to the configuration. -func (c *DeploymentConfig) AddProfile(name string, profile ProfileConfig) error { - if name == "" { - return fmt.Errorf("profile name cannot be empty") - } - - if _, exists := c.Profiles[name]; exists { - return fmt.Errorf("profile %q already exists", name) - } - - c.Profiles[name] = profile - - // If this is the first profile, make it active - if len(c.Profiles) == 1 || c.ActiveProfile == "" { - c.ActiveProfile = name - } - - return nil -} - -// UpdateProfile updates an existing profile. -func (c *DeploymentConfig) UpdateProfile(name string, profile ProfileConfig) error { - if _, exists := c.Profiles[name]; !exists { - return fmt.Errorf("profile %q does not exist", name) - } - - c.Profiles[name] = profile - return nil -} - -// DeleteProfile removes a profile from the configuration. -func (c *DeploymentConfig) DeleteProfile(name string) error { - if _, ok := c.Profiles[name]; !ok { - return fmt.Errorf("profile %q not found", name) - } - - // Don't allow deleting the active profile - if c.ActiveProfile == name { - return fmt.Errorf("cannot delete active profile; set a different active profile first") - } - - delete(c.Profiles, name) - return nil -} - -// CopyProfile creates a new profile by copying an existing one. -func (c *DeploymentConfig) CopyProfile(from, to string) error { - if to == "" { - return fmt.Errorf("new profile name cannot be empty") - } - - if _, exists := c.Profiles[to]; exists { - return fmt.Errorf("profile %q already exists", to) - } - - sourceProfile, ok := c.Profiles[from] - if !ok { - return fmt.Errorf("source profile %q not found", from) - } - - // Create a copy of the source profile - c.Profiles[to] = sourceProfile - return nil -} - -// ListProfiles returns a sorted list of profile names. -func (c *DeploymentConfig) ListProfiles() []string { - names := make([]string, 0, len(c.Profiles)) - for name := range c.Profiles { - names = append(names, name) - } - sort.Strings(names) - return names -} - -// HasProfile checks if a profile exists. -func (c *DeploymentConfig) HasProfile(name string) bool { - _, ok := c.Profiles[name] - return ok -} - -// ProfileCount returns the number of profiles. -func (c *DeploymentConfig) ProfileCount() int { - return len(c.Profiles) -} diff --git a/internal/deploy/profiles_test.go b/internal/deploy/profiles_test.go deleted file mode 100644 index 470e0731a..000000000 --- a/internal/deploy/profiles_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package deploy - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProfileConfig(t *testing.T) { - // Create a temporary directory for testing - tmpDir, err := os.MkdirTemp("", "cudly-test-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - // Override the config path for testing - originalHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", originalHome) - - t.Run("InitConfig creates default configuration", func(t *testing.T) { - err := InitConfig() - require.NoError(t, err) - - // Verify the config file was created - configPath := GetConfigPath() - assert.FileExists(t, configPath) - - // Load and verify the config - config, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, "default", config.ActiveProfile) - assert.Equal(t, 1, len(config.Profiles)) - assert.Contains(t, config.Profiles, "default") - - defaultProfile := config.Profiles["default"] - assert.Equal(t, "cudly", defaultProfile.StackName) - assert.Equal(t, "us-east-1", defaultProfile.Region) - assert.Equal(t, 3, defaultProfile.Term) - assert.Equal(t, "no-upfront", defaultProfile.PaymentOption) - assert.Equal(t, 80.0, defaultProfile.Coverage) - assert.Equal(t, "immediate", defaultProfile.RampSchedule) - assert.Equal(t, 3, defaultProfile.NotifyDays) - assert.True(t, defaultProfile.EnableDashboard) - assert.Equal(t, "arm64", defaultProfile.Architecture) - assert.Equal(t, 512, defaultProfile.MemorySize) - }) - - t.Run("InitConfig fails if config already exists", func(t *testing.T) { - err := InitConfig() - assert.Error(t, err) - assert.Contains(t, err.Error(), "already exists") - }) - - t.Run("LoadConfig returns empty config when file doesn't exist", func(t *testing.T) { - // Remove the config file - os.Remove(GetConfigPath()) - - config, err := LoadConfig() - require.NoError(t, err) - assert.NotNil(t, config) - assert.Equal(t, 0, len(config.Profiles)) - }) - - t.Run("AddProfile adds a new profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - newProfile := ProfileConfig{ - StackName: "cudly-prod", - Region: "us-west-2", - Email: "admin@example.com", - Term: 1, - PaymentOption: "all-upfront", - Coverage: 90, - RampSchedule: "gradual", - NotifyDays: 7, - EnableDashboard: true, - Architecture: "x86_64", - MemorySize: 1024, - } - - err = config.AddProfile("production", newProfile) - require.NoError(t, err) - assert.Equal(t, 1, len(config.Profiles)) - assert.Contains(t, config.Profiles, "production") - - // Save and reload to verify persistence - err = SaveConfig(config) - require.NoError(t, err) - - reloaded, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, 1, len(reloaded.Profiles)) - assert.Contains(t, reloaded.Profiles, "production") - assert.Equal(t, "cudly-prod", reloaded.Profiles["production"].StackName) - }) - - t.Run("AddProfile fails for duplicate profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - profile := ProfileConfig{StackName: "test"} - err = config.AddProfile("production", profile) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already exists") - }) - - t.Run("AddProfile fails for empty name", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - profile := ProfileConfig{StackName: "test"} - err = config.AddProfile("", profile) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot be empty") - }) - - t.Run("SetActiveProfile changes active profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.SetActiveProfile("production") - require.NoError(t, err) - assert.Equal(t, "production", config.ActiveProfile) - - // Save and verify - err = SaveConfig(config) - require.NoError(t, err) - - reloaded, err := LoadConfig() - require.NoError(t, err) - assert.Equal(t, "production", reloaded.ActiveProfile) - }) - - t.Run("SetActiveProfile fails for non-existent profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.SetActiveProfile("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) - - t.Run("GetActiveProfile returns active profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - profile, err := config.GetActiveProfile() - require.NoError(t, err) - assert.NotNil(t, profile) - assert.Equal(t, "cudly-prod", profile.StackName) - }) - - t.Run("GetProfile returns specific profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - profile, err := config.GetProfile("production") - require.NoError(t, err) - assert.NotNil(t, profile) - assert.Equal(t, "cudly-prod", profile.StackName) - }) - - t.Run("GetProfile fails for non-existent profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - _, err = config.GetProfile("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) - - t.Run("CopyProfile creates a copy", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.CopyProfile("production", "staging") - require.NoError(t, err) - assert.Equal(t, 2, len(config.Profiles)) - assert.Contains(t, config.Profiles, "staging") - - // Verify the copy has the same settings - prodProfile := config.Profiles["production"] - stagingProfile := config.Profiles["staging"] - assert.Equal(t, prodProfile.StackName, stagingProfile.StackName) - assert.Equal(t, prodProfile.Region, stagingProfile.Region) - assert.Equal(t, prodProfile.Term, stagingProfile.Term) - - // Save for next test - err = SaveConfig(config) - require.NoError(t, err) - }) - - t.Run("CopyProfile fails for non-existent source", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.CopyProfile("nonexistent", "new") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) - - t.Run("CopyProfile fails for existing destination", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - // staging already exists from the previous test - err = config.CopyProfile("production", "staging") - assert.Error(t, err) - assert.Contains(t, err.Error(), "already exists") - }) - - t.Run("CopyProfile fails for empty destination name", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.CopyProfile("production", "") - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot be empty") - }) - - t.Run("DeleteProfile removes a profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - // Should have production and staging at this point - assert.Equal(t, 2, len(config.Profiles)) - - err = config.DeleteProfile("staging") - require.NoError(t, err) - assert.Equal(t, 1, len(config.Profiles)) - assert.NotContains(t, config.Profiles, "staging") - - // Save for next tests - err = SaveConfig(config) - require.NoError(t, err) - }) - - t.Run("DeleteProfile fails for active profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - // production is the active profile - err = config.DeleteProfile("production") - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot delete active profile") - }) - - t.Run("DeleteProfile fails for non-existent profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.DeleteProfile("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) - - t.Run("ListProfiles returns sorted profile names", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - // Add a few more profiles - config.AddProfile("dev", ProfileConfig{StackName: "cudly-dev"}) - config.AddProfile("test", ProfileConfig{StackName: "cudly-test"}) - - // Save to persist - err = SaveConfig(config) - require.NoError(t, err) - - // Reload and check - config, err = LoadConfig() - require.NoError(t, err) - - profiles := config.ListProfiles() - assert.Equal(t, 3, len(profiles)) - // Should be sorted alphabetically - assert.Equal(t, []string{"dev", "production", "test"}, profiles) - }) - - t.Run("HasProfile checks profile existence", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - assert.True(t, config.HasProfile("production")) - assert.True(t, config.HasProfile("dev")) - assert.True(t, config.HasProfile("test")) - assert.False(t, config.HasProfile("nonexistent")) - }) - - t.Run("ProfileCount returns correct count", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - assert.Equal(t, 3, config.ProfileCount()) - }) - - t.Run("UpdateProfile modifies existing profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - updatedProfile := config.Profiles["dev"] - updatedProfile.Region = "eu-west-1" - updatedProfile.MemorySize = 2048 - - err = config.UpdateProfile("dev", updatedProfile) - require.NoError(t, err) - - profile, err := config.GetProfile("dev") - require.NoError(t, err) - assert.Equal(t, "eu-west-1", profile.Region) - assert.Equal(t, 2048, profile.MemorySize) - }) - - t.Run("UpdateProfile fails for non-existent profile", func(t *testing.T) { - config, err := LoadConfig() - require.NoError(t, err) - - err = config.UpdateProfile("nonexistent", ProfileConfig{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "does not exist") - }) - - t.Run("First profile becomes active automatically", func(t *testing.T) { - config := &DeploymentConfig{ - Profiles: make(map[string]ProfileConfig), - } - - profile := ProfileConfig{StackName: "first"} - err := config.AddProfile("first", profile) - require.NoError(t, err) - - assert.Equal(t, "first", config.ActiveProfile) - }) -} - -func TestGetConfigPath(t *testing.T) { - // Save original HOME - originalHome := os.Getenv("HOME") - defer os.Setenv("HOME", originalHome) - - // Set a test HOME - testHome := "/tmp/test-home" - os.Setenv("HOME", testHome) - - expected := filepath.Join(testHome, ".cudly", "deployment.yaml") - actual := GetConfigPath() - - assert.Equal(t, expected, actual) -} - -func TestGetConfigDir(t *testing.T) { - // Save original HOME - originalHome := os.Getenv("HOME") - defer os.Setenv("HOME", originalHome) - - // Set a test HOME - testHome := "/tmp/test-home" - os.Setenv("HOME", testHome) - - expected := filepath.Join(testHome, ".cudly") - actual := GetConfigDir() - - assert.Equal(t, expected, actual) -} - -func TestGetActiveProfile_NoActiveProfile(t *testing.T) { - config := &DeploymentConfig{ - ActiveProfile: "", - Profiles: map[string]ProfileConfig{ - "test": {StackName: "test-stack"}, - }, - } - - _, err := config.GetActiveProfile() - assert.Error(t, err) - assert.Contains(t, err.Error(), "no active profile set") -} - -func TestGetActiveProfile_ActiveProfileNotFound(t *testing.T) { - config := &DeploymentConfig{ - ActiveProfile: "nonexistent", - Profiles: map[string]ProfileConfig{ - "test": {StackName: "test-stack"}, - }, - } - - _, err := config.GetActiveProfile() - assert.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} diff --git a/internal/deploy/types.go b/internal/deploy/types.go deleted file mode 100644 index c51caa12b..000000000 --- a/internal/deploy/types.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package deploy provides deployment functionality for CUDly. -package deploy - -import ( - "context" - - "github.com/aws/aws-sdk-go-v2/service/cloudfront" - "github.com/aws/aws-sdk-go-v2/service/ecr" - "github.com/aws/aws-sdk-go-v2/service/ecrpublic" - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -// Config holds configuration for the deployment. -type Config struct { - Architecture string - Email string - AdminEmail string - PaymentOption string - StackName string - RampSchedule string - CORSAllowedOrigin string - ImageTag string - DashboardDomain string - HostedZoneID string - Coverage float64 - MemorySize int - NotifyDays int - Term int - SkipBuild bool - SkipPush bool - SkipFrontend bool - SkipAdmin bool - EnableDashboard bool -} - -// ECRClient interface for ECR operations. -type ECRClient interface { - DescribeRepositories(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) - CreateRepository(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) - GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) -} - -// ECRPublicClient interface for public ECR operations. -type ECRPublicClient interface { - GetAuthorizationToken(ctx context.Context, params *ecrpublic.GetAuthorizationTokenInput, optFns ...func(*ecrpublic.Options)) (*ecrpublic.GetAuthorizationTokenOutput, error) -} - -// S3Client interface for S3 operations. -type S3Client interface { - PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) - DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) - ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) -} - -// CloudFrontClient interface for CloudFront operations. -type CloudFrontClient interface { - ListDistributions(ctx context.Context, params *cloudfront.ListDistributionsInput, optFns ...func(*cloudfront.Options)) (*cloudfront.ListDistributionsOutput, error) - CreateInvalidation(ctx context.Context, params *cloudfront.CreateInvalidationInput, optFns ...func(*cloudfront.Options)) (*cloudfront.CreateInvalidationOutput, error) -} - -// CommandRunner interface for running shell commands. -type CommandRunner interface { - Run(name string, args ...string) error - RunWithStdin(name string, stdin string, args ...string) error -} From adb00c34e63717858a72f90722061b3d19578fb1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 20 Jul 2026 21:02:46 +0200 Subject: [PATCH 2/4] chore(deploy): clean up stale golangci noctx exclusion for deleted internal/deploy/docker.go The internal/deploy package was deleted; its path in the noctx per-file exclusion is now dead. Remove the dead path segment to keep .golangci.yml in sync with the actual source tree. --- .golangci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index ecd35591e..ac5a3bcc9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -113,12 +113,12 @@ linters: - revive path: internal/api/ text: "avoid meaningless package names" - # noctx: exec.Command calls in deploy/configure CLI helpers intentionally + # noctx: exec.Command calls in configure CLI helpers intentionally # omit context (CommandRunner interface contract; operator-controlled inputs). # Suppressed per-file rather than per-line so #nosec G204 can lead the comment. - linters: - noctx - path: (internal/deploy/docker|cmd/configure_gcp|cmd/configure_azure)\.go + path: (cmd/configure_gcp|cmd/configure_azure)\.go # noctx: http.Get and net.Listen in test helpers appropriately use the # background context; threading a test context through every helper would # add noise without improving test correctness. From e971e3af709b7ff72cfca0656f619dec157a204e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 21 Jul 2026 18:00:32 +0200 Subject: [PATCH 3/4] ci: bump golangci-lint timeout from 5m to 10m The previous run timed out at ~321s on a slow CI runner. The codebase has grown and 5m is too tight; 10m gives headroom without masking real lint errors. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd69b2065..b78b1cb74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: version: v2.10.1 - args: --timeout=5m + args: --timeout=10m - name: Run go vet run: go vet ./... From 1e6bc19455f9088ec19bc7752c39741cc4b77d65 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 21 Jul 2026 18:18:02 +0200 Subject: [PATCH 4/4] fix(deps): bump brace-expansion to 1.1.16 (GHSA-3jxr-9vmj-r5cp) npm audit flagged brace-expansion <1.1.16 as high severity DoS. This was disclosed 2026-07-21 and affects all PR branches equally. Lockfile-only change; no production API surface altered. --- frontend/package-lock.json | 66 +++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index adbfcc9e4..2064f1da4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,38 +8,38 @@ "name": "cudly-frontend", "version": "1.0.0", "dependencies": { - "@types/qrcode": "^1.5.6", - "chart.js": "^4.4.0", - "qrcode": "^1.5.4" + "@types/qrcode": "1.5.6", + "chart.js": "4.5.1", + "qrcode": "1.5.4" }, "devDependencies": { - "@babel/core": "^7.23.0", - "@babel/preset-env": "^7.23.0", - "@babel/preset-typescript": "^7.23.0", - "@testing-library/dom": "^9.3.0", - "@testing-library/jest-dom": "^6.1.0", - "@types/chart.js": "^2.9.41", - "@types/jest": "^29.5.0", - "@types/jsdom": "^21.1.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@ungap/structured-clone": "^1.3.1", - "babel-loader": "^9.1.0", - "copy-webpack-plugin": "^14.0.0", - "css-loader": "^6.8.0", - "css-minimizer-webpack-plugin": "^8.0.0", - "eslint": "^8.50.0", - "html-webpack-plugin": "^5.5.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jsdom": "^22.1.0", - "mini-css-extract-plugin": "^2.7.0", - "style-loader": "^3.3.0", - "ts-jest": "^29.1.0", - "ts-loader": "^9.5.0", - "typescript": "^5.3.0", - "webpack": "^5.88.0", - "webpack-cli": "^5.1.0" + "@babel/core": "7.29.7", + "@babel/preset-env": "7.28.5", + "@babel/preset-typescript": "7.28.5", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.9.1", + "@types/chart.js": "2.9.41", + "@types/jest": "29.5.14", + "@types/jsdom": "21.1.7", + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@ungap/structured-clone": "1.3.1", + "babel-loader": "9.2.1", + "copy-webpack-plugin": "14.0.0", + "css-loader": "6.11.0", + "css-minimizer-webpack-plugin": "8.0.0", + "eslint": "8.57.1", + "html-webpack-plugin": "5.6.5", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "jsdom": "22.1.0", + "mini-css-extract-plugin": "2.9.4", + "style-loader": "3.3.4", + "ts-jest": "29.4.6", + "ts-loader": "9.5.4", + "typescript": "5.9.3", + "webpack": "5.108.3", + "webpack-cli": "5.1.4" } }, "node_modules/@adobe/css-tools": { @@ -3837,9 +3837,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": {